在游戏中使用状态模式

3

最近,我试图在SFML中创建贪吃蛇游戏。然而,我也想使用一些设计模式来为未来编程打下良好的基础——这就是状态模式。但是,有一个问题我无法解决。

为了让一切清晰明了,我试图制作几个菜单——一个主菜单和其他菜单,比如“选项”之类的。主菜单的第一个选项会带玩家进入“Playing State”。但是,问题出现了——我认为整个游戏应该是一个独立的模块实现,那么现在程序所处的实际状态(例如,我们称之为“MainMenu”)应该怎么办呢?

我应该再创建一个名为“PlayingState”的状态,代表整个游戏吗?我该如何做?如何给单个状态添加新功能?你有什么想法吗?

2个回答

7
状态模式允许您拥有一个类的对象Game,并在游戏状态改变时更改其行为,从而产生这个Game对象已更改其类型的幻觉。
例如,想象一个具有初始菜单并且可以在玩游戏时按空格键暂停的游戏。当游戏暂停时,您可以通过按回退键返回到初始菜单,或者通过再次按空格键继续游戏: State-Diagram 首先,我们定义一个抽象类GameState
struct GameState {
    virtual GameState* handleEvent(const sf::Event&) = 0;
    virtual void update(sf::Time) = 0;
    virtual void render() = 0;
    virtual ~GameState() = default; 
};

所有状态类,即MenuStatePlayingStatePausedState,都将公开派生自这个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不实现处理事件、更新游戏状态和渲染的逻辑,它只将这些责任委托给其数据成员currentState_所指向的GameState对象。通过这种委托,当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_。因此,此实现包含对MenuStatePlayingState对象的引用;它们将在PlayState的构造函数中设置为指向Game::menuState_Game::playingState_数据成员。此外,在游戏暂停时,我们理想情况下希望以播放状态对应的屏幕作为起点进行渲染,如下所示。 PauseState::update()的实现什么也不做,游戏世界保持不变。
void PausedState::update(sf::Time) { /* do nothing */ }
< p > 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游戏开发 的第五章。

1
你的回答非常详细和有用!不过,我该如何了解更多关于你提到的第一种架构呢?你是从哪里学习到的? - Thorvas
为什么在 PausedState 中你有对 menuState_playingState_ 的私有引用,但是在 Game 类中你创建整个对象而不是引用? - Thorvas
第一种架构是状态模式。一个Game对象拥有所有状态对象(即类型为MenuStatePlayingStatePausedState的对象),而PausedState则没有;但是,它需要引用另外两个状态,因为它可以使Game对象转换到这些状态之一:当处于暂停状态时,如果按下空格键,游戏将继续进行,而如果按下退格键,则返回菜单。我建议您查看PausedState::handleEvent(),它返回指向下一个状态对象的GameState * - JFMR
@Thorvas 无论如何,我强烈建议您查看由状态栈组成的架构,因为它通常更适合处理游戏具有的状态。例如,回到所有权问题,状态堆栈架构不需要PausedState拥有一个PlayingState对象。相反,状态堆栈中的PausedState对象将位于PlayingState对象的顶部。 - JFMR

1

针对您的设计,我认为您可以使用增量循环来处理不同的状态:

简单示例:

// 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",并退出游戏菜单到结束菜单。
结束菜单返回要显示的新菜单,因此您回到主循环并检查每个可用菜单。
通过这种设计,您可以添加新菜单而无需更改整个架构。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接