В прошлой статье я рассказал как сделать простую игру с использованием библиотеки SFML. В конце статьи я пообещал, что продолжу и покажу как привести код программы в более правильный вид. Итак, время для рефакторинга пришло.
Первым делом я примерно прикинул, какие мне нужны классы для игры. У меня получилось, что нужен класс для работы с ресурсами игры — Assets. Из ресурсов у меня сейчас есть только загружаемый шрифт, но в будущем могут добавиться и другие ресурсы, например изображения, музыка и.т.д. Класс я сделал синглтоном, так как этот шаблон прекрасно подходит для класса Assets. За основу взял широко известный синглтон Майерса.
Дальше необходим, собственно, класс игры, отвечающий за логику работы программы и хранение ее состояния. С точки зрения идеологии MVC, этот класс — модель. Так я его и назвал — GameModel.
Для визуального отображения игры нужен класс, отвечающий за рендеринг экрана. В идеологии MVC — это Представление (View). Я назвал этот класс GameRender и унаследовал его от абстрактного класса Drawable, входящим в состав библиотеки SFML.
Ну и последний класс, который нам нужен — это класс, отвечающий за взаимодействие с игроком — это Контроллер (Controller). Здесь нужно сказать, что в классической идеологии MVC, контроллер не должен взаимодействовать с представлением напрямую. Он должен лишь воздействовать на модель, а представление считывает данные из модели самостоятельно, либо по сигналу системы сообщений. Но мне такой подход в данном случае показался несколько избыточным, поэтому я связал контроллер с представлением напрямую. Так что правильнее будет считать, что у нас не контроллер, а Presenter, согласно идеологии MVP. Класс тем не менее я назвал GameController.
Ну а теперь займемся увлекательным делом — раскидыванием нашего кода по этим классам.
Из добавленного мной в классе — это объявление члена класса font для хранение загруженного шрифта, и метод Load для его загрузки. Все остальное — это реализация синглтона Майерса.
Метод Load тоже крайне прост:
Пытается загрузить шрифт calibri и бросает исключение, если это сделать не удалось.
В класс помещена вся логика и все данные игры. Класс по-идее должен быть максимально независимым от внешнего окружения, поэтому в нем не должно быть никаких ссылок ни на вывод на экран, ни на взаимодействие с пользователем. При разработке помните, что модель игры должна оставаться работоспособной даже в случае изменения реализации классов представления или контроллера.
Все методы модели, в принципе, были описаны в прошлой статье, поэтому не буду здесь повторяться. Единственное скажу, что в модель были добавлены геттеры IsSolved и Elements для нужд класса представления.
Назначение класса GameRender — инкапсулировать в себе все данные для отображения окна игры и отрисовке игрового поля. Для связки с моделью игры, в конструкторе передается и сохраняется указатель на объект модели для данного представления. В конструкторе также вызывается метод Init, который создает и инициализирует окно игры
Метод Render() будет вызываться из цикла обработки сообщений в контроллере игры для отрисовки окна и состояния игрового поля:
Последний класс, использующийся в игре — это контроллер.
Класс довольно простой и содержит всего один метод, который и запускает игру — метод Run(). В конструкторе контроллер принимает и сохраняет указатели на экземпляры класса модели и класса представления игры.
Метод Run() содержит главный цикл игры — обработку сообщений и вызов отрисовки окна в классе представления.
Ну и напоследок осталась функция main()
В ней просто создание объектов и запуск игры.
В итоге, после рефакторинга мы получили код, разделенный на классы, каждый из которых имеет свое функциональное назначение. В случае доработок игры, нам будет проще менять содержимое отдельных частей программы.
В заключение статьи попробую кратко сформулировать некоторые правила декомпозиции кода.
Все исходники программы можно взять здесь.
Первым делом я примерно прикинул, какие мне нужны классы для игры. У меня получилось, что нужен класс для работы с ресурсами игры — Assets. Из ресурсов у меня сейчас есть только загружаемый шрифт, но в будущем могут добавиться и другие ресурсы, например изображения, музыка и.т.д. Класс я сделал синглтоном, так как этот шаблон прекрасно подходит для класса Assets. За основу взял широко известный синглтон Майерса.
Дальше необходим, собственно, класс игры, отвечающий за логику работы программы и хранение ее состояния. С точки зрения идеологии MVC, этот класс — модель. Так я его и назвал — GameModel.
Для визуального отображения игры нужен класс, отвечающий за рендеринг экрана. В идеологии MVC — это Представление (View). Я назвал этот класс GameRender и унаследовал его от абстрактного класса Drawable, входящим в состав библиотеки SFML.
Ну и последний класс, который нам нужен — это класс, отвечающий за взаимодействие с игроком — это Контроллер (Controller). Здесь нужно сказать, что в классической идеологии MVC, контроллер не должен взаимодействовать с представлением напрямую. Он должен лишь воздействовать на модель, а представление считывает данные из модели самостоятельно, либо по сигналу системы сообщений. Но мне такой подход в данном случае показался несколько избыточным, поэтому я связал контроллер с представлением напрямую. Так что правильнее будет считать, что у нас не контроллер, а Presenter, согласно идеологии MVP. Класс тем не менее я назвал GameController.
Ну а теперь займемся увлекательным делом — раскидыванием нашего кода по этим классам.
Класс Assets
#pragma once
#include <SFML/Graphics.hpp>
class Assets
{
public:
sf::Font font;
public:
static Assets& Instance()
{
static Assets s;
return s;
}
void Load();
private:
Assets() {};
~Assets() {};
Assets(Assets const&) = delete;
Assets& operator= (Assets const&) = delete;
};
Из добавленного мной в классе — это объявление члена класса font для хранение загруженного шрифта, и метод Load для его загрузки. Все остальное — это реализация синглтона Майерса.
Метод Load тоже крайне прост:
void Assets::Load()
{
if (!font.loadFromFile("calibri.ttf")) throw;
}
Пытается загрузить шрифт calibri и бросает исключение, если это сделать не удалось.
Класс GameModel
#pragma once
enum class Direction { Left = 0, Right = 1, Up = 2, Down = 3 };
class GameModel
{
public:
static const int SIZE = 4; // Размер игрового поля в плашках
static const int ARRAY_SIZE = SIZE * SIZE; // Размер массива
static const int FIELD_SIZE = 500; // Размер игрового поля в пикселях
static const int CELL_SIZE = 120; // Размер плашки в пикселях
protected:
int elements[ARRAY_SIZE];
int empty_index;
bool solved;
public:
GameModel();
void Init();
bool Check();
void Move(Direction direction);
bool IsSolved() { return solved; }
int* Elements() { return elements; }
};
В класс помещена вся логика и все данные игры. Класс по-идее должен быть максимально независимым от внешнего окружения, поэтому в нем не должно быть никаких ссылок ни на вывод на экран, ни на взаимодействие с пользователем. При разработке помните, что модель игры должна оставаться работоспособной даже в случае изменения реализации классов представления или контроллера.
Все методы модели, в принципе, были описаны в прошлой статье, поэтому не буду здесь повторяться. Единственное скажу, что в модель были добавлены геттеры IsSolved и Elements для нужд класса представления.
Класс GameRender
#pragma once
#include <SFML/Graphics.hpp>
#include "GameModel.h"
class GameRender : public sf::Drawable, public sf::Transformable
{
GameModel *m_game;
sf::RenderWindow m_window;
sf::Text m_text;
public:
GameRender(GameModel *game);
~GameRender();
sf::RenderWindow& window() { return m_window; };
bool Init();
void Render();
public:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const;
};
Назначение класса GameRender — инкапсулировать в себе все данные для отображения окна игры и отрисовке игрового поля. Для связки с моделью игры, в конструкторе передается и сохраняется указатель на объект модели для данного представления. В конструкторе также вызывается метод Init, который создает и инициализирует окно игры
GameRender::GameRender(GameModel *game)
{
m_game = game;
Init();
}
bool GameRender::Init()
{
setPosition(50.f, 50.f);
// Создаем окно размером 600 на 600 и частотой обновления 60 кадров в секунду
m_window.create(sf::VideoMode(600, 600), "15");
m_window.setFramerateLimit(60);
// Текст с обозначением клавиш
m_text = sf::Text("F2 - New Game / Esc - Exit / Arrow Keys - Move Tile", Assets::Instance().font, 20);
m_text.setFillColor(sf::Color::Cyan);
m_text.setPosition(5.f, 5.f);
return true;
}
Метод Render() будет вызываться из цикла обработки сообщений в контроллере игры для отрисовки окна и состояния игрового поля:
void GameRender::Render()
{
m_window.clear();
m_window.draw(*this);
m_window.draw(m_text);
m_window.display();
}
void GameRender::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
states.transform *= getTransform();
sf::Color color = sf::Color(200, 100, 200);
// Рисуем рамку игрового поля
sf::RectangleShape shape(sf::Vector2f(GameModel::FIELD_SIZE, GameModel::FIELD_SIZE));
shape.setOutlineThickness(2.f);
shape.setOutlineColor(color);
shape.setFillColor(sf::Color::Transparent);
target.draw(shape, states);
// Подготавливаем рамку для отрисовки всех плашек
shape.setSize(sf::Vector2f(GameModel::CELL_SIZE - 2, GameModel::CELL_SIZE - 2));
shape.setOutlineThickness(2.f);
shape.setOutlineColor(color);
shape.setFillColor(sf::Color::Transparent);
// Подготавливаем текстовую заготовку для отрисовки номеров плашек
sf::Text text("", Assets::Instance().font, 52);
int *elements = m_game->Elements();
for (unsigned int i = 0; i < GameModel::ARRAY_SIZE; i++)
{
shape.setOutlineColor(color);
text.setFillColor(color);
text.setString(std::to_string(elements[i]));
if (m_game->IsSolved())
{
// Решенную головоломку выделяем другим цветом
shape.setOutlineColor(sf::Color::Cyan);
text.setFillColor(sf::Color::Cyan);
}
else if (elements[i] == i + 1)
{
// Номера плашек на своих местах выделяем цветом
text.setFillColor(sf::Color::Green);
}
// Рисуем все плашки, кроме пустой
if (elements[i] > 0)
{
// Вычисление позиции плашки для отрисовки
sf::Vector2f position(i % GameModel::SIZE * GameModel::CELL_SIZE + 10.f,
i / GameModel::SIZE * GameModel::CELL_SIZE + 10.f);
shape.setPosition(position);
// Позицию текста подбирал вручную
text.setPosition(position.x + 30.f + (elements[i] < 10 ? 15.f : 0.f), position.y + 25.f);
target.draw(shape, states);
target.draw(text, states);
}
}
}
Класс GameController
Последний класс, использующийся в игре — это контроллер.
#pragma once
#include <SFML/Graphics.hpp>
#include "GameRender.h"
class GameController
{
GameModel *m_game;
GameRender *m_render;
public:
GameController(GameModel *game, GameRender *render);
~GameController();
void Run();
};
Класс довольно простой и содержит всего один метод, который и запускает игру — метод Run(). В конструкторе контроллер принимает и сохраняет указатели на экземпляры класса модели и класса представления игры.
Метод Run() содержит главный цикл игры — обработку сообщений и вызов отрисовки окна в классе представления.
void GameController::Run()
{
sf::Event event;
int move_counter = 0; // Счетчик случайных ходов для перемешивания головоломки
while (m_render->window().isOpen())
{
while (m_render->window().pollEvent(event))
{
if (event.type == sf::Event::Closed) m_render->window().close();
if (event.type == sf::Event::KeyPressed)
{
// Получаем нажатую клавишу - выполняем соответствующее действие
if (event.key.code == sf::Keyboard::Escape) m_render->window().close();
if (event.key.code == sf::Keyboard::Left) m_game->Move(Direction::Left);
if (event.key.code == sf::Keyboard::Right) m_game->Move(Direction::Right);
if (event.key.code == sf::Keyboard::Up) m_game->Move(Direction::Up);
if (event.key.code == sf::Keyboard::Down) m_game->Move(Direction::Down);
// Новая игра
if (event.key.code == sf::Keyboard::F2)
{
m_game->Init();
move_counter = 100;
}
}
}
// Если счетчик ходов больше нуля, продолжаем перемешивать головоломку
if (move_counter-- > 0) m_game->Move((Direction)(rand() % 4));
// Выполняем необходимые действия по отрисовке
m_render->Render();
}
}
Ну и напоследок осталась функция main()
#include "Assets.h"
#include "GameModel.h"
#include "GameRender.h"
#include "GameController.h"
int main()
{
Assets::Instance().Load(); // Загружаем ресурсы
GameModel game; // Создаем модель игры
GameRender render(&game); // Создаем представление
GameController controller(&game, &render); // Создаем контроллер
controller.Run(); // Запускаем игру
return 0;
}
В ней просто создание объектов и запуск игры.
В итоге, после рефакторинга мы получили код, разделенный на классы, каждый из которых имеет свое функциональное назначение. В случае доработок игры, нам будет проще менять содержимое отдельных частей программы.
В заключение статьи попробую кратко сформулировать некоторые правила декомпозиции кода.
- Каждый класс должен иметь одно предназначение. Не нужно делать суперкласс, который может все, иначе вы с ним сами не справитесь в будущем
- При выделении классов следите за поддержанием слабой связанности классов. Постоянно мысленно представляйте, что реализация класса может стать кардинально другой. Это тем не менее не должно повлиять на другие классы проекта. Для взаимодействия между классами используйте интерфейсы
- Используйте паттерны проектирования, где это необходимо. Это позволит избежать лишних ошибок при реализации давно отработанных решений
Все исходники программы можно взять здесь.