很久没写博客了,最近在项目刚部署到测试服,需要进行压测,老大相当专业的用了行为树来组织压测机器人的代码,这段时间陆陆续续在网上看了不少关于行为树的文章,其中有一篇我觉得写得非常到位,它原文是英文,链接将在下面给出,在这之前看了很多国内的文章都是写的糊里糊涂,后来看过这篇文章后,总算是对行为树的概念入了门。在这里我会把它的大致意思给翻译出来(注:由于原文有不少啰嗦的地方,所以没有逐句的翻译),以它的行为结构为标准,并会在译文中穿插一些自己的理解来谈谈我眼中的行为树,如果有什么不对的地方,欢迎大家指点。
一、介绍
尽管网上已经有大量关于行为树的教程,但是在开发游戏中的 AI 时还是遇到了不少关于行为树的问题。很多教程不关注于代码的实现,而是给出一些非常笼统的节点图,就像这样:
这些节点看起来非常的抽象,这对于我了解行为树的运作机制没有什么帮助,因为这样完全建立不起一个完整行为树的概念。自然也就更不知道如何在代码中进行行为树的实现。
行为树的概念见名知意,它是一棵具有层级结构的树,主要用来控制 AI 的决策,树的末端(注:叶子节点)就是 AI 实际要去做的事情。连接树枝的是各种类型的节点(注:这些节点将在后面讲解),这些节点决定了如何一个 AI 如何从根节点走到某一个叶子节点,并执行相应的命令操作。
二、树的遍历
行为树的一个特点就是它会一层一层的对节点依次进行检查,而每一层都要花费一个 Tick 的时间,所以一棵树要花费很多 Tick 的时间才能完成遍历,这和一般代码的实现有很大的区别。这是一个很没有效率的方式,尤其是当树变得很深的时候。我认为行为树的实现最好要在一次 Tick 里面完成整个行为树的逻辑判断,也就是说最好能在一次 Tick 时间里完成整棵树的遍历。
三、工作流
行为树由多种不同类型的节点组成,这些节点都会返回三种状态中的一种作为节点的运行结果。三种状态分别是:
成功 - Success
失败 - Failure
运行中 - Running
前两个,正如它们的名字一样,是用来向它们的父节点通知运行的成功或失败。第三种是指还在运行中,结果还未确定,会在下一个 Tick 的时候再去检查这个节点的运行结果。这个功能非常重要,它可以让一个节点持续运行一段时间来维持某些行为。比如一个 Walk 节点会在计算寻路和让角色保持行走的过程中持续返回 Running 来让 AI 保持这一状态。如果寻路因为某些原因失败,或是除了某些状况让行走的行为不得不中止,那么这个节点会返回 Failure 来告诉它的父节点;如果这个角色走到了指定的目的地,那么节点返回 Success 来表示这个行走的指令已经成功完成。这些状态可以用来决定行为树的走向,确保 AI 可以按照我们预期的方式来以某些顺序去执行行为树里的行为。
上面是节点的 3 种状态。说完了节点的 3 种状态,下面来说一说行为树中节点的类型。行为树节点类型分为下面 3 种:
组合节点 - Composite
修饰节点 - Decorator
叶子结点 - Leaf
这些节点类型可以说是行为树最精华的内容,下面将分别对这 3 种类型的节点做说明。
1、组合节点
我们先来看看行为树中最常见的组合节点。组合节点通常可以拥有一个或更多的子节点。这些子节点会按照一定的次序或是随机地执行,并会根据执行的结果向父节点返回 Success 、Failure,或是在未执行完毕时返回 Running 这样的结果值。
通过使用组合节点,我们就可以构造出复杂的行为树。其中组合节点又包括下面几种节点:
次序节点
选择节点
并行节点
(1)次序节点
次序节点会按从左往右的顺序依次执行子节点,每个子节点成功之后便轮到下一个执行,直到最后执行完该节点下的全部子节点。如果所有子节点都返回 Success,则向次序节点的父节点返回 Success;其间任何一个子节点返回 Failure,就会立即向次序节点的父节点返回 Failure 的结果。这种性质和与门(AND gate) 是一致的。次序节点有很多的用处,其中最常用的做法就是执行一连串有前后依存关系的行为,其中一个的失败必然导致后续的动作没有进行的意义,比如下面这个例子:
这个次序节点让 AI 角色实现了从走向门、进门、关门等一系列连续动作。整个过程可以描述成这样:次序节点 ->Walk to Door (Success) ->次序节点(Running) ->Open Door (Success) ->次序节点(Running) ->Walk through Door (Success) ->次序节点(Running) ->Close Door (Success) ->次序节点(Running) -> 向次序节点的父节点返回 Success。如果 AI 因为某些原因未能成功走到门前,比如路被挡住了之类的,那么试图开门这些动作都没有意义了。即当 Walk to Door 这个动作失败后,次序节点就会向其父节点返回 Failure。注意这里不是向次序节点返回 Success,可以将次序节点理解成就是一个桥梁的作用,真正想得到这一连串执行结果的是该次序节点的父节点。
次序节点除了非常自然地用于进行一系列前后依存的动作之外,还可以用来做一些其他的事情,比如:
在上面这个例图中,次序节点的子节点不是一系列动作而是一系列的检查。这些子节点会检查 AI 角色是不是饿了,有没有食物,是不是在安全的地点,只有在它们都返回 Success 时,角色才会吃东西。这样使用次序节点可以实现类似于代码中 IF 判断和与门(AND gate)的效果。这些用于判断的子节点可以是其他的组合节点或是修饰节点等等来实现更丰富的效果。比如下面这个使用了逆变节点(注:逆变节点是一种装饰节点,后文阐述)的例子:
尽管功能和前面的例子完全一样,但是通过逆变节点我们在这里创建了一个非门(NOT gate),只有在“Enemies Around(敌人在周围)”这个条件返回 Failure 时,这一步才会返回 Success,从而让角色继续进行吃东西的动作。这意味着这些节点的组合可以减少很多不必要的开发量。
(2)选择节点
选择节点就是次序节点的反面。作为与门(AND gate)的次序节点要求子节点都返回 Success 来让自己返回 Success,选择节点则会在任何一个子节点返回 Success 时就返回 Success 并且不再继续运行后续的子节点。相应的,当所有子节点都 Failure 时,选择节点才会返回 Failure。选择节点其实可以被理解为一个或门(OR gate)。
它的主要作用在于它可以用来表示一个行为的多种方式,从最高优先级到最低,任何一个方式的成功都会让这个动作 Success,比如 Attack(攻击) 节点下的有 Hack、Cut,Chop三个子节点,每当这个 AI 尝试攻击时,都会从这三个中选择一个来执行,只要任何一个子节点执行成功,该 Attack 节点就返回成功。
(3)并行节点
我们每个节点都会有一个运行状态,来表示当前行为是否结束。对于组合节点来说,它的运行状态就是其子节点的运行状态,选择节点和次序节点比较好处理,因为对于这两种控制节点来说,每时刻,只会有一个子节点在运行,只要返回在运行的这个子节点的状态即可。但对于并行节点来说,它同时刻会有多个子节点运行。
2、修饰节点
修饰节点也可以拥有子节点,但是不同于组合节点,它只能拥有一个子节点。取决于修饰节点的类型,它的功能要么是修改子节点返回的结果、终止子节点,或是重复执行子节点等等。常见的修饰节点有以下几种:
逆变节点
成功节点
重复节点
(1)逆变节点
Inverter(逆变节点)可以将子节点的结果倒转,比如子节点返回了 Failure,则这个修饰节点会向上返回 Success,就像上面的例子里所说的一样。
(2)成功节点
成功节点不管它的子节点向其返回的结果为何,它总是向它的父节点返回 Success 的结果。这个往往用在当你知道一个子节点一定会返回 Failure 的结果,而它的父节点是次序节点,会因为子节点的 Failure 而终止,那么你可以强行让这个子节点返回 Success,来避免这一情况的发生。我们并不需要一个专门的失败节点,因为一个逆变节点加上成功节点就可以达到这一效果。
(3)重复节点
重复节点会在它的子节点返回结果后反复继续执行它。重复节点常常被用在一棵树的最顶部来确保树的持续运行。另外重复节点也可以被设定重复执行的次数。
3、叶子节点
叶节点是最低层的节点,它们不会拥有子节点。叶节点是最强大的节点类型,它们是真正让你行为树做具体事情的节点。通过与组合节点和修饰节点的配合,再加上你自己对叶子节点功能的定义,你可以实现非常复杂的、智能的 AI 行为逻辑。拿代码作为类比的话,组合节点和修饰节点就好比那些改变代码 Flow( 工作流程 ) 的 If 判断和 While 循环等等,而叶节点就是那些真正起作用的被调用的方法,去让角色做具体的事情。
比如一个 Walk 叶子节点可以包含一个具体将要移动到的位置参数,而这个参数可以从其他变量里获得,比如角色将要前往的一个地点参数可以被 GetSafeLocation 这个节点所决定,存入一个变量里,然后 Walk 节点可以使用这个变量来定义它的目的地。在行为树中, 这样将一个节点的数据用在其他节点上的行为, 用好了是非常强大的, 行为树的运行中,这些不同的节点通过数据上下文来共同储存或使用一些持久数据(persistent data),使得行为树的功能变得强大。另一种叶节点的类型是调用其他的行为树并把当前行为树的数据传给对方,可见一种是获得数据另一种是产生数据。