转自:http://zhuanlan.zhihu.com/indiegamepixel/19891875
上次提到,行为树可以让代码更加模块化,也可以提高重用性。这次我们就来看看一个行为树框架是什么样的。
如果你对行为树比较陌生,可以先浏览一下游戏AI - 行为树Part1:简介。
在展开之前,我们先定义几个关键词(基本都以BT作为前缀...是Behavior Tree之意,别误会了...),会在下面的框架用到。
BTNode:所有节点的base class。定义了一些节点的基本功能,并提供一些可继承的函数。
BTAction:行为节点,继承于BTNode。具体的游戏逻辑应该放在这个节点里面。
BTPrecondition:节点的准入条件,每一个BTNode都会有一个。具体的游戏逻辑判断可以继承于它。
BTPrioritySelector:Priority Selector逻辑节点,继承于BTNode。每次执行,先有序地遍历子节点,然后执行符合准入条件的第一个子结点。可以看作是根据条件来选择一个子结点的选择器。
BTSequence:Sequence逻辑节点,继承于BTNode。每次执行,有序地执行各个子结点,当一个子结点结束后才执行下一个。严格按照节点A、B、C的顺序执行,当最后的行为C结束后,BTSequence结束。
BTParallel:Parallel逻辑节点,继承于BTNode。同时执行各个子结点。每当任一子结点的准入条件失败,它就不会执行。
BTParallelFlexible:Parallel的一个变异,继承于BTNode。同时执行各个子节点。当所有子结点的准入条件都失败,它就不会执行。
BTTree:将所有节点组合起来的地方。
Database:黑板,一个存放共享数据的地方,可以看成是一个Key-Value的字典。为什么需要黑板呢?因为设计良好的行为逻辑,应该是独立的,可以在行为树的任何位置部署的。也就是说行为A和行为B并没有直接的沟通方法。黑板的作用就是作为一个行为树的“数据库”,让各个行为节点都可以储存数据进去,供感兴趣的行为节点利用。(同时,在Unity3d的语境下,Database继承MonoBehavior,可以提供各种Component给节点使用。)
UML类图:
我们使用的框架的代码放在了Github:BT Framework。它是用 C# 写的,但概念可以转换到任何语言。
我们的Demo例子是Part1里提到的“贪生怕死的英雄”,Demo的代码可以在这里下载。Demo是用Unity3d写的。
下面,我们会先从BT行为树框架的使用开始,然后再解释框架的实现。
BT行为树框架与外界的入口在BTTree,下面我们来看看BTTree的子类MoveAttackAI,我们在这里构建了一个行为树:
// MoveAttackAI.cs
// 一个继承于BT Tree的一个类
protected override void Init () {
// 初始化base class
base.Init();
// 创建根节点,根节点
_root = new BTPrioritySelector();
// ... 创建准入条件,如checkOrcInSight
// ... 创建行为/逻辑节点,如findDestination,run
// 搭建行为树
// Escape 节点
BTParallel escape = new BTParallel(BTParallel.ParallelFunction.Or, checkOrcInSight);
{
escape.AddChild(findDestination);
escape.AddChild(run);
}
_root.AddChild(escape);
//... Fight 节点
_root.AddChild(fight);
//... Idle 节点
_root.AddChild(idle);
}
上图就是我们的行为树了!它基本对应了Part1里面的图,不过有所修改。
1. 在上面,我们创建Root节点,创建准入条件,和行为/逻辑节点,然后通过AddChild来搭建行为树。
2. escape节点是一个Parallel逻辑节点,因为每次执行escape的时候我们都需要先找到逃跑的目的地,然后再跑。这时候可能有朋友会问,为什么不用Sequence呢?因为Sequence每次执行,都是按照行为A、行为B、行为C这样的顺序执行的,执行完行为C之后就结束。所以如果用Sequence,我们的目的地在跑到当前目的地之前就不能更新了。
3. findDestination,和run这些行为都是继承于BTAction 。但是为什么我们要将选择目的地和跑这个动作分开呢?是为了更好地分离逻辑——Escape的跑和Fight的跑是一样的,但目的地选择不一样,Escape的目的地是半兽人的相反方向,Fight的目的地是哥布林的位置。
4. 另外,在Part1的评论里面,@余冬冬老师提到
“为什么要有ROOT呢? 直接prority selector不可以么。”
在我们的例子里,Root的确就是一个Priority Selector!不过由于在base class——BTTree里面会对Root特别对待,所以在Part1里就特别提到它。
搭建一个行为树,最核心的就是上面的几行代码了,不难吧 :)
要在Unity3d里面的使用这一个行为树也很简单,在GameObject里面加入MoveAttackAI这个component就好(BTTree继承于MonoBehavior)。
如果在其他引擎当中使用,如Cocos2d-x,BTTree则应该拥有Update函数和自定义的初始化函数。
下面我们看看BT框架的实现。
BTNode提供了节点的最重要的接口:
// BTNode.cs
public abstract class BTNode {
//...
// 节点的准入条件
public BTPrecondition precondition;
// 黑板
public Database database;
// 冷却功能
public float interval = 0;
// 当false的时候,节点不会执行
public bool activated;
// 节点初始化的接口,Database可提供Unity3d中的Component给节点使用
public virtual void Activate (Database database) {
//...
}
// 检查节点能否执行,包括是否activated,是否冷却完成,是否通过准入条件,和个性化检查 (DoEvaluate)
public bool Evaluate () {
bool coolDownOK = CheckTimer();
return activated && coolDownOK && (precondition == null || precondition.Check()) && DoEvaluate();
}
// 给子类提供个性化检查的接口
protected virtual bool DoEvaluate () {return true;}
// 节点执行的接口,需要返回BTResult.Running,或者BTResult.Ended
public virtual BTResult Tick () {return BTResult.Ended;}
// 节点清零的接口
public virtual void Clear () {}
//...
}
BTNode提供给子类的接口中最重要的两个是DoEvaluate()和Tick()。
DoEvaludate给子类提供个性化检查的接口(注意和Evaluate的不同),例如Sequence的检查和Priority Selector的检查是不一样的。例如Sequence和Priority Selector里都有节点A,B,C。第一次检查的时候,
Sequence只检查A就可以了,因为A不通过Evaluate,那么这个Sequence就没办法从头开始执行,所以Sequence的DoEvaludate也不通过。
而Priority Selector则先检查A,A不通过就检查B,如此类推,仅当所有的子结点都无法通过Evaluate的时候,才会不通过DoEvaludate。
Tick是节点执行的接口,仅仅当Evaluate通过时,才会执行。子类需要重载Tick,才能达到所想要的逻辑。例如Sequence和Priority Selector,它们的Tick也是不一样的:
Sequence里当active child节点A Tick返回Ended时,Sequence就会将当前的active child设成节点B(如果有B的话),并返回Running。当Sequence最后的子结点N Tick返回Ended时,Sequence也返回Ended。
Priority Selector则是当目前的active child返回Ended的时候,它也返回Ended。Running的时候,它也返回Running。
正是通过重载DoEvaluate和Tick,BT框架实现了Sequence,PrioritySelector,Parallel,ParalleFlexible这几个逻辑节点。如果你有特殊的需求,也可以重载DoEvaluate和Tick来实现!
BTAction是负责游戏逻辑的行为节点,也就是行为树里面的“行为”。
// BTAction.cs
public class BTAction : BTNode {
private BTActionStatus _status = BTActionStatus.Ready;
//...
// 第一次进入行为
protected virtual void Enter () {//... Debug functionality}
// 离开行为
protected virtual void Exit () {//... Debug functionality}
// 行为的执行,返回BTResult
protected virtual BTResult Execute () {//...}
// 重载BTNode的Tick,加入了Enter,Exit,Execute的概念
public override BTResult Tick () {
BTResult result = BTResult.Ended;
if (_status == BTActionStatus.Ready) {
Enter();
_status = BTActionStatus.Running;
}
// not using else so that the status changes reflect instantly
if (_status == BTActionStatus.Running) {
result = Execute();
if (result != BTResult.Running) {
Exit();
_status = BTActionStatus.Ready;
}
}
return result;
}
// 重载清零接口,因为外部没有办法调用Exit
public override void Clear () {
// not cleared yet
if (_status != BTActionStatus.Ready) {
Exit();
_status = BTActionStatus.Ready;
}
}
//...
private enum BTActionStatus {
Ready = 1,
Running = 2,
}
}
BTAction里面最重要的是Tick,它重载了BTNode的Tick,增加了对Enter,Exit,Execute的支持。如果大家对有限状态机比较熟悉,一个状态机里面的状态通常都会支持这三个方法,分别用来初始化,清零,和执行逻辑。在每一次行为节点的一个运行周期(不是生命周期)里,Enter仅在一开始被调用,Exit仅在最后被调用,Execute会在每一次Tick被调用。
例如我们可以这样实现DoRun:
// DoRun.cs
// 在某些简单的情况下,没有必要将动画和位移逻辑分开的话,可以这样写;
// 但通常为了更好的逻辑分离,我并不会将它们放在一起。而是分成两个不同的行为。
// protected override void Enter () {
// database.GetComponent().Play("Run");
// }
protected override BTResult Execute () {
//...
if (CheckArrived()) {
return BTResult.Ended; // 告诉父节点我要结束了
}
MoveToDestination();
return BTResult.Running; // 告诉父节点我还在运行
}
就是这么简单!
同时,我们可以看到DoRun并没有引用行为节点(也不应该引用),也就是说,它是一个逻辑上独立的行为节点,可以部署到行为树的任何位置。行为节点的逻辑独立,可以让我们写的每一个行为,都可以放到我们自己的逻辑库里面,给以后的项目调用!
如果你有耐心看到这里,你一定已经发现了Demo有一个bug——当半兽人和哥布林在同一方向(相对于英雄)的时候,英雄会先逃跑,然后在某一个点上迅速来回翻转。这是因为AI在Escape和Fight这两个分支上快速切换。
一个改进的方法是分等级的行为树(Hierarchical Behavior Tree)[1]:
有一个做决策的行为树A,和一个按照命令执行的行为树B。A根据游戏世界的情况做出决策,然后将命令放到Database里,然后B根据命令做出动作。由于两个行为树都放在一个Game Object里,所以Database是A、B共享的。通常,决策者A并不会每一帧都做出决策,而是设定一个冷却时间。
// DecisionAI.cs
// 设定1.5秒的冷却时间
_root.interval = 1.5f;
改进的Demo代码可以在这里下载。
BT框架还可以怎样拓展?我的下一个目标就是将它打造成一个Unity3d的插件,可以通过GUI来搭建行为树,而不用通过代码——当然,行为节点还是得自己用代码写。
在最近几次参加game jam的时候,我开始全程使用BT framework,做了几个小游戏:
最后,如果大家觉得文章不错,请帮忙在github里的BT framework点星喔!:D
[1]“分等级的行为树”这个名字的由来:Behavior Trees for Hierarchical RTS AI