跳到主要内容

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

· 阅读需 18 分钟

在游戏开发中,状态机(State Machine)和行为树(Behavior Tree)是实现 AI 逻辑的两种常见方式,它们各自有着不同的适用场景与优势,理解它们的原理和应用将有助于设计高效、灵活的游戏 AI。

提示

以下部分内容将会涉及插件Behavior Designer ; 代码仅为演示所需,并非实际实现代码,非本人所使用代码; 本文较长,请按需阅读;

为何写?

笔者在初学的时候并不写状态机,而是写到一块,做一个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,无非就是下面这样:

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

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