前言

在找资料阶段,我意外地发现中文环境下居然几乎没有关于策划应该怎么组织行为树的文章。大部分都是从程序的视野出发讨论Game AI框架怎么设计。虽然基于现在的生产关系来说,程序做个封装得当的架构然后教给策划怎么在小框架里自定义配置就足够应付手游级别复杂度的项目了。但是只有策划自己掌握了AI的细节,才能自底向上推动项目,从AI设计出发影响玩法。举个例子,前几天我在看AI的资料,2022年,日本的游戏AI研究已经在考虑多智能体如何通过传递信息来让临近单位为己方提供帮助。

根据那篇论文我自己琢磨了一个场景并画了一个草图如下,下方小人的门需要上方X按钮开启,而上方的门需要下方O按钮开启,请问AI逻辑应该如何组织能让下方小人正常寻路进门内? 这个问题的关键在于:

  • 如何描述问题的条件,AI能否理解开门的条件是需要另一个AI执行某个操作(甚至这个操作的前提是己方执行某个操作)
  • 如何让其他AI在不硬写脚本的前提下,通过自主的AI规划达成挑战 Problem

上面这个问题只是一个引子,希望能激起读者的思考。尽管国内手游界一片欣欣向荣的样子,但我们在游戏行业的积累还是太少,离欧美日的大厂还是有着不小的差距。 (这个问题没有标准答案,我给几个思路,如果还想聊可以邮件我:1. 中心式AI Manager求解并推送任务、 2. 个体通过消息推送基于出价或者Reputation来向其他AI强加任务)

在即将跑题之前,我们回到文章本身,本文只讲一种行为树的组织方式。我必须要声明的是,尽管标题看上去好像很厉害的样子,但本文中并没有什么原创的东西,只是分层有限状态机行为树混合的一种实现,将我在工作中和日常学习中积累的行为树编写经验总结了一下,其中部分的内容你可能在其他的文章或者GDC中见到过。对本文影响较多的一次分享是: AI Arborist: Proper Cultivation and Care for Your Behavior Trees,也推荐大家都看一下。

另外要声明的一点是:本框架主要希望解决的痛点是建立大规模行为树在执行顺序、信息传递与子树通讯上的规范,一定程度上会带来编写行为树时的额外复杂操作。行为树节点数量在4位数以下的项目不建议过度设计,仅供参考。

前置知识:希望读者至少已经知道行为树及各种节点类型的使用方法;了解事件驱动行为树的“事件驱动”是什么意思。

正文

1. 行为树节点

行为树是一种有向无环的流程图,用于可视化地表达一组行为节点的执行顺序。为了辅助表达,又派生出装饰器Decorator、服务节点ServiceNode等附加到行为节点增强表达能力。

装饰器的用法有:

  • 准入条件 (判断该子树是否应该被执行)
  • 产生副作用 (进入该分支前、后做点别的事,比如修改黑板,产生函数调用)
  • 响应中断(UE中是观察者中止)

服务节点的用法有:

  • 定期维护变量(黑板之类的)
  • 产生副作用(持续地做一些事情,例如SendMessage)

有些项目会出于需要设计出条件节点ConditionNode,但我认为这是一种坏设计。因为对于大规模行为树而言,从条件节点的分支下无法很快判断这个分支的返回情况,那么其他人在共享这个子树的时候就无法判断这个条件节点的语义,进而影响行为树的组织。例如,一个条件节点叫“是否看到了玩家”,结果A策划在节点成功分支上接入了带False返回路径的子树。而B策划把这个条件节点使用在了Sequence内,希望在看到了玩家之后就继续做一些事情,那么就可能会出现看到了玩家但节点却返回False的情况,导致执行路径不符合预期。

2. 黑板与行为树通信机制

黑板可以理解为与行为树交互的数据库,对应地其中变量也有几种访问级别:

  • L0:与行为树实例关联,仅实例内可读写(可以理解为实例中的变量,不同行为树实例之间由于环境的交互将导致黑板变量的取值不一致;用于描述一个个体的状态信息)
  • L1:与主树实例相关联,主树实例内所有子树实例可读写(可以理解为子树实例自动继承了这部分共享变量;用于将信息从主树传递到子树)
  • LB:行为树模板相关联,同模板内所有实例内可读写(可以理解为黑板类中的静态成员变量,同一个行为树模板创建的实例实际上访问的是同一个地址;用于改变黑板本身的全局状态)
  • LX:团队黑板,跨树共享(可以理解为全局变量,被管理并共享给指定的一些行为树实例)

定义两种黑板中的信号量数据结构,用于行为树间通信:

  • Impulse: bool类型,用于传递一次性的信息,激活时置为True,使用后置为False
  • Impulse的变体: int类型,用于传递可使用几次的消耗型的信息,激活时置为一个正数,每次使用后取值自减1,为0时不可用
  • Persistent Impulse: bool类型,用于传递状态,激活时置为True,仅当主动取消时置为False
  • Persistent Impulse的变体: str类型,用于传递状态,激活时置为想要描述的状态名(key),仅当主动取消时置为空或空引用

3. 行为树结构:分层有限状态机行为树混合(Heirachical Finite State Machine Behaviour Tree Hybrid)

这篇GDC的第二部分中,讲者介绍了分层有限状态机行为树混合。而本框架,类似地,是在纯行为树工具内实现了一套类分层状态机的状态管理机制。 MAIN/BRANCH TREE STATE TREE

如上图所示,在每个MAIN/BRANCH树内,包含3个Component(每个Component是一个独立的子树,后面会说到):SENSE、DECISION-MAKE、STATE-SWITCH。同时,我们在黑板中维护一个L1级别的STATE变量(这个State可以被认为是Persistent Impulse)。

在SENSE Component中,我们感知环境信息(AI-Perception、EQS etc.),并将结果存储在黑板中。在DECISION-MAKE Component中,我们根据黑板信息进行决策(对于MAIN/BRANCH TREE而言,主要是决定该走向哪个STATE)。在STATE-SWITCH Component中,根据黑板中的STATE取值,决定该运行哪个子树。

类似地,对于STATE TREE而言,STATE-SWITCH Component的位置被替换为ACTION Component,其作用也类似地是根据黑板中的Impulse取值来对应地选择该做什么响应,Component的具体结构在后文会说到。

这里的每个Component都是一个子树,由独立的文件进行管理,方便版本控制(根据项目规模和功能复用程度来安排,最小子树可以更小)。

总结:MAIN/BRANCH TREE本质上是一个分层状态机的无环表述,通过在DECISION-MAKE中判断转移条件(STATE TREE内部也可以访问到STATE变量或通过Impulse间接向上层传值触发修改,状态转移将在下一个TICK被执行),在STATE-SWITCH中选定真正的子树。

而每一个STATE TREE是一个最小的完备可运行的树,用于控制角色的行为。不同的STATE TREE可以通过引用相同的Component子树来达到代码复用的目的。

这里需要注意的是保证每个Component的抽象功能与其定义一致,严禁在Component内部执行与其定位不相符的逻辑节点(例如在SENSE之内执行了决策),Component间的通信必须通过黑板进行值的传递。

4. Component:可复用的逻辑单元

一个可用的AI,一定要有信息收集、思考决策、执行行为这三个步骤,常规的决策树式写法通常是类IF-ELSE地进行分叉并选择执行路径。但随着项目规模的增大,决策树式写法面临的最大问题是逻辑难以查找/DEBUG、难以插入/修改现有逻辑、模块间相互依赖融合/代码难以复用。

因此我的思路是效仿Unity Gameobject-Component的组织形式,将相对独立的逻辑封装起来并形成一个个Component,行为树可以通过组装、连接Component来达成自己的目的。这样做的好处如下:

  • 高可读性。一个问题可以被精准定位在一个具体的范围内(例如:一个AI没有对玩家进行攻击,到底是其思维模块出了错,还是其行为模块出了错?如果定位到了错误,需要修改的范围能否很快确定并且尽可能小?)。
  • 高可复用性。类似的AI通常共享相同的子树,例如人形NPC可能移动Component、目击Component的逻辑是一模一样的,那么可以用同一个子树来管理,一次修改,全面应用。
  • 低耦合,高可拓展性。基于Component组装的行为树,Component间没有耦合,往往在修改时只需要修改到很小的地方(例如,如果想要将一个移动的AI修改为一个站桩的或是飞行的AI,只需要修改DECISION-MAKE Component中对于移动能力的请求Impulse,而移动模块本身只需要插入额外的站桩、飞行Component等待调用即可)。此外,可以随时通过替换、继承Component的形式来对基础行为树模板进行迭代,而不影响到其他功能的使用(例如ACTION Component升级到了2.0版本,只需要替换执行子树的路径,即可完成AI表现的更新)。

4.1. Component的一些实现细节

对于一个Component而言,其内部仍然有可能由多个Component连接而成。为了减少复杂度,我个人更倾向于将Component横向并列摆放,减少树的深度,并且尽可能使用Selector和(False-Return Node under Sequence)来组织,在得到想要的结果后就即时退出Component,减少不必要的开销。如下图所示: ACTION COMPONENT

4.2. Component的进阶应用

利用Impulse在Component间进行通信的一个额外的优势是,可以随时通过覆盖的形式改写前置Component的Impulse。那么对应地,可以产生几种高阶的用法:

  • 团队任务/全局任务。很多项目对AI间协作有一定的需求,或是AI本身除了行为树的自我逻辑之外,可能还要承担高层级的全局任务或者被脚本控制。那么怎么处理这一类的请求呢?基于Component的形式,可以在原有的DECISION-MAKE Component后接入额外的High-level DECISION-MAKE Component,用于处理高层请求并覆写Persistent Impulse。例如,AI在和玩家战斗的过程中,本来期望于本Tick进行开枪,但这时候高层规则告诉AI,你不能把玩家打死,要放水,于是将本来的射击Impulse修改成找掩体Impulse,亦或是将战斗Persistent Impulse修改为Run-away Persistent Impulse。
  • Debug。怎么对AI Debug是一个老大难问题,常规的手段一般就是看黑板、打印日志、断点等。而有了Impulse机制后,可以在ACTION Component前前置一个Debug Component或是直接运行时修改黑板值,强制设置任何你想要的Impulse来无障碍地调试你想要的行为。某种意义上相当于内置了单元测试的大环境,如果QA再强劲一点搞些自动化做冒烟测试,AI策划就可以高枕无忧专注于设计自己的AI行为了。

5. 一些杂七杂八的经验之谈

5.1. 避免执行不需要的节点

行为树就是代码的树,永远不要执行你不需要的代码,这可以减少性能上的开销。为此,需要策划管理好节点的执行顺序,活用Selector和(False-Return Node under Sequence)提前退出。例如:如果视野中没有看到敌人,那么就不需要计算威胁等级、命中率、命中部位之类的参数。

同时,可以将命中率高的退出节点,摆放在执行顺序的较前位置。例如,如果当前枪里没有子弹的情况下,直接退出并产生换弹的Impulse就可以了,其他的SENSE或者DECISION-MAKE都没有必要执行。

5.2. sub-STATE和sub-Component的合理利用

一般来说,对于视野外的AI,程序会自行进行性能优化,但策划本身也要有性能的意识,例如在玩家感知不到的区域AI是否还要利用高消耗的EQS和射线来执行拟人化的思考和行为。是否可以建立简单呆板的次级STATE/Component来仅执行战略层面的操作。这里可以参考刺客信条起源的META-AI分享。

5.3. 避免复制粘贴节点

这里说的避免复制粘贴节点不是要你手动一个个创建,而是尽可能将包含一个独立功能语义的一组节点封装成一个子树。只有将功能模块化,后续迭代起来才方便,批量文本替换永远是最后的选择。