你是否还在面对乱作一团的代码束手无策?你是否仍然觉得复杂的逻辑无从下手?你是否觉得游戏AI高端得毫无头绪?本文将以一个复杂的弹窗逻辑和RPG游戏挂机AI的实现为案例,讲述状态机的概念及其写法。
本文分为以下部分:
有限状态机(finite-state machine) :对状态机一些概念的解释。
案例对照 :将一个复杂弹窗的普通写法和状态机编程两种实现进行对比。这里状态机的实现是多个if-else的最简单的状态机实现。
有限状态机的优势 :通过上面的对比总结状态机的优势。
如何优雅地使用状态机 :以游戏挂机自动刷怪的AI为例,提供状态模式的代码实现。
状态机的使用场景 :对状态机的使用做了一些扩充。
总结 :对本文内容的总结。
参考资料 :文中部分概念的来源以及扩展阅读的链接。
对状态机一无所知的读者可以顺序看下去;写了不少逻辑,却依旧编不好繁复代码的,可以从案例对照 开始阅读,相信可以让你对编程有个新的把握;会用状态机,却用得不优雅的读者,可以直接空降如何优雅地使用状态机 ,状态模式的实现在等着你钻研;会用一百种不同的方法花式写状态机的读者,可以直接去看文末的参考资料 ,希望对你有所帮助~
有限状态机(finite-state machine) 有限状态机,又称有限状态自动机,简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。[1]
有限状态机可以将复杂的逻辑简化为有限个稳定状态,在稳定状态中判断事件。其中有限不是指有限次处理,而是有限个稳定状态,并且有限状态机是一个闭环系统,可以用有限的状态处理无尽的事务。
例如,灯的开关就是一个非常简单的有限状态机。它有两种状态:开或关。这两个状态的切换是通过手指的输入产生的。打开开关,产生从关到开的状态变换;关闭开关,产生从开到关的状态变换。
状态机由下列几部分组成:
状态集(States):包括现态和次态在内的一系列状态,用来描述状态机所处的状态。
事件(Event):又被称为“条件”,当满足条件时,将会触发一个动作,或者执行一次状态的迁移。
动作(Action):条件满足后执行的动作。动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
转换(Transition):通过转换函数将状态从现态迁移到次态的动作。迁移后次态变为现态。
最著名的有限状态机可能是艾伦·图灵假想的设备——图灵机,他在1936年论文《关于可计算数字》中写道:这是一个预示着现代可编程计算机的机器,它们可以通过对无限长的磁带上的符号进行读写和擦除操作来进行任何逻辑运算。[2]
有限状态机实际上是一个有向图,由状态节点和状态转义函数组成。因此,当游戏策划交给你一个模块的流程图时,完全可以将流程图简化成一个或多个状态图,并进行实现。
案例对照 下面,我讲列举非状态机和状态机编程两种代码进行对比。
当我们写一个弹窗时,需求往往是这样:点击打开按钮,显示弹窗;点击关闭按钮,弹窗消失。这和本文一开始的电灯状态很相似,但这样一个简单的逻辑,并不需要使用复杂的状态机进行控制,我们可以直接对相应的按钮进行事件绑定。
example 1:
1 2 3 4 5 6 7 8 Button *openBtn = Button::create(); openBtn->addClickEventListener([=] (this ) { MyAlertDialog *dialog = MyAlertDialog::create(); dialog->show(); }
1 2 3 4 5 6 7 8 Button *closeBtn = Button::create(); closeBtn->addClickEventListener([=] (this ) { this ->dismiss(); }
但很多时候需求是复杂的,我们需要的弹窗可能是这样:弹窗开启前插入两个动画,动画间有0.5秒延迟,动画播完后1秒打开弹窗,弹窗打开后4s自动关闭或点击关闭按钮关闭,延迟2s后弹窗消失,关闭后主页产生变化。
我们仍不使用状态机编程,最终代码如下:
example 2:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 bool MainUI::init(){ ... Button *openBtn = Button::create(); openBtn->addClickEventListener([=] (this ) { runActionBeforeShowDialog(); } ... m_dialog = MyAlertDialog::create(); 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(...); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 //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; }
其实,在处理这样的逻辑时,我们已经将不同块的需求整理成了不同的状态,从弹窗打开到关闭无非经历了如下步骤:
开始
点击打开,显示动画
动画结束,延迟1s,显示弹窗
延迟2s,弹窗消失
结束,MainUI处理其他逻辑
但是,由于没有引入状态机,上述代码从清晰简单的弹窗逻辑变成了充斥着回调和定时器的代码堆砌。如果此时流程中出现问题,很难迅速定位,导致整体效率的下降。
根据上述步骤,列出状态表:
当前状态
条件
状态转换
开始
点击开始按钮
显示动画
显示动画
1秒后自动切换
弹窗开
弹窗开
点击关闭或4秒后
弹窗关
弹窗关
2秒后
弹窗消失(结束)
引入状态机来控制逻辑,最简单的写法如下:
example 3:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 enum class MAINUI_DIALOG_STATE = { READY, SHOW_ANIMATION, OPEN, CLOSE, DISMISS, END, } bool MainUI::init(){ ... m_state = MAINUI_DIALOG_STATE.READY; m_timeout = 0 ; Button *openBtn = Button::create(); openBtn->addClickEventListener([=] (this ) { setState(MAINUI_DIALOG_STATE.SHOW_ANIMATION); } ... m_dialog = MyAlertDialog::create(); 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 ); 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); } void MainUI::update(float dt){ m_timeout += 1 ; if (m_state == MAINUI_DIALOG_STATE.SHOW_ANIMATION) { if (m_timeout > (4.f + 1.f ) * 60 ) { setState(MAINUI_DIALOG_STATE.OPEN); } } else if (m_state == MAINUI_DIALOG_STATE.OPEN) { if (m_timeout > 4.f * 60 ) { setState(MAINUI_DIALOG_STATE.CLOSE) } } else if (m_state == MAINUI_DIALOG_STATE.CLOSE) { if (m_timeout > 2.f * 60 ) { 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) { } else if (state == MAINUI_DIALOG_STATE.DISMISS) { m_dialog->dismiss(); } else if (state == MAINUI_DIALOG_STATE.END) { } m_state = state; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 //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进行处理。
直觉性 。在生活中,人们总是自然地把事物思考为处在一种或另一种状态。“进入状态”、“状态不佳”也是我们常见的。在编码中,将游戏逻辑分解成一系列状态并创建相应的规则去处理是非常容易的。
灵活性 。代码增删变得方便快捷。
事实上,在写逻辑的时候已经潜在地使用了状态,只是没有把状态抽象出来,而是直接按流程去编写代码,使用响应、回调的方式做逻辑处理,这样使得在增删流程,后期维护时代码耦合过深,难以维护,最终不得不进行重构。而且当逻辑出现问题时,很难直接定位问题,降低了调试效率。
如何优雅地使用状态机 上述给出的只是最简单的状态机,适合较少状态之间的切换.当逻辑变得庞杂的时候,if-else的逻辑将变成一场噩梦。状态的切换会让我们难以把握程序的现状。往后的扩展也会变得相当困难。
这里我就要向大家介绍,如何优雅地使用状态机。
我们在开发游戏的时候,经常会碰到游戏AI,在编写游戏AI时,我们通常会选择有限状态机。
一般来说,在设计角色、怪物、NPC的时候,很有可能都是继承自同一个基类,此时状态机就不宜写成上面那种格式。我们可以先将状态写成一个抽象类:
1 2 3 4 5 6 7 8 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的类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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类继承过来。
1 2 3 4 5 6 7 8 9 class AutoState : public State{ public : AutoState() {} virtual void Enter (Player* player) ; virtual void Execute (Player* player) ; virtual void Exit (Player* player) ; }
根据逻辑补齐接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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" << GetNameOfEntity(player->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] 有限状态机
[2] Mat Buckland, Programming Game AI by example
[3] 状态模式
[4] Algorithm for converting a finite state machine into a regular expression
[5] TCP Finite State Machine
##写在最后 本文由笔者近期工作和学习所得,上述示例代码均为直接手写,若有错漏,欢迎指出。
未经允许,不得转载。
By:陈玉潇 CdiajadeX