Bootstrap

行为树详解(2)——最简单的行为树

【需求分析】

结合具体的实际需求来:假设有这样一个NPC,绕着固定的某个点以半径20米随机巡逻,让主角进入时,开始朝着主角攻击,距离主角3米时,开始攻击主角,如果距离主角超过10米,则重新巡逻 

需求描述起来很简单,但实现起来不简单,对于这样一个需求,首先做行为拆解:

  1. 固定20米巡逻
  2. 进入巡逻范围开始追击
  3. 距离主角3米时开始攻击
  4. 追击时距离主角超过10米开始回归巡逻

行为基本就是条件+动作,动作很简单,复杂的是条件。

其次,划分行为所属的状态。

如何做状态划分是任意的,划分的原则是尽可能让状态更少,这里将NPC的四个行为分成巡逻和追击两个状态。

我们明明说的是行为树,为何这里又涉及到状态了:

  • 举例不复杂,行为很少
  • 这里的状态和状态机中的状态不是一个意思,行为树中也需要状态,这些状态隐含于条件之下,是不同层次的状态,而状态机中的状态都是同一层次下的
  • 可以将行为树看作是具有不同层次状态的分层状态机,其没有脱离状态机的范畴

最后,对每个状态下的行为划分优先级,优先级高的行为先判断

【一般实现】

因为条件是实时变化的,所以我们需要在Tick中不断做条件判断。

我们专注于条件,动作先省略。

    public class SimpleNPCAI:MonoBehaviour
    {
        private GameObject owner;
        private GameObject target;
        private Vector3 idleCenterPos;

        private bool idle;
        private void Update()
        {
            if(idle)
            {
                if(Vector3.Distance(idleCenterPos, target.transform.position)<20)
                {
                    Debug.Log("执行巡逻时的动作");
                }
                else
                {
                    idle = false;
                }
                
            }
            else
            {
                var dis = Vector3.Distance(owner.transform.position, target.transform.position);
                if(dis<3)
                {
                    Debug.Log("执行攻击的动作");
                }
                else if(dis>10)
                {
                    Debug.Log("执行回归巡逻的动作");
                    idle = true;
                }
                else
                {
                    Debug.Log("执行寻路追踪的目标的动作");
                }
            }
        }
    }

【行为树实现】

根据前文的描述,可以确认出如下几点,这些点构成了我们需要创建的类、类的字段、类的方法:

  • 有行为树和节点两个概念,对应两个类,有不同种类的节点,继而继承出不同类型的节点。
  • 行为树需要有一个进入的根节点
  • 行为树和节点之前要互相持有,确定归属关系
  • 行为树和主体要互相持有,确定归属关系
  • 不同节点要有ID、Name、Type等做区分
  • 父节点要有子节点
  • 没有做编辑配置,需要有添加删除各类节点的方法
  • 每个节点有自己的执行逻辑
  • 等等

合起来代码如下:

    public class SimpleBT
    {

        private GameObject owner;
        private Node rootNode;
        public void Init(GameObject owner)
        {
            this.owner = owner;
        }

        public NodeStatus Update()
        {
            var res = rootNode.Update();
            return res;
        }

        public void Destroy()
        {

        }
    }

    public enum NodeStatus
    {
        Success,
        Fail,
        Running,
    }

    public class Node
    {
        public string nodeName;
        public int nodeId;
        public NodeStatus status;
        public SimpleBT owner;

        public virtual void Init(string nodeName, int nodeId, SimpleBT owner)
        {
            this.owner = owner;
            this.nodeName = nodeName;
            this.nodeId = nodeId;
        }

        public NodeStatus Update()
        {
            return OnUpdate();
        }

        protected virtual NodeStatus OnUpdate()
        {
            return NodeStatus.Success;
        }

        public virtual void Destroy()
        {

        }

    }

    public class RootNode:Node 
    {
        public Node subNode;

        protected override NodeStatus OnUpdate()
        {
            return subNode.Update();
        }
    }

    public class ControlNode:Node 
    { 
        public List<Node> subNodes;

        protected override NodeStatus OnUpdate()
        {
            return Update();
        }
    }

    public class SequenceNode:ControlNode
    {
        protected override NodeStatus OnUpdate()
        {
            foreach (var node in subNodes)
            {
                var status = node.Update();
                if (status != NodeStatus.Success)
                    return status;
            }
            return NodeStatus.Success;
        }
    }

    public class ActionNode:Node
    {
        protected override NodeStatus OnUpdate()
        {
            return base.OnUpdate();
        }
    }

基本逻辑如下:

  • 主体调用SimpleBT的Init,添加节点方法,节点的Init方法完成整个行为树的数据初始化
  • 主体调用SimpleBT的Update方法,继而层层调用到不同节点的Update方法
  • 主体调用SimpleBT的Destroy方法,不同节点的Destroy方法销毁整个行为树

【几点说明】

是否继承MonoBehaviour的区别

继承的好处在于可以将脚本直接挂载在GameObject上,同时也可以很方便的在Inspector中做数据显示

不继承时数据显示也能做,会麻烦些,同时在代码中添加行为树。一般来说,做通用的东西,其核心不会继承MonoBehaviour

如何添加数据

我们没有在示例代码中给出添加节点的方法,这些方法的实现比较容易

添加节点方法本质是为了给定行为树的初始化数据,通常,初始化数据要支持编辑配置

我们这里假定初始化数据已经有了

【如何使用】

基本的调用如下:

public class NPC:MonoBehaviour
{
    private SimpleBT bt;
    public bool idle;
    public Vector3 idleCenterPos;
    public GameObject target;
    void Start()
    {
        bt = new SimpleBT();
        bt.Init(this.gameObject);
        //初始化bt数据
    }

    void Update()
    {
        bt.Update();
    }

    void OnDestroy()
    {
        bt.Destroy();
    }
}

在数据初始化中,RootNode的subNode是一个SequenceNode

SequenceNode的subNode依次是不同的ActionNode。这里简单来说就两个Node,每个状态下一种,Node实现如下

这里为了保真逻辑的正常执行,节点返回的NodeStatus有些奇怪。实际上应该有更多的节点来保证逻辑的正常,我们再后面的文章中结合实际来添加更多节点。

    public class IdleNode:ActionNode
    {
        protected override NodeStatus OnUpdate()
        {
            var npc = owner.owner.GetComponent<NPC>();
            var res = NodeStatus.Fail;
            if(npc != null)
            {
                if (npc.idle)
                {
                    if (Vector3.Distance(npc.idleCenterPos, npc.target.transform.position) < 20)
                    {
                        Debug.Log("执行巡逻时的动作");
                        res = NodeStatus.Fail;
                    }
                    else
                    {
                        npc.idle = false;
                        res = NodeStatus.Success;
                    }
                }
                else
                {
                    res = NodeStatus.Success;                   
                }
            }
            return res;
        }
    }

    public class AttackNode : ActionNode
    {
        protected override NodeStatus OnUpdate()
        {
            var npc = owner.owner.GetComponent<NPC>();
            var res = NodeStatus.Fail;
            if(npc != null)
            {
                var dis = Vector3.Distance(owner.owner.transform.position, npc.target.transform.position);
                if (dis < 3)
                {
                    Debug.Log("执行攻击的动作");
                }
                else if (dis > 10)
                {
                    Debug.Log("执行回归巡逻的动作");
                    npc.idle = true;
                }
                else
                {
                    Debug.Log("执行寻路追踪的目标的动作");
                }
                res = NodeStatus.Success;
            }
            return res;
        }

    }

前后对比可以发现:真正的执行动作的代码区别不大,执行时所需参数的来源和之前一样

;