-潘宏
-2012年12月
-本人水平有限,疏忽错误在所难免,还请各位高手不吝赐教
-email: popyy@netease.com
-weibo.com/panhong101
任何一款游戏产品,都需要在几种界面之间进行转换:logo、trailer、main menu、in-game、settings menu等等,并且会在这些转换之间处理资源问题。对于实现这样的转换,不同的游戏做法有所差异,但基本上会实现一个游戏状态机系统。状态机系统在游戏开发中根深蒂固,以至于该系统应该是游戏引擎不可或缺的一个核心部件。
简单游戏状态机结构
状态机的实现方法有很多。相对简单的有switch-case方法,它通过对游戏状态进行枚举化来进行选择判断。下面的示例代码展示了这一点:
enum GameState
{
GAME_STATE_LOGO = 0,
GAME_STATE_TRAILER,
GAME_STATE_MAIN_MENU,
GAME_STATE_INGAME,
GAME_STATE_SETTINGS_MENU,
};
void gameCycle( int gameState )
{
switch( gameState )
{
case GAME_STATE_LOGO: {...}
case GAME_STATE_TRAILER: {...}
case GAME_STATE_MAIN_MENU: {...}
case GAME_STATE_INGAME: {...}
case GAME_STATE_SETTINGS_MENU: {...}
}
}
这就是一个相当简单的游戏状态机系统,实现起来很直接、简洁。我们在几年前的一个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类。
我们的类初步设计如下:
class GameState
{
public:
virtual ~GameState() {}
virtual void cycle() = 0;
virtual void draw( GraphicsContext& g ) = 0;
};
class StateMgr
{
public:
void addState( GameState* state )
{
m_states.push_back( state );
}
void cycle()
{
m_curState->cycle();
}
void draw( GraphicsContext& g )
{
m_curState->draw( g );
}
private:
std::set< GameState* > m_states;
GameState* m_curState;
};
从代码中可以很容易看出该系统的工作原理。
GameState是state的base class,提供了GameState::cycle和GameState::draw两个方法,分别处逻辑更新和渲染两种工作。该base class是抽象的——只允许完成具体工作的derived class进行实例化。
StateMgr就是manager类,它通过m_states保存所有状态,并对当前状态m_curState进行更新和渲染。StateMgr::addState方法用语增加新的游戏状态。
我们看GameState的具体类的一个例子:
class GameState_Logo : public GameState
{
public:
GameState_Logo()
{
Init m_logoImage and m_logoPos...
}
virtual void cycle()
{
if( m_logoPos is not identical to the screen center )
{
make m_logoPos close to the screen center...
}
}
virtual void draw( GraphicsContext& g )
{
draw m_logoImage at m_logoPos...
}
private:
Image* m_logoImage;
Point2D m_logoPos;
};
上面的类处理进入游戏之后的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会很方便:
class GameState
{
// ...as above
public:
void setParent( GameState* state ) { m_parent = state; }
GameState* getParent() { return m_parent; }
private:
GameState* m_parent;
};
这样,我们可以这样实现pause menu的draw方法:
void GameState_PauseMenu::draw( GraphicsContext& g )
{
m_parent->draw( g );
draw the transparent mask layer...
draw pause menu items...
}
我们首先渲染parent,对于pause menu状态来说,它的parent就是in-game状态。然后渲染半透明覆盖层。最后渲染pause menu的选项。
此外,parent pointer对于状态的转换也是非常方便的。
为了能够方便地操纵游戏状态在状态树上进行转换,我们扩展manager类:
class StateMgr
{
// ...as above
public:
enum StateOP
{
STATE_OP_PUSH = 0,
STATE_OP_POP,
};
public:
void changeState( GameState* newState, int op )
{
if( op == STATE_OP_PUSH )
{
newState->setParent( m_curState );
m_curState = newState;
}
else if( op == STATE_OP_POP )
{
m_curState = m_curParent->getParent();
}
}
};
我们增加了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操作进行转换。我们扩展上面的类如下:
class GameState
{
// ...as above
public:
int getStateOP() const { return m_stateOP; }
int getNextPhase() const { return m_phaseToLoad; }
protected:
int m_stateOP;
int m_phaseToLoad;
};
class GameState_Loading : public GameState
{
public:
enum Phase
{
PHASE_LOGO = 0,
PHASE_TRAILER,
PHASE_MAIN_MENU,
PHASE_INGAME,
};
public:
void setNextPhase( int phase ) { m_phaseToLoad = phase; }
GameState* getNextState() { return m_nextState; }
virtual void cycle()
{
free the old phase...
init the new phase frame by frame...
save the new states to StateMgr::m_states...
if( initialization is completed )
{
m_nextState = default state of the phase
m_stateOP = StateMgr::STATE_OP_NEW_STACK;
}
}
virtual void draw( GraphicsContext& g )
{
draw the progress interface...
}
private:
int m_phaseToLoad;
GameState* m_nextState;
};
class StateMgr
{
// ...as above
public:
enum StateOP
{
STATE_OP_NONE = -1,
STATE_OP_PUSH = 0,
STATE_OP_POP,
STATE_OP_LOAD,
STATE_OP_NEW_STACK,
};
public:
void cycle()
{
// ...as above
leaveFrame();
}
private:
void leaveFrame()
{
if( m_curState->getStateOP() != STATE_OP_NONE )
{
if( m_curState->getStateOP() == STATE_OP_LOAD )
{
GameState_Loading* state = new GameState_Loading;
state->setNextPhase( m_curState->getNextPhase() );
m_curState = state;
}
else if( m_curState->getStateOP() == STATE_OP_NEW_STACK )
{
GameState_Loading* state = static_cast< GameState_Loading*>( m_curState );
changeState( state->getNextState(), STATE_OP_PUSH );
delete state;
}
}
}
};
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模式,提供了易于维护的系统结构。当然,该系统还有很大的提升空间,这完全取决于开发者的积累。
该系统已经在多个实际项目中使用,并获得了不错的效果。希望开发者能够从中得到设计灵感。