引言
在游戏中大大小小的生物,都有着自己的行为模式:兔子见到人会逃跑,你要是一直追着它,还会进洞;各种有攻击性的怪物比如蜘蛛、猎狗等,看到玩家会主动攻击。这些行为模式是如何实现的呢?一个生物,该如何判断在什么场景下进行何种行为?一个很自然的想法是,给生物内置一个持续运行的程序,也就是所谓的人工智能。只要生物能够体现出一定的行为模式,都可以称之为人工智能。强大的人工智能可以做到下围棋赢下世界第一的棋手,但那样需要消耗大量的计算资源。在游戏中通常不需要那么复杂的人工智能,也不需要那么复杂的计算。重点在于,让生物能够应对外部环境,根据条件做出不同的反应。在游戏领域中,最简单的人工智能当属状态机(State Machine),和上一期介绍的StateGraph很相似,其实某种意义上,StateGraph也可以看作是一种人工智能。浆果丛被采集了,就换个外观,然后等几天后再变回来,再次可供采集。如果是冬天,就停止生长。这样的一系列逻辑也是对外在环境的反应。但是,如果希望生物的智能更高一些,状态机能处理的情况就有限了。因为状态机是需要基于状态节点来构建的,而实际上生物的行为非常复杂,而且还有许多判断条件和动作优先级问题,状态机使用起来就很不方便。最大的问题是,需要编写大量的状态跳转代码。在这个游戏中,人工智能是通过行为树来实现的。在游戏编程领域中,这是一种非常经典的人工智能编程模型,很多游戏引擎比如UE4,Unity等都内置了这一模型。下面我会先介绍行为树的基本概念,再来展开讲讲这个游戏中的行为树是如何实现的。
概览
让我们从一个简单的例子开始:兔子。兔子的行为比较简单,一般我们容易感知到的是:靠近兔子它就会跑,如果追得紧了还会进洞。如果把兔子能吃的食物放进陷阱里,兔子也会过去吃掉然后被捕获。兔子完整的行为逻辑如下所示,系统会从左往右,依次向下遍历整个行为树节点,直到其中一个节点返回SUCCESS或RUNNING,或者全部节点都执行过一遍。
遍历流程步骤大致如下
- 如果被作祟了,兔子会Panic(感到慌乱,到处乱跑)
- 如果受到了火焰伤害,兔子也会Panic
- 兔子会逃离一定范围内最近的带有scarytoprey标签的生物,这个标签不仅存在于玩家身上,也存在于阿比盖尔的姐姐和一些怪物身上。具体的逃离规则比较复杂,我们把这个带scarytoprey标签的生物称为hunter,兔子会根据hunter与自己的距离来判断,在距离太近时就会逃跑,距离太远时会停下。
- 同样是RunAway,与3的区别在于,逃跑时会回家(返回洞穴)。设计成两个RunAway我猜测是如此考虑的:3的逃离(不回家)检测范围比较小,4的逃离(回家)检测范围比较大,也就是说,如果兔子远远地看到了hunter,它就会直接往洞钻。但如果hunter移速过快或者使用了瞬移,在兔子还没反应过来的时候就已经逼近安全距离,此时直接钻洞里很可能会被逮住,这时候兔子就会往先往远离hunter的方向逃跑,而不是钻洞。
- 如果接收到了gohome的事件,会直接回家
- 如果时间到了晚上,会直接回家
- 如果时间到了春天,会直接回家
- 进食。一定的条件下,兔子会吃掉附近的食物。
- 漫游。兔子会在洞穴附近游荡。
每隔一段时间,这个流程都会按以上顺序重新执行。默认间隔是1秒,兔子的设置是0.25秒,其它生物则有不同的设置,常用的就是0.25,0.5,1这三个间隔。执行间隔越短,对系统资源的消耗也就越大,但生物的反应决策也越快,在实际的使用中,可以根据需要进行调整。
由上面的例子可以看出,行为树是以行为节点(Panic,RunAway,DoAction,Wander等)作为结束点来构建的,通过遍历的形式来持续决策:如果一个行为不满足执行条件,返回了FAILED,就会继续访问下一个行为节点,直到有一个行为节点返回了SUCCESS或者RUNING,或者访问完所有节点,一轮遍历结束后稍等一会又重新开始。通过在行为节点上游添加一些额外的控制节点,就可以实现一些更复杂的调度情况,比如增加一些行为的前置条件,或者逆转行为的结果等等,甚至可以允许平行执行多个动作,直到所有动作都完成。
状态
在行为树中,最基本的概念是节点的状态,业界中复杂的行为树可能拥有很多不同的状态,但在这个游戏中,只有四种基本状态。
- READY:节点的默认状态,当节点被重置后会设置为该状态。
- SUCCESS:表明动作执行完成并且成功了,产生了相应的动作效果。
- FAILED:表明动作执行中断,失败了,没有产生动作效果。
- RUNNING:表明动作还在执行中,需要继续等待
子节点的状态会影响到父节点的状态,依父节点的访问逻辑决定。
节点
在行为树中,依据子节点数量的多少,节点可以分为三类
- 组合节点:拥有不定数量的子节点,通过某种逻辑来从子节点的状态确定自身节点的状态。可以实现较为复杂的子节点组合控制。
- 修饰节点:拥有一个子节点。可以通过设置条件来决定是否访问子节点,以及根据子节点的访问状态来确定自身的状态。
- 行为节点:没有子节点,是逻辑执行的最终端,在这一层上往往会处理具体的游戏逻辑。
接下来让我们更进一步,分别看看每种节点的细节,加深理解。
组合节点
组合节点拥有若干个子节点。通过设置访问顺序的逻辑,可以实现多种变换。
最简单的形式就是按顺序访问子节点,直到其中一个节点返回RUNING或FAILED。这样可以保证brain可以按顺序执行一整个动作序列,直到序列动作完成,或者其中一个动作失败。最顺利的情况是,依序访问子节点,第一个子节点返回SUCCESS后,继续访问第二个,依此类推,直到结束。然而,某些子动作节点需要较长时间的操作,比如攻击行为,攻速较慢的情况下,可能会超过brain的刷新间隔,这种情况下,子动作节点在刷新期间返回的是RUNNING,直到动作完成,子动作节点状态变为SUCCESS或FAILED之前,每次刷新后,brain都会直接从访问这个子节点开始,这就保证了每次刷新之间的动作连贯性。第三种情况是,因为某些原因,比如攻击动作丢失了目标,导致子动作节点FAILED了,后续的动作也无法再执行,这时候节点状态就转为FAILED,所有子节点状态重置,在下一轮检测中从头开始。
更复杂一点的形式,是根据情况选择。比如想要攻击某个目标,最简单的情况下,就是走过去,直到进入攻击范围,展开攻击。但如果路上有一堵墙,导致自己无法移动到攻击范围内,那么就应该先破坏墙,再进行攻击。抽象出来,就是完成一件事,有若干种不同的方法来应对不同的场景,在不确定实际场景状况的情况下,可以按一定顺序尝试使用不同的方法,直到完成目标或者失败。
还有一种情况是,动作需要反复执行,比如猪人砍树,要执行多次直到树被砍到。有时候,几个动作还会同时执行,比如一边干活,一边说话。
以上提到的几种不同的形式,都有相应的组合节点可以使用,以应对不同的需求。总的来说,可以把组合节点理解为:根据需要,以特定顺序访问子节点。多数情况下,如果子节点在RUNING,组合节点会等待该子节点的状态变为SUCCESS或FAILED,在此期间,组合节点的状态也是RUNNING。
修饰节点
修饰节点只拥有一个子节点,可以理解为对这个子节点的一种拓展,用于在子节点执行前或执行后进行一些额外的处理。
最常见的修饰就是设置条件,只有在满足某种条件的情况下,才会访问子节点执行动作。比如,如果设置一个玩家AI,可以设置当他饱食度低于50%时才会尝试进食。还可以附加一些其它的操作,比如在执行动作的同时,记录一些信息,或者让角色说话。另一方面,还可以对子节点的状态进行修改,比如SUCCESS切换成FAILED。
修饰节点在游戏中有大量的使用,这是为了实现解耦:动作节点只需要关心如何执行具体的动作,而其它的部分,比如何时执行动作,动作执行前后的处理等等,就可以交给修饰节点来进行,从而使得代码的使用更为灵活。
行为节点
行为节点没有子节点,它本身就是执行的最终端,负责处理实际的游戏逻辑。比如进食,砍树,漫游,回家等等,都会直接调用相应的组件来执行游戏逻辑交互。游戏提供了一个通用的行为节点,可以设置函数来完善细节。如果动作比较复杂,在一个检测周期内可能无法完成,就需要另外定义新的行为节点,游戏里也定义了一些常用的行为节点,比如破坏墙壁、漫游等等,逻辑相对复杂或者执行时间较长,需要记录状态。
游戏实现
让我们来看看兔子的brain代码,看看在代码层面上,brain是如何实现的,相关源码的位置在brains/rabbitbrain.lua
,前面的代码都是一些初始化内容和函数定义,只需要看最后OnStart
函数定义的部分即可
...
-- 兔子的AI定义
local RabbitBrain = Class(Brain, function(self, inst)
Brain._ctor(self, inst)
end)
...
-- 在这个函数下定义兔子的行为树
function RabbitBrain:OnStart()
-- 这个root就是完整的行为树定义部分,PriorityNode下,这个table的每一个元素,都对应上文行为树图中的一条线。
local root = PriorityNode(
{
-- 条件修饰节点,被作祟后执行Panic,此处的Panic就是个行为节点
WhileNode( function() return self.inst.components.hauntable and self.inst.components.hauntable.panic end, "PanicHaunted", Panic(self.inst)),
-- 条件修饰节点,受到火焰伤害后执行Panic
WhileNode( function() return self.inst.components.health.takingfiredamage end, "OnFire", Panic(self.inst)),
-- 动作节点,逃跑
RunAway(self.inst, "scarytoprey", AVOID_PLAYER_DIST, AVOID_PLAYER_STOP),
-- 动作节点,还是逃跑
RunAway(self.inst, "scarytoprey", SEE_PLAYER_DIST, STOP_RUN_DIST, nil, true),
-- 修饰节点,触发事件后执行回家的动作
EventNode(self.inst, "gohome",
DoAction(self.inst, GoHomeAction, "go home", true )),
-- 条件修饰节点,如果不是白天,执行回家的动作
WhileNode(function() return not TheWorld.state.isday end, "IsNight",
DoAction(self.inst, GoHomeAction, "go home", true )),
-- 条件修饰节点,如果到了春天,执行回家的动作
WhileNode(function() return TheWorld.state.isspring end, "IsSpring",
DoAction(self.inst, GoHomeAction, "go home", true )),
-- 动作节点,吃东西
DoAction(self.inst, EatFoodAction),
-- 动作节点,在自己的洞附近漫游
Wander(self.inst, function() return self.inst.components.knownlocations:GetLocation("home") end, MAX_WANDER_DIST)
}, .25)-- 每0.25秒刷新检测
self.bt = BT(self.inst, root)
end
return RabbitBrain
从上面的代码可以看出,每一个brain都是先构建Brain
的一个子类,然后重新定义这个类的OnStart
函数。在这个函数中,定义一个root变量,这个变量是用PriorityNode
构建的,行为树的相关设置定义就从这个PriorityNode
展开。最后执行代码self.bt = BT(self.inst, root)
收尾。
如果你想了解不同的生物的行动逻辑,可以先找到这个Prefab的定义文件,再查看它设置的brain名称,在同名的brain文件下找到对应的OnStart
函数,在这个函数下的代码就是生物的行动逻辑了。
总结
生物的AI是通过设置brain来得到的,而brain是通过行为树来构建的。想要深入理解生物的行动逻辑,就需要细致地解读brain定义的行为树,并且了解每个行为树节点的用途。为此,我也列出了官方定义的全部行为树节点的详细解释。