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

devtools/2024/10/21 8:20:08/

以下部分内容将会涉及插件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,无非就是下面这样:

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

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


http://www.ppmy.cn/devtools/127511.html

相关文章

ip-geoaddress-generator 基于IP的真实地址生成器

ip-geoaddress-generator 是一个基于 Web 的在线应用程序&#xff0c;能够根据 IP 地址生成真实的随机地址信息。通过多个 API 获取位置数据和随机用户信息&#xff0c;该工具为用户提供了完整的虚拟身份。它由 Next.js 和 Radix UI 构建&#xff0c;具备自动检测当前 IP 地址和…

数据结构:二叉树、堆

目录 一.树的概念 二、二叉树 1.二叉树的概念 2.特殊类型的二叉树 3.二叉树的性质 4.二叉树存储的结构 三、堆 1.堆的概念 2.堆的实现 Heap.h Heap.c 一.树的概念 注意&#xff0c;树的同一层中不能有关联&#xff0c;否侧就不是树了&#xff0c;就变成图了&#xff…

Spring Boot中使用MyBatis-Plus和MyBatis拦截器来实现对带有特定注解的字段进行AES加密。

1. 添加依赖 首先&#xff0c;在pom.xml文件中添加必要的依赖项&#xff1a; xml 深色版本 <dependencies> <!-- Spring Boot Starter Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifac…

MySQL【知识改变命运】10

联合查询 0.前言1.联合查询在MySQL里面的原理2.练习一个完整的联合查询2.1.构造练习案例数据2.2 案例&#xff1a;⼀个完整的联合查询的过程2.2.1. 确定参与查询的表&#xff0c;学⽣表和班级表2.2.2. 确定连接条件&#xff0c;student表中的class_id与class表中id列的值相等2.…

【c++篇】:解析c++类--优化编程的关键所在(一)

文章目录 前言一.面向过程和面向对象二.c中的类1.类的引入2.类的定义3.类的封装和访问限定符4.类的作用域5.类的实例化6.类对象模型 三.this指针1.this指针的引出2.this指针的特性3.C语言和c实现栈Stack的对比 前言 在程序设计的广袤宇宙中&#xff0c;C以其强大的功能和灵活性…

Vscode 插件开发 - TreeView

树状视图很多插件都可能用到&#xff0c;通常作为一个简单的列表使用&#xff0c;比如管理项目依赖、展示搜索结果。那么 Vscode 的 TreeView 具体怎么玩呢&#xff1f; 基础操作 1.创建视图 在 package.json 的 contributes.views 节点下定义视图&#xff1a; {"name…

SqlDbx连接oracle(可用)

解压SqlDbx.zip,将SqlDbx放到C:盘根目录 1.Path里面增加&#xff1a;C:\SqlDbx Path是为了找tnsnames.ora 2.增加系统变量&#xff1a;ORACLE_HOME&#xff0c;路径&#xff1a;C:\SqlDbx ORACLE_HOME是为了找oci.dll 3.用sqlDbx查询时&#xff0c;如果出现中文乱码&#xf…

【Vue.js】vue2 项目在 Vscode 中使用 Ctrl + 鼠标左键跳转 @ 别名导入的 js 文件和 .vue 文件

js 文件跳转 需要安装插件 Vetur 然后需要我们在项目根目录下添加 jsconfig.json 配置&#xff0c;至于配置的作用&#xff0c;可以参考我的另外一篇博客&#xff1a; 【React 】react 创建项目配置 jsconfig.json 的作用 它主要用于配置 JavaScript 或 TypeScript 项目的根…