行为树总结
概述
行为树,是游戏中应用较为广泛的一种行为结构。它的逻辑其实不复杂,复杂的是编辑工具和序列化和反序列化。Unity商城中的「Behaviour Designer」是比较流行的工具,虚幻引擎也自带此类编辑工具。接下来,我们会先对它进行介绍,并用代码实现它。树状结构在不借助可视化工具的情况下是不容易呈现清楚的,不过仅仅是程序使用的话,实现方便的构建代码,也是可以用的,对于复杂的行为还是必须得开发可视化工具。
有限状态机(FSM)
有限状态机(Finite State Machine, FSM)和行为树(Behavior Tree, BT)都是用于游戏开发、机器人学以及其他需要实现复杂行为逻辑的领域中常用的设计模式。它们各自有独特的优点和缺点,适用于不同的场景。
有限状态机是一种数学模型,用来表示一个系统可以处于的状态集合以及这些状态之间的转换规则。每个状态都有相关的动作或活动,并且只有特定事件发生时才能从一个状态转移到另一个状态。
优点:
- 简单直观:FSM很容易理解和实现,特别是对于简单的任务。
- 易于调试:因为状态间的转换是明确的,所以问题更容易追踪和修复。
- 性能高:在处理相对直接的行为逻辑时,FSM通常能够提供很好的性能。
- 状态明确:每个状态的行为都是独立定义的,状态转移逻辑清晰。
缺点: - 难以扩展:随着状态数量的增长,管理所有可能的状态转换会变得非常困难。
- 灵活性低:如果需求变更频繁,则修改现有的FSM可能会比较麻烦。
- 不适合复杂逻辑:当涉及到多个条件判断或者循环结构时,使用FSM就显得不太合适了。
通用行为树节点介绍
行为树的运行逻辑是从根节点往下搜索,找到合适的动作来执行。每次都从根节点重新执行这个搜索过程。
行为树把条件判断逻辑也封装成了节点,作为逻辑节点,来实现顺序,组合,重复等复杂逻辑。常见的节点包括:
- 组合节点(Composite),指有多个子节点的节点。这类节点包括:
- 顺序器(Squence)
- 选择器(Selector)
- 并行器(Parallel)
- 过滤器(Filter)
- 主动选择器(ActiveSelector)
- 监视器(Monitor)
- 修饰节点(Decorator),指仅有一个子节点的特殊节点,具体包括:
- 取反器(Inverter)
- 重复执行器(Repeat)
- 动作节点(Action或Behaviour),指可以自定义扩展的节点,实现具体的行为逻辑,这类节点没有子节点。
节点基类
我们首先来定义节点基类。所有它定义了所有节点的共同属性和行为。所有节点都需要继承该类来扩展自己的特殊逻辑。
using System;
using System.IO;
namespace BehaviourTree.Core
{
public abstract class Behaviour
{
public bool IsTerminated => IsSuccess || IsFailure;
public bool IsSuccess => Status == EBehaviourStatus.Success;
public bool IsFailure => Status == EBehaviourStatus.Failure;
public bool IsRunning => Status == EBehaviourStatus.Running;
protected EBehaviourStatus m_Status;
protected BTree m_Tree;
protected IBTreeOwner BTreeOwner
{
get
{
return m_Tree != null ? m_Tree.GetOwner() : null;
}
}
public EBehaviourStatus Status
{
get { return m_Status; }
}
public virtual int ChildCount
{
get
{
return 0;
}
}
public Behaviour()
{
m_Status = EBehaviourStatus.Invalid;
}
protected virtual void OnInitialize()
{
}
public void SetTree(BTree tree)
{
m_Tree = tree;
}
protected abstract EBehaviourStatus OnUpdate();
protected virtual void OnTerminate(){}
public EBehaviourStatus Tick()
{
if (!IsRunning)
{
OnInitialize();
}
m_Status = OnUpdate();
if (!IsRunning)
{
OnTerminate();
}
return m_Status;
}
public virtual void AddChild(Behaviour child){}
public void Reset()
{
m_Status = EBehaviourStatus.Invalid;
}
public void Abort()
{
OnTerminate();
m_Status = EBehaviourStatus.Abort;
}
public virtual Behaviour GetChild(int index)
{
return null;
}
public abstract void ReadDataFromStream(BinaryReader br);
public virtual void WriteDataToStream(BinaryWriter bw)
{
Type type = this.GetType();
bw.Write(type.Name);
}
}
}
在Tick方法中,未处于运行状态时,首先执行初始化操作。然后执行 OnUpdate方法更新自身状态。如果 OnUpdate之后,仍然处于非运行状态,则终止该节点的执行(OnTerminate)。最终返回状态值,供上层调用参考,并决定上层节点的后续行为。
这样看还是比较抽象,因为在这里只定义了节点的通用行为,具体的需要结合后续的实现来看。
组合节点
接着来看组合接点。
using System.Collections.Generic;
using System.IO;
namespace BehaviourTree.Core
{
public abstract class Composite:Behaviour
{
protected List<Behaviour> m_Children;
public Composite()
{
m_Children = new List<Behaviour>();
}
public virtual void RemoveChild(Behaviour child)
{
m_Children.Remove(child);
}
public void ClearChildren()
{
m_Children.Clear();
}
public override void AddChild(Behaviour child)
{
m_Children.Add(child);
}
public override Behaviour GetChild(int index)
{
if (index >= 0 && index < m_Children.Count)
{
return m_Children[index];
}
return null;
}
public override int ChildCount
{
get
{
return m_Children.Count;
}
}
public override void ReadDataFromStream(BinaryReader br)
{
// Nonthing to read
}
}
}
组合节点有多个子节点,所以这里的实现只是把多个子节点这一特定实现了出来,具体的逻辑由后继的节点实现。
顺序器(Sequence)

顺序器会按从左到右的顺序执行其子节点。当其中一个子节点执行失败时,将停止执行,也就是说,任一子节点失败,顺序器就会失败。只有所有子节点运行都成功,顺序器才算成功。
using System.IO;
namespace BehaviourTree.Core
{
public class Sequence : Composite
{
protected int m_CurrentChildIndex;
protected override void OnInitialize()
{
m_CurrentChildIndex = 0;
}
protected override EBehaviourStatus OnUpdate()
{
if (m_Children.Count <= 0 || !InRange(m_CurrentChildIndex))
{
return EBehaviourStatus.Failure;
}
while (m_CurrentChildIndex < m_Children.Count)
{
Behaviour child = m_Children[m_CurrentChildIndex];
var status = child.Tick();
if (status != EBehaviourStatus.Success)
{
return status;
}
m_CurrentChildIndex++;
if (m_CurrentChildIndex >= m_Children.Count)
{
break;
}
}
return EBehaviourStatus.Success;
}
protected bool InRange(int index)
{
return index >= 0 && index < m_Children.Count;
}
public override void ReadDataFromStream(BinaryReader br)
{
// Nothing to read
}
}
}
选择器(Selector)

选择器每次只会选择一个可以运行的子节点来运行。
但从代码上来说,选择器的结构和顺序器完全一致,只是运行逻辑变化了: 按从左到右的顺序执行其子节点。当其中一个子节点执行成功时,就停止执行,也就是说,任一子节点成功运行,就算运行成功。只有所有子节点运行都失败,选择器才算运行失败。
所以,只要简单地继承「顺序器」并修改它的OnUpdate逻辑,就能得到选择器啦!
using System.IO;
namespace BehaviourTree.Core
{
public class Selector : Sequence
{
protected override EBehaviourStatus OnUpdate()
{
if (m_Children.Count <= 0)
{
return EBehaviourStatus.Failure;
}
while(m_CurrentChildIndex < m_Children.Count)
{
Behaviour child = m_Children[m_CurrentChildIndex];
var status = child.Tick();
if (status != EBehaviourStatus.Failure)
{
return status;
}
m_CurrentChildIndex++;
if (m_CurrentChildIndex >= m_Children.Count)
{
return EBehaviourStatus.Failure;
}
}
return EBehaviourStatus.Failure;
}
public override void ReadDataFromStream(BinaryReader br)
{
// Nonthing to read
}
}
}
并行器(Parallel)

并行器,它会同时执行所有子节点。
这样就需要解决两个问题:
- 怎么「同时」运行,要用多线程吗?
- 同时执行必然会返回多个结果,该如何确定最终返回运行结果呢?
对于问题1,是不用多线程的,我们只要在一帧中把所有子节点都执行一次,就算是「同时」执行了。 对于问题2,我们可以根据需求自行设置并行器成功或失败的标准,一般可分为「全都」和「只要有一个」。 看看代码就知道了:
using System.IO;
namespace BehaviourTree.Core
{
public class Parallel : Composite
{
public enum Policy
{
RequireOne,
RequireAll,
}
protected Policy m_SuccessPolicy;
protected Policy m_FailurePolicy;
public Parallel(Policy successPolicy = Policy.RequireOne, Policy failurePolicy = Policy.RequireOne)
{
m_SuccessPolicy = successPolicy;
m_FailurePolicy = failurePolicy;
}
protected override EBehaviourStatus OnUpdate()
{
int successCount = 0;
int failureCount = 0;
foreach (Behaviour behaviour in m_Children)
{
if (!behaviour.IsTerminated)
{
behaviour.Tick();
}
if (behaviour.IsSuccess)
{
successCount++;
if (m_SuccessPolicy == Policy.RequireOne)
{
return EBehaviourStatus.Success;
}
}
if (behaviour.IsFailure)
{
failureCount++;
if (m_FailurePolicy == Policy.RequireOne)
{
return EBehaviourStatus.Failure;
}
}
}
if (m_SuccessPolicy == Policy.RequireAll && successCount == m_Children.Count)
{
return EBehaviourStatus.Success;
}
if (m_FailurePolicy == Policy.RequireAll && failureCount == m_Children.Count)
{
return EBehaviourStatus.Failure;
}
return EBehaviourStatus.Running;
}
protected override void OnTerminate()
{
foreach (Behaviour behaviour in m_Children)
{
if (behaviour.IsRunning)
{
behaviour.Abort();
}
}
}
public override void WriteDataToStream(BinaryWriter bw)
{
base.WriteDataToStream(bw);
bw.Write((int)m_SuccessPolicy);
bw.Write((int)m_FailurePolicy);
}
public override void ReadDataFromStream(BinaryReader br)
{
m_SuccessPolicy = (Policy)br.ReadInt32();
m_FailurePolicy = (Policy)br.ReadInt32();
}
}
}
过滤器(Filter)
过滤器,由顺序器改造而来,就是在进入子节点之前,加了些条件判断,如果不满足任意一个,就不能执行后续的子节点,此即为「过滤」。

你会发现,它们甚至可以直接看作是在同一个列表里,只是「条件」都在前半段,真正要运行的子节点都在后半段。代码也确实可以这么设计:
using System.IO;
namespace BehaviourTree.Core
{
public class Filter : Sequence
{
public void AddCondition(Behaviour condition)
{
m_Children.Insert(0, condition);
}
public void AddAction(Behaviour action)
{
m_Children.Add(action);
}
public override void ReadDataFromStream(BinaryReader br)
{
base.ReadDataFromStream(br);
// Nonthing to read
}
}
}
主动选择器(ActiveSelector)
主动选择器是选择器的改造,功能很类似。但与选择器不同的是,它每次都对所有子节点进行重新判断。
namespace BehaviourTree.Core
{
public class ActiveSelector : Selector
{
protected override EBehaviourStatus OnUpdate()
{
int prevIndex = m_CurrentChildIndex;
base.OnInitialize();
var res = base.OnUpdate();
if (InRange(prevIndex) && m_CurrentChildIndex != prevIndex)
{
m_Children[prevIndex].Abort();
}
return res;
}
}
}
监视器(Monitor)
监视器是对「并行器」的改造,改造的目的也是为了能持续检查并行行为的条件。

从逻辑上看,它有两个子树,一边负责条件,一边负责具体行为。这种分离方式是合理的,防止了同步和争用问题,因为只有一个子树将运行对世界进行更改的操作。
从代码上来说,其实它的改造方法和「过滤器」完全一致,因为我们完全可以把这两个子树看作一个,只是前半部分全是条件,后半部分全是具体动作而已:
using System.IO;
namespace BehaviourTree.Core
{
public class Monitor : Parallel
{
public Monitor(Policy successPolicy = Policy.RequireOne, Policy failedPolicy = Policy.RequireOne):base(successPolicy, failedPolicy)
{
}
public void AddCondition(Behaviour condition)
{
m_Children.Insert(0, condition);
}
public void AddAction(Behaviour action)
{
m_Children.Add(action);
}
}
}
终于,所有常用的组合节点我们都实现了,下面就该讲讲修饰节点了。
修饰节点
修饰节点只有一个子节点,因为这样就足够了,想要多个条件只需要配合组合节点就可以实现。所以它的基类也十分简单:
using System.IO;
namespace BehaviourTree.Core
{
public abstract class Decorator : Behaviour
{
protected Behaviour m_Child;
public override int ChildCount
{
get
{
return m_Child != null ? 1 : 0;
}
}
public override void AddChild(Behaviour child)
{
m_Child = child;
}
public override Behaviour GetChild(int index)
{
return m_Child;
}
public override void ReadDataFromStream(BinaryReader br)
{
// Nonthing to read
}
}
}
修饰节点理论上可以扩展成各种「条件」,完全取决于开发者的需求。所以这里,我们就不在这方面展开,就说说几个比较实用的修饰器吧。
取反器
简单地对子节点执行结果的「成功」或「失败」进行颠倒而已,但这小小的功能却能帮我们省去很多冗余的代码,比如有「存在敌人」的条件节点时,再想要「不存在敌人」的条件节点,就不必去写代码了,只需要在「存在敌人」前加上这样一个「取反器」就可以了。
它的实现也很简单:
using System.IO;
using System.Text;
namespace BehaviourTree.Core.Decorators
{
public class Inverter:Decorator
{
protected override EBehaviourStatus OnUpdate()
{
m_Child.Tick();
if (m_Child.IsFailure)
{
return EBehaviourStatus.Success;
}
if (m_Child.IsSuccess)
{
return EBehaviourStatus.Failure;
}
return EBehaviourStatus.Running;
}
public override void ReadDataFromStream(BinaryReader br)
{
// Nonthing to read
}
}
}
重复执行器
重复执行某(些)行为也是常见的动作需求,这些动作往往都是已实现的单一动作,例如,有了「点射」动作,我们就可以仅给它加上一个重复执行器,就可以实现「扫射」。
重复执行器的逻辑也很直接:
using System.IO;
namespace BehaviourTree.Core.Decorators
{
public class Repeat : Decorator
{
private int m_Counter;
private int m_Limit;
public Repeat(int limit = 1)
{
m_Limit = limit;
}
protected override void OnInitialize()
{
m_Counter = 0;
}
protected override EBehaviourStatus OnUpdate()
{
while (m_Counter < m_Limit)
{
m_Child.Tick();
if (m_Child.IsRunning)
{
return EBehaviourStatus.Running;
}
if(m_Child.IsFailure)
{
return EBehaviourStatus.Failure;
}
m_Counter++;
}
return EBehaviourStatus.Success;
}
public override void ReadDataFromStream(BinaryReader br)
{
m_Counter = br.ReadInt32();
m_Limit = br.ReadInt32();
}
public override void WriteDataToStream(BinaryWriter bw)
{
base.WriteDataToStream(bw);
bw.Write(m_Counter);
bw.Write(m_Limit);
}
}
}
动作节点
动作节点也是自由发挥的节点,具体功能随需求,但有一点要严格遵守——不能有子节点。 要实现动作节点,只要继承并重写节点基类就可以了,例如一个打印一些字的节点:
public class DebugNode : Behavior
{
private string word;
public DebugNode(string word)
{
this.word = word;
}
protected override EBehaviourStatus OnUpdate()
{
Debug.Log(word);
return EBehaviourStatus.Success;
}
}
本章小结
一句话总结行为树就是根据预定义的参数和外部参数来筛选并执行一定的动作逻辑。可以发现,它的中间节点都是逻辑控制,只有底层的叶子节点才是具体的动作节点。上面介绍的节点是一些通用的节点,但是细心考究就会发现,我们可以根据业务需求,去扩展更多更丰富的逻辑控制节点,来组合出更丰富的行为。
行为树的难点并不在数据结构本身。一个好用的行为树,需要一个好用的编辑器来构造它,需要高效的序列化和反序列化方法来保存和加载它,同时还有一些优化方法,把它改成事件驱动的模式,能做到空间换时间,把每次Tick的开销优化掉。
构建与运行
对于我个人的小项目来说,花费时间去实现一个编辑器实在是得不偿失。所以我实现了一个构建器,可以方便地构造出行为树,并直接保存成二进制数据文件,在运行时加载使用。下面的代码就构造了一棵简单的行为树,它定义了怪物闲置,移动,攻击和死亡的行为,并且定义了常量参数。这可以Hold住简单的行为逻辑,对于复杂的逻辑仍不适用。
using BehaviourTree.Builder;
using BehaviourTree.Core;
using DG.Runtime.BehaviourTree.Builder;
using UnityEditor;
namespace DG.Editor.BehaviourTree
{
public class BehaviourTreeEditor
{
[MenuItem("Tools/Behaviour Tree/Create Monster Behaviour Tree")]
public static void CreateMonsterBehaviourTree()
{
XBTreeBuilder builder = new XBTreeBuilder();
BTree tree = builder.ActiveSelector<XBTreeBuilder>()
.MonsterDieAction()
.MonsterIdleAction(2)
.MonsterWalkAction(2, 2)
.MonsterAttackAction()
.End();
tree.SaveToFile("Assets/Data/BehaviourTrees/MonsterBTree.bytes");
}
}
}
Builder
基本的构建器代码如下,它定义了构架器的内部结构,可以看到是使用一个栈来记录加入树中的节点,出栈操作遵循了链式构建法则。
using System.Collections;
using System.Collections.Generic;
using BehaviourTree.Core;
using BehaviourTree.Core.Decorators;
namespace BehaviourTree.Builder
{
public partial class BTreeBuilder
{
private Stack<Behaviour> m_BehaviourStack = new Stack<Behaviour>();
private BTree m_Tree;
public BTreeBuilder()
{
m_Tree = new BTree(null);
m_BehaviourStack = new Stack<Behaviour>();
}
public void AddBehaviour(Behaviour behaviour)
{
if (m_Tree.HaveRoot)
{
m_BehaviourStack.Peek().AddChild(behaviour);
}
else
{
m_Tree.SetRoot(behaviour);
}
if (behaviour is Composite || behaviour is Decorator)
{
m_BehaviourStack.Push(behaviour);
}
}
public void TreeTick()
{
m_Tree.Tick();
}
public BTreeBuilder Pop()
{
m_BehaviourStack.Pop();
return this;
}
public BTree End()
{
m_BehaviourStack.Clear();
return m_Tree;
}
}
}
同时,在另一部分实现对常规节点的构建方法,同样遵循了链式构建法则:
using BehaviourTree.Core;
using BehaviourTree.Core.Decorators;
namespace BehaviourTree.Builder
{
public partial class BTreeBuilder
{
public BTreeBuilder Sequence()
{
var sequence = new Sequence();
AddBehaviour(sequence);
return this;
}
public T Seletctor<T>() where T : BTreeBuilder
{
var tp = new Selector();
AddBehaviour(tp);
return this as T;
}
public T Filter<T>() where T : BTreeBuilder
{
var tp = new Filter();
AddBehaviour(tp);
return this as T;
}
public T Parallel<T>(Parallel.Policy success, Parallel.Policy failure) where T : BTreeBuilder
{
var tp = new Parallel(success, failure);
AddBehaviour(tp);
return this as T;
}
public T Monitor<T>(Parallel.Policy success, Parallel.Policy failure) where T : BTreeBuilder
{
var tp = new Monitor(success, failure);
AddBehaviour(tp);
return this as T;
}
public T ActiveSelector<T>() where T : BTreeBuilder
{
var tp = new ActiveSelector();
AddBehaviour(tp);
return this as T;
}
public T Repeat<T>(int limit) where T : BTreeBuilder
{
var tp = new Repeat(limit);
AddBehaviour(tp);
return this as T;
}
public T Inverter<T>() where T : BTreeBuilder
{
var tp = new Inverter();
AddBehaviour(tp);
return this as T;
}
}
}
这样就可以方便地把它封装到Unity的Package里。在应用层中,可以继承 Behaviour类实现自己的动作节点,并且继承 BTreeBuilder实现自己的构建器,这是需要随着项目一起扩展和迭代的内容。
using BehaviourTree.Builder;
using DG.Runtime.BehaviourTree.Actions;
namespace DG.Runtime.BehaviourTree.Builder
{
public class XBTreeBuilder:BTreeBuilder
{
public XBTreeBuilder MonsterAttackAction()
{
var res = new MonsterAttackAction();
AddBehaviour(res);
return this;
}
public XBTreeBuilder MonsterDieAction()
{
var res = new MonsterDieAction();
AddBehaviour(res);
return this;
}
public XBTreeBuilder MonsterIdleAction(float attackRadius)
{
var res = new MonsterIdleAction(attackRadius);
AddBehaviour(res);
return this;
}
public XBTreeBuilder MonsterWalkAction(float repathRadius, float attackRadius)
{
var res = new MonsterWalkAction(repathRadius, attackRadius);
AddBehaviour(res);
return this;
}
}
}
序列化和反序列化
可以看到上面的代码中,我给节点和树都加了输出节点数据的方法,WriteDataToStream等。这些方法基于前序遍历的方式,把数据写入到文件中。这实现起来很容易。
using System.IO;
using BehaviourTree.Core;
using BehaviourTree.Factory;
namespace BehaviourTree.Builder
{
public static class BTreeSerializer
{
public static BTree LoadFromBytes(IBTreeFactory factory, byte[] rawData)
{
BTree tree = new BTree();
using (MemoryStream ms = new MemoryStream(rawData))
{
using (BinaryReader br = new BinaryReader(ms))
{
Behaviour root = DecodeBehaviour(tree, factory, br);
tree.SetRoot(root);
}
}
return tree;
}
private static Behaviour DecodeBehaviour(BTree tree, IBTreeFactory factory, BinaryReader br)
{
string typeName = br.ReadString();
Behaviour currentNode = factory.CreateBehaviour(typeName);
currentNode.ReadDataFromStream(br);
currentNode.SetTree(tree);
int childCount = br.ReadInt32();
for (int i = 0; i < childCount; i++)
{
Behaviour child = DecodeBehaviour(tree, factory, br);
currentNode.AddChild(child);
}
return currentNode;
}
public static void SaveToFile(this BTree tree, string filePath)
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
using (FileStream fs = new FileStream(filePath, FileMode.CreateNew))
{
using (BinaryWriter bw = new BinaryWriter(fs))
{
Behaviour root = tree.GetRoot();
EncodeBehaviour(root, bw);
}
}
}
private static void EncodeBehaviour(Behaviour currentNode, BinaryWriter bw)
{
currentNode.WriteDataToStream(bw);
int childCount = currentNode.ChildCount;
bw.Write(childCount);
if (childCount > 0)
{
for (int i = 0; i < childCount; i++)
{
Behaviour child = currentNode.GetChild(i);
EncodeBehaviour(child,bw);
}
}
}
}
}
麻烦的是反序列化方法,要根据节点类型,来创建节点。我这里使用了简单工厂的模式来创建节点。并且定义了工厂基类来实现对通用节点的创建,应用层就需要继承这个节点来创建自己的节点。
然而,工厂方法和构建器每创建一个节点,都需要手动去添加代码,是非常麻烦的事情。因此工业化的做法应该是引入代码自动生成的方法来自动生成这些繁琐的代码。那么,就可以购买Unity插件:VTCodeTemplate Pro来完美地完成这个事情,维护模板文件和数据注入逻辑,自动一键生成代码。
