忏悔时间:我有点过分了在这章塞下太多东西。表面我是讨论状态模式,但是我不可能在讨论这个时不涉及到更基本的有限状态机(FSM)的概念。一旦我讲到那了,我发现我又不得不介绍分层状态机(hierarchical state mechine)和下推自动机(pushdown automata)。
要讲的东西太多了,所以为了使篇幅尽可能短,一些代码例子省略了一些细节,这些需要你自己补全。我希望这些仍然是简单清晰的使你了解框架。
不要感到悲伤如果你没听过状态机。熟知AI和编译器的程序员,也对其他圈子不甚了解。我认为他们了解的比较广了,所以我这次抛给他们一个不一样的问题。
We've all been there
我们做一个横版小游戏。我们的工作是实现玩家控制的女英雄。这意味着要使其响应玩家的输入。按下“B”,她应该跳起来。很简单:
void Heroine::handleInput(Input input) { if (input == PRESS_B) { yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } }
找到bug了没?
没有任何阻止“空中跳”的条件-当她在空中时不断地敲击“B”键,然后她就会一直漂浮在空中。简单地修补是为heroine添加一个bool型变量isJumping_,追踪她什么时候跳起来的,然后:
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_) { isJumping_ = true; // Jump... } } }
接下来,我们想要在地面上的heroine下蹲当我们按下“下”键,松开后重新站起来:
void Heroine::handleInput(Input input) { if (input == PRESS_B) { // Jump if not jumping... } else if (input == PRESS_DOWN) { if (!isJumping_) { setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { setGraphics(IMAGE_STAND); } }
发现这次的bug了没?
这段代码,玩家可以:
1.按下“下”,可以下蹲。
2.在下蹲的状态下,按下“B”跳起来。
3.在空中松开“下”键。
heroine会在半空中显示为站起的姿态。是时候添加另一个标志了……
void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // Jump... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true; setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { if (isDucking_) { isDucking_ = false; setGraphics(IMAGE_STAND); } } }
又到了寻找bug时间。找到了吗?
我们检查到你不能在空中跳但是在下落的过程中照样可以。仍然我们需要另一个标志……
我们的方法已经出错。每一次我们补充这段代码,我们打破一些东西。我们需要添加一堆动作-我们还没有添加行走的动作-但是以这种速度,在我们完成之前它已经倒塌成一堆bug。
finite state machine to the rescue
在一阵沮丧之后,你清掉桌子上的所有东西除了一支笔和一张纸,然后开始画流程图。你为heroine的每个行为画一个方框称为状态:standing,jumping,diving,ducking。当她的一个状态可以响应一个按键时,你就画一个箭头从一个状态指向要改变到的状态,箭头标记上按键的行为。
恭喜,你已经创建了一个有限状态机。他们属于一支计算机科学称为自动机理论,他们家族还包括著名的图灵机。FSM是家族里最简单的成员。
要点是:
你有确定的状态集合。这里是standing,jumping,diving,ducking。
状态机同一时刻只能处在一个状态。heroine不能同时jumping又standing。事实上,防止这个就是我们使用状态机的一个理由。
一连串“输入”或“事件”被发给状态机。在这个例子中,就是一连串原始的按键的按下与释放。
每一个状态有一个转换函数的集合,每一个转换都与一个输入相关联,并且指向一个状态。当一个输入到来,如果他匹配当前状态的某个转换,状态机转到转化指向的状态。
例如,当处在standing状态,按下“下”键,会转到ducking状态。当处在jumping状态,按下“下”键,会转到diving状态。如果当前状态没有为输入定义转换,那么输入会被忽略。
以抽象形式,就是由这几部分组成:状态,输入,转换函数。你可以将他画成一个流程图。不幸的是,编译器识别不了我们的涂鸦,那么我们如何实现它呢?“四人帮”的状态模式就是一个方法-我将要开始讲的部分-不过我们先从更简单的开始。
Enums and switches
heroine类有一个问题就是一些bool型的标志不能相互组合:例如,isJumping_与isDucking_不能同时为真。当你有一些标志同一时刻只能有一个为真时,这其实是一个使用枚举的提示。
在这个例子中,枚举值实际上就是FSM的状态,所以让我们定义它:
enum State { STATE_STANDING, STATE_JUMPING, STATE_DUCKING, STATE_DIVING };
heroine将会只有一个State成员,而不用有一堆flag。我们翻转分支的顺序。在上面的代码中,我们以input进行switch,然后是state。这可以使代码先集中处理一个输入,但是这样会弄脏一个状态。我们想集中处理一个状态,所以我们以state进行switch。就像这样:
void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_B) { state_ = STATE_JUMPING; yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } else if (input == PRESS_DOWN) { state_ = STATE_DUCKING; setGraphics(IMAGE_DUCK); } break; case STATE_JUMPING: if (input == PRESS_DOWN) { state_ = STATE_DIVING; setGraphics(IMAGE_DIVE); } break; case STATE_DUCKING: if (input == RELEASE_DOWN) { state_ = STATE_STANDING; setGraphics(IMAGE_STAND); } break; } }
这看起来有点琐碎,但是已经是对之前的代码很大的改进。我们仍然有很多条件分支,但是我们把可变的状态简化成一个。所有处理同一个state的代码很好地集中在一处。这是一个最简单的方法实现状态机,对一些情况还不错。
但是你的问题会急剧增加。假设我们想为heroine添加一个动作,下蹲一会充能然后释放一个特殊攻击。当她下蹲时,我们需要追踪充能时间。
我们添加一个chargeTime_存储heroine已经充能多长时间了。假设我们已经有一个update()函数,而且每一帧都会被调用。在其中,我们添加:
void Heroine::update() { if (state_ == STATE_DUCKING) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { superBomb(); } } }
我们需要重置时间当她下蹲时,所以我们修改handleInput():
void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_DOWN) { state_ = STATE_DUCKING; chargeTime_ = 0; setGraphics(IMAGE_DUCK); } // Handle other inputs... break; // Other states... } }
总的来说,为了添加这个蓄力攻击,我们不得不修改两个函数并为heroine添加一个chargeTime_变量,即使这个变量只是对ducking有意义。我们想要做的是把所有代码和数据很好地封装到一块。“四人帮”帮我们讲了这个。
The state pattern
对于深陷面向对象思路的人,每一个条件分支都是一个使用动态绑定的机会(就是c++里的虚函数)。我认为你可能太过了。有时if就是你所有的需要。
但是在我们的例子中,使用面向对象更合适。这让我们使用状态模式。“四人帮”对状态模式的描述:
允许一个对象当其内部状态改变时改变它的行为。这个对象将会出现改变它的类。
这没告诉我们多少东西。我们的switch就能做到这些。他们描述的模式应用到我们的heroine就像这样:
A state interface
首先,我们为state定义一个接口。依赖于state的行为-之前每一个switch出现的地方-变成一个虚函数。这里,就是handleInput()和update()函数:
class HeroineState { public: virtual ~HeroineState() {} virtual void handleInput(Heroine& heroine, Input input) {} virtual void update(Heroine& heroine) {} };
classes for each state
对每一个state,我们定义一个类继承自HeroineState。它的方法定义了该状态下的heroine的行为。换句话说,把之前switch的每一个case分支移到state类中。例如:
class DuckingState : public HeroineState { public: DuckingState() : chargeTime_(0) {} virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Change to standing state... heroine.setGraphics(IMAGE_STAND); } } virtual void update(Heroine& heroine) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { heroine.superBomb(); } } private: int chargeTime_; };
注意,我们也把chargeTime_从heroine挪到了duckingState中。这很好-这个数据只是在duckingstate时才有意义,现在我们的对象模型显示反映了这点。
delegate to the state
接下来,我们为heroine定义一个指向当前状态的指针,丢掉繁杂的switch,委托给state:
class Heroine { public: virtual void handleInput(Input input) { state_->handleInput(*this, input); } virtual void update() { state_->update(*this); } // Other methods...private: HeroineState* state_; };
为了改变状态,我们只需要把state_指向一个不同的HeroineState对象即可。这就是整个状态模式。
where are the state objects?
我掩盖了一个事实。要改变状态,我们需要把state_指向一个不同新的对象,但是这个对象是哪来的?用枚举实现时,那很简单-枚举值就像数字一样是基本值。但是,现在state是类,这就是说我们需要实例化对象。对这个问题有两个答案:
static states
如果state没有任何其他成员,它唯一的数据是虚表指针。这种情况,就没理由创建多余一个的实例了。每一个实例都是相同的。
这种情况下,你可以静态实例。即使你有一堆FSM同一时刻处在同一状态,它们都可以指向同一个state实例,因为它没有特定于状态机的属性数据。
你要把这些静态实例放在哪随便你。找一个有意义的地方。没有特别的原因,我们就把它们放到基类中:
class HeroineState { public: static StandingState standing; static DuckingState ducking; static JumpingState jumping; static DivingState diving; // Other code... };
每一个静态实例是游戏中要使用的state对象。为了让heroine跳起来,standingstate可能会这么做:
if (input == PRESS_B) { heroine.state_ = &HeroineState::jumping; heroine.setGraphics(IMAGE_JUMP); }
instantiated states
有时,这不好用。ducking就不能是一个静态state。它有一个chargeTime_成员,不同heroine有不同的下蹲时间。如果只有一个heroine,这可能碰巧好用,但是如果我们要做双人模式,两个heroine同时出现在屏幕上,就会出现问题。
这种情况下,我们不得不创建实例,当我们要转向这个状态时。这让每一个FSM都拥有自己的state实例。当然,如果我们创建一个新state,那么我们就要释放当前的state。我们必须小心这里,因为触发改变的代码是在当前状态的函数中的。我们不能自己删除自己。
但是,我们允许HeroineState的handleInput()返回一个新state。当这么做时,Heroine会删除旧的state,转向新state,就像这样:
void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; } }
这种方法,我们知道当前状态返回之后才删除它。现在,ducking状态可以转向standing状态,通过创建一个新state:
HeroineState* StandingState::handleInput(Heroine& heroine, Input input) { if (input == PRESS_DOWN) { // Other code... return new DuckingState(); } // Stay in this state. return NULL; }
如果可以,我宁愿使用static实例,因为那样不会浪费内存和CPU周期每次改变状态的时候。而对于有很多属性数据的state,使用这种方法无可厚非。
Enter and Exit Actions
状态模式的目的是把行为和数据封装进一个单独的类中。我们已经到一半了,还有一部分枝节问题。
当heroine改变状态时,也会改变sprite。现在,改变的代码是在当前状态。当她从ducking到standing时,ducking设置她的图像:
HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { heroine.setGraphics(IMAGE_STAND); return new StandingState(); } // Other code... }
我们真正想要的是每一个state控制它自己的图形。我们可以为state添加一个进入动作:
class StandingState : public HeroineState { public: virtual void enter(Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); } // Other code... };
回到Heroine,我们修改处理改变状态的代码:
void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; // Call the enter action on the new state. state_->enter(*this); } }
这让我们简化duckingState的代码:
HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { return new StandingState(); } // Other code... }
所有它要做的是切换到standing,由standing处理图形。现在我们的状态是真正地被封装了。进入操作特别好的一点是它们在进入状态的时候才执行不必管是从哪个状态进入的。
大多数真实世界的状态图是一个状态有好多个转换指向自己。例如,heroine在jumping或diving之后都进入standing。这说明我们会在转换的地方重复一些代码。进入操作给我们一个可以合并的地方。
我们当然也可以扩展state支持退出操作。这是一个改变状态退出当前状态时要调用的函数。
What's the catch?
我已经花了大量时间向你推销FSM,现在我准备将你下面的地毯拉开。目前为止,我所讲的都是对的,FSM的确对一些问题适合。但是它的最大的长处也是它最大的瑕疵。
状态机帮助你清理恐怖的代码通过强加一个约束的结构。所有你需要的是一个确定的状态集合,一个单独的当前状态,还有硬编码的转换函数。
如果你想对更复杂的东西像游戏AI使用状态机,你会因为这个模型的局限性被打肿脸。幸运的是,我们的前辈已经找到了一些方法躲避障碍。我会通过向你展示一些这种方法结束这一章。
concurrent state machines
我们决定给heroine加携带武器的功能。当她携带武器时,她可以做以前的所有的动作:run,jump,duck等。但是她也需要在做这些动作的同时可以开火。如果我们还坚持使用一个FSM,那么我们不得不增加一倍数量的state。对于每一个状态,我们需要响应的携带武器时的状态:standing,standing with gun,jumping,jumping with gun,还有其他你知道的。
添加几个武器,那么组合的状态数量会爆炸。状态数量不仅多,也是一个巨大的冗余:拿不拿武器几乎是是相同的除了一小部分代码。
问题是我们把两个状态-她正在做什么与她正拿着什么-塞进一个FSM。为了模拟所有的组合,我们需要为每对创建一个状态。解决方法非常明显:用两个FSM。
我们保持原来的状态机原样不动。然后再定义一个状态机处理heroine正携带什么。heroine就有了两个状态,就像这样:
class Heroine { // Other code... private: HeroineState* state_; HeroineState* equipment_; };
当heroine委托给状态处理输入时,她两个都要委托:
void Heroine::handleInput(Input input) { state_->handleInput(*this, input); equipment_->handleInput(*this, input); }
每一个状态机都能响应输入,产生行为,独立改变状态。当两个状态集合最大不相关时,很有效。
在实际中,你很难找到不交互的状态。例如,你在跳时不能开枪,拿武器不能dive。为了处理这个,你可能需要在相关的状态中添加if来协同。这不是优雅的方式,但却很有效。
hierarchical state machines
pushdown automata
so how useful are they?
即使使用这些扩展的状态机,还是相当局限。最近的游戏AI的趋势是使用激动人心的方法像行为树(behavior tree)和计划系统(planning systems)。如果复杂的AI是你的兴趣,这一章所有内容只是为了激发你的欲望。你要读其他的书来满足。
这不是说FSM,下推自动机,其他的简单地系统没有用。他们对于特定的问题是一个典型的工具。FSM在以下情况有用:
物体行为改变基于内部状态。
状态被强制分成少部分不同的选项。
物体会随着时间响应一连串输入和事件。
在游戏中,它最著名的就是用在游戏AI中,但是它还常用于实现user input handling, navigating menu screens, parsing text, network protocols, 和其他异步行为。