Bootstrap

状态机与行为树的实现;Behavior Designer的使用与自写状态机的几种方法;

以下部分内容将会涉及插件BehaviorDesigner
代码仅为演示所需,并非实际实现代码,非本人所使用代码;

前往个人博客,获取更好的阅读体验

状态机与行为树

为何写?

笔者在初学的时候并不写状态机,而是写到一块,做一个if判断大王,直到写出了下面这个屎上屎的代码:

public void Refresh()
{
    if (_state != PlayerState.Dead && _state != PlayerState.Start)
    {
        OnMove();
        Jump();
        Fall();
        ToGround();
    }
}

private void OnMove()
{
    //...
    if (moveInput != Vector2.zero)
    {
        _targetSpeed = _input._isRunInput ? _runSpeed : _walkSpeed;
        playerState = _input._isRunInput ? PlayerState.Run : PlayerState.Walk;
        _curSpeed = Mathf.Lerp(_curSpeed, _targetSpeed, Time.deltaTime * 8);

        if (moveInput.x != 0)
        {
            if (moveInput.x > 0)
            {
                transform.localScale = new Vector3(1, 1, 1);
            }
            else if (moveInput.x < 0)
            {
                transform.localScale = new Vector3(-1, 1, 1);
            }
        }
    }
    else
    {
        playerState = PlayerState.Idle;
        _curSpeed = Mathf.Lerp(_curSpeed, 0, Time.deltaTime * 10);
    }

    if (!_isAir)
    {
        _state = playerState;
    }
    //...
}


这个是GameJame上写的一个PlayerCon,完全通过if来判断,的确能跑,但是极其不美观,毫无扩展性而言。
为什么会这样? 用比较官方的话来说,是因为:

  • 缺乏模块化设计:所有的逻辑都集中在一个方法中,导致代码难以组织和理解。
  • 状态管理不清晰:没有明确的状态管理机制,导致对角色行为的控制变得混乱。
  • 可读性差:混合了多个职责(如移动、跳跃、落地等)的代码让逻辑变得复杂,不利于调试和扩展。

通俗的说,就是:你一个OnMove负责了多少东西啊,能不复杂才怪。

应该怎么写?

大概的解决方法就是将各个部分解耦,将一个模块分解为多个模块。例如以下:

  • 一个控制类,控制状态的切换;
  • 每个状态(Idle,Move,Run),分别一个类;
  • 每个类定义一个各自的Updata,更新各自的行为;

实际上这就是状态机了,在真正写状态机前,先来聊聊行为树;
具体代码示例会在后文给出;

区别与分析

首先阐明一个观点,我认为状态机(State Machine)和行为树(Behavior Tree)本质上是一样的。

  • 状态机管理多个状态,负责状态的切换;
  • 行为树则序列执行各个行为,当满足一定条件后,进行行为的跳转;

通常来说,状态包括此时执行什么动画,刷新那些位置,更关注当前做什么
而行为树,则是当前的完整的行为,不仅包括当前的行为,还有一套完整的决策系统,决定接下来做什么。

即状态机驱动处理当前状态,行为树驱动所有的的行为

public class PlayerIdlingState : PlayerMovementState  
{  
    GameTimer GameTimer { get; set; }  
    //调用父类的构造函数  
    
    public override void Enter()  
    {
        animator.SetBool(AnimatorID.HasInputID, false);  
  
    }  
    public override void Update()  
    {
        //该状态操作 
    }  
    public override void Exit()    
    {        
		//调整animator 
	}
}

但是如果状态包括当收到什么内容时呼叫状态机切换至什么状态,那就认为,这个状态机就可以认为是一个具备部分行为树特征的状态机,甚至就是状态机。例如下列代码:

public class PlayerIdlingState : PlayerMovementState  
{  
    GameTimer GameTimer { get; set; }  
    //调用父类的构造函数  
    
    public override void Enter()  
    {
        animator.SetBool(AnimatorID.HasInputID, false);  
  
    }  
    public override void Update()  
    {
        //该状态操作 
        Move();
    }  
    public override void Exit()    
    {        
		//调整animator 
	}
	public void Move()
	{
		if(_input.Space == true)
		{
			StateMachine.ChangeState("Jump");
		}
	}
}

因而可以看出,行为树是状态机的Pro版,其拥有复杂的AI逻辑和动态决策。相比状态机,更能适应复杂逻辑的设计;

切记笔者的一个观点,标准的状态机是不包括决策的!包括决策的状态机,就是一个有着行为树特征的“状态机”。

状态机

怎么设计?

首先梳理一下框架:

  • 我们需要一个状态机管理状态的初始化以及状态的切换;
  • 每个状态应该会有独立的行为,也有可能需要进行一些事件的订阅;
  • 为了更好的进行状态机的编写,我们会引入部分行为树的特性
    • 每个状态将包含一定的决策,负责状态的切换

实际上相当的简单,以下是示例代码:

//状态机内容
public class StateMachine
{
    private IState currentState;
    // 提供状态切换的接口;
    public void ChangeState(IState newState)
    {
        if (currentState != null)
        {
            currentState.Exit();
        }
        currentState = newState;
        
        currentState.Enter();
    }
    public void Update()
    {
        if (currentState != null)
        {
            currentState.Execute();
        }
    }
}

//示例状态
public class MoveState : IState
{
    public override void Enter()
    {
        Debug.Log("Entering Move State");
        animator.Play("Move"); // 播放移动动画
    }
    public override void Execute()
    {
        Move();
        StateChange();
        //进入移动动画
        Debug.Log("Executing Move State");
    }
    public override void Exit()
    {
        Debug.Log("Exiting Move State");
        //取消特定的监听。
    }
    private void Move()
    {
        //实现移动
    }
    private void StateChange()
    {
	    if(_Input.Run == true)
	    {
		    StateMachine.ChangeState("Run");
	    }
    }
}

怎么用?

具体使用见仁见智,个人比较喜欢在状态机中初始化所有状态,然后在Start生命周期函数中进入初始状态,然后就无需关注了。

  • 这里会利用父指针可以指向儿子节点这一隐形转换。

例如一下代码:

public class StateMachine
{
    private IState currentState;
    private IState idleState;
    private IState moveState;

    // 初始化状态机,设置初始状态
    public void Start()
    {
        idleState = new IdleState();
        moveState = new MoveState();
        ChangeState(idleState); // 使用实例调用
    }

    // 提供状态切换的接口
    public void ChangeState(IState newState)
    {
        if (currentState != null)
        {
            currentState.Exit(); // 执行当前状态的退出操作
        }

        currentState = newState; // 切换到新状态
        currentState.Enter(); // 执行新状态的进入操作
    }

    // 更新当前状态
    public void Update()
    {
        if (currentState != null)
        {
            currentState.Execute(); // 执行当前状态的逻辑
        }
    }
}

关于行为树

考虑到绝大多数人并不会去写一个纯的状态机,事实上,前面我写的"状态机",其实是一个行为树。
所以这儿就不过多聊纯代码向的行为树,这里聊聊很多人用过,没用过的未来也可能会用,一个很棒的行为树插件,Behavior Designer;

请注意,我反对将任何核心功能以调用插件的形式实现,即便调用插件,也应当具备修改甚至写出这个插件的能力!!!

它都有什么?

在行为树插件中,主要分为:

  • 控制节点(Control Nodes):如选择器(Selector)、顺序节点(Sequence)等,用于控制子节点的执行顺序。
  • 叶节点(Leaf Nodes):如行为节点(Action)和条件节点(Condition),用于执行具体的动作或判断条件。

简单的去说,就是前者决定执行顺序,以及执不执行,而后者决定执行什么或者告诉前者后者怎么样;
例如下图:
例图

  • 最初有一个分支,决定待机,还是战斗。
  • Idle状态下,执行子节点行为IdleState,如果IdleState返回发现敌人,Idle这个控制器上报信息,由Sequence控制进入右侧状态;
  • 右侧首先进入Walk状态,当返回一定信息后切换到Attack状态。
  • AttackRepeater,再次进入Walk状态。

事实上这里只是演示极小部分内容,具体该插件有多丰富的功能大家可以试试看。

核心内容

控制节点(Control Nodes)

控制节点是行为树的基础,负责决定子节点的执行顺序和执行条件。在 Behavior Designer 中,常用的控制节点主要有 SelectorSequenceRepeater


1. Selector(选择器)

  • 功能Selector 节点按顺序依次执行子节点,直到其中一个子节点成功。若某个子节点成功,Selector 会立即停止剩余子节点的执行并返回成功;若所有子节点都失败,Selector 返回失败。

  • 特点

    • 类似于“或”逻辑,表示只要有一个子节点成功,Selector 节点就会返回成功。
    • 适合用于处理有多个备选方案的情况,例如角色在执行攻击时,可以先尝试远程攻击,若不成功,再尝试近战攻击。
  • 示例

    Selector
        -> 检查远程攻击范围 -> 远程攻击
        -> 检查近战攻击范围 -> 近战攻击
    

    在该示例中,角色会首先尝试远程攻击,如果失败,再检查近战攻击。若所有攻击方式都失败,Selector 返回失败。


2. Sequence(顺序节点)

  • 功能Sequence 节点依次执行所有子节点,直到其中一个子节点失败。若某个子节点失败,Sequence 会立即停止后续子节点的执行并返回失败;只有当所有子节点都成功时,Sequence 才返回成功。

  • 特点

    • 类似于“与”逻辑,所有子节点都必须成功,Sequence 才会成功。
    • 常用于需要顺序执行的任务,比如角色执行一系列连贯动作,例如先寻找玩家,再靠近玩家,最后进行攻击。
  • 示例

    Sequence
        -> 检查是否看到玩家
        -> 移动到玩家位置
        -> 攻击玩家
    

    在此示例中,角色必须依次完成每个步骤,才能最终攻击玩家。若在任何一步失败(例如未找到玩家),Sequence 返回失败。


3. Repeater(重复器)

  • 功能Repeater 节点用于重复执行子节点,可以根据设定条件(例如重复次数或某个子节点的结果)来决定是否继续执行。

  • 特点

    • 通常用于需要重复尝试的行为,例如持续监视环境、重复寻找目标等。
    • 可以设定无限循环、指定重复次数,或基于子节点的返回结果决定是否继续。
  • 示例

    Repeater(直到成功)
        -> 检查是否发现玩家
    

    在这个例子中,Repeater 会不断执行子节点,直到 检查是否发现玩家 返回成功。


叶节点(Leaf Nodes)

叶节点是行为树中的终端节点,具体执行某个行为或判断。在 Behavior Designer 中,叶节点通常用来实现具体的逻辑行为,比如等待、移动、攻击等。在实际开发中,的确可以通过插件提供的操作直接调用Unity的脚本,但是为了更加灵活,一般我是自己写叶节点。

考虑到这个插件有一万种方法实现一个功能,这里就不过多赘述,直接上几个个人示例;

示例1:BehaviorIdleState

这个叶节点判断敌人与玩家之间的距离,发现玩家时返回 Success,否则保持 Running(继续运行)。

public class BehaviorIdleState : Action
{
    private Animator animator; // 动画控制器
    private EnemyBase enemyBase; // 敌人基础脚本
    
    public override void OnAwake()
    {
        // 获取必要的组件
        enemyBase = GetComponent<EnemyBase>();
        animator = GetComponent<Animator>();
    }
    
    public override void OnStart()
    {
        // 设置敌人的动画状态为“无目标”
        animator.SetBool("HasTarget", false);
    }

    public override TaskStatus OnUpdate()
    {
        // 如果玩家在检测范围内,返回成功
        if (Vector3.Distance(transform.position, enemyBase.player.position) < enemyBase.detectionRange)
        {
            Debug.Log("Idle: 玩家进入检测范围,返回成功");
            return TaskStatus.Success;
        }

        // 否则继续维持当前状态
        return TaskStatus.Running; 
    }
    
    public override void OnEnd()
    {
        // 设置敌人的动画状态为“有目标”
        animator.SetBool("HasTarget", true);
    }
}
示例2:BehaviorAttackState

这个叶节点控制敌人进入攻击状态,并根据玩家的距离判断是否继续攻击。

public class BehaviorAttackState : Action
{
    private Transform player; // 玩家目标
    private Animator animator; // 动画控制器
    private EnemyBase enemyBase; // 敌人基础脚本

    public override void OnAwake()
    {
        Debug.Log("进入攻击状态");
        // 获取必要的组件
        animator = GetComponent<Animator>();
        enemyBase = GetComponent<EnemyBase>();
        player = GameObject.FindGameObjectWithTag("Player").transform;
    }

    public override void OnStart()
    {
        // 旋转敌人面向玩家
        Quaternion targetRotation = Quaternion.LookRotation(player.position - transform.position);
        transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5);

        // 设置动画状态为“攻击”
        animator.SetBool("Attack", true);
    }
    
    public override TaskStatus OnUpdate()
    {
        // 攻击玩家,若玩家脱离攻击范围,返回成功
        Debug.Log("正在攻击玩家...");
        if (Vector3.Distance(transform.position, player.position) > enemyBase.stopDistance)
        {
            return TaskStatus.Success;
        }
        
        // 否则持续执行攻击状态
        return TaskStatus.Running;
    }
    
    public override void OnEnd()
    {
        // 结束攻击,重置动画状态
        animator.SetBool("Attack", false);
    }
}

总结

没想到写了那么多,实际上总结起来很简单。
不管是状态机还是行为树,他们都是将复杂的状态分解为

  • 最初是什么状态
  • 状态切换时当前状态退出需要处理什么,进入下一状态需要加载什么
  • 当前状态要去做什么
  • 什么情况下进入另一个状态

这样一分解,分开写,就是行为树/状态机了。至于传说中的AI,无非就是下面这样:

  • 最初待机状态
  • 通过射线检测,角度计算判断视野范围
  • 发现玩家后退出待机状态,进入追击状态
  • 追击玩家后退出追击状态,进入攻击状态
  • 攻击后查看是否可以继续攻击到目标,不然进入追击状态
  • 攻击时监测能量,满足时放大招

等等等等,无非就是多几个状态的状态机罢了。

;