用800行代码做个行为树(Behavior Tree)的库(3)

第一部分 第二部分

行为树最后一个要讲的地方,是关于前提(Precondition),在第一部分里,我略微提到了一下,这次我们来仔细看看,再来看看关于前提的纯虚基类的定义:

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

每一个前提类,都需要实现这个判断的虚函数。我在《用类来表示逻辑运算–关于行为树前提的一种实现方式》提到,我们可以用类来表示逻辑运算,这样的好处是可以做到模块化,同样的判断条件可以复用,所以在库中,我也实现了这种逻辑的表达方式,定义了基本的逻辑运算类

 1: class BevNodePreconditionTRUE{};
 2: class BevNodePreconditionFALSE{};
 3: class BevNodePreconditionNOT{};
 4: class BevNodePreconditionAND{};
 5: class BevNodePreconditionOR{};
 6: class BevNodePreconditionXOR{};

从这些类的名字应该就可以明显的看出这些类的含义了,和逻辑操作符一样,有些类的构造函数需要两个参数,以此来表示二元的逻辑运算(AND,OR,XOR),有些只需要一个参数,以此来表示一元的逻辑运算(NOT)。前提类被用来附在行为树的节点上(每一个节点都可以附加),默认情况下,节点上是没有前提类的,也就是不存在“外在前提”,而只有“内在前提”,这和附了一个BevNodePreconditionTRUE(永远返回True)的“外在前提”的节点是等价的。

好了,行为树库的内容基本就是这些了。接下去我们来看看例子程序,介绍如何用库来创建行为树,例子的代码在BevTreeTest这个工程中,编译后可直接运行,这个例子分别演示了三个行为树,从简单到复杂,单击鼠标可以在这三个例子间切换。这个程序实现了这样一个功能,“在场景地图上,定时会产生一个目标点,智能体就会根据行为树的定义,用不同的行为模式移动到目标点”。

在这个程序中,我为智能体一共定义了4个行为:

 1: class NOD_Idle{};      //空闲,表现是颜色不停变化
 2: class NOD_Breathe{};   //呼吸,表现是大小规律性变化
 3: class NOD_MoveTo{};    //移动,平移到某目标点
 4: class NOD_FaceTo{};    //转向,转向到某方向

再定义了2个“外在前提”:

 1: class CON_HasReachedTarget{};    //是否到达目标点
 2: class CON_HasFacedToTarget{};    //是否朝向目标点

我就用第一个例子来说,第一例子的行为树图如下:

BevTreeTest1

这是一个很简单的行为树,根节点是一个带优先级的选择节点,所以MoveTo比Idle的优先级高,MoveTo带有一个“外在前提”,“当没有到达目标点”时,会选在MoveTo的行为,反之,则选Idle的行为。

在代码中,可以这样来定义这棵行为树

 1: BevNode& ret =
 2:     BevNodeFactory::oCreatePrioritySelectorNode(NULL, "root");
 3:         BevNodeFactory::oCreateTeminalNode<NOD_MoveTo>(&ret, "move to")
 4:             .SetNodePrecondition(new BevNodePreconditionNOT(new CON_HasReachedTarget()));
 5:         BevNodeFactory::oCreateTeminalNode<NOD_Idle>(&ret, "idle")
 6:             .SetNodePrecondition(new BevNodePreconditionTRUE());
 7: m_BevTreeRoot = &ret;

我在库中定义了一些工厂方法,帮助创建相关的节点。值得注意的是,我在这里演示了用类表示逻辑的用法。我在定义行为树的时候,会用一些格式上的缩进,来表示相应的父子结构,这仅仅是为了视觉上比较明了。当然,以后可以改进行为树的定义接口,更可以用数据文件来定义行为树。

这样定义完毕后,我们就可以用行为树来决策我们的行为了,代码相当简单

 1: BevNodeInputParam input(&m_BevTreeInputData);
 2: BevNodeOutputParam output(&m_BevTreeOutputdata);
 3: if(m_BevTreeRoot->Evaluate(input))
 4: {
 5:     m_BevTreeRoot->Tick(input, output);
 6: }

在例子中,我尽量把行为树中要输出的变量写到BevNodeOutputParam结构中(而不是直接修改智能体的信息),这样做的好处是可以让行为树的输入和输出的接口相当清晰,做成黑盒,可以参考我在这里的讨论。

第二个例子演示了并行节点的用法,第三个例子演示了序列节点的用法,就不多说了,大家可以自行看代码。

所有的代码可以通过以下方式获得:

下载地址:

GoogleCode下载点(exe文件夹中已包含可执行文件)

也可用svn通过以下地址来得:

http://tsiu.googlecode.com/svn/branches/blogver/

编译方法:

用VS2005以上打开,选择Debug NoDx或者Release NoDx,编译后,运行BevTreeTest.

相关代码:

TAI_BevTree.h

TAI_BevTree.cpp

关于TsiU

TsiU是我一直在维护的一个自己用的小型的框架,我平时做的一些AI的sample,或者一些工具,都会基于这个框架,TsiU有一些基本的UI控件库,网络模块库,GDI绘图模块,D3D绘图模块等等,可以快速的做成一个小型的示例程序,很方便(具体可参考SampleApps里的例子程序),并且整个架构是用Object的方式来组织,非常容易理解和扩展。整个框架很轻量化,基本就是做了一些底层的基本的功能,这样我在平时做东西的时候,就不需要重新写底层了,把精力都放在高层的实现了。以后分享代码都会基于这个框架,大家也可以通过svn来随时update到我最新的改动。下图就是TsiU里的几个工程介绍,代码不多,大家想看的也可以自己看一下:)

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

(已被阅读20,384次)

53 评论

  1. 博主你好,我是某个刚毕业的实习生小白,目前在公司负责AI的开发,现在我使用的是Unity的一款叫Behaviour Designer的插件,但是感觉效率似乎很低,看了你的文章,我就想自己去实现实现行为树,看对效率会不会有所提升,也可以加深自己对行为树的了解,但是你文末给的连接似乎下载不了,可以单独发我嘛?

    这是我的邮箱:1402625215@qq.com

    不甚感激!!!!

  2. 博主,您好。最近在研究游戏AI,刚好搜到您的博客,很有启发。只是现在已经不能访问您提供的源码下载地址了,可以发到我的邮箱里么?

  3. 看了博主的代码,受益非浅,先谢过博主。

    看到BevNodeLoop::_DoTick时,感觉 mi_CurrentCount == mi_LoopCount 判断条件应该为 mi_CurrentCount != mi_LoopCount 时才应该设置为 k_BRS_Executing。

    if(mi_LoopCount != kInfiniteLoop)
    {
    mi_CurrentCount++;
    if(mi_CurrentCount == mi_LoopCount)
    {
    bIsFinish = k_BRS_Executing;
    }
    }
    else
    {
    bIsFinish = k_BRS_Executing;
    }

    1. 嗯,更好的是mi_CurrentCount < mi_LoopCount,这个代码有点老,以后会放在github上面,这样方便提交更新

  4. 想跑一下博主的代码,但是https://onedrive.live.com/?cid=cbfa832a508e03af&id=CBFA832A508E03AF%21394这个网址也下载不下来,我的邮箱是934035340@qq.com,感谢博主可以发我邮箱,最近开始了解到行为树,很感兴趣,就感觉博主在这方面挺有研究,以后还要在你这里多学习,嘿嘿

    1. 楼主,你好,几个下载地址都下载不了,邮箱1129200317@qq.com,可以发一份吗

  5. 在BevNodeTerminal::_DoTick中,对状态k_TNS_Ready和k_TNS_Running都有SetActiveNode操作,k_TNS_Ready逻辑中的SetActiveNode是否感觉有些多余,因为按照执行流程,始终会执行到k_TNS_Running中的SetActiveNode。还是说为了多线程考虑,楼主请指点。
    if(me_Status == k_TNS_Ready)
    {
    _DoEnter(input);
    mb_NeedExit = TRUE;
    me_Status = k_TNS_Running;
    SetActiveNode(this);
    }
    if(me_Status == k_TNS_Running)
    {
    bIsFinish = _DoExecute(input, output);
    SetActiveNode(this);
    if(bIsFinish == k_BRS_Finish || bIsFinish < 0)
    me_Status = k_TNS_Finish;
    }

  6. 您好博主,看了你的博客后对行为树有了一定的了解,能否发一份行为树和库代码到我的邮箱fm0722@foxmail.com。非常感谢。

  7. 博主 svn和https://onedrive.live.com/redir?resid=CBFA832A508E03AF%21394都不能用啊
    能发一份我邮箱吗1173205461@qq.com
    非常想研究一下 麻烦博主 谢谢!

  8. Alinch :您好博主,这几天我也在做关于AI游戏方面的事情,已经连续看了好几天你的博文了,受益匪浅,但是现在貌似googlecode上的链接打不开,应该是被墙了,同样麻烦博主是否能发一份你的库代码到我邮箱

    少写了邮箱 xiaowei90.19@163.com 谢谢!
    另外博主是否考虑一下另外建立一个下载站站,以便更多的人学习收益~

  9. Alinch :您好博主,这几天我也在做关于AI游戏方面的事情,已经连续看了好几天你的博文了,受益匪浅,但是现在貌似googlecode上的链接打不开,应该是被墙了,同样麻烦博主是否能发一份你的库代码到我邮箱

    少写了邮箱 xiaowei90.19@163.com 谢谢!
    另外博主是否考虑一下另外建一个下载站点,以便更多人能收益

  10. 您好博主,这几天我也在做关于AI游戏方面的事情,已经连续看了好几天你的博文了,受益匪浅,但是现在貌似googlecode上的链接打不开,应该是被墙了,同样麻烦博主是否能发一份你的库代码到我邮箱

  11. 楼主,您好!
    看了你的文章受益匪浅,特别佩服你对每一个网友的问题都那么认真地一一回复。想下载你写的BT库学习一下,无奈你给的链接我下载不了(可能是我的网络问题),能不能麻烦你把库代码发到我的邮箱:mtjp@163.com。谢谢!

  12. 博主你好,看了你的文章,我不明白的地方是先决条件节点BevNodePreconditionTRUE和非先决条件节点BevNodePreconditionNOT他们的区别是什么,在该行为树中,他们分别起到了什么样的作用呢?请求解答

    1. BevNodePreconditionTRUE是表示永远为真,BevNodePreconditionNOT是表示对他的子节点的逻辑运算结果取反,概念上就和c里面的“true”和”!”一样

  13. 你好,看完了你的几篇文章。对行为树,比较理解了。。但是还有几点问题,想不通。
    第一、为什么要有输出参数?在我看来,只要有输入就可以了,行为节点会根据输入参数进行各种表现。比如移动到一个地点,输入参数封装时间,坐标。然后再行为节点的excute里面执行就可以了。。觉得没必要输出,再根据输出做表现。
    第二、看代码发现Evaluate函数根据输入参数决定了行为树的一个决策路径。比如有一个输入,是移动到一个地点。这时行为树通过Evaluate决定的路径是0->1->3.然后过一会又收到一个输入,是释放一个技能。这时候路径变成了0->2->1->4。然后状态就切换了。。。我的问题是,其实输入只要传递个Evaluate函数就可以了。。为什么还要传给Tick函数?有可能一个小时只能只有一个输入产生,这时候应该只调用一次Evaluate,然后就一直调用Tick(float fElapse)函数。而Tick做具体的事情,如果行为节点也执行完了,就等待下一个输入参数。。。
    第三、还是接着第二点。我的理解是Evaluate其实可以理解为一个接收事件的处理函数,这个事件就是各种输入参数。而参数也不能传入引用,传递指针,到每个节点里面或者节点的前提里面判断这个参数是不是要处理的几种事件类型,不是就返回false,继续让其他节点判断,如果是,在判断是不是满足条件。

    以上就是我这两天很迷惑的地方,很可能我理解错了。希望楼主帮我解答一下。。另外我看到你个的例子,在TestObject的Tick函数中,每一帧都要调用Evaluate。。。其实我觉得只要5秒调用一次,其他时间都调用Tick就可以了。。。不知道对不对。。。

    1. hi,

      1.为什么要有输出?定义输出可以更好的降低耦合,使得每一个行为树逻辑很纯粹,就像函数有输入和返回值一样,另一个原因是行为树会被用在不同的层次上(例子里用在了行为层),我在上一个项目里,就用到了两层的行为树,一层是控制AI(输入是世界信息,输出是我要做什么),一层控制行为(输入是我要做什么,输出是怎么做的结果),因为后续还有动画,物理等处理,并不是就像例子里的设个位置和朝向这么简单。
      2.3 我就一起回答了,Evaluate每帧判断的一个原因是要让行为树有快速和正确的反应,因为外部的信息是一直在变的,比如死亡的行为,我们就希望他能马上执行,而不是5s后才执行。当然,你可以用其它办法来减少行为树遍历Evaluate的次数,你可以对输入做一些前置的判断,比如检查是不是一个新的输入,是不是真的改变了等等。但宗旨就是,行为树要反应及时,一旦外部输入会改变行为树的状态,就一定要让行为树重新选择一个正确的行为来做

    1. 我也看到了这篇文章,他是想解决这样一个问题,当行为树的分支太多后,每次都要遍历一遍整个树寻找可以运行的节点,效率比较低,所以做了些改进,具体的内容,我会写篇博文来讨论,最近在准备去GDC的资料,还没时间研究。有好的想法,可以留言,或者发信给我。

  14. 三篇都看完了,非常感谢您提供的源代码,不过由于现在电脑里没有vs,可能要过些日子才能看,由于平时是用unity3D开发的,C++还得适应适应。曾经看过http://angryant.com/上面的行为树,发现在一些节点的用法上还是有些不同的,目前还没有想清楚为什么不同,比如说他没有内部前提的概念,我继续研究研究。

    目前有几个问题:

    第一个是“二”中的表格中,对优先级的选择节点的Evaluate描述,文章说的是从头检查,为什么不是:“如果当前节点为false,则检查当前节点下一个节点。”

    第二个问题,有关于内部前提,这个东西是否应该看作“action节点的一部分”。

    第三个问题,这里面没有“Decorator”的概念吗?

    1. 每个人对行为树的实现会有些不同,用法也就不同了,不过基本概念是差不多的。优先级节点的优先级是从头开始排列的呀,也就是说第一个子节点的优先级最高,所以每次都必须从头检查,要不就体现不出优先级了。内部前提就是作为这个行为节点的固有前提,是它的一部分,也就是不管这个节点被复用在行为树的什么地方,都存在这个前提。Decorator只是一种设计模式,其实像循环节点,就可以看成是使用了这种设计模式。

发表评论

邮箱地址不会被公开。

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据

Copyright © 2011-2020 AI分享站    登录