行为树、GOAP 与 Utility AI¶
常见现象¶
做 NPC 时最容易出现两个问题:敌人站着不动,或者目标一变就来回切换。前者通常是条件写漏了,后者是决策频率和优先级没处理好。
行为树、GOAP 和 Utility AI 都能解决“下一步做什么”,但它们的控制方式不一样。先看项目更需要可预测性,还是需要组合能力。1
行为树¶
行为树用节点描述执行顺序,常见节点只有三种状态:
Selector 依次尝试子节点,有一个成功就返回;Sequence 依次执行,全部成功才返回成功。条件节点、冷却和超时通常放在装饰器里。
行为树适合战斗、巡逻、Boss 阶段这种层级明确的逻辑。设计师能直接看出执行顺序,运行时也容易可视化。Unity 也提供了 Behavior 包,可以直接搭可视化行为树。3
缺点是条件多起来后,分支会迅速膨胀。尤其是“有敌人但没弹药”“有掩体但队友占了”这类组合,最后容易改出一棵很难维护的树。2
GOAP¶
GOAP 把行为拆成带前置条件和效果的动作,再由规划器搜索一条能达成目标的序列。
public readonly struct AgentAction
{
public readonly string Name;
public readonly float Cost;
public readonly Func<WorldState, bool> CanRun;
public readonly Func<WorldState, WorldState> Apply;
}
例如“装弹”的前置条件是 HasAmmo == false,效果是把 HasAmmo 设为 true。规划器从当前状态出发,尝试动作,累计成本,直到满足目标。
它适合战术、生存和资源管理类 AI,因为动作可以组合出设计者没有手写过的序列。代价是搜索空间会随动作数量增长,必须限制深度、节点数和单帧预算。
一个常见的优化是分层:先决定“进攻/撤退/补给”这种高层目标,再在局部动作里做小规模规划。不要让每个 NPC 每帧都从零开始搜索。
Utility AI¶
Utility AI 给每个候选行为打分,选当前分数最高的一个:
public static float ScoreAttack(
float distance,
float healthRatio,
float ammoRatio,
float threat)
{
var range = 1f - math.saturate(distance / 20f);
var sustain = math.saturate(ammoRatio * 0.7f + healthRatio * 0.3f);
return range * 0.45f + sustain * 0.2f + threat * 0.35f;
}
这种写法很适合混合多个因素,比如距离、血量、弹药和威胁。行为过渡也比较自然。
问题是权重要反复调,而且分数接近时容易抖动。可以加一点滞回:
这样只有新行为明显更好时才切换,避免 NPC 在开火和找掩体之间来回跳。
怎么选¶
| 方案 | 可预测性 | 组合能力 | 适合场景 |
|---|---|---|---|
| 行为树 | 高 | 中 | 战斗、巡逻、Boss |
| GOAP | 中 | 高 | 战术、生存、资源管理 |
| Utility AI | 中 | 中 | 开放世界、群体行为 |
| HTN | 高 | 中高 | 潜入、基地、战役 |
HTN 介于行为树和 GOAP 之间,由设计者定义任务如何分解,执行结果更可控。这里不展开,实际项目可以把它理解为“带规划的层级任务”。
决策频率¶
NPC 不需要每个渲染帧都重新思考。比较常见的分配是:
- 感知更新:5–15 Hz,分帧执行。
- 目标选择:2–10 Hz,或者事件触发。
- 路径重算:目标或地形变化时执行。
- 动画切换:由行为事件驱动。
把 AI 分成不同更新组,错开每帧的峰值,往往比单纯优化评分函数更有效。
让决策过程能被看见¶
做一个运行时调试面板,显示当前行为、候选分数、黑板关键字段和最近一次决策原因。很多“AI 看起来傻”的问题,不是算法不对,而是某个条件一直为真,或者目标 ID 在对象池复用时没有清掉。
调试信息最好和随机种子一起记录。固定种子重放同一场战斗,能很快复现问题。