第 35 章 · 行为树在 UdonSharp 里的替代写法
这一章解决:如何借用行为树的决策思想,又不把复杂 BT 框架搬进 UdonSharp。
先看一下
普通 Unity 项目里,NPC 行为常用 Behavior Tree(行为树,把条件和动作组织成树状决策)。在 VRChat 世界里直接照搬这套框架,会遇到脚本执行、调试、同步和序列化的约束。更稳的写法是保留「先判断优先级,再执行当前动作」的思想,用枚举状态机和小表格落地。
这一章会拿到什么
- 标准行为树到 UdonSharp 的翻译规则
- 枚举状态机 + 优先级表的最小 NPC 决策模型
- 一段 Owner 端 AI tick 片段
- 一份「什么时候别做复杂行为树」的判断清单
依赖前面
- 第 20 章的版本号和幂等思路
- 第 26 章的轻实时 tick
- 第 34 章的 NPC 三种架构
这是 difficulty 4 章节。只想先完成最小主项目时,可以先跳到第八部的闭环,再回来看本章的高风险系统。
行为树提供的是决策顺序
Section titled “行为树提供的是决策顺序”行为树常见结构是 Selector、Sequence、Condition、Action。它的价值不在于「树」这个数据结构本身,而在于决策顺序清楚:先看高优先级条件,再看普通行为,最后进入 fallback。
把这个思想翻译到本卷的合作防守 NPC,可以得到一张优先级表。
| 优先级 | 条件 | 动作 |
|---|---|---|
| 1 | HP 小于等于 0 | 进入 DEAD |
| 2 | 玩家进入攻击范围 | 进入 ATTACK |
| 3 | 看见目标玩家 | 进入 CHASE |
| 4 | 到达巡逻点 | 切下一个巡逻点 |
| 5 | 没有目标 | 进入 IDLE 或 PATROL |
这张表已经是一个小型行为树:从上往下检查,命中第一条就执行。它没有节点对象、没有递归遍历、没有运行时动态组树,但足以覆盖大部分 VRChat NPC。
用枚举状态表示当前行为
Section titled “用枚举状态表示当前行为”NPC 当前行为用 byte 或 int 表示即可。常见状态包括 IDLE、PATROL、CHASE、ATTACK、DEAD。同步字段只保留当前状态、目标玩家和 HP 这类恢复必需信息。
npcState 是迟入恢复的关键字段。迟入玩家不需要重放 NPC 过去怎么从巡逻走到追击,只需要知道「现在是 CHASE,目标是 playerId 12,HP 还剩 60」。历史行为不重放,当前状态要可读。
Owner 端按优先级 tick
Section titled “Owner 端按优先级 tick”AI 决策只在 NPC Owner 端运行。远端客户端只播放表现。
private void TickNpcAI(){ if (!Networking.IsOwner(gameObject)) return; if (hp <= 0) { SetNpcState(NPC_DEAD); return; }
VRCPlayerApi target = FindNearestValidTarget(); targetPlayerId = target == null ? -1 : target.playerId;
if (target != null && IsInAttackRange(target)) { SetNpcState(NPC_ATTACK); TryAttack(target); return; }
if (target != null && CanSeeTarget(target)) { SetNpcState(NPC_CHASE); MoveToward(target.GetPosition()); return; }
SetNpcState(NPC_PATROL); MoveAlongPatrolRoute();}这段就是「Selector」的手写版本:从最高优先级往下试,命中后立刻返回。它比完整 BT 少了抽象层,但保留了行为树最有用的部分。
状态设置入口要先比较新旧值,只有状态真的变化时才 RequestSerialization()。NPC 可以每 0.2 秒决策一次,但同步只在状态、目标、HP 这类共识字段变化时发生。
数据驱动配置只做小表
Section titled “数据驱动配置只做小表”数据驱动不是把所有逻辑都塞进 JSON。第七部建议用小数组表达配置,例如 stateMoveSpeed[]、stateThinkInterval[]、stateDamage[]。逻辑仍然写在代码里,数组只负责调数值。
这种配置适合表达:IDLE 不动,PATROL 慢走,CHASE 快跑,ATTACK 停下。它不适合承载复杂逻辑,例如「如果玩家带某个道具且队伍人数大于 3,再切换成绕后策略」。这种规则直接写成方法更清楚。
翻译:标准 BT 到 UdonSharp
Section titled “翻译:标准 BT 到 UdonSharp”把标准行为树术语翻译成 UdonSharp 写法,可以按这张表处理。
| 标准 BT 概念 | UdonSharp 替代 | 说明 |
|---|---|---|
| Selector | 按优先级排列的 if / return | 命中第一条即结束 |
| Sequence | 一个方法里的前置检查链 | 任一检查失败就返回 |
| Condition | CanSeeTarget / IsInAttackRange 这类 bool 方法 | 条件应短小,可单独测试 |
| Action | MoveToward / TryAttack / EnterDead | 动作只改自己有权改的字段 |
| Blackboard | 明确字段:hp、targetPlayerId、lastAttackTime | 可同步字段和本地缓存分开 |
| Decorator | 冷却、距离、阶段检查 | 放在动作入口前 |
这样写还有一个好处:Network Debug 和日志能看懂。日志里出现 NPC 7 state CHASE target 12,比「BT 节点执行到第 18 个装饰器」更容易定位。
什么时候别做复杂行为树
Section titled “什么时候别做复杂行为树”有几种情况适合压低 AI 复杂度。
| 情况 | 更稳的做法 |
|---|---|
| NPC 只活几秒 | 用预设路径或本地动画 |
| NPC 数量超过十几个 | 降低决策频率,分批 tick |
| 行为只有巡逻、追击、攻击 | 枚举状态机足够 |
| Quest 支持是硬目标 | 减少每帧感知和路径查询 |
| 多人一致性比聪明更重要 | 优先同步状态和目标,少做局部复杂决策 |
AI 的复杂度应该服务玩法。合作防守里,玩家通常更在意「敌人何时出现、打死是否计分、boss 阶段是否一致」,而不是每只小怪是否有完整行为树。
挑一条试。
- 给优先级表加一个 FLEE 状态:HP 低于 20 时逃跑。它应该排在 ATTACK 前还是 CHASE 后?
- 把
FindNearestValidTarget的检测频率从每 tick 一次改成每 1 秒一次。NPC 反应会变慢多少?网络字段会少变多少?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. NPC HP 归零 | 优先进入 DEAD,不继续攻击或追击 | … |
| 2. 玩家进入攻击范围 | Owner 切到 ATTACK,远端只播放表现 | … |
| 3. 玩家离开视野 | NPC 从 CHASE 回到 PATROL 或 IDLE | … |
| 4. 状态未变化连续 tick | 不重复 RequestSerialization() | … |
| 5. 迟入玩家进入 | 能从 npcState、targetPlayerId、hp 恢复当前表现 | … |
- 能把 Selector 翻译成优先级
if链 - 能区分当前状态字段和本地感知缓存
- 能说明为什么 NPC AI tick 不等于同步频率
- 能用小数组做数值配置,而不把逻辑塞进字符串
- 能指出一种不该做完整 BT 框架的场景
- Unity Manual 2022.3 · NavMesh Agent — Unity NPC 寻路和避障基础。
- 第 20 章 · 版本号、序号与幂等 — 网络重复和状态更新边界。
- 第 26 章 · 轻实时范式 — tick 与同步包频率分离。
- 第 34 章 · NPC 的三种架构 — 本章的 Owner 驱动前提。