一个多态性的游戏状态机系统

-潘宏

-2012年12月

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

-email: [email protected]

-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模式,提供了易于维护的系统结构。当然,该系统还有很大的提升空间,这完全取决于开发者的积累。


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

你可能感兴趣的:(设计模式在游戏开发中的使用)