Files
zeling_v2/Docs_Dev/superpowers/specs/2026-07-29-enemy-ai-composable-modules-design.md
T
joywayerandClaude Opus 5 e59e24cca0 docs(enemy): 修正 AI 配方解析方式——直接引用替代注册表双源
实施计划阶段发现两处硬阻塞:
1. BaseGames.AI 只引用 BaseGames.Core,无法收集依赖 Enemies 的 AiRecipeSO;
2. 项目 database SO 走 [SerializeField] 直接引用而非 Addressables,静态注册表取不到。

改为 EnemyAiBrain 上 _recipe 资产引用 / _definitionId 脚本 id 二选一。
净减 AiRecipeDatabaseSO、Addressables 加载、AddressKeys 改动、asmdef 改动;
flyweight 与配方路径零字符串 id 均保持。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:38:07 +08:00

548 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 敌人 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. 架构
### 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<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 语义显式化
```csharp
public enum RushExit
{
/// 追不到就放弃:脱离全部感知区即停。
OnLostTarget,
/// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。
/// 冷却门由本模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲,
/// 出现 冲锋态 ↔ 未发现态 每帧抖动。
Committed,
}
```
一个枚举值同时决定「脱战边怎么写」和「进入条件带不带冷却门」,因为这两件事本就是同一语义的两面。
**两个耦合 flag → 一个语义枚举**,是消除开关面板的实证。
```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<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` 边:
```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<IAiContext, bool> 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`。**唯一能防住 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 加码;双轨单独立项 |