你是否还在面对乱作一团的代码束手无策?你是否仍然觉得复杂的逻辑无从下手?你是否觉得游戏AI高端得毫无头绪?本文将以一个复杂的弹窗逻辑和RPG游戏挂机AI的实现为案例,讲述状态机的概念及其写法。
本文分为以下部分:
对状态机一无所知的读者可以顺序看下去;写了不少逻辑,却依旧编不好繁复代码的,可以从 案例对照 开始阅读,相信可以让你对编程有个新的把握;会用状态机,却用得不优雅的读者,可以直接空降 如何优雅地使用状态机 ,状态模式的实现在等着你钻研;会用一百种不同的方法花式写状态机的读者,可以直接去看文末的 参考资料 ,希望对你有所帮助~
有限状态机,又称有限状态自动机,简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。[1]
有限状态机可以将复杂的逻辑简化为有限个稳定状态,在稳定状态中判断事件。其中有限不是指有限次处理,而是有限个稳定状态,并且有限状态机是一个闭环系统,可以用有限的状态处理无尽的事务。
例如,灯的开关就是一个非常简单的有限状态机。它有两种状态:开或关。这两个状态的切换是通过手指的输入产生的。打开开关,产生从关到开的状态变换;关闭开关,产生从开到关的状态变换。
状态机由下列几部分组成:
状态集(States):包括现态和次态在内的一系列状态,用来描述状态机所处的状态。
事件(Event):又被称为“条件”,当满足条件时,将会触发一个动作,或者执行一次状态的迁移。
动作(Action):条件满足后执行的动作。动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
转换(Transition):通过转换函数将状态从现态迁移到次态的动作。迁移后次态变为现态。
最著名的有限状态机可能是艾伦·图灵假想的设备——图灵机,他在1936年论文《关于可计算数字》中写道:这是一个预示着现代可编程计算机的机器,它们可以通过对无限长的磁带上的符号进行读写和擦除操作来进行任何逻辑运算。[2]
有限状态机实际上是一个有向图,由状态节点和状态转义函数组成。因此,当游戏策划交给你一个模块的流程图时,完全可以将流程图简化成一个或多个状态图,并进行实现。
下面,我讲列举非状态机和状态机编程两种代码进行对比。
当我们写一个弹窗时,需求往往是这样:点击打开按钮,显示弹窗;点击关闭按钮,弹窗消失。这和本文一开始的电灯状态很相似,但这样一个简单的逻辑,并不需要使用复杂的状态机进行控制,我们可以直接对相应的按钮进行事件绑定。
example 1:
//MainUI.cpp init 函数内 //打开按钮 Button *openBtn = Button::create(); openBtn->addClickEventListener([=] (this) { MyAlertDialog *dialog = MyAlertDialog::create(); dialog->show(); }
//MyAlertDialog.cpp init 函数内 //关闭按钮 Button *closeBtn = Button::create(); closeBtn->addClickEventListener([=] (this) { //关闭按钮在弹窗内部 this->dismiss(); }
但很多时候需求是复杂的,我们需要的弹窗可能是这样:弹窗开启前插入两个动画,动画间有0.5秒延迟,动画播完后1秒打开弹窗,弹窗打开后4s自动关闭或点击关闭按钮关闭,延迟2s后弹窗消失,关闭后主页产生变化。
我们仍不使用状态机编程,最终代码如下:
example 2:
//MainUI.cpp bool MainUI::init() { ... //打开按钮 Button *openBtn = Button::create(); openBtn->addClickEventListener([=] (this) { runActionBeforeShowDialog(); } ... m_dialog = MyAlertDialog::create();//调整为成员变量进行控制,需在头文件中声明并在构造中置为nullptr m_dialog->setDismissFunc(std::bind(&MainUI::dismissDialog, this)); return true; } //动画 void MainUI::runActionBeforeShowDialog() { Action *action1 = SomeAction::create(2.0f); Action *action2 = OtherAction::create(1.5f);//第二个动画 CallFunc *callback = CallFunc::create(std::bind(&MainUI::showDialog, this)); Sequence *seq = Sequence::create(action, DelayTime::create(0.5f), action2, DelayTime::create(1.f), callback, nullptr);//增加延迟 this->runAction(seq); } //优化:把打开弹窗的代码整理成函数 void MainUI::showDialog() { dialog->show(); } void MainUI::dismissDialog() { dialog->dismiss(); ...do something... scheduleOnce(...);//关闭弹窗后新的定时器操作 }
//MyAlertDialog.cpp bool MyAlertDialog::init() { ... //关闭按钮 Button *closeBtn = Button::create(); closeBtn->addClickEventListener([=] (this) { //延迟两秒关闭 scheduleOnce(std::bind(&MyAlertDialog::m_dismissFunc, this), 2.0f); } //延迟4s自动关闭,关闭延迟两秒程序员偷懒未做 scheduleOnce(std::bind(&MyAlertDialog::m_dismissFunc, this), 4.0f); ... return true; } void setDismissFunc(std::function<void()> func) { m_dismissFunc = func; }
其实,在处理这样的逻辑时,我们已经将不同块的需求整理成了不同的状态,从弹窗打开到关闭无非经历了如下步骤:
但是,由于没有引入状态机,上述代码从清晰简单的弹窗逻辑变成了充斥着回调和定时器的代码堆砌。如果此时流程中出现问题,很难迅速定位,导致整体效率的下降。
根据上述步骤,列出状态表:
当前状态 | 条件 | 状态转换 |
---|---|---|
开始 | 点击开始按钮 | 显示动画 |
显示动画 | 1秒后自动切换 | 弹窗开 |
弹窗开 | 点击关闭或4秒后 | 弹窗关 |
弹窗关 | 2秒后 | 弹窗消失(结束) |
引入状态机来控制逻辑,最简单的写法如下:
example 3:
//MainUI.cpp enum class MAINUI_DIALOG_STATE = { READY, SHOW_ANIMATION, OPEN, CLOSE, DISMISS,//调用关闭后2s,弹窗才会消失 END, } bool MainUI::init() { ... m_state = MAINUI_DIALOG_STATE.READY; m_timeout = 0;//存储时间间隔,作为延迟的判断条件 //打开按钮 Button *openBtn = Button::create(); openBtn->addClickEventListener([=] (this) { //runActionBeforeShowDialog(); setState(MAINUI_DIALOG_STATE.SHOW_ANIMATION);//点击打开,展示动画 } ... m_dialog = MyAlertDialog::create();//调整为成员变量进行控制,需在头文件中声明并在构造中置为nullptr m_dialog->setDismissFunc(std::bind(&MainUI::dismissDialog, this)); return true; } //动画 void MainUI::runActionBeforeShowDialog() { Action *action1 = SomeAction::create(2.0f); Action *action2 = OtherAction::create(1.5f);//第二个动画 //CallFunc *callback = CallFunc::create(std::bind(&MainUI::showDialog, this));不再需要回调 Sequence *seq = Sequence::create(action, DelayTime::create(0.5f), action2, nullptr);//移除回调和回调前的延迟 this->runAction(seq); } //优化:把打开弹窗的代码整理成函数 void MainUI::showDialog() { dialog->show(); } void MainUI::dismissDialog() { setState(MAINUI_DIALOG_STATE.CLOSE);//主动点关闭,状态变为CLOSE } void MainUI::update(float dt) { m_timeout += 1;//每次update自加1 //通过当前状态判断是否进入下一状态 if (m_state == MAINUI_DIALOG_STATE.SHOW_ANIMATION) { if (m_timeout > (4.f + 1.f) * 60)//cocos2d-x每秒60帧,此处即为1s延迟 + 4s动画时间 { setState(MAINUI_DIALOG_STATE.OPEN);//延迟一秒,打开弹窗 } } else if (m_state == MAINUI_DIALOG_STATE.OPEN) { if (m_timeout > 4.f * 60)//4s后自动关闭 { setState(MAINUI_DIALOG_STATE.CLOSE) } } else if (m_state == MAINUI_DIALOG_STATE.CLOSE) { if (m_timeout > 2.f * 60)//2s后弹窗消失 { setState(MAINUI_DIALOG_STATE.DISMISS) } } else if (m_state == MAINUI_DIALOG_STATE.DISMISS) { setState(MAINUI_DIALOG_STATE.END)//状态结束,没有延迟 } } void MainUI::setState(MAINUI_DIALOG_STATE state) { m_timeout = 0;//进入新状态时,时间间隔清零 if (state == MAINUI_DIALOG_STATE.SHOW_ANIMATION) { runActionBeforeShowDialog(); } else if (state == MAINUI_DIALOG_STATE.OPEN) { showDialog(); } else if (state == MAINUI_DIALOG_STATE.CLOSE) { //do nothing } else if (state == MAINUI_DIALOG_STATE.DISMISS) { //do nothing m_dialog->dismiss(); } else if (state == MAINUI_DIALOG_STATE.END) { //状态结束 } m_state = state; }
//MyAlertDialog.cpp bool MyAlertDialog::init() { ... //关闭按钮 Button *closeBtn = Button::create(); closeBtn->addClickEventListener([=] (this) { //延迟两秒关闭 //scheduleOnce(std::bind(&MyAlertDialog::m_dismissFunc, this), 2.0f); m_dismissFunc();//无需在这里处理延迟,调用函数设置关闭状态即可 } //延迟4s自动关闭,关闭延迟两秒程序员偷懒未做 //scheduleOnce(std::bind(&MyAlertDialog::m_dismissFunc, this), 4.0f); //此处延迟已统一由MainUI进行处理 ... return true; } void setDismissFunc(std::function<void()> func) { m_dismissFunc = func; }
通过example 2、3的对比,我们可以看出,使用状态机,不仅让代码更加清晰,而且将逻辑都放在了MainUI处理,包括弹窗的显示和消失,弹窗只关注自身内部的变化,不去对自己进行dismiss的操作,使逻辑解耦。并且在这一过程中任何一个步骤出现问题,都能很快进行定位,并直接对相应状态下的代码进行调整,不会影响其他的状态。
同时我们可以看到状态机的四个部分,首先在枚举中定义了所有的 状态 ,用m_state表示现态;在update函数和按钮响应事件中设置动作触发的 事件 ; 动作 触发后执行响应逻辑并通过转移函数进行状态的切换;而setState则是状态的 转移函数 。
通过上述案例,我们可以得出有限状态机的五个优点:
事实上,在写逻辑的时候已经潜在地使用了状态,只是没有把状态抽象出来,而是直接按流程去编写代码,使用响应、回调的方式做逻辑处理,这样使得在增删流程,后期维护时代码耦合过深,难以维护,最终不得不进行重构。而且当逻辑出现问题时,很难直接定位问题,降低了调试效率。
上述给出的只是最简单的状态机,适合较少状态之间的切换.当逻辑变得庞杂的时候,if-else的逻辑将变成一场噩梦。状态的切换会让我们难以把握程序的现状。往后的扩展也会变得相当困难。
这里我就要向大家介绍,如何优雅地使用状态机。
我们在开发游戏的时候,经常会碰到游戏AI,在编写游戏AI时,我们通常会选择有限状态机。
一般来说,在设计角色、怪物、NPC的时候,很有可能都是继承自同一个基类,此时状态机就不宜写成上面那种格式。我们可以先将状态写成一个抽象类:
class State { public: virtual ~State() {} virtual void Enter(Player*) = 0; virtual void Execute(Player*) = 0; virtual void Exit(Player*) = 0; }
这里预留了Enter和Exit的接口,方便做状态切换时的 动作 。上述三个接口都有一个Player的指针作为传参。这里我不想以简单我怪物的逻辑作为示例来讲解,现在很多RPG类的手游都提供了挂机刷怪的逻辑,点开这个设置,角色就会自动跑到附近的副本里刷怪升级,减轻玩家的负担。
这里我设定一个逻辑,开始挂机时,自动寻找附近副本,刷怪,刷怪需要体力值,体力过低时会自动回城休息,刷怪获得物品占满物品栏时会自动回城贩卖。达到设定要求时挂机停止。如图2所示。
根据上述条件,我们可以得出Player的类。
Class Player : public BaseGameEntity { private: State* m_pCurrentState; location m_location;//当前位置 int m_gold;//金币数 int m_exp;//经验数 int m_strength;//体力值 int m_goods;//物品数 public: Player(int uid); void update(); //状态转移函数 void ChangeState(State* newState); } void Player::update() { if (m_pCurrentState) { m_pCurrentState->Execute(this); } } void Player::ChangeState(State* newState) { //现态退出时的动作 m_pCurrentState->Exit(this); m_pCurrentState = newState; //次态进入时的动作 m_pCurrentState->Enter(this); }
通过图2我们可以看到,一共有四个状态:
* 挂机:将角色移动到副本中,寻找附近的怪物击杀,获取经验和金钱,扣除体力。若经验到达设定值,则停止挂机。
* 回城休息:角色体力过低,自动移动位置到城里休息。休息完毕回到挂机状态。
* 回城贩卖:角色背包装满,回城自动贩卖,若金钱到达设定值,则停止挂机,否则回到挂机状态。
* 结束:挂机过程结束,角色回城。
以挂机状态为例,实现这个状态只需要直接将State类继承过来。
class AutoState : public State { public: AutoState() {} virtual void Enter(Player* player); virtual void Execute(Player* player); virtual void Exit(Player* player); }
根据逻辑补齐接口:
void AutoState::Enter(Player* player) { //寻找副本 player->ChangeLocation(dungeon); } void AutoState::Execute(Player* player) { //认为每次执行就击杀了一个怪物 player->AddGold(1); player->AddExp(1); player->AddGoods(1); player->DecreaseStrength(); //背包装满,则回城贩卖 if (player->PocketsFull()) { player->ChangeState(new GoBackAndSellState()); } //体力值过低,则回城休息 if (player->NeedRest()) { player->ChangeState(new GoBackAndRestState()); } } void AutoState::Execute(Player* player) { cout << "/n" <Uid()) << ": "<< "I'm leaving the dungeon!"; }
上面的代码简单地讲述了如何使用状态模式编写一段游戏AI,上述的实现方式就是 状态模式 [3]。为了方便讲解,这里所列举出的状态都是比较独立的,以便于我们对状态机本身的理解和状态模式的把握。
通过这几段代码,和上面example 3作对比,我们可以发现新的写法丢弃了繁重的if-else结构,通过类的继承的方式来实现整个逻辑,这样不仅简化了逻辑的编写,也让我们搭建游戏框架变得更加方便。状态的增删也仅需要新建和移除状态子类即可,十分快捷。
当然,细心的朋友可能发现,我们在每次切换状态的时候都做了一次new的操作,在状态切换频繁的时候会消耗很多资源。这里可以具体问题具体分析,究竟是直接new,还是将子类写成单例,则需要读者根据需求自己把握了。
状态机的使用场景非常广泛,除了上述在游戏中处理UI逻辑和编写游戏AI时需要使用状态机编程以外,还有很多地方会用到状态机。
状态机本身广泛应用于硬件控制电路设计中,比如比较经典的电梯、洗衣机的控制。
软件中如正则表达式[4]、词法分析,网络协议如下图所示的TCP/IP协议[5]等,可以说有限状态机是无处不在的。
当然, 任何编程规范都不宜被滥用。 在最初的时候,example 1就已经是比较合适的写法了,没有必要过度追求编程规范,反而会降低开发效率。本文中只是以一个复杂的弹窗(结算动画、中奖提示等类型)讲述状态机的优势,在实际应用场景中,游戏主逻辑、游戏大厅等具有复杂UI交互的类,都可以考虑使用状态机来进行代码编写,细分状态,保证代码的健壮性,方便以后扩展新的特性。
本文侧重游戏开发中的状态机,这里提到的一些使用场景在文末 参考资料 部分附上了链接,有兴趣的朋友可以进行深入阅读。
在游戏开发中,状态机有利于处理复杂模块的逻辑,降低耦合度,方便扩展特性。
简单实现的状态机会面临if-else过多所造成的难以维护的问题,而状态模式则是实现状态机的最优解法,在细节处仍有不少可优化的地方。
状态机应用广泛,但不宜滥用状态机。
[1] [有限状态机](https://zh.wikipedia.org/wiki/%E6%9C%89%E9%99%90%E7%8A%B6%E6%80%81%E6%9C%BA "Title")
[2] Mat Buckland, Programming Game AI by example
[3] [状态模式](http://design-patterns.readthedocs.org/zh_CN/latest/behavioral_patterns/state.html "Title")
[4] Algorithm for converting a finite state machine into a regular expression
[5] TCP Finite State Machine
本文由笔者近期工作和学习所得,上述示例代码均为直接手写,若有错漏,欢迎指出。
By:陈玉潇