Files
zeling_v2/Docs_Dev/superpowers/specs/2026-07-29-enemy-ai-composable-modules-design.md
T
joywayerandClaude Opus 5 d013fe8e2e docs(enemy): 写明 RushExit.Committed 防抖动的前提是能力 cooldown > 0
质量审查发现:Committed 的冷却门若遇上 cooldown==0 的能力,
CanUseAbility 结束下一帧即恢复,Rush↔Rest 每帧抖动照旧出现。
这属能力侧配置错误,不在模块内兜底,纳入 AiDefinitionValidator 校验项。

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

28 KiB
Raw Blame History

敌人 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 个敌人类型,其分布高度不均:

占比 类别 特征
~7080% 原型实例 行为骨架相同,差别在能力集、数值、区域皮肤
~15% 变体 同原型换数值 / 换招
~510% 定制 有独门机制,值得单独写代码

「一个敌人 = 一个 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 > 0EnemyAbilityBase.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 攻击)

ApproachAttackEngagementAttack去掉 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 加载」。该方案有两处硬阻塞,已废弃:

  1. 程序集方向不允许 —— AiDefinitionRegistryBaseGames.AI,该程序集只引用 BaseGames.Core,无法引用 BaseGames.Enemies(模块依赖 IEnemyActor / EnemyAbilitySO), 也未引用 Unity.Addressables
  2. 与项目既有模式不符 —— 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} + AiRuntimeIsControllable 让位门独占处理。在决策层是重复表达,留着会诱导在 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 是否配了 EnemyAttackSelectorRushExit.Committed 所引用的能力 cooldown 是否 > 0(见下)。唯一能防住 450+ 引用出错的东西 紧接本次
EnemyAiRecipeWizard 建配方资产的脚手架(CLAUDE.md 第 2 条:不裸建),按 AssetFolderSpec 定名定路径。本次必须先有它E001 的配方资产才能合规产出 本次
_definitionId 下拉 Drawer 定制路径(约 5%)从 AiDefinitionRegistry.Ids 取值,预制体上不再手打字符串 紧接本次
AiGraphExporter AiGraph 导出为 MermaidTransition.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 留空

字段取值:

字段
未发现层 DisguiseThenPatrolIdle / Patrol
交战层 RushEngagement(能力 = e001_chase 对应的 EnemyAbilitySORushExit.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());
}

顺带清理:现有 E001CaoZhiAiEntry 被临时改为巡逻态用于验证寻路失效, 注释标明「验证完成后必须改回」。寻路库已整包移除(改为 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 加码;双轨单独立项