从有限状态机(FSM)到行为树(Behavior Tree)

选此次主题,要感谢一位网友的来信,他询问了一些如何将有限状态机转成行为树的问题,当时,我回信给了一些建议,但后来我仔细想了一下,以为可能说得还不够全面,因此我就想经过这篇文章,来整理出一些比较典型的转化“模板”,给有这方面疑惑的朋友一些帮助,若是有朋友有一些本身的看法的,能够在后面留言,咱们一块儿讨论。 node

有限状态机维护了一张图,图的节点是一个个的状态,节点和节点的连线是状态间根据必定的规则作的状态转换,每个状态内的逻辑均可以简要描述为: 测试

若是知足条件1,则跳转到状态1 spa

若是知足条件2,则跳转到状态2 设计

code

不然,不作跳转,维持当前状态 ci

稍做整理的话,咱们能够对状态机的几种跳转的状况一一描述出来,而后看看若是将这些状况用行为树来表示的话,能够怎么作。这就是我前面说的“转化模板”,固然我不能保证我下面列出的是状态机的全部可能状况,若是你们在实践中发现还有其余的状况,欢迎留言,我随时更新。 it

在这以前,咱们能够先回忆一些关于行为树的一些概念(能够参考1,2) io

控制节点:选择节点,序列节点,并行节点,等等 table

行为节点:两种运行状态,“运行中”和“完成” 模板

前提条件

模式1:当处在任何状态中,一旦某条件知足,即跳转到某个特定的状态。

好比,在状态机中的一些错误处理,常常会用到上面的这种模式,当状态在运行过程当中,发生了某些异常,那通常,咱们会把状态机跳转到某个异常状态。这种状况,咱们能够用到带优先级的选择节点(Priority Selector)方式,以下图,能够看到,咱们把状态c做为行为树里的行为节点,跳转条件(Condition1)做为这个节点的前提(Precondition)。

再用上面举到的错误处理的例子,在状态机中,咱们通常会这样写:

STATE A::Update()
 2: {
 3:     ...
 4:     if(error)
 5:     {
 6:         return TO_ERROR_STATE();
 7:     }
 8:     ...
 9:     return UNCHANGED_STATE();
 10: }

转换到行为树中,咱们会经过外部的黑板来作通讯(能够参考这里),在行为节点a中,咱们会这样写

EXECUTE_STATE A::Execute(BlackBoard& out)
 2: {
 3:     ...
 4:     if(error)
 5:     {
 6:         out.error = error;
 7:         return EXECUTE_STATE_FINISH;
 8:     }
 9:     ...
 10:     return EXECUTE_STATE_RUNNING;
 11: }

而后对于节点c的前提里,咱们来读取黑板里的error值

bool Condition1::IsTrue(const BlackBoard& in) const
 2: {
 3:     return in.error == true;
 4: }

模式2:对于同一个跳转条件,处在不一样的状态会有不一样的跳转

好比,咱们有两个状态,a和b,他们都对同一个跳转条件做出响应,但和模式1不一样的是,a对跳转到状态c,而b会跳转到状态d,换句话说,这是一种带有上下文的状态跳转方式。对于这种状况,能够用到序列节点的相关特性,以下图

序列节点中,当前一个节点运行完成后,会执行下一个节点,利用此特性,在上图中能够看到,咱们在a中,当知足条件Condition1的时候,则返回“完成”,那行为树就会自动跳转到c节点中,参考代码以下:

EXECUTE_STATE A::Execute(BlackBoard& out)
 2: {
 3:     ...
 4:     if(condition1 == true)
 5:     {
 6:         return EXECUTE_STATE_FINISH;
 7:     }
 8:     ...
 9:     return EXECUTE_STATE_RUNNING;
 10: }

对于这种模式的另外一种转化,能够不用序列节点,仍是用到选择和前提的组合,但咱们在前提中加上一个当前状态的附加条件,以下图

在第二层的前提中,咱们能够这样写

bool InACState::IsTrue(const BlackBoard& in)
 2: {
 3:     return in.current_running_node = A::GetID() ||
 4:            in.current_running_node = C::GetID();
 5: }
 6: bool InBDState::IsTrue(const BlackBoard& in)
 7: {
 8:     return in.current_running_node = B::GetID() ||
 9:            in.current_running_node = D::GetID();
 10: }

这样对于c的前提就是Condition1和InACState的“与”(回想一下前提的相关内容)。因为咱们保留了上下文的信息,因此经过对于前提的组合,咱们就转化了这种模式的状态机。

模式3:根据条件跳转到多个状态,包括自跳转

这是在状态机里最多见的模式,因为是基于条件的跳转,因此能够很是方便的用选择节点和前提的组合来描述,特别值得注意的是,对于自跳转而言,其实就是维持了当前的状态,因此,在构建行为树的时候,咱们不须要特别考虑自跳转的转换。以下图所描述了,咱们先用序列节点来保证跳转的上下文(能够参考模式2中的相关内容),这里用到的另外一个技巧是,咱们会在状态a结束的时候,在黑板中记录其结束的缘由,以供后续的选择节点来选择。另外,咱们在第二层选择节点第一次用到了非优先级的选择节点,和带优先级的选择节点不一样,它每次都会从上一次所能运行的节点来运行,而不是每次都从头开始选择。

固然,和模式2相似的是,咱们也能够不用序列节点,而是单纯的用选择节点,这样的话,做为默认状态的状态a就须要处在选择节点的最后一个,由于仅当全部跳转条件都不知足的时候,咱们才会维持在当前的状态。如上图的下面那颗行为树那样。请仔细查看,我在前三个节点对于前提的定义,除了自己的跳转条件外,还加上了一个额外的条件,InAXState,它保证了仅在上一次运行的是A状态或自身的时候,咱们才会运行当前的节点,这样就保证了和本来状态机描述是一致的。

模式4:循环跳转

在状态机中存在这样一种模式,在状态a中,根据某条件1,会跳转到状态b中,而在状态b的时候,又会根据某条件2,跳转到状态a,产生了这样一个跳转的“环”。显而易见的是,行为树是一种树形结构,而带环的状态机是一种图的结构,因此对于这种状况,我想了下,以为须要引入一种新的选择节点,我称之为动态优先级选择节点(Dynamic Priority Selector),这种选择节点的工做原理是,永远把当前运行的节点做为最低优先级的节点来处理。以下图

当咱们在节点a的时候,咱们会先判断b的前提,当b的前提知足的时候,咱们会运行节点b,下一帧再进来的时候,因为如今运行的是节点b,那它就是最低优先级的,因此,咱们会先判断节点a的前提,知足的话,就运行节点a,不知足则继续运行节点b,依次类推。下面是我写的相关代码,能够给你们参考。

void DynamicPrioritySelector::Test(const Blackboard& in) const
 2: {
 3:     bool hasRunningChild = IsValid(m_iCurrentRunningChildIndex);
 4:     int nextRunningChild = -1;
 5:     for(int i = 0; i < m_ChildNodes.Count(); ++i)
 6:     {
 7:         if(hasRunningChild &&
 8:            m_iCurrentRunningChildIndex == i)
 9:         {
 10:             continue;
 11:         }
 12:         else
 13:         {
 14:             if(m_ChildNodes[i]->Test(in))
 15:             {
 16:                 nextRunningChild = i;
 17:                 break;
 18:             }
 19:         }
 20:     }
 21:     if(IsValid(nextRunningChild))
 22:     {
 23:         m_iCurrentRunningChildIndex = nextRunningChild;
 24:     }
 25:     else
 26:     {
 27:         //最后测试当前运行的子节点
 28:         if(hasRunningChild)
 29:         {
 30:             if(!m_ChildNodes[m_iCurrentRunningChildIndex]->Test(in))
 31:             {
 32:                 m_iCurrentRunningChildIndex = -1;
 33:             }
 34:         }
 35:     }
 36:     return IsValid(m_iCurrentRunningChildIndex);
 37: }

总结

从上面4种模式的转化方式中,咱们好像会有种感受,用行为树的表达好像并无状态机的表述清晰,显的比较复杂,罗嗦。这主要是由于咱们用行为树对状态机作了直接的转化,并想要尽力的去维持状态机的语义的缘故。其实,在AI设计过程当中,通常来讲,咱们并非先有状态机,再去转化成行为树的,当咱们选择用行为树的时候,咱们就要充分的理解控制节点,前提,节点等概念,并试着用行为树的逻辑方式去思考和设计。

不过,有时,咱们也许想用行为树改造一个已有的状态机系统,那这时就能够用我上面提到的这些模式来尝试着去转换,固然在实际转换的过程当中,个人建议是,先理清并列出每个状态跳转的条件,查看哪些是带上下文的跳转,哪些是不带上下文的跳转,哪些是单纯的序列跳转(好比,从状态A,到状态B,到状态C,相似这样的单线跳转,常见于流程控制中),哪些跳转是能够合并的等等,而后再用行为树的控制节点,把这些状态都串联起来,当发现有些跳转用已有的控制节点不能很好的描述的时候,能够像我上面那样,添加新的控制节点。

这四种模式,是我如今能想到的,可能不全,若是你们有问题,能够在后面留言,有指教的也欢迎一块儿讨论。

相关文章
相关标签/搜索