天天看点

游戏AI设计经验分享——行为树研究

简介

因为网上有太多的行为树的教程和手册,当我在决定哪一个适合Zomboid项目时,总是反复遇到相同的问题。我看的很多手册都很严重地依赖于具体代码的实现,或者简单地基于通用的节点的工作流,都没有实际的实现案例,就像下面这张图:

游戏AI设计经验分享——行为树研究

因为那些教程对于我理解行为树的核心规则没有用处,我发现我尽管知道行为树是如何操作的,但对于在游戏中应当使用何种节点,或者真正完整的行为树是怎样的,都没有一个实际的概念。

我已经花了海量的时间做试验,所以我不担心实际的编码实现,而且关于这有大量的教程,基于各种游戏引擎的都有。

可能在我描述的稍微具体点的修饰节点类型中有些实际上是包含于JBT的,而不是通常的行为树的概念,但是我发现它们在PZ行为树完全适用,所以,如果你的行为树框架不支持的话,也很值得考虑实现一下。

我并不是想说我想在行为树上成为专家,然而在开发Zomboid项目的NPC的过程中我发现并不能这样,所以我花心思搞出几样东西,有了它们会让我的第一次尝试更加顺畅,或者至少让我知道用行为树能做到什么。我不会深入阐述具体实现,仅仅给出几个抽象的例子,它们都是我在Zomboid项目中使用的。

基础

顾名思义,不同于有限状态机或者其它AI系统,行为树就是一棵节点层次分明的树,控制着AI物体的一系列决定。从树延伸出的叶子节点,执行控制AI物体的指令。各种工具节点组成树的分支,来控制AI指令的走向以形成一系列的指令,这样来满足游戏需要。

它可以是一棵很高的树,可以具有完成特定功能的子树,开发者可以创建行为库并把它们适当地连接起来以达到非常真实的AI行为。开发过程是高度可迭代的,你可以先排出一个基础的行为树,然后创建新的分支来处理各种达到目标的可选方案,这些分支按照它们的优先级排列,这样AI在一个特定的行为失败可以回溯到另一个策略,这是行为树巨大优势所在。

数据驱动 vs 代码驱动

这个区别与这篇手册关联不大,但是应该提一下,行为树可能有很多种方法来实现。一个主要的区别是行为树是否在代码之外被定义的:可能用XML文件或者其它专门的格式,用外部编辑器来修改;也可能是直接在代码中的嵌套的类实例。

JBT用一种比较奇特的方法,混合上述两种方式。你可以用一个编辑器来可视化建立你的行为树,但是实际上是一个导出的命令行工具生成了java代码,在代码中表示你的行为树。

不论如果实现,叶子节点是你实际处理游戏逻辑的地方,用来控制你的角色或者判断角色所处的情景或周围的事物,这些东西你都需要自已在代码里定义,代码可以是你本地的语言或者Lua和Python这样的脚本语言,而行为树会利用它们达到复杂的行为。这些节点都是有实际作用的,有时它们就像标准库一样调用,行为树自己处理内部数据,而不是简单地给角色发送指令。行为树这一点让我很兴奋。

树的遍历

行为树的一个核心方面就是,不同于你代码中的方法,某个特定的节点或者分支可以要花好几帧才能完成。行为树的基本实现中,系统每一帧从树的根部开始遍历,检测每一个节点是否被激活,沿途重新检查所有节点,直到到达当前激活的节点让它刷新。

这不是一个高效的方法,尤其当随着开发过程它变得越来越高,扩展得很大的时候。我想说很有必要在你实现的行为树中保存正在处理的节点,这样下次就能直接刷新而不是每一帧都遍历整棵树。应该感谢JBT已经做到了这一点。

工作流

行为树是由很多类型节点组成的,但是它们都有一些核心的功能,那就是它们都返回三种状态之一。(这依赖于行为树的具体实现,可以有三种以上的状态,但是我还没有实践过这些,它们和主题也没太大关系)有如下三个状态:

Success

Failure

Running

前面的两个,就像它们的名字一样,通知它们的父节点它们的操作是成功或者失败的。第三个表明成功或者失败还不确定,这个节点还会一直运行,下一次整棵树刷新时它仍然会刷新,那时将再次有机会决定它是成功、失败或者继续运行。

这个功能是行为树强大的关键所在,因为这允许一个节点持续几帧进行操作。例如,一个“行走”节点,在它计算路径时和移动角色到目标地点时会提交Running状态。如果因为某种原因寻路失败,或者有障碍阻挡角色到达目的地,这个节点会返回failure给它的父节点。一旦角色到达了目的地,它会返回success,表明Walk命令成功执行了。

这说明这个节点就它本身来说有一个固定不变的协议来表示成功和失败,任何使用它的行为树都可以从它获取到这个结果。这些状态传导和定义整棵行为树的工作流,生成一系列事件和多个不同的执行路径,从而达到想要的AI行为。

行为树节点的原型

Composite

Decorator

Leaf

游戏AI设计经验分享——行为树研究

Composite(合成节点)

合成节点可以有一个或多个子节点。它们处理子节点的顺序可以是从第一个到最后一个,或者某些特定的合成节点的随机顺序,在某一阶段会根据它的子节点的处理结果向它的父节点返回success或者failure,通常这取决于它的子节点的success或者failure(译者:这里提到了三层节点)。当它在处理子节点时,会向它的父节点持续发送running。

最常用的合成节点是Sequence节点,它按照顺序运行每一个子节点,如果任何一个子节点返回了failure,它返回failure;如果所有子节点返回成功状态,它才返回成功。

Decorator(修饰节点)

修饰节点同合成节点相似,可以拥有子节点,与之不同点在于,它有且只有一个子节点。它的功能就是:将子节点的结果传递给父节点,停止子节点;或者重复执行子节点,这取决于具体的修饰节点类型。

一个常用的修饰节点的用法就是Inverter(反相器),它只是把子节点的结果反相。当它的子节点返回了失败,它给它的父节点返回成功,反之亦反。

Leaf(叶子节点)

它是最底层的节点类型,不能拥有子节点。

但叶子节点是最强大的节点类型,因为它在游戏中被你定义和实现,来做具体游戏或具体角色的检测或者动作,让你的行为树真正做一些事情。

举一个例子,和之前类似,是一个行走的行为。一个Walk节点会让角色行走到指定的地点,然后根据行走的结果来返回成功或者失败。

因为你可以定义你自已的叶子节点(经常是少量代码),放在合成节点和修饰节点以下,使你可以做出很强大的行为树,可以有很复杂的层次和智能优先级的行为,这非常优秀。

用游戏代码去类比,可以将合成和修饰节点当作函数、分支结构和循环结构,还有其它编程语言的结构,用它们来定义你代码的逻辑。而叶子节点就像游戏中具体的代码逻辑,会让你的AI角色做一些实际上的事情或者检测它们的状态或场景。

叶子节点可以带参数。例如Walk节点就可以接收一个角色将走向的坐标。

这些参数可以取自保存在行为树空间中的变量。例如一个目标点可以被一个“获取安全地点”节点来决定,保存在变量中,然后Walk节点可以利用这个值来定义目标地点。这是通过使用一个节点之间共享的空间来保存和修改任意的长驻的变量来实现的,它让行为树变得无比强大。

另一个叶子节点的大类型是调用其它的行为树的节点,将已存在的行为树的数据空间传递给被调用的行为树。

这一点很重要,因为这允许你将行为树深度模块化来创建可以无限重用的行为树,可能会用到空间中一个特定的变量来操作。例如,一个“闯入建筑”的行为可能需要一个“目标建筑”的变量来进行操作,所以父树可以在空间设置这个变量,然后通过一个子树的叶子节点调用另一棵子树。

Composite Nodes(合成节点)

接下来我们会讨论在行为树中见到的最常用的合成节点。也有其它的,但我们只包含这些基本的,已经可以让你写出很复杂的行为树了。

Sequences(序列)

行为树中应用的最简单的合成节点,它的名称已经说明了一切。一个序列节点会顺序访问每个子节点,从第一个开始,如果它返回成功,那么访问下一个,依次类推。如果任何子节点失败了,它立即向它的你节点返回失败;如果最后一个子节点也成功,序列节点会向它的父节点返回成功。

你必须明白这个类型的节点在行为树中有着广泛的应用。最明显的一个用法是,定义一系列必须全部完成的任务,任何一个节点的失败都意味着后续节点的处理都是无用的。

例如:

游戏AI设计经验分享——行为树研究

这个序列很明显,让角色穿过一扇门,然后关上身后的门。事实上,这些节点可能有点抽象,在实际生产环境中会使用一些参数。Walk(地点),Open(是否开着),Walk(地点),Close(是否开着);

处理顺序如下:

Sequence -> Walk to Door (success) -> Sequence (running) -> Open Door (success) -> Sequence (running) -> Walk through Door (success) -> Sequence (running) -> Close Door (success) -> Sequence (success) -> at which point the sequence returns success to its own parent.

如果角色没有走到门前,可能路被挡住了,那么尝试去开门也没有意义了,更不用说穿过门。序列节点在行走失败的时候已经返回失败了,然后这个序列节点的父节点可以很好地处理这个失败。

上述的序列节点让角色完成一系列的动作,因为这似乎是行为树的唯一用途,所以你可能不会想到除了让角色完成一系列“事情”之外,还有很多不同的方法来使用序列节点。想一下下面的例子:

游戏AI设计经验分享——行为树研究

在上面的例子中,我们没有用一系列的动作而用了检测。子节点检测角色是否饿了,是否有食物,是否在安全地点,只有这些检测都成功了之后,角色才会吃食物。这样使用序列节点让你可以在执行一个动作之前执行一个或多个检测,就像代码里的if条件,电路里的与门。因为需要所有子节点都成功,而且子节点可以是任意合成节点、修饰节点和叶子节点的组合,你可以在你的AI中创建非常强大的条件判断。

思考下面的例子,用到了上文提到的反相器:

游戏AI设计经验分享——行为树研究

与上一例子功能相同,我们展示了如何使用反相器来将任何检测取反,这样你得到了一个非门。这意味着你可以暴力地剪掉一堆节点来测试角色或者游戏的一些逻辑。

Selector(选择器)

选择器与序列节点正好相反。序列节点的作用是“与”,需要所有子节点都成功才返回成功,而选择器只要有一个子节点返回了成功,它就返回成功,而且不再处理后续的节点。它先处理第一个节点,如果失败了,就处理第二个,如果再失败了,就第三个…直到有一个成功,那么选择器会立即返回成功。如果所有子节点都失败了,它才返回失败。这表示选择器是一个“或”门,或者作为一个条件判断用来判断多个条件中是否有一个真的。

它最大的优点在于它可以代表多种不同的动作组合,按照最希望的到最不希望排列优先级,如果任何一支成功了它就返回成功。它可以包含很多的结果,利用它可以快速构建出很复杂的行为树。

让我再看一下之前的进门序列案例,让它变得更复杂一点,加入一个选择器来解决。

游戏AI设计经验分享——行为树研究

如你所看到的,我们可以智能地解决上锁的门,仅仅用了少数几个节点。

所以当选择器在处理时发生了什么呢?

首先,它先处理“开门”节点,最希望的动作就是直接开门,毫无疑问。如果顺利开门了,那选择器成功,知道了这个动作已经成功完成。那么就没有必要处理后面的子节点了。

但是,如果因为有人锁上了,开不了门,那“开门”节点会失败,将失败状态返回给选择器。这时选择器会执行第二个节点(或者第二希望执行的动作),来尝试打开门锁。

这里我们创建了另一个序列(必须全部完成才会向选择器返回成功),先打开门锁,然后尝试打开门。

如果开锁也失败了(可能AI没有锁匙,或者没有开锁技巧,或是已经撬开了锁,但发现门是固定的根本打不开?),那么它会向选择器返回失败,然后它会尝试第三种做法,把门暴力地撞开。

如果角色不够强壮,那他可能又要失败了。这时没有更多的动作组合,那这个选择器就返回失败,相应地它的父节点也返回失败,放弃穿过门的尝试了。

让我们走得更远些,可能在那个序列节点上面还有一个选择器,因为这个序列节点的失败决定使用另一套动作?

游戏AI设计经验分享——行为树研究

这里我们扩展了这个行为树,在最上层增加了一个选择器。左边(最希望的)我们从门进入,如果失败了,就尝试从窗户进入。实际上的实现会和这个不太一样,和我们在Zomboid项目中相比还是很简单,但是足够表达意思了,后面我们将会得到更通用和更实用的实现。

总之,我们得到了一个可靠的“进入建筑”的行为,或者进入建筑,或者通知父节点不能进入。可能根本连窗户都没有呢?这样最顶层的选择器就失败了,可能这时一个父节点会让AI去另一个建筑?

对于我以前的尝试来说,大大简化行为树开发的一个重要因素就是,失败并不意味着就要停止我正在做的事情(例如,寻路失败了,怎么办?),而是很自然地在行为树中做出自然而合适的决定。

你可以将容错机制和适应所有可能情况的可选行为组合放进去。一个Zomboid项目中例子就是EnsureItemInInventorybahviour。

这个行为接收一个物品类型,然后使用一个选择器来从几种不同动作中决定一个,来确定这个物品是否在NPC的物品栏里,包括使用不同的参数对这个行为进行递归调用。

首先,它会检测这个物品是否已经存在于这个角色的主物品栏中,这是最理想的情况,什么都不必再做。如果是的话,选择器成功,整个行为成功。EnsureItemInInventory就成功了,可以使用这个物品。

如果不在角色的物品栏中,那么它会检测角色的袋子或者背包中的内容。如果找到了,它会把物品传送到主物品栏中。这会返回一个成功,然后整个行为成功。

如果上面失败了,那选择器的第三个分支会做的的确定它是否在角色居住的建筑中。如果是,角色会走到有这个物品的容器的位置,将它拿出来。依然行为是成功的。

如果上面还失败了,就要考验NPC的手艺了。它会遍历合成菜单,找到想要的物品,还会遍历找到合成需要的原料,再递归地调用EnsureItemInventorybehaviour找到每一个原料。那些动作都成功了,我们就知道NPC拥有合成那个物品的所有原料了。角色会使用这些原料制作出物品,我们已经知道拥有这个物品了,然后返回成功。

如果上面还是失败了,那EnsureItemInInventorybehaviour行为就失败了,没有再多回溯,NPC会将这个物品列入愿望清单,在没有这个物品的情况继续生存,并在完成任务过程中寻找它。

事实是,只要拥有原料,NPC就能立即制作出来,即使没有原料也可以从建筑中取到。

因为行为可以递归的特性,如果他自己没有原料,他会尝试用更底层的原料来制作原料,如有必要还会搜索建筑,将各个阶段的物品制作出来,以制作最终想要的物品。

这样我马上就拥有了一个很复杂而且很好看的AI行为,实现方式也只是几层节点。EnsureItemInInventory行为可以在其它行为树中任意使用,适用于所有我们需要确定NPC是否拥有某种物品的情况。

我觉得有些情况下,在开发过程中我们会做得更多,会有另一个回溯,假如他急切需要这个物品,就允许NPC出去寻找它,选择一个掠夺的目标,很有可能会得到那个物品。

另一个相对优先级比较高的容错机制是,考虑别的具有相同功用的物品。如果我们实现了对临时工具的支持,当需要钉钉子时,比起穿越整个街区去一个被僵尸感染的五金店找锤子,还不如寻找不太有效的替代工具比如石头。

因为开发过程中扩展行为树很方便,可以先创建一个简单的行为“做某事”,然后使用选择器通过加入容错机制和回溯机制来减少失败的可能性。制造的回溯被加在很后面,而且也仅仅是找到装备更多的NPC,他们具有帮助别人制造物品的行为。

除些之外,如果优先级分配很合理,这些回溯操作除了要高效的实现代码,还要处理智能问题和自然决策。

Random Selectors / Sequences(随机选择器/序列节点)

我不再细细探究这个了,因为它们的行为之前已经说过了。随机选择器/序列节点的工作方式就像它们的名字,除了子节点的实际操作顺序是随机的。这适用于角色对于每一种动作组合没有偏向性,给予它更多的不可预测的因素。

Decorator Nodes(修饰节点)

Inverter(反相器)

我们之前已经说过了。将它们放在节点之上可以将其结果反相,成功变失败,失败变成功。它最常用在条件的测试上。

Succeeder(成功节点)

成功节点不管子节点返回什么,它都返回成功。这适用于,有一个你希望或者可预料到会返回失败的节点,但是你不想让它阻止它所在的序列的运行。如果是相反的情况,你会需要一个failer节点。

Repeater(重复节点)

重复节点每当它的子节点返回一个结果时,会重复处理这个节点。这适用于行为树中非常基础的部分,需要它持续运行。重复节点可以是重复执行指定次数就返回。

Repeat Until Fail(重复直到失败)

像重复节点一样,这个节点也会持续重复处理子节点。但是直到子节点返回了失败,它就会向父节点返回成功。

Data Context(数据空间)

它的具体实现取决于行为树的具体实现、使用的编程语言和其它因素,所以我们只在抽象和概念层面讨论它。

当AI物体的行为树被调用时,也会创建一个数据空间,作为一个存储机制来存储数据,这些数据在节点中解释和修改(使用C#中的字典、Java中的HashMap、可能用C++的string/void* STL map创建序列,已经很久不用C++了,应该有更好的方式)。

节点可以读写这些变量,用以后续节点的处理,这样行为树就成为一个有机的整体。一旦你开始着重使用这块内容,行为树的复杂度和适用范围就非常可观了,你指尖的力量将是巨大的。一会儿当我们再次回到我们的“门和窗”行为时将会用到这个。

Defining Leaf Nodes(定义叶子节点)

同样的,它的具体内容取决于具体实现。为了赋予叶子节点功能,让具体的游戏逻辑能够添加到行为树中,大多系统都有两个需要实现的方法。

Init – 当节点第一次被父节点访问时调用。例如,当序列节点要处理它的子节点时会调用这个方法,它完成了这次处理返回了之后,下一次再执行时,就不会调用init方法了。这个方法用于初始化节点,开始节点的动作。拿我们的例子来说,它会接收参数,可能初始化寻路工作。

方法里返回成功或失败,它的执行将会终止,结果返回给父节点。如果他返回Running,它会在下一帧被重复执行,直到它返回成功或失败。在我们的例子当中,在寻路返回成功或失败之前,它会一直返回Running。

节点可以拥有一些字段,可能是明确指定传入的参数,也可以是数据空间的变量的引用。

我不会讨论具体实现,因为它不仅依赖于语言还依赖于行为树的实现,但是参数和数据存储的概念是通用的。

例如,我们可能会这样定义Walk节点:

Walk (character, destination)

  • success: Reached destination
  • failure: Failed to reach destination
  • running: En route

这种情况下,Walk有两个参数,角色和目标地点。我们会很自然地想到运行这个AI行为的角色是确定的,因而我们没必要明确将他作为参数传递,但是最好不要这样想,尽管对于Walk是一个很靠谱的假设。有太多次的经验,尤其是在条件节点,我在测试不同的角色状态时或者交互时总是需要修改代码,所以最好是多废点力气将角色当参数传入,即使你坚信只有那个AI会需要它。

目标地点这个参数,就像我之前说的,可以手动填入X,Y,Z坐标。但是很有可能它会保存在数据空间,被另一个节点引用,可能包含了另一个游戏物体、建筑的位置,或者可能根据NPC的所在位置计算出来的安全地点。

Stacks(栈)

第一次思考行为树时,很自然地把节点的使用范围与角色动作、条件判断或者角色环境联系起来,这将会限制你发挥出行为树的强大力量。

当我用节点实现栈操作时,我发现了这一点。所以我在游戏中加入了以下的实现:

PushToStack(item, stackVar)

PopFromStack(stack, itemVar)

IsEmpty(stack)

就是这样,就这么三个节点。它们需要的也是init/process方法,用了很少的代码实现了创建和修改标准库的栈的操作,而且,衍生出了更多可能性。

例如,PushToStack会存储传入变量名,压入栈中,如果栈不存在则创建一个。

相似地,pop方法将元素弹出栈,将值存储在itemVar变量中,如果栈是空的,则会失败,所以有IsEmpty节点来检查栈是不是空的,如果是空就返回成功。

有了上面的节点,我们可以这样来遍历整个栈:

游戏AI设计经验分享——行为树研究

使用一个“直到失败”的重复节点,我们可以重复从栈中弹出元素,并执行一些操作,直到栈为空,PopFromStack会返回失败,然后退出“直到失败”重复节点。

  

接下来是几个其它我常用的很重要的工具

SetVariable(varName, object)

IsNull(object)

这允许我们通过行为树设置任意的变量。合成节点和修饰节点未提供足够的支持来让我们获取到行为树的信息,这时它们就非常有用了。随后我们会创造这么个情景,尽管我觉得还是有方法来解决的,它不是必需的。

现在假设我们添加一个节点叫GetDoorStackFromBuilding,会传入一个建筑物体,它会从中取出门物体的一个列表,用这些物体新建并且填充一个栈,然后设置目标。我怎么用上面提到的工具来完成呢?

哎呀,搞得略复杂,一眼看过去很难知道到底在干嘛,但和任何语言一样,到最后还是很容易理解的,而且你牺牲可读性换取了复杂度。

但是它到底做了什么?一开始你可能有点头疼,但是只要你熟悉了节点的工作方法,以及失败和成功的状态是怎么传递的,就很容易理解了。如有必要我可能扩展这一部分到行为树的Walk,假如我的描述不够充分的话。

简而言之,这是一个会获取建筑所有门并进入,并且如果角色进入任意一个门就会返回成功的行为,如果未能进入,则返回失败。

首先它获取一个包含了进入建筑的所有门的栈,然后调用一个“直到失败”的重复节点,它会重复执行直到子节点返回失败。

那个子节点是一个序列节点,先从栈中弹出一个门,存储在door变量中。

如果栈是空的,那说明根本没有门,这个节点就会失败,直到跳出重复节点,重复节点返回一个成功(“直到失败”节点总是返回成功),继续处理这个序列,我们加入了一个反相的IsNull检测usedDoor。如果usedDoor是空(因为从来没有设置过这个值),这会导致整个行为失败。

如果栈确实弹出了一个门物体,就会调用另一个序列(加了反相),它会尝试走向门,打开然后穿过。

假如NPC用尽各种方法也没穿过门去(门锁了,NPC也不够强壮将其打开),选择器就失败,返回失败给它的父节点,是一个反相器,将失败反相为成功,意味着它无法跳出重复节点,然后回来再次调用它的子序列,将下一个门弹出,然后NPC会尝试这个门。

如果NPC成功进入了一个门,那么它将会将usedDoor设置为door的值,这时序列节点返回一个成功,这个成功被反相为失败,之后跳出重复节点的循环。

这种情况下,我们在IsNull节点的节点返回失败,因为usedDoor不是空。它被反相为成功,导致整个行为成功。更高一层的父节点知道NPC成功找到一个门,进入了建筑。

如果行为是失败的,那么会用一个GetWindwoStackFromBuilding节点来重复执行,来重复之前的操作从窗户进入,需要少量的节点执行栈操作。或许你可以先后调用GetDoorStackFromBuilding和GetWindowStackFromBuilding,将窗户压入门的栈顶,然后在同一个循环里处理所有,对门和窗执行相同的Open,Unlock,Close操作,还有变量的检测。

最后,你可以注意到我到close door节点之上加了一个成功节点,这是因为如果NPC是破坏掉门进去的,它关门的动作会失败。

如果没有那个成功节点,会导致这个序列返回失败,没有给useDoor变量赋值,并尝试下一个门。一个可选方案是让CloseDoor节点总是返回成功,即使门被破坏了。但是,我们想要检测关门是否成功(例如,在“保护安全屋”行为中,会将关不上门视为失败,因为门已经不在门框上,也就是不安全了!),所以成功节点可以让那个失败被忽略,如果需要那种行为。

原文发布时间为:2018-07-05

本文作者:Chris Simpson

本文来自云栖社区合作伙伴“

Golang语言社区

”,了解相关信息可以关注“

继续阅读