Использование паттерна состояний в играх
Недавно я попытался создать игру Snake в SFML. Однако я также хотел использовать некоторый шаблон проектирования, чтобы выработать хорошие привычки для будущего программирования - это был шаблон состояния. Но - есть проблема, которую я не могу решить.
Чтобы все было понятно, я попытался сделать несколько меню - одно главное меню, а другие вроде "Параметры" или что-то в этом роде. Первая опция главного меню переводит игрока в "Состояние игры". Но тут возникает проблема - я думаю, что вся игра должна быть независимым программным модулем. Итак, что мне делать с фактическим состоянием программы? (например, назовем это состояние "MainMenu").
Должен ли я создать дополнительное состояние под названием "PlayingState", которое будет представлять всю игру? Как бы я это сделал? Как можно добавить новую функциональность в одно состояние? Есть ли у вас какие-либо идеи?
2 ответа
Шаблон состояния позволяет вам, например, иметь объект класса Game
и изменять его поведение при изменении состояния игры, создавая иллюзию, что это Game
объект изменил свой тип.
В качестве примера представьте игру, в которой есть начальное меню, и ее можно приостановить во время игры, если вы нажмете клавишу пробела. Когда игра поставлена на паузу, вы можете вернуться в исходное меню, нажав клавишу возврата, или продолжить игру, снова нажав пробел:
Сначала мы определяем абстрактный класс, GameState
:
struct GameState {
virtual GameState* handleEvent(const sf::Event&) = 0;
virtual void update(sf::Time) = 0;
virtual void render() = 0;
virtual ~GameState() = default;
};
Все государственные классы - т.е. MenuState
, PlayingState
, PausedState
- публично извлечет из этого GameState
класс. Обратите внимание, чтоhandleEvent()
возвращает GameState *
; это для обеспечения переходов между состояниями (т. е. следующего состояния, если переход происходит).
Давайте сосредоточимся на Game
класс вместо этого. В конце концов, мы намерены использоватьGame
класс следующим образом:
auto main() -> int {
Game game;
game.run();
}
То есть в основном run()
функция-член, которая возвращается, когда игра окончена. Мы определяемGame
класс:
class Game {
public:
Game();
void run();
private:
sf::RenderWindow window_;
MenuState menuState_;
PausedState pausedState_;
PlayingState playingState_;
GameState *currentState_; // <-- delegate to the object pointed
};
Ключевым моментом здесь является currentState_
член данных. Всегда,currentState_
указывает на одно из трех возможных состояний игры (т. е. menuState_
, pausedState_
, playingState_
).
В run()
функция-член полагается на делегирование; он делегирует объекту, на который указываетcurrentState_
:
void Game::run() {
sf::Clock clock;
while (window_.isOpen()) {
// handle user-input
sf::Event event;
while (window_.pollEvent(event)) {
GameState* nextState = currentState_->handleEvent(event);
if (nextState) // must change state?
currentState_ = nextState;
}
// update game world
auto deltaTime = clock.restart();
currentState_->update(deltaTime);
currentState_->render();
}
}
Game::run()
называет GameState::handleEvent()
, GameState::update()
а также GameState::render()
функции-члены, которые каждый конкретный класс, производный от GameState
должен переопределить. То есть,Game
не реализует логику обработки событий, обновления состояния игры и рендеринга; он просто делегирует эти обязанностиGameState
объект, на который указывает его член данных currentState_
. Иллюзия того, чтоGame
похоже, меняет свой тип, когда его внутреннее состояние достигается посредством этого делегирования.
Теперь вернемся к конкретным состояниям. Мы определяемPausedState
класс:
class PausedState: public GameState {
public:
PausedState(MenuState& menuState, PlayingState& playingState):
menuState_(menuState), playingState_(playingState) {}
GameState* handleEvent(const sf::Event&) override;
void update(sf::Time) override;
void render() override;
private:
MenuState& menuState_;
PlayingState& playingState_;
};
PlayingState::handleEvent()
должен в какой-то момент вернуть следующее состояние для перехода, и это будет соответствовать либо Game::menuState_
или Game::playingState_
. Следовательно, эта реализация содержит ссылки как наMenuState
а также PlayingState
объекты; они будут указывать наGame::menuState_
а также Game::playingState_
члены данных в PlayState
строительство. Кроме того, когда игра приостановлена, мы в идеале хотим, чтобы экран, соответствующий состоянию игры, использовался в качестве отправной точки, как мы увидим ниже.
Реализация PauseState::update()
состоит в том, чтобы ничего не делать, игровой мир остается прежним:
void PausedState::update(sf::Time) { /* do nothing */ }
PausedState::handleEvent()
реагирует только на события нажатия клавиши пробела или возврата:
GameState* PausedState::handleEvent(const sf::Event& event) {
if (event.type == sf::Event::KeyPressed) {
if (event.key.code == sf::Keyboard::Space)
return &playingState_; // change to playing state
if (event.key.code == sf::Keyboard::Backspace) {
playingState_.reset(); // clear the play state
return &menuState_; // change to menu state
}
}
// remain in the current state
return nullptr; // no transition
}
PlayingState::reset()
для очистки PlayingState
в исходное состояние после создания, когда мы вернемся в исходное меню перед началом игры.
Наконец, определим PausedState::render()
:
void PausedState::render() {
// render the PlayingState screen
playingState_.render();
// render a whole window rectangle
// ...
// write the text "Paused"
// ...
}
Во-первых, эта функция-член отображает экран, соответствующий состоянию воспроизведения. Затем поверх этого отображаемого экрана состояния воспроизведения он отображает прямоугольник с прозрачным фоном, который соответствует всему окну; таким образом мы затемняем экран. Поверх этого визуализированного прямоугольника он может отображать что-то вроде текста "Пауза".
Стек состояний
Другая архитектура состоит из стека состояний: состояния складываются поверх других состояний. Например, состояние паузы будет находиться поверх состояния воспроизведения. События доставляются из самого верхнего состояния в самое нижнее, равно как и состояния обновляются. Рендеринг выполняется снизу вверх.
Этот вариант можно рассматривать как обобщение случая, описанного выше, поскольку вы всегда можете иметь - как частный случай - стек, который состоит только из одного объекта состояния, и этот случай будет соответствовать обычному шаблону состояния.
Если вам интересно узнать больше об этой другой архитектуре, я бы рекомендовал прочитать пятую главу книги " Разработка игр SFML".
Для вашего дизайна, я думаю, вы можете использовать инкрементный цикл для другого состояния:
Простой пример:
// main loop
while (window.isOpen()) {
// I tink you can simplify this "if tree"
if (state == "MainMenu")
state = run_main_menu(/* args */);
else if (state == "Play")
state = run_game(/* args */);
// Other state here
else
// error state unknow
// exit the app
}
И когда игра запущена:
state run_game(/* args */)
{
// loading texture, sprite,...
// or they was passe in args
while (window.isOpen()) {
while (window.pollEvent(event)) {
// checking event for your game
}
// maybe modifying the state
// Display your game
// Going to the end game menu if the player win/loose
if (state == "End")
return run_end_menu(/* args */);
// returning the new state, certainly MainMenu
else if (state != "Play")
return state;
}
}
У вас есть главное меню и игра, ваше состояние по умолчанию "MainMenu"
.
Когда вы входите в главное меню, вы нажимаете кнопку воспроизведения, затем состояние возвращается "Play"
и вы вернетесь в основной цикл.
Состояние "Play"
Итак, вы переходите в меню игры и начинаете игру.
Когда игра заканчивается, вы меняете свое состояние на "EndGame"
и выйдите из игрового меню в конечное меню.
Конечное меню возвращает новое меню для отображения, поэтому вы возвращаетесь к основному циклу и проверяете каждое доступное меню.
С таким дизайном вы можете добавить новое меню, не меняя всей архитектуры.