游戏AI - 行为树Part2:框架

游戏AI - 行为树Part2:框架

上次提到,行为树可以让代码更加模块化,也可以提高重用性。这次我们就来看看一个行为树框架是什么样的。

如果你对行为树比较陌生,可以先浏览一下游戏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提供了节点的最重要的接口:

// 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是负责游戏逻辑的行为节点,也就是行为树里面的“行为”。

// 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<Animator>().Play("Run");
//   }

protected override BTResult Execute () {
   //...

   if (CheckArrived()) {
      return BTResult.Ended;   // 告诉父节点我要结束了
   }
   MoveToDestination();
   return BTResult.Running;   // 告诉父节点我还在运行
}

就是这么简单!

同时,我们可以看到DoRun并没有引用行为节点(也不应该引用),也就是说,它是一个逻辑上独立的行为节点,可以部署到行为树的任何位置。行为节点的逻辑独立,可以让我们写的每一个行为,都可以放到我们自己的逻辑库里面,给以后的项目调用!


Demo的局限和改进方法

如果你有耐心看到这里,你一定已经发现了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代码可以在这里下载。

这样一个分等级的行为树有两个好处:

  1. 让决策逻辑和执行逻辑分离。面对同样的决策,不同Game Object可能有不同的执行方法。
  2. 玩家控制的角色和AI控制的角色可以分享同一个执行逻辑——只需要负责玩家控制的代码将命令存放到Database里面供执行逻辑使用就可以了。


总结

  • 我们从BT框架的使用为学习入口,解释了行为树框架的实现原理;
  • 也了解了怎么去拓展出个性化的逻辑节点和行为节点来满足项目需求;
  • 通过逻辑独立地拓展BTAction,我们能够积累自己的逻辑库!
  • 最后我们提到了分等级的行为树,它可以帮助我们将决策逻辑和执行逻辑分开(并解决了demo里面的一个bug)。

BT框架还可以怎样拓展?我的下一个目标就是将它打造成一个Unity3d的插件,可以通过GUI来搭建行为树,而不用通过代码——当然,行为节点还是得自己用代码写。



题外话

在最近几次参加game jam的时候,我开始全程使用BT framework,做了几个小游戏:

  1. 剑侠,可以参见这篇笔记的编程部分,有讲到用BT framework的感想 :)
  2. 弓箭手,9月低的一个练手小作品 ;p

最后,如果大家觉得文章不错,请帮忙在github里的BT framework点星喔!:D


Reference

[1]“分等级的行为树”这个名字的由来:Behavior Trees for Hierarchical RTS AI

编辑于 2014-11-14

文章被以下专栏收录