假设我们需要设计一个敌人 AI,它存在“按照路线巡逻”、“警觉”、“追击玩家”三种状态,它们之间的关系如下:
- AI 默认处于“按照路线巡逻”状态
- 在“按照路线巡逻”中,如果发现了玩家,就进入“警觉”状态
- 在警觉状态中,如果玩家持续存在,经过两秒后进入“追击玩家”状态
- 在“警觉”和“追击玩家”状态中,如果玩家逃脱巡查范围或死亡,则回到“按照路线巡逻”状态
对初学者来说,我们很容易会想到为每个状态设置一个bool值,用来判断对象是否处于某个状态中,例如:
1 | bool isPatrol = true; // 初始处于巡逻状态 |
实际在开发的过程中,我们可能因为策划的需要而为敌人 AI 加入新的状态,例如追击一段时间后开始奔跑等等。为了实现这个新的状态,我们就需要加入一个新的 bool 值 isRunning 来判断敌人是否处于奔跑状态。当状态越来越多,需要维护的 bool 值也越来越多,复杂的分支和状态导致代码的可读性降低,非常容易出 bug。
那么,有什么能拯救敌人 AI 的方法吗?
状态机(State Machine)与状态模式
有限状态机(FSM)
如果把刚才的三种状态抽象成一张图:
恭喜,你成功创建了一个有限状态机(FSM)。FSM 是一种基于状态转换的行为模型。它将对象的行为建模为一系列状态,通过定义状态之间的转换条件和动作来控制对象的行为。状态机由多个状态组成,每个状态表示对象在特定情况下的行为。当特定条件满足时,状态之间会进行切换,从而驱动对象执行相应的动作。
有限状态机借鉴了计算机科学里的自动机理论(automata theory)中的一种数据结构(图灵机)思想。有限状态机可以看作是最简单的图灵机。
在状态机中,整个状态机由状态、事件和转换组成。展开来说:
- 状态机拥有一组状态,可以在这些状态之间进行切换,但同一时间只能处于一种状态中;
- 状态机会接受到一组事件(可能由输入等造成)作为转换条件;
- 每个状态之间有一组转换,每个转换都关联着一个事件并指向另一个状态。状态的转换取决于转换条件和当前所处状态。
为了避免写出复杂的 if-else 语句,我们可以考虑用枚举来表示每个状态,并用 switch-case 分支语句构建每个状态的行为和转换逻辑。在这种写法中,所有处理单个状态的代码都集中在一起了,这是实现状态机最简单的方法。在 if-else 写法中,通过 bool 值标识可能会存在一些没有意义的值,但在枚举写法中,每一个枚举值都是有意义的。
1 | // AI 的三种状态 |
状态模式
状态模式将这可能存在于玩家控制器中的一大串 switch-case 解耦成了更符合面向对象思维的写法。在状态模式中,我们可以定义状态接口IState:
1 | interface IState{ |
接着,我们可以为每个状态定义一个类(以警觉为例,我们正好可以实现一下计时器方法),并实现状态接口:
1 | class AlertState : IState{ |
接着,在 AI 控制器 AIController.cs 一类的脚本中,我们需要一个用来表示 AI 当前状态的指针,并直接调用接口方法(来执行每个状态实现的方法)。在修改状态时,改变状态指针所指的对象即可。如果状态类中不包含数据成员(比如Protal和Chase),那么这些不论怎么实例化都始终一样的对象,可以考虑作为静态对象存放在状态的类 AIState 中,例如:
1 | class AIState{ |
但对于警戒状态来说,它包含了与计时器有关的变量_timer,采用静态方法就行不通了。而且对于多个 AI 来说,它们可能同时处于一个状态中,就需要采用实例化的方式代替静态状态,为每个 AI 的状态机创建一个当前状态的实例。
那应该如何实现状态之间的切换呢(就像我们在前面的 HandleInput 代码中只是用一行注释简单带过了)?我们可以考虑在状态的方法中返回新的状态,做法就是:
1 | // 警觉状态的事件处理 |
在 AI 控制器中,判断返回的状态,如果非空则认为状态发生了变化。在这个基础上,我们还可以实现 Entry 和 Exit 方法实现状态进入/退出时的特殊功能,完善状态机的健壮性。
并发状态机
在前文的有限状态机中同一时间 AI 只能处于一个状态中,那么如果我希望 AI 在追击角色的同时执行持枪/开火的循环状态,应该怎么办呢?对FSM,我们可能要把状态加上“巡逻时持枪”、“巡逻时开火”、“警觉时持枪”、“警觉时开火”、“追击时持枪”和“追击时开火”了,提升了代码的复杂性。
但其实仔细想想,持枪/开火这个循环其实和其他状态是独立的,如下图所示,那么我们就可以在 AI 控制器中声明两个状态指针,分别指代持枪/开火和其他的 AI 行为,在执行 handleInput 时两个状态都调用一下就可以了。这就是并发状态机的思想,适合多个毫无关联的状态机并发执行(如果有关联,并发状态机依然可以完成任务,但需要结合一些 if 语句进行判断)。
层次状态机
在我们实际编写状态机代码时,可能会发现许多状态包含了重复的逻辑,例如在“警觉”和“追击”两个状态中,丢失玩家只会都会回到“巡逻”状态。有没有办法减少这些重复代码呢?我们可以考虑采用继承的方式。“警觉”和“追击”都可以继承自一个父状态“非巡逻(或者一个更好的名字)”,在"非巡逻"的 handleInput 中:
1 | if(!playerTarget){ |
接着,在继承了“非巡逻”的状态中实现自己的特殊行为即可。我们通常把这种状态机叫做层次状态机,一个状态有一个父状态,当有一个事件进来的时候,如果子状态不处理它,那么沿着继承链传给它的父状态来处理。
即使状态模式已经在一定程度上解耦了状态机的复杂代码,但在实际项目中,我们随时可能增加新状态、减少状态或者改变状态之间的迁移关系,如果状态越来越多,任何一点小修改都会产生很大的工作量(例如我们需要维护新的状态类)。
行为树(Behavior Tree)
行为树(Behavior Tree)是一种基于树结构的行为模型。它以树状的方式描述了对象的行为决策过程。行为树由多个节点组成,每个节点代表一个具体的行为或决策。节点之间通过连接关系形成树状结构,决定了对象在特定情况下应该执行哪些行为。通过遍历行为树,对象可以根据节点的逻辑规则来选择合适的行为进行执行。行为树则适合用于描述对象的复杂行为决策过程,例如角色在特定情况下应该采取何种行为(如追逐、逃避、攻击等)。
作为一种树,行为树由三种节点构成,每个节点都有成功、失败和运行中三种返回值。
- 行为节点(Task)
行为树的叶子节点,代表了 AI 的具体行为(例如移动、攻击、什么也不做),包含初始化函数 Init 和行为逻辑函数 OnTick(每帧执行)。 - 控制节点(Composite)
控制节点一般为行为树的中间节点,控制行为树的行为变换。具体来说有以下三种类型:- 顺序节点(Sequence)
相当于与(and),依次执行所有子节点,若当前子节点返回成功,则继续执行下一个子节点;若子当前节点返回失败,则中断后续子节点的执行,并把结果返回给父节点。
- 选择节点(Selector)
相当于或(or),依次执行所有子节点,若当前子节点返回成功,则中断后续节点运行,并把结果返回给父节点。
- 平行节点(Parallel)
依次执行所有子节点,无论失败与否,都会把所有子节点执行一遍。至于Parallel节点该返回什么值给父节点,这要看需求。比如:成功数 > 失败数返回成功,否则返回失败。
- 随机节点(Random)
随机选择一个子节点来运行。
- 顺序节点(Sequence)
- 装饰节点
- 逆变节点(Inverter)
对子节点的返回值取反,相当于非(not),它只会有一个子节点。 - 成功节点(Succeeder)
不管其子节点返回何值,都会返回Success给父节点 - 重复节点(Repeater)
重复执行n次子节点。 - 重复直至失败节点(Repeat Until Fail)
重复执行子节点,直到失败为上;同样也有类似的重复直至成功节点这里就不列出了。 - 执行一段时间(MaxTime)
重复执行子节点一段时间。
- 逆变节点(Inverter)
行为树的出现从根本上解决了前文提到的复杂代码维护的问题,它把每个行为作为一个原子项,任何人都可以决定 AI 的执行流程,作为程序的我们只需要集中精力根据需求增加新的行为,而不用关心具体流程、转换方式是什么样的。
不过在性能上,行为树的运算通过帧循环的 update 来驱动,每帧会遍历所有非行为节点,在树比较复杂时会造成资源浪费。并且对于简单的操作在行为树也要使用节点,相对比较繁琐。针对性能优化方面,如果用状态机实现简单状态的切换,进入某个状态(例如战斗)后再用行为树表示复杂行为逻辑,这会是一个有效的优化。尤其是怪物很多时,大部分时间段,大部分怪都处于巡逻状态,完全没有必要遍历行为树。
参考资料
游戏编程模式(游戏设计与开发)
https://zhuanlan.zhihu.com/p/540191047
https://lifan.tech/2020/02/15/game/behavior-tree/