行为树是实现游戏AI的一个重要方案,本文主要介绍行为树的理论基础,并通过实现一些简单示例来加深理解。本文的理论和示例基于著名的 Unity 插件 Behaviour Designer 而来,并参考了腾讯的行为树开源方案 behavic
1. 什么是行为树
1.1 决策模型
我们在开发游戏过程中通常需要为游戏中的非玩家控制主体,如小怪、Boss等,附加一些AI行为,核心是根据当前的游戏世界状态信息,进行决策,确定主体的行为并对行为状态进行监控,例如 Boss 根据自身血量范围、玩家的血量范围,确定是逃跑还是释放技能。下面这张图展示了一个行为决策模型。
1.2 状态机
有限状态机(FSM,Finite State Machine)是最简单最经典的行为决策模型,主体绑定一个包含若干状态的 FSM,任何时候主体必处于若干状态中的一种,当外界条件发生变化时,检查 FSM,看看当前状态在接受该条件时将会转换到哪一个状态,将主体切换到该状态下。
有限状态机作为决策模型存在以下的问题:
- 并不具备模块性。状态本身并没有复用的意义
- 扩展性差。当你需要在目前的状态机中增加一个状态(或条件)时,你需要考虑已有的所有状态和条件跟新增状态(或条件)的连接关系,当状态和条件的数目较大时,这是灾难性的变化
- 维护性差。特别是状态和条件数量较大时,蜘蛛网一般的状态机相当难以维护
1.3 行为树
行为树的决策方式基于他的树形数据结构,在需要进行决策时,从树的根节点出发,按照一定顺序遍历子节点,遍历的过程中进行一系列的条件判断,决策得到最后的行为节点并执行。下面的示例是一个很简单的行为树,它描述的决策逻辑是:当自身血量低于 40% 时逃跑,否则进行攻击。这里先对行为树有个整体大概的印象,暂不必纠结于中间各种节点的作用。
2. 行为树中的节点类型
2.1 三大类节点
行为树由若干节点组成,节点可以分为三大类:根节点、控制类节点和行为类节点
- 根节点
行为树必须包含一个根节点,根节点是行为树决策的起始,它只能有一个子节点。 - 行为类节点
行为类节点通常绑定具体的游戏逻辑,又可以分为条件节点和动作节点,条件节点通常是读取游戏世界的状态,例如判断自身血量、敌人数量等,动作节点通常是改变游戏世界状态,例如攻击、逃跑、释放技能等,在行为树中,行为类节点都是叶子节点,且叶子节点只能是行为类节点。 - 控制类节点
和游戏世界以及游戏逻辑没有任何关系,作用是驱动决策逻辑、限制行为类节点,后面会详细介绍常用的控制类节点及其作用,控制类节点不能是叶子节点,必须包含以其他控制类节点或行为类节点为子节点。
2.2 节点返回值
行为树中每个节点都有返回值。叶子节点的返回值由它绑定的条件或行为方法确定,非叶子节点的返回值由它的子节点的返回值根据一定规则来确定,返回值的类型有三种:
- Success 成功
通常来自于条件判断类节点的返回,或瞬时完成的动作节点的返回,例如 BehaviourDesigner 中 Log 在打印日志后直接返回 Sucess - Failure 失败
通常来自于条件判断类节点的返回,或瞬时操作的动作节点返回 - Running 正在运行
通常由具有状态的动作节点返回,例如往目标移动,在移动到目标位置之前,都处于移动的状态,此时移动这个节点的返回值因为 Running,Running 返回值的存在和打断在行为树中非常关键
2.3 行为类节点
行为类节点通常与游戏世界状态相关,其中条件节点Conditional通常读取游戏世界的某个状态,动作节点Action 执行游戏世界的某个动作。
使用 Behaviour Designer为场景中的空 GameObject 新建一个 Behaviour Tree 如图
这个行为树的根节点直接连接一个 Action 作为子节点,该子节点的作用是打印日志,保存行为树,运行场景,该 GameObject 确实驱动行为树打印了一行日志
BehaviourDesigner 内置了一系列的 Action 节点,包括:
- Basic 组
集合了 Unity 内置组件的方法,如Animator.Play 等,当你选择对应的组件和方法时,在执行该 Action 时会从 gameObject 上获取对应的组件,执行对应的方法,并返回 Success - Reflection 组
非常值得注意的一组,包含了如下选项
Reflection 节点组
逻辑是通过反射的方法,来获取 gameObject 上自定义脚本,并调用脚本方法、读写字段值、读写属性值,并返回 Success - 其它
BehaviourDesigner 内置的行为,如打印日志 Log、等待一定时间 Wait 等,Wait 节点在等待的时间内返回值为 Running,超过等待时间则返回 Success
BehaviourDesigner 内置的Conditionals节点必须包含 Bool 类型的返回值,Conditionals 节点包含
- Basic/Physic
Unity 内置组件的 boolean 类型的方法或属性,返回对应的值 - Reflection
通过反射的方法调用 gameObject 上的脚本方法,返回对应的 boolean 值 - IsClient/IsServer
基本不用,且在高版本已经 deprecated - Random Probability
指定一个概率值,随机返回 Success或 Failure
2.4 控制类节点
控制类节点和游戏世界无关,聚焦行为树框架本身的控制逻辑,通常是用来控制和管理子节点,并根据子节点的返回结果来确定自己的返回结果。根据子节点的数量,可以将控制类节点分为复合节点Composites和装饰节点Decorators。
2.4.1 复合节点
复合节点可以包含1个或多个子节点,常见的复合节点类型包括序列、选择和并行等。
-
序列节点 Sequence
当条件满足时
类似逻辑与操作,从左只有一次执行子节点,当某个子节点返回 Failure 时,自身返回 Failure,后续子节点不再执行;若所有子节点都返回 Success,则自身返回 Success。Sequence 最常用的是附加一个条件子节点和动作子节点,当满足条件时执行该动作子节点,以达到if (condition) { doAction();}
的效果
当条件不满足时,将不会执行动作子节点,且 Sequence 自身也返回 Failure
当条件不满足时 -
选择节点 Selector
Selector
类似逻辑或操作,从左至右依次执行子节点,当某个子节点的执行返回 Success时,自身返回 Success,后续子节点不会再执行;若所有子节点执行结果都返回 Failure,则自身返回 Failure。Selector 通常用来在若干个可选操作中按照顺序依次选择一个。
-
并行节点 Parallel
并行节点
并行节点同时执行所有子节点,子节点全部执行完成后再根据他们的返回值来确定自身返回值。返回值确定的逻辑类似 Sequence 节点,当有子节点返回 Failure 时自身返回 Failure,当所有子节点都返回 Success时,并行子节点返回 Success
当子节点都返回 Success时
并行节点 -
其它的复合节点
其它复合节点基本都是这三种节点的变种,具体的复合节点返回值逻辑可以查看源码,例如 RandomSelector/RandomSequence 的返回值逻辑分别和 Selector/Sequence 的返回值逻辑相同,只不过他们对子节点的执行顺序进行了随机排序。
你也可以扩展自己的复合节点,以实现特定的返回值控制逻辑,本文的 3.2.1 小节扩展了一个具有 if-else逻辑的复合节点
可以扩展自己的复合节点,来实现特定的返回值控制逻辑。如下代码扩展了一个具有 if-else 逻辑的复合节点
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using BehaviorDesigner.Runtime.Tasks;
public class IfElse : Composite
{
private int currentChildIndex = 0;
private TaskStatus executionStatus = TaskStatus.Inactive;
public override int CurrentChildIndex()
{
return currentChildIndex;
}
public override bool CanExecute()
{
if (currentChildIndex == 0)
{
return true;
}
if (executionStatus == TaskStatus.Success)
{
return currentChildIndex == 1;
}
else
{
return currentChildIndex == 2;
}
}
public override void OnChildExecuted(TaskStatus childStatus)
{
if (currentChildIndex == 0)
{
if (childStatus == TaskStatus.Success)
{
currentChildIndex = 1;
} else
{
currentChildIndex = 2;
}
}
else
{
// if 或 else 分支的节点返回了,整个节点就返回了
currentChildIndex = 99;
}
executionStatus = childStatus;
}
public override void OnConditionalAbort(int childIndex)
{
currentChildIndex = childIndex;
executionStatus = TaskStatus.Inactive;
}
public override void OnEnd()
{
executionStatus = TaskStatus.Inactive;
currentChildIndex = 0;
}
}
该组合节点有3个子节点,第一个子节点为条件节点,组合节点的逻辑是:当第一个条件子节点返回 Success时,执行第二个子节点,否则执行第三个子节点。当设置第一个条件子节点返回值为 Failure 时的执行情况:
If-else
修改条件子节点,使其返回 Success,执行情况为
if-else 节点.png
2.4.2 装饰节点
装饰节点只能有一个子节点,装饰节点的作用是针对这个子节点的执行逻辑和返回值进行一些限制和调整。
-
反转 Inverter
Inverter节点
Inverter 节点将子节点的返回值进行取反,类似逻辑非操作,作用如下图
-
条件计算 Conditional Evaluator
条件计算节点实现了实现了第一个节点为条件节点的 Selector 复合节点功能。条件计算节点本身需要指定一个条件计算,当该条件计算返回 Success 时,执行子节点并返回子节点的返回值;当该条件计算返回 Failure时,子节点将不会执行,本身返回 Failure
条件计算节点 -
打断 Interrupt
-
循环 Repeater
循环节点可以让子节点循环执行指定次数或永久循环,这里需要注意的循环并不是在行为树的一个 tick 内循环,而是每一个 tick 执行一次。这里使用一个打印当前是第几帧的自定义 Action 节点 FrameCountAction 来作为循环节点的子节点,设置循环10次,行为树如下图
循环执行
打印的结果是
FrameCount -
Return Success/Return Failure
无论子节点的返回值是 Success还是 Failure,自身都返回 Success/Failure;如果子节点返回 Running,则自身也返回 Running
当指定一个无限循环子节点(返回值恒为 Running) 时的情形
子节点无限循环
当指定一个子节点返回为 Success的情形如下图,尽管子节点返回 Success,但装饰节点还是返回了 Failure
子节点返回成功
-
循环直到失败 Until Failure
在子节点返回 Failure 之前,自身都返回 Running,当子节点返回 Failure 时,自身返回 Failure -
循环直到成功 Until Success
在子节点返回 Success 之前,自身都返回 Running,当子节点返回 Success 时,自身返回 Success
2.5 扩展节点
大多数的行为树框架都提供了节点的扩展方案,你可以定制自己的 Actions/Conditionals/Decorators/Composites 节点,这里以扩展一个打印当前帧数的动作节点为例,描述如何在 BehaviourDesigner 中进行节点的扩展
2.5.1 首先实现脚本来计算当前帧数
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BehaviourController : MonoBehaviour
{
private int _frameCount;
public int frameCount
{
get
{
return this._frameCount;
}
set
{
this._frameCount = value;
}
}
void Start()
{
this._frameCount = 0;
}
// Update is called once per frame
void Update()
{
this._frameCount += 1;
}
}
2.5.2 扩展 Action 类,实现 FrameCountAction
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using BehaviorDesigner.Runtime.Tasks;
[TaskDescription("打印当前的帧数")]
[TaskIcon("{SkinColor}LogIcon.png")]
public class FrameCountAction : Action
{
public override TaskStatus OnUpdate()
{
// Log the text and return success
BehaviourController controller = this.gameObject.GetComponent<BehaviourController>();
Debug.Log("frameCount:" + controller.frameCount);
return TaskStatus.Success;
}
public override void OnReset()
{
}
}
要点包括
- 引入 BehaviourDesigner 相关的命名空间
- 继承
BehaviourDesigner.Runtime.Tasks.Action
类 - 实现对应的 OnUpdate() 方法
2.5.3 其它节点的扩展
Conditionals/Composites/Decorators 节点的扩展方式非常类似,集成对应的类,实现对应方法即可,Composites 类的扩展可能相对复杂一点,这里提供了一个具备 if-else 功能的复合节点扩展,代码如下
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using BehaviorDesigner.Runtime.Tasks;
public class IfElse : Composite
{
private int currentChildIndex = 0;
private TaskStatus executionStatus = TaskStatus.Inactive;
public override int CurrentChildIndex()
{
return currentChildIndex;
}
public override bool CanExecute()
{
// 条件节点必然可以执行
if (currentChildIndex == 0)
{
return true;
}
// 条件节点成功时执行 if 分支
if (executionStatus == TaskStatus.Success)
{
return currentChildIndex == 1;
}
else
{
// 否则执行 else 分支
return currentChildIndex == 2;
}
}
public override void OnChildExecuted(TaskStatus childStatus)
{
if (currentChildIndex == 0)
{
// 条件节点执行完成后根据执行结果确定下一个执行的节点
if (childStatus == TaskStatus.Success)
{
currentChildIndex = 1;
} else
{
currentChildIndex = 2;
}
}
else
{
// if 或 else 分支的节点返回了,整个节点就返回了
currentChildIndex = 99;
}
executionStatus = childStatus;
}
public override void OnConditionalAbort(int childIndex)
{
currentChildIndex = childIndex;
executionStatus = TaskStatus.Inactive;
}
public override void OnEnd()
{
executionStatus = TaskStatus.Inactive;
currentChildIndex = 0;
}
}
这里需要特别注意的是 OnChildExecuted
和 CanExecute
方法的实现,可以参考内置的各种 Composites 都是如何实现的
3. 行为树运行逻辑
3.1 行为树多久执行一次?
我们知道,每次行为树执行是从根节点开始遍历子节点执行,这样的一次遍历称为一个 tick,通常情况下,每一帧会 tick 一次,这样可能会有性能的损耗,而实际上很多怪物其实不需要如此敏感和密集的决策,可以优化行为树的 tick 频率。在 BehaviourDesigner 中,在行为树返回结果非 Running 时,tick 一下行为树就立即结束了,除非你勾选了 Restart When Complete
选项
当然,如果行为树根节点返回值为 Running,则每一帧都会 tick
3.2 返回值为 Running 的行为树执行机制
返回值为 Running 的行为树,估计是保存了 Running 的节点,每一次 tick 都直接执行该 Running 节点,当然,如果有复合节点设置了打断条件,那应该每一个 tick 都会执行该复合节点的条件子节点来判断打断条件是否满足
4. 节点运行状态的打断
复合节点可以设置打断类型,行为树每次 tick 时会检测设置打断类型的子条件节点,当条件的返回值发生变化时,会中断正在 Running 状态的节点,立即执行条件子节点并返回。
可以设置的打断类型包括:
- None,默认值,不打断,不会进行检测
- Self,只打断 Running 状态的子节点
- Lower Priority,只打断优先级比自己低的 Running 状态节点,通常是在自己右边的节点
- Both,Self 和 Lower Priority 取并集
4.1 Self:打断子节点
- 组合节点自身必须包含条件子节点,且必须是直接子节点,孙子节点不行
- 条件子节点的优先级要高于 Running 状态的子节点(条件子节点在 Running 状态子节点左边)
我们这里新建一个行为树,添加一个 Selector 节点,为其添加一个条件子节点,判断条件是鼠标是否按下,再添加一个动作节点 Wait,等待时间设置为 10000 秒,我们知道该行为树运行时,由于鼠标未按下,将会返回 Wait 的 Running 状态。
行为树
当我们按下鼠标时
- 假若 Selector 的打断设置 AbortType 为 None,则不会有任何变化
-
若 Selector 打断设置 AbortType 为 Self,会执行条件节点,返回值发生了变化,打断 Wait 的 Running 状态,将条件节点的返回值 Success 返回给 Selector 节点,如动图所示
001.gif
4.2 Lower Priority 打断低优先级节点
- 组合节点自身非 Running 状态
- 组合节点右边的兄弟节点(低优先级)为 Running 状态,当然此时组合节点的父节点状态为 Running
-
组合节点自身必须包含条件子节点,且必须是直接子节点,孙子节点不行
我们构造一个行为树,添加一个 Selector 节点,为其添加一个 Sequence 作为子节点,Sequence 的子节点包含一个鼠标是否按下的条件节点,一级一个等待 10000 秒的 Wait 节点,再为 Selector 添加一个 Wait 类型的子节点,同样设置为 Wait 10000秒,将 Sequence 节点的打断方式 AbortType 设置为 Lower Priority
静态行为树
当按下鼠标时,Sequence 的条件子节点发生变化,所有打断了 Running 状态的 Sequence 的兄弟节点,Selector 的第二个子节点 Wait 的 Running 状态被打断,重新检查 Sequence 的条件子节点,返回 Success,所以进入了 Sequence 子节点 Wait 的 Running 状态,如动图所示
打断兄弟节点
5. 数据共享
行为树不同节点之间传递数据,节点间共享全局数据的逻辑,可以参考这篇文章
BehaviourDesigner中的变量
网友评论