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

第一部分

上一次说到了节点的基类,它描述了在行为树上一个节点的基本结构。我们知道,在行为树上有两大类的节点,一种我称之为“控制节点”,像“选择节点”,“并行节点”,“序列节点”都属于此类,这类节点负责行为树逻辑的控制,是和具体的游戏逻辑无关的,属于行为树库的一部分,并且这类节点一般不会作为叶节点。还有一类称为“行为节点”,也就是行为树上挂载的具体行为,是和游戏逻辑相关的,不属于行为树库的一部分,需要自己去继承和实现,这类节点一般都作为叶节点出现。

先来看看“行为节点”的代码,我先从节点的基类继承了一个所有“行为节点”的基类

 1: class BevNodeTerminal : public BevNode
 2: {}

在它的Tick方法中,我做了一个简单的状态机(可以自行看代码),负责处理进入行为(Enter),更新行为(Execute),退出行为(Exit),所有的行为节点应该继承自BevNodeTerminal类,并且重写这些虚函数,在进入和退出行为里,可以做一个初始化和清理的工作:

 1: class BevNodeTerminal : public BevNode
 2: {
 3: protected:
 4:     virtual void                _DoEnter(const BevNodeInputParam& input)                                {}
 5:     virtual BevRunningStatus    _DoExecute(const BevNodeInputParam& input, BevNodeOutputParam& output)  { return k_BRS_Finish;}
 6:     virtual void                _DoExit(const BevNodeInputParam& input, BevRunningStatus _ui_ExitID)    {}
 7: }

值得注意的是,在Tick方法中,它有一个返回值,表示当前节点是否处理完毕,在库中,我定义了一个enum来表示节点的运行状态:

 1: enum BevRunningStatus
 2: {
 3:     k_BRS_Executing                 = 0,
 4:     k_BRS_Finish                    = 1,
 5:     ...
 6: };

当返回k_BRS_Finish的时候,就表示当前节点已经处理完毕了,如果再次进入该节点,就认为是重新进入了。用上面描述的那个状态机的来说的话就是,如果是重新进入,会先调用_DoEnter方法,然后调用_DoExecute方法,如果_DoExecute返回正在运行(k_BRS_Executing),那么以后再进入这个节点就会直接调用_DoExectue,如果返回已经结束(k_BRS_Finish),则会调用_DoExit,以后再进入这个节点就会重新调用_DoEnter方法了。

对于控制节点来说,它的运行状态和子节点的运行状态是息息相关的,比如,选择节点的运行状态,就是它当前选择的这个节点的运行状态,并且,有时控制节点的控制逻辑也和子节点的运行状态有关,比如序列节点,当它前一个子节点运行结束,序列节点就会自动的切换到下一个子节点运行。所以在实现具体的行为类时,我们应该要正确的返回节点的运行状态。在例子程序中,我做的一个“空闲”(idle)的行为节点,就能很好的说明问题:

 1: class NOD_Idle : public BevNodeTerminal
 2: {
 3: public:
 4:     NOD_Idle(BevNode* _o_ParentNode)
 5:         :BevNodeTerminal(_o_ParentNode)
 6:     {}
 7: protected:
 8:     virtual void _DoEnter(const BevNodeInputParam& input)
 9:     {
 10:         m_WaitingTime = 0.5f;
 11:     }
 12:     virtual BevRunningStatus _DoExecute(const BevNodeInputParam& input, BevNodeOutputParam& output)
 13:     {
 14:         const BevInputData& inputData = input.GetRealDataType<BevInputData>();
 15:         BevOutputData& outputData = output.GetRealDataType<BevOutputData>();
 16:
 17:         f32 timeStep = inputData.m_TimeStep;
 18:         m_WaitingTime -= timeStep;
 19:         if(m_WaitingTime < 0)
 20:         {
 21:             outputData.m_BodyColor = D_Color(rand() % 256, rand() % 256, rand() % 256);
 22:             return k_BRS_Finish;
 23:         }
 24:         return k_BRS_Executing;
 25:     }
 26: private:
 27:     float m_WaitingTime;
 28: };

这段代码中的某些内容不明白也没有关系,我们主要关注的是关于节点运行状态的部分。这个Idle行为做了一件这样的事,就是不停的变换自己的颜色,间隔是0.5秒,当时间一到,就会返回运行结束(k_BRS_Finish),并输出当前的颜色,当时间还没到,则返回运行中(k_BRS_Executing),并且维持当前颜色。可以看到,我们用运行状态控制了计时器的重置,选择在_DoEnter方法中重置了计时器,当然,更合理的做法是在时间一到的时候,就重置计时器,并且永远返回运行中,不过这个例子里,我主要就是想用来演示运行状态,和_DoEnter的相关用法。

接下去再来看看控制节点,我一共写了5种控制节点,带优先级的选择节点(BevNodePrioritySelector),不带优先级的选择节点(BevNodeNonePrioritySelector),序列节点(BevNodeSequence),并行节点(BevNodeParallel),循环节点(BevNodeLoop),这些节点的进入条件和选择逻辑都是按照在行为树中改节点的定义来做的,我想用一张表格来说明:

测试(Evaluate) 更新(Tick)
带优先级的选择节点(BevNodePrioritySelector) 从第一个子节点开始依次遍历所有的子节点,调用其Evaluate方法,当发现存在可以运行的子节点时,记录子节点索引,停止遍历,返回True。 调用可以运行的子节点的Tick方法,用它所返回的运行状态作为自身的运行状态返回
不带优先级的选择节点(BevNodeNonePrioritySelector) 先调用上一个运行的子节点(若存在)的Evaluate方法,如果可以运行,则继续运保存该节点的索引,返回True,如果不能运行,则重新选择(同带优先级的选择节点的选择方式) 调用可以运行的子节点的Tick方法,用它所返回的运行状态作为自身的运行状态返回
序列节点(BevNodeSequence) 若是从头开始的,则调用第一个子节点的Evaluate方法,将其返回值作为自身的返回值返回。否则,调用当前运行节点的Evaluate方法,将其返回值作为自身的返回值返回。 调用可以运行的子节点的Tick方法,若返回运行结束,则将下一个子节点作为当前运行节点,若当前已是最后一个子节点,表示该序列已经运行结束,则自身返回运行结束。若子节点返回运行中,则用它所返回的运行状态作为自身的运行状态返回
并行节点(BevNodeParallel) 依次调用所有的子节点的Evaluate方法,若所有的子节点都返回True,则自身也返回True,否则,返回False 调用所有子节点的Tick方法,若并行节点是“或者”的关系,则只要有一个子节点返回运行结束,那自身就返回运行结束。若并行节点是“并且”的关系,则只有所有的子节点返回结束,自身才返回运行结束
循环节点(BevNodeLoop) 预设的循环次数到了就返回False,否则,只调用第一个子节点的Evaluate方法,用它所返回的值作为自身的值返回 只调用第一个节点的Tick方法,若返回运行结束,则看是否需要重复运行,若循环次数没到,则自身返回运行中,若循环次数已到,则返回运行结束

可能看表格内的描述会感觉有点拗口,可以结合代码一起看,会理解的更好。特别要提一点的是,在某些控制节点的Evaluate方法中,我会修改和记录可以运行的节点索引,当调用Tick的时候,就可以用这个索引来找到可以运行的节点了。这种模式和我以前提到的行为树更新模式有点不太一样,不过本质上是相同的。

(待续…)

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

(已被阅读13,132次)

15 评论

  1. 你好,看了你的库代码很受启发。有个问题请教一下,我之前已经封装了一些基础代码块 funcB,这些代码块需要多帧来执行。
    假如 :
    root = createSelector(null);
    TerminalA = createTeminal(root);

    在你的库基础上,我在派生行为节点 TerminalA 的 _DoTick 里调用 funcB,funcB没完成之前再次进入 TerminalA->_DoTick()的话直接返回 k_BRS_Executing。
    这样处理后会带来一个问题:root->_DoEvaluate() 返回false时可能 funcB 可能还在继续执行,这显然不合理。所以
    我又修改了一下,在 root->_DoEvaluate()返回false之前对当前正在 k_BRS_Executing 的节点进行_DoTransition处理(5种控制节点都做了这种处理)。
    不知你是否遇到类似_DoTick里调用 funcB(多帧) 这种情况?你是怎么处理的?

    1. 补充: funcB 虽然需要多帧执行,但只需要调用一次; 进入 _DoExit 时如果 funcB 还没执行完则强行终止 funcB。

    2. 每次update行为树前,先要掉evalute,在这个函数里会更新执行路径,如果evalute返回true,调用update的时候,就会按照更新后的路径去调用相应的节点,不会存在evalute返回false,但是那个节点还在调用的情况

    3. 我遇到的情况是需要 调用一次 _DoTick 处理多帧任务(任务处理中多次进入该_DoTick直接返回k_BRS_Executing不再次执行行为)。
      我在做测试的时候发现,BevNode::Evaluate 这里面是先检测自身的前提条件,得到true后再去检测子节点的条件,当我上面说的多帧任务正在执行中,下一帧根节点的自身前提返回false,那么不会再去检测子节点了,此时子节点就不知道需要终止任务。当然这种问题只会出现在我上面说的这种调用一次_DoTick处理多帧任务的情况下(比如引擎提供了移动方法MoveTo::Create(time,position)),而每次_DoTick都手动更新位置是不会出现这种情况的。你的游戏全部是每次_DoTick只手动处理一帧的变化吗?

    4. 一般tick函数里都是处理一帧的事情,不知道你是什么情况需要在一个tick里处理多帧的任务,能具体描述一下吗?

    5. 举个例子,有一个任务是封装好独立的,它接受请求、查询执行状态、终止任务指令,执行完成后通过回调返回结果。假设这个任务需要5帧完成,在行为树里某帧只需要请求一次这个任务,接下来的5帧里它会通过自身的DoTick系统完成操作,不需要在行为树再去请求(它的DoTick跟这颗行为树的DoTick没有直接关系)。

  2. 你好 你写的行为树的博客让我对行为树有了很好的了解 现在有一个问题想请教一下 如果一个游戏里面相同Ai的怪物有很多实例 那这么多实例的怪物应该是共享同一棵行为树吧 那此时用于表示行为节点状态的变量me_Status是否应该作为怪物类的属性而不是作为树行为节点的一个属性呢?

    1. 运行时的状态没办法共享,因为所有的怪物处在不同的行为中,当然,你也可以让他们处在同一个行为里。不过行为树的结构数据是可以共享的,这就是共享型行为树

  3. 反复看了几遍,还是对行为树的作用感到不明确,有些游戏的行为逻辑是跟状态挂钩的,从行为树上如何实现对状态的管理呢?比如说:按下键盘控制人物A跳起来,被人物B一脚踢飞,此时人物A播放被踢飞的动画同时进入受伤状态,受伤状态下不能被二次攻击且不接受控制。用行为树又如何描述人物A这一过程呢?

    1. 状态是通过外部的黑板来做的,这可以看作是一个循环,行为树改变了外部状态,外部状态又影响了行为树的下一次行为决策

  4. root = createParallel(null);

    a = createParallel(root);
    createTerminal(a);
    createTerminal(a);

    b = createPriority(root);
    createTerminal(b);
    createTerminal(b);

    a是每次必须要执行的行为 b是根据条件成立才执行的行为

    如果root用Parallel 那在b条件不成立时 root的Evaluate不成立 不会进入tick 整个tree都不执行

    如果root用Sequence 那 ab 运行在不同的tick里(b在下一个tick才会运行)

    请教 这种需求下 应该如何组织行为树呢 谢谢

    1. 嗯,我原本parallel的evaluate写的比较“粗暴”,没法做到这个需求。可以改一下,写成或关系(或者可配置),把可以运行的节点记录下来,然后再tick的时候只运行那些可以运行的节点

发表评论

邮箱地址不会被公开。

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

Copyright © 2011-2020 AI分享站    登录