【Unity】手搓一个简单的行为树

936 阅读6分钟

开发角色AI时,通常会使用到两种方式,分别是状态机行为树。状态机和行为树都有各自的使用场景。

状态机

当需要的角色AI执行的是简单、线性的行为逻辑,则优先使用状态机,执行效率高,资源消耗低。但当系统变得复杂时,状态机的状态数量和转换关系会变得难以维护,容易出现状态爆炸问题。扩展性较差,新增状态或修改逻辑时可能会导致整个状态图的重构。

行为树

行为树更适合复杂、层次化的行为逻辑,具有更高的灵活性和扩展性。行为树可以通过组合节点动态扩展行为逻辑,及动态调整行为的执行顺序和优先级,适合处理动态环境下的复杂行为。缺点就是执行效率可能较低,资源消耗较高。

实现简单的行为树

Node类,树中所有结点对象的父类

using System.Collections.Generic;
//结点状态
public enum NodeState
{
    SUCCESS,
    FAILURE,
    RUNNING
}

public class Node
{
    //父结点
    public Node parent;
    //子结点集合
    public List<Node> childs = new List<Node>();
    //当前结点状态
    public NodeState nodeState;

    public Node()
    {
        parent = null;
    }
    //初始化当前结点所有子结点,将子结点的父结点引用指向当前结点
    public Node(List<Node> childrens)
    {
        foreach (var child in childrens)
        {
            Attach(child);
        }
    }

    public void Attach(Node node)
    {
        node.parent = this;
        childs.Add(node);
    }
    //结点执行的虚方法
    public virtual NodeState Evaluate() => NodeState.FAILURE;
}

Tree类,当前脚本挂载在角色上

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEditor.SceneManagement;
using UnityEngine;

public abstract class Tree : MonoBehaviour
{
    //根结点
    private Node root;

    void Awake()
    {
        root = SetupTree();
    }
    //执行根结点的结点评估方法
    void Update()
    {
        if (root != null)
        {
            root.Evaluate();
        }
    }
    //构建树的抽象方法
    public abstract Node SetupTree();
}

SequenceNode顺序节点,用于控制流程的结点:

1.当子结点的结点状态为Success或Running时,将当前结点状态设为Success或Running,继续执行下一个子结点

2.当子结点的结点状态为Failure时,直接终止后续子结点运行,直接返回当前结点状态为Failure

3.当所有子结点都正常运行,最后返回当前结点状态为Success或Running

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SequenceNode : Node
{
    public SequenceNode()
        : base() { }

    public SequenceNode(List<Node> childs)
        : base(childs) { }

    public override NodeState Evaluate()
    {
        bool hasRuningChildNode = false;
        //遍历所有子结点,并且调用其Evaluate(评估)方法
        foreach (var child in childs)
        {
            switch (child.Evaluate())
            {
                case NodeState.SUCCESS:
                    nodeState = NodeState.SUCCESS;
                    continue;
                case NodeState.RUNNING:
                    hasRuningChildNode = true;
                    nodeState = NodeState.RUNNING;
                    continue;
                case NodeState.FAILURE:
                    nodeState = NodeState.FAILURE;
                    return nodeState;
            }
        }
        return nodeState = hasRuningChildNode ? NodeState.RUNNING : NodeState.SUCCESS;
    }
}

SelectorNode选择节点,用于控制流程的结点:

1.当子结点的结点状态为Failure时,将当前结点状态设为Failure,继续执行下一个子结点

2.当子结点的结点状态为Success或Running时,直接终止后续子结点运行,直接返回当前结点状态为Success或Running

3.当所有子结点都运行失败,最后返回当前结点状态为Failure

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SelectorNode : Node
{
    public SelectorNode()
        : base() { }

    public SelectorNode(List<Node> childs)
        : base(childs) { }

    public override NodeState Evaluate()
    {
        foreach (var child in childs)
        {
            switch (child.Evaluate())
            {
                case NodeState.SUCCESS:
                    nodeState = NodeState.SUCCESS;
                    return nodeState;
                case NodeState.RUNNING:
                    nodeState = NodeState.RUNNING;
                    return nodeState;
                case NodeState.FAILURE:
                    nodeState = NodeState.FAILURE;
                    continue;
                default:
                    continue;
            }
        }
        nodeState = NodeState.FAILURE;
        return nodeState;
    }
}

此时已写好当前行为树的所有基础类,接下来编写角色AI特定的行为结点。

下面的行为结点为:

1.角色在一定范围内执行巡逻行为的巡逻结点。

2.判断视角范围是否存在Enemy的结点。

3.存在则追击Enemy的追击结点。

4.当enemy脱离视角则角色回归巡逻结点。

Patrol结点

执行巡逻任务

这里会使用Unity自带的AI寻路系统

创建当前类之前,需要给地面添加NavMeshSurface组件,并烘焙,生成寻路所需要的Mesh。

屏幕截图 2025-02-23 181559.png

给角色对象挂载NavMeshAgent组件,用于控制角色在烘焙的Mesh上进行移动

屏幕截图 2025-02-23 181826.png

using UnityEngine;
using UnityEngine.AI;

public class PatrolNode : Node
{
    public Transform transform;
    //角色巡逻后的当前休息时间
    private float waitTime;
    //默认巡逻后的休息时间
    private float originalWaitTime = 1f;
    //Unity的AI寻路功能对象
    private NavMeshAgent navMeshAgent;
    //巡逻范围
    private float patrolRange = 1f;
    //当前巡逻点坐标
    private Vector3 currentPatrolPoint;
    //角色生成的初始坐标
    private Vector3 orginalPoint;
    
    public PatrolNode(Transform transform)
    {
        //创建结点时通过构造方法传递角色Transform属性
        this.transform = transform;
        生成初始坐标,用于固定巡逻点的范围
        orginalPoint = new Vector3(
            transform.position.x,
            transform.position.y,
            transform.position.z
        );
        navMeshAgent = transform.GetComponent<NavMeshAgent>();
        //为角色生成第一个巡逻点
        currentPatrolPoint = GetNextPatrolPoint();
        //初始化休息时间
        waitTime = originalWaitTime;
    }

    public override NodeState Evaluate()
    {
        //角色在原地休息
        if (waitTime > 0)
        {
            waitTime -= Time.deltaTime;
            transform.position = transform.position;
        }
        else
        {
            //休息结束
            //判断角色和巡逻点之间的距离
            if (Vector3.Distance(transform.position, currentPatrolPoint) > 0.5f)
            {
                //朝向巡逻点
                transform.rotation = Quaternion.LookRotation(currentPatrolPoint);
                //移动至巡逻点,若巡逻点不在导航Mesh上则不生效
                navMeshAgent.SetDestination(currentPatrolPoint);
            }
            else
            {
                //到达巡逻点,重置等待时间
                waitTime = originalWaitTime;
                //获取下一个巡逻点
                currentPatrolPoint = GetNextPatrolPoint();
            }
        }
        //返回状态为结点状态为Running
        nodeState = NodeState.RUNNING;
        return nodeState;
    }
    //获取随机巡逻点
    private Vector3 GetNextPatrolPoint()
    {
        var rangeX = Random.Range(-patrolRange, patrolRange);
        var rangeZ = Random.Range(-patrolRange, patrolRange);
        NavMeshHit hit;
        //获取随机巡逻点在导航Mesh上最近的点,如果存在则返回最近点坐标,不存在则返回当前角色坐标
        var nextPatrolPoint = NavMesh.SamplePosition(
            new Vector3(orginalPoint.x + rangeX, orginalPoint.y, orginalPoint.z + rangeZ),
            out hit,
            patrolRange,
            1
        )
            ? hit.position
            : transform.position;
        return nextPatrolPoint;
    }
}

CheckEnemyInFovNode结点

判断敌人是否在范围内

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CheckEnemyInFovNode : Node
{
    private Transform transform;
    //视野范围
    private float fovRange = 2f;

    public CheckEnemyInFovNode(Transform transform)
    {
        this.transform = transform;
    }

    public override NodeState Evaluate()
    {
        获取球形检测范围内的所有collider
        var colliders = Physics.OverlapSphere(transform.position, fovRange);
        foreach (var collider in colliders)
        {
            //存在敌人对象则保存敌人对象,并且返回当前结点状态为Success
            if (collider.CompareTag("Enemy"))
            {
                var heroBT = transform.GetComponent<HeroBT>();
                heroBT.target = collider.gameObject;
                nodeState = NodeState.SUCCESS;
                return nodeState;
            }
        }
        //未找到则返回当前结点状态为Failure
        nodeState = NodeState.FAILURE;
        return nodeState;
    }
}

GoToTarget结点

追击Enemy

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.AI;

public class GoToTargetNode : Node
{
    private Transform transform;
    private HeroBT heroBT;
    private NavMeshAgent navMeshAgent;
    private float range = 0.5f;

    public GoToTargetNode(Transform transform)
    {
        this.transform = transform;
        heroBT = transform.GetComponent<HeroBT>();
        navMeshAgent = transform.GetComponent<NavMeshAgent>();
    }

    public override NodeState Evaluate()
    {
        //创建Enemy的在角色同等高度的坐标
        var target = new Vector3(
            heroBT.target.transform.position.x,
            transform.position.y,
            heroBT.target.transform.position.z
        );
        if (Vector3.Distance(transform.position, target) > 0.01f)
        {
            //看向Enemy
            transform.rotation = Quaternion.LookRotation(target);
            //因为当前enemy坐标不在导航Mesh上,所以需要返回当前坐标点最近的导航点
            NavMeshHit hit;
            NavMesh.SamplePosition(target, out hit, range, 1);
            navMeshAgent.destination = hit.position;
        }
        else
        {
            //到达Enemy后停止移动,返回当前结点状态为Success
            transform.position = transform.position;
            nodeState = NodeState.SUCCESS;
            return nodeState;
        }
        //追击中则返回当前结点状态为Running
        nodeState = NodeState.RUNNING;
        return nodeState;
    }
}

HeroBT结点

控制角色的行为树,脚本挂载在角色上

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HeroBT : Tree
{
    public GameObject target;

    public override Node SetupTree()
    {
        //为保证每一次只有一个节点会执行,最外层使用Selector选择结点
        var root = new SelectorNode(
            new List<Node>
            {
            //CheckEnemyInFovNode和GoToTargetNode是共同协作的结点,
        且CheckEnemyInFovNode执行不成功,GoToTargetNode结点不会执行,CheckEnemyInFovNode执行成功,
        GoToTargetNode结点继续执行,所以需要放在同一个控制结点中,且满足条件的为SequenceNode顺序结点
                new SequenceNode(
                    new List<Node>
                    {
                        new CheckEnemyInFovNode(transform),
                        new GoToTargetNode(transform)
                    }
                ),
                //PatrolNode结点只有在找不到Enemy的时候执行,优先级低于前两个结点,所以顺序放在前两个结点之后
                new PatrolNode(transform)
            }
        );
        return root;
    }
}

最终的执行效果

录制_2025_02_23_19_11_04_624.gif