现在的位置: 首页 > 综合 > 正文

多态状态机

2014年08月29日 ⁄ 综合 ⁄ 共 9644字 ⁄ 字号 评论关闭

-潘宏

-2012年12月

-本人水平有限,疏忽错误在所难免,还请各位高手不吝赐教

-email: popyy@netease.com

-weibo.com/panhong101

任何一款游戏产品,都需要在几种界面之间进行转换:logo、trailer、main menu、in-game、settings menu等等,并且会在这些转换之间处理资源问题。对于实现这样的转换,不同的游戏做法有所差异,但基本上会实现一个游戏状态机系统。状态机系统在游戏开发中根深蒂固,以至于该系统应该是游戏引擎不可或缺的一个核心部件。

简单游戏状态机结构

状态机的实现方法有很多。相对简单的有switch-case方法,它通过对游戏状态进行枚举化来进行选择判断。下面的示例代码展示了这一点:


  1. enum GameState  
  2. {  
  3.     GAME_STATE_LOGO = 0,  
  4.     GAME_STATE_TRAILER,  
  5.     GAME_STATE_MAIN_MENU,  
  6.     GAME_STATE_INGAME,  
  7.     GAME_STATE_SETTINGS_MENU,  
  8. };  
  9.   
  10. void gameCycle( int gameState )  
  11. {  
  12.     switch( gameState )  
  13.     {  
  14.         case GAME_STATE_LOGO: {...}  
  15.         case GAME_STATE_TRAILER: {...}  
  16.         case GAME_STATE_MAIN_MENU: {...}  
  17.         case GAME_STATE_INGAME: {...}  
  18.         case GAME_STATE_SETTINGS_MENU: {...}  
  19.     }  
  20. }  


这就是一个相当简单的游戏状态机系统,实现起来很直接、简洁。我们在几年前的一个java引擎中就使用了这样的一个状态机系统(当然,实际代码要比这复杂一些,但结构是这样的)。它表现得很好,能够满足大多数的需求——有好几个商业游戏都使用了这个结构。

可是,在那之后,我们在一个新的C++引擎中,却放弃了这种方法。我们的理由主要有以下几点:

1)该方法不是OO的,我们的引擎是完全OO的。

2)该系统难以维护——所有的状态判断都在gameCycle的switch-case中,我们每增加或者修改一个状态,都需要在enum和gameCycle中增加新的代码,这会导致大量的重新编译。

3)大量的状态逻辑被集中到了switch-case中,导致代码臃肿,难以维护。

4)我们希望把每一个game state逻辑交给一个工程师来编写,这让我们很难做到。

5)“switch-case在OO中是一种‘坏味道’”思潮影响。

考虑到上面的几个原因,我们开始探索新的实现方式,然后,我们就有了一个新的、基于多态性的游戏状态机系统。

状态机基本结构设计

该系统的一个基本结构如下所示:

State manager就是状态管理器(后面简称manager),它聚合并管理多个game state(后面简称state)。注意,Manager只聚合state的基类指针,而state拥有自己的类体系。因此,manager通过多态的方式处理各种state。

该方法实际上实际上是一种state模式(如果对该模式感兴趣,请参考GoF的《设计模式》)。这里StateMgr相当于该模式的Context类,而GameState相当于该模式的State类。

我们的类初步设计如下:


  1. class GameState  
  2. {  
  3. public:  
  4.     virtual ~GameState() {}  
  5.   
  6.     virtual void cycle() = 0;  
  7.     virtual void draw( GraphicsContext& g ) = 0;  
  8. };  
  9.   
  10. class StateMgr  
  11. {  
  12. public:  
  13.     void addState( GameState* state )  
  14.     {  
  15.         m_states.push_back( state );  
  16.     }  
  17.   
  18.       
  19.     void cycle()  
  20.     {  
  21.         m_curState->cycle();  
  22.     }  
  23.   
  24.     void draw( GraphicsContext& g )  
  25.     {  
  26.         m_curState->draw( g );  
  27.     }  
  28.       
  29.   
  30. private:  
  31.     std::set< GameState* >        m_states;  
  32.     GameState*              m_curState;  
  33. };  


从代码中可以很容易看出该系统的工作原理。

GameState是state的base class,提供了GameState::cycle和GameState::draw两个方法,分别处逻辑更新和渲染两种工作。该base class是抽象的——只允许完成具体工作的derived class进行实例化。

StateMgr就是manager类,它通过m_states保存所有状态,并对当前状态m_curState进行更新和渲染。StateMgr::addState方法用语增加新的游戏状态。

我们看GameState的具体类的一个例子:


  1. class GameState_Logo : public GameState  
  2. {  
  3. public:  
  4.     GameState_Logo()  
  5.     {  
  6.         Init m_logoImage and m_logoPos...  
  7.     }  
  8.   
  9.     virtual void cycle()  
  10.     {  
  11.         if( m_logoPos is not identical to the screen center )  
  12.         {  
  13.             make m_logoPos close to the screen center...  
  14.         }  
  15.     }  
  16.   
  17.     virtual void draw( GraphicsContext& g )  
  18.     {  
  19.         draw m_logoImage at m_logoPos...  
  20.     }  
  21.   
  22. private:  
  23.     Image*      m_logoImage;  
  24.     Point2D     m_logoPos;  
  25. };  


上面的类处理进入游戏之后的logo界面。GameState_Logo的ctor初始化logo图片和位置这两个成员。GameState_Logo::cycle将logo的位置逐帧移动到屏幕中心。GameState_Logo::draw则在当前位置画出logo图片。

基本结构就是这样,简单吧!对于游戏不同状态的编写,基本上就是对不同的GameState子类进行实现。一个典型的游戏状态体系如下所示:


这样一个结构设计的好处是什么呢?

1)StateMgr只依赖GameState,和GameState的derived class没有耦合。

2)增加任何一个新的state,都不会影响manager,不会导致额外的重新编译。

3)state模式的全部优势。

4)该方法是完全OO的。

坏处呢?

1)使用了virtual function抽象,增加了间接层开销。

2)增加了大量的类源文件,实现起来不够紧凑。

现在,我们已经有了基本的结构。接下来要做的,就是在这些state之间进行转换。

游戏状态转换设计

游戏中的状态转换都会形成一个树形结构——游戏状态树。下图就是一个典型的游戏状态树:


在游戏中,某个时刻只有当前state在运行。因此,游戏将会在树上进行状态转换。比如我们刚刚进入游戏之后,会进入logo界面,然后转到trailer界面,接下来是主菜单,这几步都是不可逆的。然后玩家可以选择in-game(进入游戏)、credits(制作团队介绍)和settings(设置)这三个状态,并且可以从这三个状态返回主菜单状态。在in-game状态下可以进入pause menu(暂停菜单)并返回。

此外,我们有时候需要在一种状态下显示另一种状态。比如在pause menu中显示暂停选项的时候仍然显示游戏背景(用某种颜色的全屏幕半透明矩形覆盖使其暗化,并且游戏逻辑此时不会更新),如下图所示:

这意味着给state增加一个parent pointer会很方便:


  1. class GameState  
  2. {  
  3.   
  4. // ...as above  
  5.   
  6. public:  
  7.     void setParent( GameState* state ) { m_parent = state; }  
  8.     GameState* getParent() { return m_parent; }  
  9. private:  
  10.     GameState*      m_parent;  
  11. };  


这样,我们可以这样实现pause menu的draw方法:

  1. void GameState_PauseMenu::draw( GraphicsContext& g )  
  2. {  
  3.     m_parent->draw( g );  
  4.     draw the transparent mask layer...  
  5.     draw pause menu items...  
  6. }  

我们首先渲染parent,对于pause menu状态来说,它的parent就是in-game状态。然后渲染半透明覆盖层。最后渲染pause menu的选项。

此外,parent pointer对于状态的转换也是非常方便的。

为了能够方便地操纵游戏状态在状态树上进行转换,我们扩展manager类:

  1. class StateMgr  
  2. {  
  3.   
  4. // ...as above  
  5.   
  6. public:  
  7.     enum StateOP  
  8.     {  
  9.         STATE_OP_PUSH = 0,  
  10.         STATE_OP_POP,  
  11.     };  
  12.       
  13. public:  
  14.     void changeState( GameState* newState, int op )  
  15.     {  
  16.         if( op == STATE_OP_PUSH )  
  17.         {  
  18.             newState->setParent( m_curState );  
  19.             m_curState = newState;  
  20.         }  
  21.         else if( op == STATE_OP_POP )  
  22.         {  
  23.             m_curState = m_curParent->getParent();  
  24.         }  
  25.     }  
  26. };  


我们增加了state操作方法StateMgr::changeState并通过两个操作类型:push和pop,可以很方便地在状态树上移动,如下图所示:


Loading状态

以上设计有一个很大的问题,你能看出来吗?似乎所有的state同时存在,这将导致大量的资源存在于内存中。就算是当进入到main menu状态之后,我们再也无法返回trailer或者logo状态,它们的资源也还驻留在内存里。因此,我们需要把这些状态划分阶段(phase),只让当前一个phase内的所有state留在内存里。当游戏从一个phase转到另一个phase的时候,会释放旧phase资源,然后载入新phase资源。这通过一个叫做GameState_Loading的类来实现。在释放旧资源和载入新资源的过程中,GameState_Loading将接管局面,并显示载入进度界面。我们先把目前的状态树划分phase如下:


整个状态树被划分为4个phase:

logo(logo)

trailer(trailer)

main menu(main menu, credits, settings menu)

in-game(in-game, pause menu)

括号里面的就是该phase所包含的状态,会在一个loading过程中全部驻留内存。每一个phase实际上都形成一个子树,通过一个stack结构和上面的push、pop操作进行转换。我们扩展上面的类如下:


  1. class GameState  
  2. {  
  3. // ...as above  
  4.   
  5. public:  
  6.     int getStateOP() const { return m_stateOP; }  
  7.     int getNextPhase() const { return m_phaseToLoad; }  
  8.   
  9. protected:  
  10.     int m_stateOP;  
  11.     int m_phaseToLoad;  
  12.   
  13. };  
  14.   
  15. class GameState_Loading : public GameState  
  16. {  
  17. public:  
  18.     enum Phase  
  19.     {  
  20.         PHASE_LOGO = 0,  
  21.         PHASE_TRAILER,  
  22.         PHASE_MAIN_MENU,  
  23.         PHASE_INGAME,  
  24.     };  
  25.   
  26. public:  
  27.     void setNextPhase( int phase ) { m_phaseToLoad = phase; }  
  28.     GameState* getNextState() { return m_nextState; }  
  29.   
  30.     virtual void cycle()  
  31.     {  
  32.         free the old phase...  
  33.         init the new phase frame by frame...  
  34.         save the new states to StateMgr::m_states...  
  35.   
  36.         if( initialization is completed )  
  37.         {  
  38.             m_nextState = default state of the phase  
  39.             m_stateOP = StateMgr::STATE_OP_NEW_STACK;  
  40.         }  
  41.     }  
  42.   
  43.     virtual void draw( GraphicsContext& g )  
  44.     {  
  45.         draw the progress interface...  
  46.     }  
  47.   
  48. private:  
  49.     int m_phaseToLoad;  
  50.     GameState* m_nextState;  
  51. };  
  52.   
  53. class StateMgr  
  54. {  
  55.   
  56. // ...as above  
  57.   
  58. public:  
  59.     enum StateOP  
  60.     {  
  61.         STATE_OP_NONE = -1,  
  62.         STATE_OP_PUSH = 0,  
  63.         STATE_OP_POP,  
  64.         STATE_OP_LOAD,  
  65.         STATE_OP_NEW_STACK,  
  66.     };  
  67. public:  
  68.     void cycle()  
  69.     {  
  70.         // ...as above  
  71.   
  72.         leaveFrame();  
  73.     }  
  74.   
  75. private:  
  76.     void leaveFrame()  
  77.     {  
  78.         if( m_curState->getStateOP() != STATE_OP_NONE )  
  79.         {  
  80.             if( m_curState->getStateOP() == STATE_OP_LOAD )  
  81.             {  
  82.                 GameState_Loading* state = new GameState_Loading;  
  83.                 state->setNextPhase( m_curState->getNextPhase() );  
  84.                 m_curState  = state;  
  85.             }  
  86.             else if( m_curState->getStateOP() == STATE_OP_NEW_STACK )  
  87.             {  
  88.                 GameState_Loading* state = static_cast< GameState_Loading*>( m_curState );  
  89.                 changeState( state->getNextState(), STATE_OP_PUSH );  
  90.                 delete state;  
  91.             }  
  92.         }  
  93.     }  
  94. };  


GameState_Loading类处理所有的状态转换工作,这当然包括旧资源释放和新资源初始化,同时绘制loading界面。

StateMgr新增了两个操作方式。StateMgr::STATE_OP_LOAD就是开始建立一个新的phase,也就是从旧phase进入loading状态,然后进行资源载入和新phase中各个state的建立等工作,这些工作在GameState_Loading::cycle中逐帧完成。StateMgr::STATE_OP_NEW_STACK表示从当前loading状态进入到新建立的phase的默认state中。

StateMgr::cycle方法中新增加调用一个新加入的方法StateMgr::leaveFrame。该方法用于在离开当前帧的时候做一些事情。在这里我们主要处理state转换。

GameState增加了两个成员,m_stateOP用于告诉StateMgr是否需要转换到另一个phase,默认值是StateMgr::STATE_OP_NONE——什么也不做。m_phaseToLoad告诉StateMgr它要转换到哪一个phase。这些phase都定义在GameState_Loading中。比如在logo状态中需要转换到trailer状态,我们可以在GameState_Logo::cycle中写:

m_stateOP = StateMgr::STATE_OP_LOAD;

m_phaseToLoad = GameState_Loading::PHASE_TRAILER;

StateMgr::leaveFrame就会建立一个loading状态来进行状态转换。当GameState_Loading::cycle完成了初始化,它就会通过StateMgr::STATE_OP_NEW_STACK让流程进入新的phase的默认state中,正如上面代码所示。

(我在程序中使用了一些伪码来避免陷入过多细节,目的是更好的表达出这个结构的思路。如果你非常需要了解该系统的具体实现,可以和我联系)

改进方向

好了!我们已经完成了该系统的基本框架。读者完全可以根据该框架实现一个自己的游戏状态机,并取得良好的运行效果。但我还是要说,这和真正游戏中使用的工程级别代码比,还差一些!下面我会指出一些设计上的改进和扩展,让该系统更容易在游戏产品中使用。感兴趣的读者可以自行实现。

1 给GameState加上自定义“构造函数”和“析构函数”

如果能给state增加方法:

GameState::onActive

GameState::onUnactive

会让很多事情事半功倍,且可以得到良好的结构和健壮性。 在StateMgr::changeState中进行state转换(push和pop)的时候, 给即将停止的state调用onUnactive,给即将运行的state调用onActive,可以给这些state一个机会做一些构造和析构工作(比如释放和申请一些小资源,或重新初始化一些数据等等)。我们的代码就强烈地依赖这些方便的小方法。

2 增加state之间的界面过渡

很多游戏在界面过渡之间都使用了一些特效,最常见的就是淡入淡出效果。令人兴奋的是,通过上面的状态机系统增加这样的过渡效果非常方便。比如我们自己设计了一个叫做FullScreenEffect的基类,通过设计不同的子类来完成不同的过渡效果。

提示:在StateMgr里面合成该类的一个实例,然后在StateMgr::cycle和StateMgr::draw中调用FullScreenEffect::cycle和FullScreenEffect::draw方法,并通过一些标志来禁止和启动StateMgr::m_curState的更新和渲染。

3 通过事件分发系统进行状态改变通知

通过我们之前介绍的事件分发系统(http://blog.csdn.net/popy007/article/details/8242787)来通知系统进行state转换是个很不错的设计思路!

4 把StateMgr写成一个singleton

StateMgr应该只有一个且可以被方便地访问,写成一个singleton吧!(关于singleton模式,可以参考GoF的著作)

5 给loading状态增加一个资源载入管理器

在loading状态中,我们有时候需要画出当前的进度比例,这个比例如何计算出呢?很多游戏用的是假数据——只体现一个递增的效果。但还有些用的是真实数据,对于真实数据来说,该机制和你游戏的资源管理系统有很大关系,这里我提供一个简单思路。

我们将需要载入或申请的所有资源进行分类,比如:

字符串

纹理

关卡数据

逻辑脚本

缓存

自定义回调函数

...

给这些资源定义一个通用的结构,并用一个ID来区分。然后这些资源就有了一个统一的表示结构,比如

struct Res;

然后建立一个(你喜欢的任何容器都可以)

std::list< Res >

把所有要载入的资源全部放到这个list中,之后list.size()就是你要载入的所有资源数。在loading状态里面,每帧只处理一个Res。处理完毕后,就从这个list中把该Res删除。在这个过程中,你就可以知道当前的载入进度了。这个方法的好处在于避免了资源加载过程中多线程的使用。

总结

以上我们设计并实现了一个基本的游戏状态机系统——它很清晰、简洁,并有很强的扩展性。它基于state模式,提供了易于维护的系统结构。当然,该系统还有很大的提升空间,这完全取决于开发者的积累。

该系统已经在多个实际项目中使用,并获得了不错的效果。希望开发者能够从中得到设计灵感。

抱歉!评论已关闭.