Использование паттерна состояний в играх

Недавно я попытался создать игру 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" и выйдите из игрового меню в конечное меню.

Конечное меню возвращает новое меню для отображения, поэтому вы возвращаетесь к основному циклу и проверяете каждое доступное меню.

С таким дизайном вы можете добавить новое меню, не меняя всей архитектуры.

Другие вопросы по тегам