质量审查发现:Committed 的冷却门若遇上 cooldown==0 的能力, CanUseAbility 结束下一帧即恢复,Rush↔Rest 每帧抖动照旧出现。 这属能力侧配置错误,不在模块内兜底,纳入 AiDefinitionValidator 校验项。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
28 KiB
敌人 AI:组合式模块架构(替换 PerceptionStateMachine)
日期:2026-07-29 状态:设计已确认,待实施 背景:
PerceptionStateMachine是唯一的共享 AI 组合函数,其Config已退化为开关面板 (EngagementStyle+PatrolBetweenChases两个 flag 互相耦合),每新增一种打法都要修改 该共享文件。同时「一个敌人 = 一个 C#AiScript类」的作者模型在目标体量下不成立。
1. 结论摘要
| 决策 | 内容 |
|---|---|
| 组合而非继承 | 不给状态机写父类。AiScript 已经是父类;复用单位是图片段(模块),靠组合拼装 |
| 四层,三层可插拔 | 固定的感知骨架 + 可插拔的 未发现层 / 交战层 / 死亡层 |
| 两种定义源 | 约 95% 敌人建 PerceptionRecipeSO 配方资产(零代码);约 5% 定制敌人写 AiScript 子类 |
| 能力引用资产化 | AI 层引用 EnemyAbilitySO 资产而非字符串 id,从根本上消除拼写错误 |
AiSignal 8 → 1 |
删除 7 个零发送方零消费方的枚举值;受击反应由物理状态机 + IsControllable 门独占 |
| 工具是规模瓶颈 | 校验器 / 配方脚手架 / id 下拉 与架构同等优先,图导出列入路线图 |
核心原则:每个构件只声明自己拥有的态,只挂自己负责的边。新增一种行为 = 新增一个类,不修改任何已有文件。
2. 问题分析
2.1 Config 是开关面板,不是配置
PerceptionStateMachine.Config 有 14 个字段。其中两个是行为开关,且互相耦合:
if (c.Engagement == EngagementStyle.ChaseAbility && c.PatrolBetweenChases)
PatrolBetweenChases 的真实含义是「冲锋起手后不因丢失感知中途停下,打完回巡逻走冷却」——
这是 committed(起手即锁定) 语义。它同时决定了两件事:脱战边怎么写、进入条件带不带冷却门。
原设计把这两面拆成两个字段交给调用方自己配对,一旦只配一半就会出现
Chase ↔ Patrol 每帧抖动。这是 flag 面板的典型代价。
每新增一种打法,都要:改共享文件 → 加 enum 值 → 加 if 分支 → 加 Config 字段。
2.2 状态名的可配置性大部分是零收益
CurrentStateName 的消费方只有测试与调试面板 —— 动画完全由能力与 EnemyLocomotion 驱动,
状态名是纯粹的 trace / 图导出标签。Config 里 9 个状态名字段中,只有 Entry / Rest
承载语义(出生态、脱战回归态),其余 7 个是无收益的可配置性,还制造了拼写错误面。
2.3 未发现层不该由骨架写死
现有骨架硬编码「Idle + Patrol 两个未发现态」,两态转换规则逐字相同、彼此之间无边。 但真实需求有三种不同形状(见 §3.2 证据表),骨架写死其中一种就永远只能服务一种。
2.4 作者模型在目标体量下不成立
目标体量意味着 100–200 个敌人类型,其分布高度不均:
| 占比 | 类别 | 特征 |
|---|---|---|
| ~70–80% | 原型实例 | 行为骨架相同,差别在能力集、数值、区域皮肤 |
| ~15% | 变体 | 同原型换数值 / 换招 |
| ~5–10% | 定制 | 有独门机制,值得单独写代码 |
「一个敌人 = 一个 C# 类」在这个分布下意味着 150 个类、150 次重新编译、约 120 份彼此差 几行的复制粘贴。必须让绝大多数敌人成为数据而非代码。
3. 架构
3.1 四层
PerceptionSkeleton 固定:感知升降级规则(唯一不可插拔的部分)
├── IUnawareModule 未发现层:怎么待着
├── IEngagementModule 交战层:怎么打
└── IDeathModule 死亡层:怎么死
骨架永远只做一件事:把「未发现 → 警觉 → 交战」的升降级边,挂到三个模块声明的态上。
public static class PerceptionSkeleton
{
public const string Alert = "Alert";
public static void Add(BrainBuilder b,
IUnawareModule unaware,
IEngagementModule engagement,
IDeathModule death)
{
unaware.Build(b); // 声明未发现态 + 内部边
engagement.Build(b, unaware.Rest); // 声明交战态 + 脱战边
death.Build(b); // 声明死亡链
b.Entry(unaware.Entry);
b.Global().To(death.EntryState).OnEvent(AiSignal.Died);
foreach (var s in unaware.States)
b.State(s)
.To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel)
.To(Alert).When(HasAlertAndInVision, "InVision+HasAlert");
AiStateFragments.Locomotion(b, Alert, LocomotionMode.Face)
.To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel)
.To(unaware.Rest).When(x => !x.Sensor.InVisionZone(), "leftVision");
}
}
保留的感知语义(与现状一致,由测试锁定):
- 追逐区优先于警觉:未发现态先判
CanEngage,再判警觉。 - 追击丢失后永不回警觉,直接回
unaware.Rest。 IEnemyActor.HasAlertState(来自EnemyStatsSO)为 false 时警觉边不成立,未发现态直连交战。- 死亡是全局事件边,优先级最高,且不受
IsControllable门阻挡(AiRuntime既有行为)。
3.2 三个契约
/// 未发现层:玩家尚未被发现时的行为形状。
public interface IUnawareModule
{
void Build(BrainBuilder b);
string Entry { get; } // 图入口(出生态)
string Rest { get; } // 脱战 / 警觉丢失后回到的态
IReadOnlyList<string> States { get; } // 需要挂升级边的所有未发现态
}
/// 交战层:发现玩家之后怎么打。骨架只认四件事。
public interface IEngagementModule
{
void Build(BrainBuilder b, string rest);
string EntryState { get; }
Func<IAiContext, bool> CanEngage { get; } // 构造时算好并缓存,不每次分配
string EngageLabel { get; } // trace / 图导出的边标签
}
/// 死亡层:单态终结 / 单段能力 / 两段演出(可带生成物)。
public interface IDeathModule
{
void Build(BrainBuilder b);
string EntryState { get; }
}
CanEngage 归交战模块所有是本设计的关键。 冷却门只对 committed 冲锋有意义,
它留在骨架上正是当前两个 flag 互相耦合的根源。所有权移交后,骨架永远不需要知道有冷却门这回事。
3.3 正交性证据
仅现有 6 个敌人就已出现三层各自的多种形状 —— 这是选择组合而非继承的实证依据:
| 敌人 | 未发现层 | 交战层 | 死亡层 |
|---|---|---|---|
| E001 草蛭 | 伪装静止 →(不可逆)→ 巡逻 | 接触冲锋(committed) | 演出走物理状态机 |
| E002 簧蛭 | —(陷阱型,不用骨架) | — | 单段 |
| E003 幼蛭 | 单一巡逻(前接掉落链) | 接触冲锋 | 单段 |
| E004 蛭母 | 出场链 + 单一待机 | 逼近 ↔ 选招 | 两段(挣扎 → 爆体) |
| E005 肥蛭 | 单一待机 | 逼近 ↔ 选招 | 两段 + 生成物 |
| E006 讙 | 站立 ⇄ 巡逻定时交替 | 跳跃突进(committed) | 单段 |
3 种未发现层 × 2 种交战层 × 3 种死亡层 = 18 种敌人,只需 8 个小类。
3.4 首批模块(8 个)
| 层 | 模块 | 参数 | 用于 |
|---|---|---|---|
| 未发现 | SinglePost |
LocomotionMode |
单一态(站桩 / 单一巡逻)· E003 E004 E005 |
DisguiseThenPatrol |
两个 LocomotionMode |
伪装静止 →(不可逆)→ 巡逻 · E001 | |
AlternatingIdlePatrol |
idleDwell patrolDwell |
站立 ⇄ 巡逻定时交替 · E006 | |
| 交战 | RushEngagement |
AbilityRef RushExit |
接触冲锋 · E001 E003 E006 |
ApproachAttackEngagement |
—(招式由 EnemyAttackSelector 提供) |
逼近 ↔ 选招攻击 · E005(E004 待 Boss 双轨决策,见 §8) | |
| 死亡 | TerminalDeath |
— | 演出走物理状态机 · E001 |
AbilityDeath |
AbilityRef |
单段死亡能力 · E002 E003 E006 | |
TwoStageDeath |
两个 AbilityRef |
挣扎 → 爆体(含生成物)· E004 E005 |
预计最终增长到 15–20 个模块,即目标体量下敌人行为的全部词汇量。
3.5 committed 语义显式化
public enum RushExit
{
/// 追不到就放弃:脱离全部感知区即停。
OnLostTarget,
/// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。
/// 冷却门由本模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲,
/// 出现 冲锋态 ↔ 未发现态 每帧抖动。
Committed,
}
一个枚举值同时决定「脱战边怎么写」和「进入条件带不带冷却门」,因为这两件事本就是同一语义的两面。 两个耦合 flag → 一个语义枚举,是消除开关面板的实证。
Committed的防抖动保证有一个前提:所引用的冲锋能力自身cooldown > 0。EnemyAbilityBase.CanUse是!_isRunning && !IsOnCooldown,若EnemyAbilitySO.cooldown == 0, 能力结束的下一帧CanUseAbility就恢复为真,抖动照旧出现。这属于能力侧的配置错误, 不在本模块内兜底(第 6 条),由AiDefinitionValidator显式报出(见 §6)。 旧的PatrolBetweenChases路径有同样的弱点,本次不改变该前提,只是把它写明并纳入校验。
public sealed class RushEngagement : IEngagementModule
{
public const string Rush = "Rush";
public RushEngagement(AbilityRef ability, RushExit exit = RushExit.OnLostTarget)
{
if (string.IsNullOrEmpty(ability.Id))
throw new ArgumentException("RushEngagement 必须指定冲锋能力。", nameof(ability));
_ability = ability; _exit = exit;
string id = ability.Id; // 捕获类型级常量,不捕获敌人实例
_canEngage = exit == RushExit.Committed
? x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(id)
: x => x.Sensor.InChaseZone();
}
public string EntryState => Rush;
public Func<IAiContext, bool> CanEngage => _canEngage;
public string EngageLabel => _exit == RushExit.Committed ? "InChaseZone+offCD" : "InChaseZone";
public void Build(BrainBuilder b, string rest)
{
string id = _ability.Id;
var s = AiStateFragments.Ability(b, Rush, id);
if (_exit == RushExit.Committed)
s.To(rest).When(x => !x.Combat.IsAbilityRunning(id), "rushDone→CD");
else
s.To(rest).When(AiStateFragments.LostAllZones, "leftAllZones");
}
}
3.6 行为变更:攻击起手后打完(committed 攻击)
ApproachAttackEngagement 的 Attack 态去掉 leftAllZones → rest 边:
b.State(Approach)
.OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
.Tick (x => x.Locomotion.Pursue(x.Sensor.LastKnown))
.OnExit(x => x.Locomotion.Stop())
.To(Attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange")
.To(rest) .When(AiStateFragments.LostAllZones, "leftAllZones");
// 攻击一旦起手就打完:不挂 leftAllZones 边。
// 玩家在前摇中跑出感知区时,招式仍完整打完,之后经 Approach 自然脱战——
// 只多一帧,且消除了「起手到一半凭空收招」的观感缺陷。
b.State(Attack)
.OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
.OnExit (x => x.Combat.InterruptAbilities())
.To(Approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
这是有意的行为变更,不是照搬现状。前摇可读性是本类型手感的核心,起手后收招会打破玩家刚建立的读招预期。
3.7 建态原语
从 PerceptionStateMachine 的 private static 提升为公开原语 —— 它们本就是通用的,
被关在骨架内部才是错误。定制敌人写自定义态时同样使用它们。
public static class AiStateFragments
{
/// 由 EnemyLocomotion 驱动:进入 / 每帧声明意图,离开时停。
public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode);
/// 由能力驱动:进入 / 每帧确保能力在跑(受击打断后自动重触发),离开中断。
public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId);
/// 无行为的终态:死亡演出交给物理状态机(EnemyBase.PerformDeath)时使用。
public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state);
/// 共享条件:脱离全部感知(追逐区与视野区都不在)。
public static readonly Func<IAiContext, bool> LostAllZones;
}
4. 两种定义源
public interface IAiDefinition { string Id { get; } AiGraph GetOrBuildGraph(); }
public abstract class AiScript : IAiDefinition // 定制路径(现状保留)
public abstract class AiRecipeSO : ScriptableObject, IAiDefinition // 配方路径(新增)
4.1 配方资产:一个 SO 类型覆盖所有感知型敌人
[CreateAssetMenu(menuName = "BaseGames/AI/感知型 AI 配方")]
public sealed class PerceptionRecipeSO : AiRecipeSO
{
[SerializeReference, SubclassSelector] IUnawareModule _unaware = new SinglePost();
[SerializeReference, SubclassSelector] IEngagementModule _engagement = new RushEngagement();
[SerializeReference, SubclassSelector] IDeathModule _death = new TerminalDeath();
protected override void Build(BrainBuilder b)
=> PerceptionSkeleton.Add(b, _unaware, _engagement, _death);
}
[SerializeReference] + SubclassSelector 的意义:新增一个模块类,它自动出现在所有配方的
下拉里,不需要改枚举、不需要改任何已有文件。 数据侧与代码侧的扩展性对齐。
SubclassSelector 需要自建 PropertyDrawer(收集接口的所有 [Serializable] 实现,
以特性标注的中文显示名列出)。模块因此必须是 [Serializable] 的普通类,不能是静态类。
4.2 作者路径
| 占比 | 路径 | 成本 |
|---|---|---|
| ~80% | 建一个 PerceptionRecipeSO 资产,三个下拉 + 填能力引用 |
零代码,零编译 |
| ~15% | 同上 + 在配方里追加 1–2 个自定义前置态(如掉落链、出场链) | 少量代码 |
| ~5% | 写 [AiDefinition] AiScript 子类,完全自由 |
与现状相同 |
4.3 解析方式:配方走直接引用,脚本走注册表
修订(2026-07-29,实施计划阶段):初稿设计为「注册表双源 +
AiRecipeDatabaseSO经 Addressables 加载」。该方案有两处硬阻塞,已废弃:
- 程序集方向不允许 ——
AiDefinitionRegistry在BaseGames.AI,该程序集只引用BaseGames.Core,无法引用BaseGames.Enemies(模块依赖IEnemyActor/EnemyAbilitySO), 也未引用Unity.Addressables。- 与项目既有模式不符 ——
FormSkillDatabaseSO等库资产走的是[SerializeField]直接引用,不是 Addressables。静态注册表拿不到序列化引用;改用 Addressables 异步加载 则与EnemyAiBrain.Start()的同步取图产生时序冲突。
改为两条互斥的解析路径,各自最短:
// BaseGames.AI —— 只依赖 BrainBuilder / AiGraph,无 Enemies 依赖
public interface IAiDefinition { AiGraph GetOrBuildGraph(); }
public abstract class AiScript : IAiDefinition { ... }
public abstract class AiRecipeSO : ScriptableObject, IAiDefinition { ... }
// BaseGames.Enemies
public sealed class PerceptionRecipeSO : AiRecipeSO { ... }
EnemyAiBrain 上二选一,两者互斥:
[Tooltip("配方路径(约 95% 敌人):直接引用 AI 配方资产")]
[SerializeField] AiRecipeSO _recipe;
[Tooltip("定制路径(约 5% 敌人):[AiDefinition(id)] 的 AiScript 子类 id")]
[SerializeField] string _definitionId;
Awake 校验「恰好设置其一」,否则 Debug.LogError 并禁用组件(沿用现状对漏配 id 的处理)。
这条修订带来三点净收益:
- 无需
AiRecipeDatabaseSO、无需 Addressables、无需改AddressKeys、无需改任何 asmdef。 预制体到配方是硬引用,Unity 保证随预制体一起加载,不存在"库没加载到"的失败模式。 - flyweight 不变 —— 所有实例引用同一个 SO 资产,
AiRecipeSO内缓存唯一一份AiGraph。 - 配方路径彻底没有字符串 id,痛点「硬编码字符串」在 95% 的敌人上完全消失。
AiDefinitionRegistry 保持单源(反射收集 AiScript),逻辑不变,仅让 AiScript 实现
IAiDefinition。校验器侧枚举配方资产用 AssetDatabase.FindAssets("t:AiRecipeSO")(编辑器专用)。
4.4 关闭 Domain Reload 的约束
项目已关闭 Domain / Scene Reload。AiRecipeSO 内缓存的 AiGraph 属于 SO 运行时态,
必须走 PlayModeResetHook 清理,否则修改配方后进入播放模式仍使用旧图。
AiDefinitionRegistry 的静态字典已有 [RuntimeInitializeOnLoadMethod] 重置,保持不变。
4.5 能力引用资产化
目标体量下约有 450+ 个能力 id 引用,是最大的静默错误面。配方侧改为直接引用
EnemyAbilitySO 资产 —— Unity 引用系统保证拼不错、改名不断链。
[Serializable]
public struct AbilityRef
{
[SerializeField] EnemyAbilitySO _asset;
string _literal; // 定制脚本路径使用
public string Id => _asset != null ? _asset.abilityId : _literal;
public static implicit operator AbilityRef(string id) => new AbilityRef(id);
}
隐式转换使 new RushEngagement("e001_chase", RushExit.Committed) 在定制脚本中照常可写,
Inspector 中则呈现为资产槽。运行期仍走 ICombatant.UseAbility(string),接口不变。
5. AiSignal 清理(8 → 1)
public enum AiSignal { Died }
| 删除 | 理由 |
|---|---|
Damaged Parried Staggered KnockedUp |
受击反应由物理状态机 {Hurt, Stagger, KnockUp} + AiRuntime 的 IsControllable 让位门独占处理。在决策层是重复表达,留着会诱导在 AI 图里写第二套受击逻辑并与物理层打架 |
PlayerSpotted |
零发送方零消费方;感知条件边已完整表达 |
AbilityEnded |
零发送方零消费方;!IsAbilityRunning(...) 条件边已完整表达 |
PhaseChanged |
零发送方零消费方;Boss 阶段走 BossPhaseEventChannelSO |
5.1 为什么不预留「战斗激活」占位
E003(战斗区激活才掉落)、E004(战斗触发才出场)将来需要一条外部 → AI 的边。
届时的做法是:新增 AiSignal.Engaged,敌人写 b.State(Ceiling).To(Fall).OnEvent(AiSignal.Engaged),
场景侧触发器调 EnemyAiBrain.Send(...)。
骨架现在就完全支持这件事,不需要任何预留 —— b.Entry 可指任意态、OnEvent 边任意挂、
未发现层与图入口解耦。现在加一个无发送方的枚举值,只会制造新的死枚举。
真正的接缝是能力,不是占位符。
6. 工具(规模的真正瓶颈)
150 张 AI 图,写出来只是开始。以下四件与架构同等优先:
| 工具 | 作用 | 优先级 |
|---|---|---|
AiDefinitionValidator |
接入 SOValidationRunner。以 AssetDatabase.FindAssets("t:AiRecipeSO") 枚举全部配方,校验:模块字段是否留空、AbilityRef 是否两侧同时非空、ApproachAttackEngagement 是否配了 EnemyAttackSelector、RushExit.Committed 所引用的能力 cooldown 是否 > 0(见下)。唯一能防住 450+ 引用出错的东西 |
紧接本次 |
EnemyAiRecipeWizard |
建配方资产的脚手架(CLAUDE.md 第 2 条:不裸建),按 AssetFolderSpec 定名定路径。本次必须先有它,E001 的配方资产才能合规产出 |
本次 |
_definitionId 下拉 Drawer |
定制路径(约 5%)从 AiDefinitionRegistry.Ids 取值,预制体上不再手打字符串 |
紧接本次 |
AiGraphExporter |
把 AiGraph 导出为 Mermaid(Transition.Label 已为此准备)。150 张图若不能一眼看懂就没人敢改 |
路线图 |
7. 明确不做的四件事
| 不做 | 理由 |
|---|---|
| 可视化节点编辑器 | 已否决过一次(移除第三方行为树插件)。C# 声明式 + SO 配方下拉已覆盖约 95% 作者需求,节点编辑器的维护成本换不回等值收益 |
| HFSM 层级子状态机 | 招式内的「前摇 → 判定 → 后摇」相位归能力管,这条分界已确立且正确。再加图层级 = 同一件事两处表达,必然打架 |
| 群体协同 / 编队 AI | 本类型敌人基本各自为战,投入产出比极低 |
| 行为树 | 敌人行为是状态化的而非任务化的,HFSM 是正确选择 |
8. 范围与阶段
| 阶段 | 内容 |
|---|---|
| 本次 | 四层架构 + 8 个模块 + AiStateFragments + BrainBuilder.RequireState + AiSignal 清理 + AbilityRef + IAiDefinition / AiRecipeSO / PerceptionRecipeSO + EnemyAiBrain 双路径解析 + PlayModeResetHook + SubclassSelector Drawer + EnemyAiRecipeWizard 脚手架 + E001 迁移为配方资产 + SceneObjectPlacerTool 提示更新 + 测试重写 |
| 紧接 | AiDefinitionValidator 校验器 + _definitionId 下拉 Drawer |
| 随敌人落地 | 新模块按需增加;AiSignal.Engaged 在 E003 / E004 需要时才加 |
| 单独立项 | Boss 双轨统一(BossSkillExecutor + BossSkillSO vs EnemyAttackSelector + EnemyAbilitySO)。E004「小 BOSS」归属未定前不动。本次 ApproachAttackEngagement 按精英怪(E005)尺度做,不为 E004 加码 |
| 路线图 | AiGraphExporter 图导出 |
时机判断:配方层现在建、而非等到数十只敌人时再建 —— 此刻只有 1 只敌人需要迁移, 是这个架构决策成本最低的时点。
9. E001 迁移
E001 迁移为 PerceptionRecipeSO 配方资产,依 AssetFolderSpec.md 命名与放置:
- 配方资产:
Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset(与既有ENM_E001_Stats.asset/ENM_E001_AnimConfig.asset同目录同前缀) - 由
EnemyAiRecipeWizard创建(CLAUDE.md 第 2 条:不裸建) - 预制体
EnemyAiBrain._recipe直接引用该资产,_definitionId留空
字段取值:
| 字段 | 值 |
|---|---|
| 未发现层 | DisguiseThenPatrol(Idle / Patrol) |
| 交战层 | RushEngagement(能力 = e001_chase 对应的 EnemyAbilitySO,RushExit.Committed) |
| 死亡层 | TerminalDeath(死亡演出走 EnemyBase.PerformDeath) |
删除 E001CaoZhiAi.cs。等价的定制脚本写法(作为文档范例保留):
[AiDefinition("E001")]
public sealed class E001CaoZhiAi : AiScript
{
protected override void Build(BrainBuilder b) => PerceptionSkeleton.Add(b,
unaware: new DisguiseThenPatrol(),
engagement: new RushEngagement("e001_chase", RushExit.Committed),
death: new TerminalDeath());
}
顺带清理:现有 E001CaoZhiAi 的 Entry 被临时改为巡逻态用于验证寻路失效,
注释标明「验证完成后必须改回」。寻路库已整包移除(改为 IEnemyNavigator + 直接移动),
该验证已失去对象,出生态恢复为伪装静止。
10. 测试策略
现有 PerceptionStateMachineTests(489 行)拆为五份。行为断言基本照搬 ——
以 fake IAiContext 驱动 AiRuntime,断言 CurrentStateName 与 trace label。
| 测试 | 覆盖 |
|---|---|
AiStateFragmentsTests |
三个原语的 Enter / Tick / Exit 契约;LostAllZones 条件 |
PerceptionSkeletonTests |
升降级优先级(追逐区优先于警觉);HasAlertState=false 直连交战;追击丢失后永不回警觉;死亡全局边;RequireState 校验异常 |
UnawareModuleTests |
三种未发现层形状的 Entry / Rest / States 与内部边 |
RushEngagementTests |
两种 RushExit 的进入条件与脱战边;committed 不被感知丢失打断;CD 期不抖动 |
ApproachAttackEngagementTests |
逼近 ↔ 攻击往返;脱战;新增:攻击起手后玩家离开感知区仍完整打完(§3.6 行为变更) |
补充:PerceptionRecipeSoTests —— 配方产出的图与等价手写组装的图行为一致;同一配方多次
GetOrBuildGraph() 返回同一实例(flyweight)。AiDefinitionRegistry 逻辑未变,现有测试保持不动。
11. 文件清单
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/{SinglePost, DisguiseThenPatrol, AlternatingIdlePatrol}.cs
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/{RushEngagement, ApproachAttackEngagement}.cs
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/{TerminalDeath, AbilityDeath, TwoStageDeath}.cs
新增 Assets/_Game/Scripts/AI/IAiDefinition.cs
新增 Assets/_Game/Scripts/AI/AiRecipeSO.cs (BaseGames.AI:无 Enemies 依赖)
新增 Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs
新增 Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs
新增 Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs
新增 Assets/_Game/Scripts/Editor/AI/EnemyAiRecipeWizard.cs
新增 Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset(经向导产出)
删除 Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs
删除 Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs(迁移为配方资产)
改 Assets/_Game/Scripts/AI/BrainBuilder.cs + RequireState(name)
改 Assets/_Game/Scripts/AI/AiSignal.cs 8 → 1
改 Assets/_Game/Scripts/AI/AiScript.cs 实现 IAiDefinition
改 Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs 配方引用 / 脚本 id 二选一解析
改 Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs 7 处 AI 挂载提示改为配方路径
测试 Assets/Tests/EditMode/AI/ 拆 PerceptionStateMachineTests → 5 份,扩 AiDefinitionRegistryTests
文档 Docs/Guides/ 新增一节「新敌人 AI 怎么写」(四层组装规则 + 三条作者路径 + 六个敌人范例)
BrainBuilder.RequireState(name):b.State(name) 是幂等新建的 —— 若模块声明了升级边却
没人声明该态的行为,敌人会杵着不动而无任何报错。此方法在态未声明时抛出,把静默错误
变成构建期异常(CLAUDE.md 第 6 条:根因暴露,不做兜底)。
12. 风险与缓解
| 风险 | 缓解 |
|---|---|
[SerializeReference] 多态字段在类改名 / 移动命名空间后丢失数据 |
模块类一律加 [MovedFrom](UnityEngine.Scripting.APIUpdating);模块数量少、改名频率低 |
AbilityRef 隐式转换让「填了资产又填字符串」的歧义不易察觉 |
Id 以资产优先;校验器对两者同时非空的情况报警告 |
| 配方 SO 缓存图在关闭 Domain Reload 下变陈旧 | PlayModeResetHook 清缓存(§4.4),并在测试中覆盖 |
| 三层拆分后一次性改动面偏大 | 现有 489 行 EditMode 测试是安全网;按「原语 → 契约 → 模块 → 骨架 → 配方 → 迁移」顺序推进,每步可独立编译通过 |
Boss 双轨未决可能推翻 ApproachAttackEngagement 的投入 |
本次按精英怪尺度做,不为小 BOSS 加码;双轨单独立项 |