# 敌人 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 个字段。其中两个是行为开关,且互相耦合: ```csharp 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. 架构 > ### 修订(2026-07-29,T9 执行中):死亡层取消,四层收为三层 > > 初稿把「死亡」做成第三个可插拔层(`IDeathModule` + `TerminalDeath` / `AbilityDeath` / > `TwoStageDeath`)。实施 T9 时的质量审查发现 `TwoStageDeath` **在真实运行中必然卡死**, > 追根后确认整个死亡层放错了层。已取消该层,骨架自己声明 `Death` 终态。 > > **为什么必然卡死**:`EnemyBase.PerformDeath` 先 `ForceState(EnemyStateType.Dead)` > (终态,`ForceState` 拒绝任何转出)再 `_brain.Send(AiSignal.Died)`。 > `EnemyBrainContext.IsControllable` 是 `CurrentState == Controlled`,此时已永久为假。 > `AiRuntime.Tick` 的顺序是:事件转换(不受门限制)→ **`IsControllable` 让位门,为假即 return** > → 条件转换。于是 `Died` 事件能把状态切进死亡链,但此后**任何条件边都不再被求值**—— > `TwoStageDeath` 的 `preDone` 边永远不触发,前段演出播完就永久停住。 > > **为什么 `AbilityDeath` 也不成立**:敌人确实能活到下一帧(`PerformDeath` 在死亡动画 > `OnEnd` 才归还对象池),死亡态的 `OnEnter` 会执行。但那时 `_abilities.InterruptAll(Dead)` > 已跑过、碰撞体已关、`Dead` 动画已在播——此刻再触发一个能力是在和 `PerformDeath` > 自己的死亡演出打架。 > > **根因**:死亡在本项目里完全归物理层:`PerformDeath`(切终态/清效果/关碰撞/播死亡动画/ > 归还池/广播事件)、`EnemyDeathSequence`(死亡前摇无敌演出,播完回调 `PerformDeath`; > 前摇动画上可挂 `SpawnProjectile` 事件配合 `EnemySpawnerOnEvent` 生成小怪——E004/E005 > 的「两段死亡 + 生成幼蛭」正是由它覆盖)、`EnemyAnimationConfigSO.Dead`。 > **AI 图需要 `Death` 态只是为了让全局 `Died` 边有去处并让决策停止。死亡不是一个决策。** > > 初稿之所以做成一层,是因为旧 `PerceptionStateMachine.Config` 有 `DeathAbilityId` 字段—— > 而其唯一使用者 E001 把它留空,注释写明「死亡演出走物理状态机」。那个字段从一开始就是 > 投机的,初稿把投机原样放大成了一个三模块的层。按 YAGNI 与第 6 条取消。 > > 受影响:`IDeathModule` 契约删除;三个死亡模块删除;`PerceptionSkeleton.Add` 去掉 > `death` 参数、自行声明 `Death` 终态;模块总数 8 → 5。 ### 3.1 三层 ``` PerceptionSkeleton 固定:感知升降级规则(唯一不可插拔的部分) ├── IUnawareModule 未发现层:怎么待着 ├── IEngagementModule 交战层:怎么打 └── IDeathModule 死亡层:怎么死 ``` 骨架永远只做一件事:把「未发现 → 警觉 → 交战」的升降级边,挂到三个模块声明的态上。 ```csharp 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 三个契约 ```csharp /// 未发现层:玩家尚未被发现时的行为形状。 public interface IUnawareModule { void Build(BrainBuilder b); string Entry { get; } // 图入口(出生态) string Rest { get; } // 脱战 / 警觉丢失后回到的态 IReadOnlyList States { get; } // 需要挂升级边的所有未发现态 } /// 交战层:发现玩家之后怎么打。骨架只认四件事。 public interface IEngagementModule { void Build(BrainBuilder b, string rest); string EntryState { get; } Func 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 语义显式化 ```csharp public enum RushExit { /// 追不到就放弃:脱离全部感知区即停。 OnLostTarget, /// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。 /// 冷却门由本模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲, /// 出现 冲锋态 ↔ 未发现态 每帧抖动。 Committed, } ``` 一个枚举值同时决定「脱战边怎么写」和「进入条件带不带冷却门」,因为这两件事本就是同一语义的两面。 **两个耦合 flag → 一个语义枚举**,是消除开关面板的实证。 > **`Committed` 的防抖动保证有一个前提**:所引用的冲锋能力自身 `cooldown > 0`。 > `EnemyAbilityBase.CanUse` 是 `!_isRunning && !IsOnCooldown`,若 `EnemyAbilitySO.cooldown == 0`, > 能力结束的下一帧 `CanUseAbility` 就恢复为真,抖动照旧出现。这属于**能力侧的配置错误**, > 不在本模块内兜底(第 6 条),由 `AiDefinitionValidator` 显式报出(见 §6)。 > 旧的 `PatrolBetweenChases` 路径有同样的弱点,本次不改变该前提,只是把它写明并纳入校验。 ```csharp 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 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` 边: ```csharp 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 提升为公开原语 —— 它们本就是通用的, 被关在骨架内部才是错误。定制敌人写自定义态时同样使用它们。 ```csharp 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 LostAllZones; } ``` --- ## 4. 两种定义源 ```csharp public interface IAiDefinition { string Id { get; } AiGraph GetOrBuildGraph(); } public abstract class AiScript : IAiDefinition // 定制路径(现状保留) public abstract class AiRecipeSO : ScriptableObject, IAiDefinition // 配方路径(新增) ``` ### 4.1 配方资产:一个 SO 类型覆盖所有感知型敌人 ```csharp [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 加载」。该方案有两处硬阻塞,已废弃: > 1. **程序集方向不允许** —— `AiDefinitionRegistry` 在 `BaseGames.AI`,该程序集只引用 > `BaseGames.Core`,无法引用 `BaseGames.Enemies`(模块依赖 `IEnemyActor` / `EnemyAbilitySO`), > 也未引用 `Unity.Addressables`。 > 2. **与项目既有模式不符** —— `FormSkillDatabaseSO` 等库资产走的是 `[SerializeField]` > 直接引用,不是 Addressables。静态注册表拿不到序列化引用;改用 Addressables 异步加载 > 则与 `EnemyAiBrain.Start()` 的同步取图产生时序冲突。 改为两条互斥的解析路径,各自最短: ```csharp // 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` 上二选一,两者互斥: ```csharp [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 引用系统保证拼不错、改名不断链。 ```csharp [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) ```csharp 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`。等价的定制脚本写法(作为文档范例保留): ```csharp [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 加码;双轨单独立项 |