转自:http://www.aisharing.com/archives/517

最近一直在忙新项目的准备,甚少涉及AI的东西,所以博客也疏于更新。春节前,收到一个网友的邮件,说看了行为树的一些东西,但还是不知道如何去入手实现,我就乘着春节假期,动手写了一个简单的行为树的库,和大家一起边分析代码,边说说行为树的具体实现方法。这个库很简单,一共也就800行的代码左右,不过麻雀虽小,五脏俱全,行为树中的主要部分基本都有涵盖,包括前提(Precondition),选择节点(Selector),并行节点(Parallel),序列节点(Sequence)等等。在分析代码前,如果有朋友对行为树的相关概念还不是很了解,建议先阅读本站上对于行为树介绍的相关文章。

这次的代码以及示例程序,还是基于我自己维护的一个框架TsiU,在系列文章的最后会给出下载链接。

行为树,由名字就可以看到,它是一个树结构,通过各个节点相互连接,所以我先定义了节点的基类:

 1: class BevNode{}

要把树链接起来,需要在这个类中保留父节点指针,和子节点指针,我用了一个固定的数组来保存子节点指针,它的大小是16,也就是说,一个节点最多可以有16个子节点

 1: class BevNode
 2: {
 3: protected:
 4:     BevNode*    mao_ChildNodeList[k_BLimited_MaxChildNodeCnt];
 5:     ...
 6:     BevNode*    mo_ParentNode;
 7: }

有了这些变量的定义,我们就可以串联起一颗树了。到目前为止,这个节点类还仅仅是一个树的节点,作为行为树的节点还差了些东西,在以前的介绍中,我们知道行为树的每一个节点都可以绑定一个称为前提(Precondition)的部分,用来作为是否进入这个节点的条件,在我的实现中,我把这个前提拆分成了两个部分,一个称为“内在前提”,一个称为“外在前提”。“内在前提”是和节点类静态绑定的(也就是说,这个节点的固有前提),而“外在前提”是可以和节点做动态绑定的。这样做的原因是,由于在行为树上,节点是可以被复用的,在不同的子树上他的进入条件往往是不同的。比如,“移动”,这是一个常见的行为节点,逃跑的时候,可能需要“移动”,追击的时候也需要“移动”,但进入这个节点需要不同的“外在前提”,所以这里就需要让节点支持动态绑定的前提。“内在前提”,我用继承的方式来实现,而“外在前提”,我用了另一个类来实现

 1: class BevNode
 2: {
 3: public:
 4:     bool Evaluate(const BevNodeInputParam& input)
 5:     {
 6:         return (mo_NodePrecondition == NULL || mo_NodePrecondition->ExternalCondition(input)) && _DoEvaluate(input);
 7:     }
 8: protected:
 9:     virtual bool _DoEvaluate(const BevNodeInputParam& input)
 10:     {
 11:         return true;
 12:     }
 13: protected:
 14:     BevNodePrecondition* mo_NodePrecondition;
 15: }

可以看到这里用到了一个叫做BevNodePrecondition的类,用来表示“外在前提”,他是一个纯虚函数,只有一个方法,先看一下它的定义,后面会有详细的讨论。

 1: class BevNodePrecondition
 2: {
 3: public:
 4:     virtual bool ExternalCondition(const BevNodeInputParam& input) const = 0;
 5: };

_DoEvaluate虚方法就是需要被子类继承并实现的“内在前提”,这两种前提在Evaluate方法中被结合了起来,用来检测进入条件,当返回True时,就表示当前节点可以被运行。返回False时,就表示当前节点进入条件不满足,不能被运行。

在节点基类的中,还有两个重要的方法是:

 1: class BevNode
 2: {
 3: public:
 4:     void Transition(const BevNodeInputParam& input)
 5:     {
 6:         _DoTransition(input);
 7:     }
 8:     BevRunningStatus Tick(const BevNodeInputParam& input, BevNodeOutputParam& output)
 9:     {
 10:         return _DoTick(input, output);
 11:     }
 12: }

转移(Transition)的概念是第一次出现,转移(Transition)指从上一个可运行的节点切换到另一个节点的行为。这个方法会被在节点切换的时候调用,比如,在一个带优先级的选择节点下有节点A,和节点B,节点A的优先级高于节点B,当前运行的节点是B,然后发现节点A可以运行了,但带优先级的选择节点就会选择去运行节点A,这时就会调用节点B的Transition方法,所以在这个方法中,一般可以用来做一些清理的工作。Tick方法就是通常的更新方法,就不多说了。

再来看一下这三个重要方法的参数,一共有两种类型的参数,BevNodeInputParam和BevNodeOutputParam,前者是传入参数,可以认为是行为树的输入,用const作为限定符,表示只读,后者是传出参数,可以认为是行为树的输出,可以修改。其实,从代码中可以看到,这两种类型的本质都是一样的,都是一个名为AnyData的类

 1: typedef AnyData BevNodeInputParam;
 2: typedef AnyData BevNodeOutputParam;

由于输入和输出参数是游戏相关的,所以这里用AnyData这个类来表示,这个类可以存放任意的数据结构,所以,这个类中真正的内容是需要玩家自己定义的。

最后来看看行为树是如何被定义和更新的(可以在示例程序中找到相关代码)

 1: //define input & output data
 2: struct BevInputData{...}
 3: struct BevOutputData{...}
 4: BevInputData    m_BevTreeInputData;
 5: BevOutputData   m_BevTreeOutputdata;
 6: ....
 7: //create tree
 8: m_BevTreeRoot = CreateTree();
 9: ...
 10: //update
 11: BevNodeInputParam input(&m_BevTreeInputData);
 12: BevNodeOutputParam output(&m_BevTreeOutputdata);
 13: if(m_BevTreeRoot->Evaluate(input))
 14: {
 15:     m_BevTreeRoot->Tick(input, output);
 16: }
  1. 定义自己的输入和输出参数(BevInputData,BevOutputData)

  2. 创建行为树,保存根节点指针(m_BevTreeRoot)

  3. 测试是否有可以运行的节点,如有则更新

(待续…)