状态机与行为树的实现;Behavior Designer的使用与自写状态机的几种方法;
在游戏开发中,状态机(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
状态。 Attack
后Repeater
,再次进入Walk
状态。
事实上这里只是演示极小部分内容,具体该插件有多丰富的功能大家可以试试看。
核心内容
控制节点(Control Nodes)
控制节点是行为树的基础,负责决定子节点的执行顺序和执行条件。在 Behavior Designer 中,常用的控制节点主要有 Selector
、Sequence
和 Repeater
。
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,无非就是下面这样:
- 最初待机状态
- 通过射线检测,角度计算判断视野范围
- 发现玩家后退出待机状态,进入追击状态
- 追击玩家后退出追击状态,进入攻击状态
- 攻击后查看是否可以继续攻击到目标,不然进入追击状态
- 攻击时监测能量,满足时放大招
等等等等,无非就是多几个状态的状态机罢了。