转自: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: }
定义自己的输入和输出参数(BevInputData,BevOutputData)
创建行为树,保存根节点指针(m_BevTreeRoot)
测试是否有可以运行的节点,如有则更新
(待续…)