跳转到内容

第 35 章 · 行为树在 UdonSharp 里的替代写法

约 5 分钟 难度:4 动手章

这一章解决:如何借用行为树的决策思想,又不把复杂 BT 框架搬进 UdonSharp。

先看一下

普通 Unity 项目里,NPC 行为常用 Behavior Tree(行为树,把条件和动作组织成树状决策)。在 VRChat 世界里直接照搬这套框架,会遇到脚本执行、调试、同步和序列化的约束。更稳的写法是保留「先判断优先级,再执行当前动作」的思想,用枚举状态机和小表格落地。

这一章会拿到什么

  • 标准行为树到 UdonSharp 的翻译规则
  • 枚举状态机 + 优先级表的最小 NPC 决策模型
  • 一段 Owner 端 AI tick 片段
  • 一份「什么时候别做复杂行为树」的判断清单

依赖前面

  • 第 20 章的版本号和幂等思路
  • 第 26 章的轻实时 tick
  • 第 34 章的 NPC 三种架构

这是 difficulty 4 章节。只想先完成最小主项目时,可以先跳到第八部的闭环,再回来看本章的高风险系统。


行为树常见结构是 Selector、Sequence、Condition、Action。它的价值不在于「树」这个数据结构本身,而在于决策顺序清楚:先看高优先级条件,再看普通行为,最后进入 fallback。

把这个思想翻译到本卷的合作防守 NPC,可以得到一张优先级表。

优先级条件动作
1HP 小于等于 0进入 DEAD
2玩家进入攻击范围进入 ATTACK
3看见目标玩家进入 CHASE
4到达巡逻点切下一个巡逻点
5没有目标进入 IDLE 或 PATROL

这张表已经是一个小型行为树:从上往下检查,命中第一条就执行。它没有节点对象、没有递归遍历、没有运行时动态组树,但足以覆盖大部分 VRChat NPC。


NPC 当前行为用 byteint 表示即可。常见状态包括 IDLEPATROLCHASEATTACKDEAD。同步字段只保留当前状态、目标玩家和 HP 这类恢复必需信息。

npcState 是迟入恢复的关键字段。迟入玩家不需要重放 NPC 过去怎么从巡逻走到追击,只需要知道「现在是 CHASE,目标是 playerId 12,HP 还剩 60」。历史行为不重放,当前状态要可读。


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 这类共识字段变化时发生。


数据驱动不是把所有逻辑都塞进 JSON。第七部建议用小数组表达配置,例如 stateMoveSpeed[]stateThinkInterval[]stateDamage[]。逻辑仍然写在代码里,数组只负责调数值。

这种配置适合表达:IDLE 不动,PATROL 慢走,CHASE 快跑,ATTACK 停下。它不适合承载复杂逻辑,例如「如果玩家带某个道具且队伍人数大于 3,再切换成绕后策略」。这种规则直接写成方法更清楚。


把标准行为树术语翻译成 UdonSharp 写法,可以按这张表处理。

标准 BT 概念UdonSharp 替代说明
Selector按优先级排列的 if / return命中第一条即结束
Sequence一个方法里的前置检查链任一检查失败就返回
ConditionCanSeeTarget / IsInAttackRange 这类 bool 方法条件应短小,可单独测试
ActionMoveToward / TryAttack / EnterDead动作只改自己有权改的字段
Blackboard明确字段:hptargetPlayerIdlastAttackTime可同步字段和本地缓存分开
Decorator冷却、距离、阶段检查放在动作入口前

这样写还有一个好处:Network Debug 和日志能看懂。日志里出现 NPC 7 state CHASE target 12,比「BT 节点执行到第 18 个装饰器」更容易定位。


有几种情况适合压低 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. 迟入玩家进入能从 npcStatetargetPlayerIdhp 恢复当前表现
  • 能把 Selector 翻译成优先级 if
  • 能区分当前状态字段和本地感知缓存
  • 能说明为什么 NPC AI tick 不等于同步频率
  • 能用小数组做数值配置,而不把逻辑塞进字符串
  • 能指出一种不该做完整 BT 框架的场景