# 敌人 AI 组合式模块架构 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 用「固定感知骨架 + 可插拔的 未发现层 / 交战层 / 死亡层」替换 `PerceptionStateMachine`,并让约 95% 的敌人 AI 成为配方资产而非 C# 类。 **Architecture:** 两个模块契约(`IUnawareModule` / `IEngagementModule`)各自声明自己的状态与边;`PerceptionSkeleton` 只负责把「未发现 → 警觉 → 交战」的升降级边挂到模块声明的态上,并自行声明 `Death` 终态(死亡归物理层,不做成可插拔层——见 Task 9R)。模块是 `[Serializable]` 普通类,既能在 C# 里 `new`,也能经 `[SerializeReference]` 存进 `PerceptionRecipeSO` 配方资产。`EnemyAiBrain` 上「配方资产引用」与「AiScript 定义 id」二选一。 **Tech Stack:** Unity / C# 9 / NUnit EditMode / 自研 BrainGraph(`BaseGames.AI`) **Spec:** `Docs_Dev/superpowers/specs/2026-07-29-enemy-ai-composable-modules-design.md` --- ## 计划阶段发现的两处设计漏洞(已纳入本计划修正) 写计划时核对 `AiRuntime` 求值顺序与 Unity 序列化行为,发现 spec 的接口形态有两处会产生**运行时错误**,本计划按修正后的形态实施: ### 漏洞 A:未发现层内部边会抢在升级边前面 `AiRuntime.TryTransition` 按**声明顺序**评估条件边。若 `IUnawareModule.Build` 一次性声明「态 + 内部边」,则 `AlternatingIdlePatrol` 的巡逻计时边会先于骨架的追击边被评估 —— 玩家进入追逐区的那一帧若恰好计时到点,敌人会切去另一个待机态而不是扑上来。 **修正**:`IUnawareModule` 拆成两阶段 —— `Declare(b)` 只声明态与行为,`Link(b)` 挂内部边。骨架的调用顺序为 `Declare` → 挂升级边 → `Link`,保证升级边优先级最高。Task 5 定义、Task 6 实现、Task 10 有专门的优先级回归测试。 ### 漏洞 B:`[SerializeReference]` 反序列化不保证走构造函数 spec 里 `RushEngagement` 在构造函数中预计算并缓存 `CanEngage` 委托。Unity 反序列化 `[SerializeReference]` 实例时不保证调用构造函数,配方资产加载出来的模块 `_canEngage` 会是 `null`。 **修正**:`CanEngage` 改为**惰性构建并缓存的属性**;所有参数校验从构造函数移到 `Build()`(图构建时仍然快速失败);所有 `States` 数组改为 `static readonly`(静态初始化器一定执行)。Task 5 / 7 落实。 ### 顺带修正:死亡态每帧重触发能力 现有 `PerceptionStateMachine.AddAbilityState` 的 `Tick` 会在能力结束后重新触发它。用在死亡态上意味着死亡演出无限循环。本计划新增 `AiStateFragments.AbilityOnce`(只在 `OnEnter` 触发一次),死亡层专用。 --- ## 如何运行测试 **编译检查(每次改完代码先做):** MCP 调 `mcp__unity__unity_get_compilation_errors`,期望返回无错误。 **跑 EditMode 测试:** Unity Editor → `Window` → `General` → `Test Runner` → `EditMode` 标签 → 在树中定位到指定测试类 → 右键 `Run`。 **批量跑:** Test Runner 顶部 `Run All`,或 MCP `mcp__unity__unity_execute_code` 调用 `UnityEditor.TestTools.TestRunner.Api.TestRunnerApi`。 **本计划中每个「运行测试」步骤给出的是测试类名**,在 Test Runner 中按该类名过滤运行。 --- ## 文件结构 新代码集中在两处,各自职责单一: ``` Assets/_Game/Scripts/AI/ 框架层(只依赖 BaseGames.Core) IAiDefinition.cs 统一「能产出 AiGraph 的东西」 AiRecipeSO.cs 配方基类(抽象,无 Enemies 依赖) BrainBuilder.cs + RequireState AiSignal.cs 8 → 1 AiScript.cs 实现 IAiDefinition Assets/_Game/Scripts/Enemies/AIBrain/Modules/ 敌人决策组合层 AiStateFragments.cs 建态原语(4 个) IUnawareModule.cs / IEngagementModule.cs PerceptionSkeleton.cs 感知规则 + 自声明 Death 终态 Unaware/SinglePost.cs Unaware/DisguiseThenPatrol.cs Unaware/AlternatingIdlePatrol.cs Engagement/RushExit.cs Engagement/RushEngagement.cs Engagement/ApproachAttackEngagement.cs (无 Death/ 目录:死亡归物理层,见 Task 9R) Assets/_Game/Scripts/Enemies/AIBrain/Recipes/ PerceptionRecipeSO.cs 一个 SO 类型覆盖所有感知型敌人 Assets/_Game/Scripts/Enemies/Abilities/ AbilityRef.cs 能力引用(资产优先,字符串兜底) Assets/_Game/Scripts/Editor/AI/ SubclassSelectorDrawer.cs [SerializeReference] 子类下拉 EnemyAiRecipeWizard.cs 配方资产脚手架 ``` 每个模块一个文件 —— 模块是本架构的增长点(预计长到 15–20 个),一文件一模块保证新增时不触碰任何已有文件。 --- ## Task 1: `BrainBuilder.RequireState` 模块要给「别人声明的态」挂边。`b.State(name)` 是幂等新建的 —— 若那个态其实没人声明行为,会静默新建一个空态,敌人杵着不动且无任何报错。这个方法把静默错误变成构建期异常。 **Files:** - Modify: `Assets/_Game/Scripts/AI/BrainBuilder.cs` - Test: `Assets/Tests/EditMode/AI/BrainBuilderTests.cs` - [ ] **Step 1: 写失败测试** 在 `BrainBuilderTests.cs` 的类体内追加: ```csharp [Test] public void RequireState_Throws_WhenStateNotDeclared() { var b = new BrainBuilder(); var ex = Assert.Throws(() => b.RequireState("Ghost")); StringAssert.Contains("Ghost", ex.Message); } [Test] public void RequireState_Passes_WhenStateDeclared() { var b = new BrainBuilder(); b.State("Real"); Assert.DoesNotThrow(() => b.RequireState("Real")); } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `BrainBuilderTests`。 期望:编译错误 `'BrainBuilder' does not contain a definition for 'RequireState'`。 - [ ] **Step 3: 实现** 在 `BrainBuilder.cs` 中 `public GlobalBuilder Global() => new GlobalBuilder(this);` 之后插入: ```csharp /// /// 要求状态已被声明(含行为回调)。模块给"别人声明的态"挂边前调用。 /// 未声明即抛——否则 State() 会静默新建一个空态,敌人杵着不动且无任何报错。 /// public void RequireState(string name) { if (!_states.ContainsKey(name)) throw new InvalidOperationException( $"BrainBuilder: 状态 '{name}' 尚未声明。请先声明它的行为(AiStateFragments.* 或 State(name)),再挂转换。"); } ``` - [ ] **Step 4: 运行测试确认通过** 运行 `BrainBuilderTests`。期望:全部 PASS。 - [ ] **Step 5: 提交** ```bash git add Assets/_Game/Scripts/AI/BrainBuilder.cs Assets/Tests/EditMode/AI/BrainBuilderTests.cs git commit -m "feat(ai): BrainBuilder.RequireState——未声明状态挂边时构建期报错" ``` --- ## Task 2: `AiSignal` 清理(8 → 1) 7 个枚举值零发送方零消费方。受击反应由物理状态机 `{Hurt, Stagger, KnockUp}` + `AiRuntime` 的 `IsControllable` 让位门独占处理;留着这些值会诱导在 AI 图里写第二套受击逻辑并与物理层打架。 `Parried` / `Staggered` 目前仅被三个框架测试当作「任意信号」使用,需一并改写。 **Files:** - Modify: `Assets/_Game/Scripts/AI/AiSignal.cs` - Modify: `Assets/Tests/EditMode/AI/AiFrameworkTests.cs` - [ ] **Step 1: 改枚举** `AiSignal.cs` 全文替换为: ```csharp namespace BaseGames.AI { /// /// 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。 /// /// 只保留真实存在发送方与消费方的信号。受击 / 硬直 / 击飞 / 弹反**不在此列**—— /// 它们由敌人物理状态机(EnemyStateType)处理,决策层经 IActorVitals.IsControllable /// 让位门自动挂起。在这里重复定义会诱导写出与物理层打架的第二套受击逻辑。 /// /// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留。 /// public enum AiSignal { Died, } } ``` - [ ] **Step 2: 改写三个使用 Parried/Staggered 的框架测试** 在 `AiFrameworkTests.cs` 中,把 `EventTransition_TakesPriorityOverCondition_SameTick` 与 `UnmatchedSignal_ConsumedThenConditionEvaluated` 两个方法里的 `AiSignal.Parried` 全部替换为 `AiSignal.Died`(这两个测试只需要一个信号)。 然后把 `MultipleQueuedSignals_FirstMatchWins_RemainingConsumed` 整个方法替换为: ```csharp [Test] public void QueuedDuplicateSignals_TransitionOnce_RemainderConsumed() { // 注:原测试用两个不同信号验证"第一个匹配的先转"。AiSignal 现只有 Died 一个值, // 该形态暂不可表达;等将来出现第二个真实信号(如战斗激活)时恢复此覆盖。 // 此处覆盖仍然成立的部分:残留信号被消费掉,不会重复触发转换。 var b = new BrainBuilder(); b.Entry("A"); b.State("A").To("B").OnEvent(AiSignal.Died); b.State("B").OnEnter(c => c.Blackboard.Set("enters", c.Blackboard.Get("enters") + 1)); var ctx = new FakeAiContext(); var rt = new AiRuntime(b.Build(), ctx); rt.Send(AiSignal.Died); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual("B", rt.CurrentStateName); rt.Tick(0.1f); Assert.AreEqual("B", rt.CurrentStateName); Assert.AreEqual(1, ctx.BB.Get("enters")); // 残留信号被消费,未重入 B } ``` - [ ] **Step 3: 运行测试确认通过** 运行 `AiFrameworkTests`、`AiRuntimeTests`、`PerceptionStateMachineTests`。 期望:全部 PASS(`PerceptionStateMachine` 只用 `Died`,不受影响)。 - [ ] **Step 4: 提交** ```bash git add Assets/_Game/Scripts/AI/AiSignal.cs Assets/Tests/EditMode/AI/AiFrameworkTests.cs git commit -m "refactor(ai): AiSignal 8→1,删除零发送方零消费方的死枚举" ``` --- ## Task 3: `AbilityRef` 能力引用:配方侧引用 `EnemyAbilitySO` 资产(Unity 引用系统保证拼不错、改名不断链),定制脚本侧仍可传字符串。 **Files:** - Create: `Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs` - Test: `Assets/Tests/EditMode/AI/AbilityRefTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/AbilityRefTests.cs`: ```csharp using NUnit.Framework; using UnityEngine; using BaseGames.Enemies.Abilities; namespace BaseGames.Tests.EditMode.AI { public class AbilityRefTests { [Test] public void ImplicitFromString_ExposesLiteralId() { AbilityRef r = "e001_chase"; Assert.AreEqual("e001_chase", r.Id); Assert.IsFalse(r.IsEmpty); } [Test] public void FromAsset_ExposesAssetAbilityId() { var so = ScriptableObject.CreateInstance(); so.abilityId = "from_asset"; AbilityRef r = so; Assert.AreEqual("from_asset", r.Id); Object.DestroyImmediate(so); } [Test] public void Default_IsEmpty() { var r = default(AbilityRef); Assert.IsTrue(r.IsEmpty); Assert.IsNull(r.Id); } [Test] public void AssetWins_WhenBothSet() { var so = ScriptableObject.CreateInstance(); so.abilityId = "asset_id"; var r = new AbilityRef(so, "literal_id"); Assert.AreEqual("asset_id", r.Id); Assert.IsTrue(r.HasConflict); // 供校验器报警告 Object.DestroyImmediate(so); } } } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `AbilityRefTests`。期望:编译错误 `The type or namespace name 'AbilityRef' could not be found`。 - [ ] **Step 3: 实现** 新建 `Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs`: ```csharp using System; using UnityEngine; namespace BaseGames.Enemies.Abilities { /// /// 能力引用。配方资产侧引用 EnemyAbilitySO(Unity 引用系统保证拼不错、改名不断链); /// 定制 AiScript 侧仍可直接写字符串 id。资产优先。 /// [Serializable] public struct AbilityRef { [Tooltip("能力配置资产;配方路径用这个")] [SerializeField] EnemyAbilitySO _asset; [NonSerialized] string _literal; // 定制脚本路径用;不参与序列化 public AbilityRef(EnemyAbilitySO asset) { _asset = asset; _literal = null; } public AbilityRef(string id) { _asset = null; _literal = id; } public AbilityRef(EnemyAbilitySO asset, string id) { _asset = asset; _literal = id; } /// 解析出的能力 id;资产优先,都没有则 null。 public string Id => _asset != null ? _asset.abilityId : _literal; public bool IsEmpty => string.IsNullOrEmpty(Id); /// 资产与字符串同时非空——歧义配置,供校验器报警告。 public bool HasConflict => _asset != null && !string.IsNullOrEmpty(_literal); public static implicit operator AbilityRef(string id) => new AbilityRef(id); public static implicit operator AbilityRef(EnemyAbilitySO asset) => new AbilityRef(asset); } } ``` - [ ] **Step 4: 运行测试确认通过** 运行 `AbilityRefTests`。期望:4 项全部 PASS。 - [ ] **Step 5: 提交** ```bash git add Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs Assets/Tests/EditMode/AI/AbilityRefTests.cs git commit -m "feat(enemy): AbilityRef——AI 层能力引用资产化,字符串仅作定制脚本兜底" ``` --- ## Task 4: `AiStateFragments` 建态原语 从 `PerceptionStateMachine` 的 private static 提升为公开原语(它们本就是通用的),并新增 `AbilityOnce` 修死亡态无限重触发的隐患。 **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs` - Test: `Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs`: ```csharp using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class AiStateFragmentsTests { static AiRuntime Run(System.Action build, FakeAiContext ctx) { var b = new BrainBuilder(); b.Entry("S"); build(b); return new AiRuntime(b.Build(), ctx); } [Test] public void Locomotion_SetsModeOnEnter() { var ctx = new FakeAiContext(); Run(b => AiStateFragments.Locomotion(b, "S", LocomotionMode.Patrol), ctx); Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); } [Test] public void Locomotion_FaceMode_FacesLastKnown() { var ctx = new FakeAiContext(); ctx.S.Last = new UnityEngine.Vector2(3f, 0f); Run(b => AiStateFragments.Locomotion(b, "S", LocomotionMode.Face), ctx); Assert.AreEqual(new UnityEngine.Vector2(3f, 0f), ctx.L.FacedAt); } [Test] public void Ability_RetriggersOnTick_WhenNotRunning() { var ctx = new FakeAiContext(); var rt = Run(b => AiStateFragments.Ability(b, "S", "atk"), ctx); Assert.AreEqual(1, ctx.C.Used.Count); ctx.C.Running = null; // 模拟被受击打断 rt.Tick(0.1f); Assert.AreEqual(2, ctx.C.Used.Count); // 重新触发 } [Test] public void AbilityOnce_DoesNotRetrigger() { var ctx = new FakeAiContext(); var rt = Run(b => AiStateFragments.AbilityOnce(b, "S", "death"), ctx); Assert.AreEqual(1, ctx.C.Used.Count); ctx.C.Running = null; // 演出播完 rt.Tick(0.1f); rt.Tick(0.1f); Assert.AreEqual(1, ctx.C.Used.Count); // 不重播 } [Test] public void LostAllZones_TrueOnlyWhenBothZonesEmpty() { var ctx = new FakeAiContext(); Assert.IsTrue(AiStateFragments.LostAllZones(ctx)); ctx.S.Chase = true; Assert.IsFalse(AiStateFragments.LostAllZones(ctx)); ctx.S.Chase = false; ctx.S.Vision = true; Assert.IsFalse(AiStateFragments.LostAllZones(ctx)); } } } ``` - [ ] **Step 2: 让 `FakeLocomotion` 记录 `Face` / `Pursue` 的参数** 现有 `FakeLocomotion` 只记方法名不记参数,测不了「朝向哪里 / 逼近哪里」。 打开 `Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs`,把这两行: ```csharp public void Pursue(Vector2 p) { CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); } ``` ```csharp public void Face(Vector2 p) { CurrentMode = LocomotionMode.Face; Calls.Add("Face"); } ``` 分别替换为: ```csharp public Vector2 PursuedTo; public void Pursue(Vector2 p) { PursuedTo = p; CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); } ``` ```csharp public Vector2 FacedAt; public void Face(Vector2 p) { FacedAt = p; CurrentMode = LocomotionMode.Face; Calls.Add("Face"); } ``` (`PursuedTo` 供 Task 8 使用,一并加上避免二次改动。) - [ ] **Step 3: 运行测试确认失败** 运行 `AiStateFragmentsTests`。期望:编译错误 `'AiStateFragments' could not be found`。 - [ ] **Step 4: 实现** 新建 `Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs`: ```csharp using System; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 建态原语。骨架、各模块、以及定制 AiScript 写自定义态时共用这几个形状, /// 保证"进入声明意图 / 离开收尾"的契约在全项目一致。 /// public static class AiStateFragments { /// 共享条件:脱离全部感知(追逐区与视野区都不在)。 public static readonly Func LostAllZones = x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(); /// 由 EnemyLocomotion 驱动的态:进入 / 每帧声明移动意图,离开时停。 public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode) => b.State(state) .OnEnter(x => ApplyLocomotion(x, mode)) .Tick (x => ApplyLocomotion(x, mode)) .OnExit(x => x.Locomotion.Stop()); /// /// 由能力驱动的持续态:进入 / 每帧确保能力在跑(受击打断后自动重触发),离开中断。 /// 用于追击、冲锋这类"只要还在这个状态就该一直在做"的能力。 /// public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId) { RequireAbilityId(state, abilityId); return b.State(state) .OnEnter(x => EnsureAbility(x, abilityId)) .Tick (x => EnsureAbility(x, abilityId)) .OnExit(x => x.Combat.InterruptAbilities()); } /// /// 由能力驱动的一次性态:只在进入时触发,不每帧重触发。 /// 用于死亡等一次性演出——若用 Ability(),演出播完会被 Tick 无限重播。 /// public static BrainBuilder.StateBuilder AbilityOnce(BrainBuilder b, string state, string abilityId) { RequireAbilityId(state, abilityId); return b.State(state).OnEnter(x => EnsureAbility(x, abilityId)); } /// /// 无行为的终态。不需要在这里停移动——转入本态时,上一个态的 OnExit 已经收尾。 /// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath)"的敌人。 /// public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state) => b.State(state); static void ApplyLocomotion(IAiContext x, LocomotionMode mode) { if (mode == LocomotionMode.Face) x.Locomotion.Face(x.Sensor.LastKnown); else x.Locomotion.SetMode(mode); } // 建图期校验:空 id 会让状态存在却什么都不做——敌人杵着不动、无任何报错, // 正是 BrainBuilder.RequireState 要防的同一类静默失败。放建图期而非运行时回调里, // 既能快速失败,也省掉热路径上每帧一次的判空。 static void RequireAbilityId(string state, string abilityId) { if (string.IsNullOrEmpty(abilityId)) throw new ArgumentException( $"AiStateFragments: 状态 '{state}' 未配置能力 id。若该状态本就不需要能力" + "(如死亡演出交给物理状态机),请改用 Terminal()。", nameof(abilityId)); } static void EnsureAbility(IAiContext x, string abilityId) { if (!x.Combat.IsAbilityRunning(abilityId)) x.Combat.UseAbility(abilityId); } } } ``` > 注:`RequireAbilityId` 是计划执行中根据质量审查加入的(原稿沿用了旧代码里 > `if (!string.IsNullOrEmpty(abilityId) && ...)` 的静默跳过)。旧写法在 `AiStateFragments` > 成为**公开**原语后不可接受——定制 AiScript 直接调用时,漏配 id 会产出一个什么都不做的 > 状态且无任何报错。`Terminal()` 才是"本就不需要能力"的正确出口。 - [ ] **Step 5: 运行测试确认通过** 运行 `AiStateFragmentsTests`。期望:5 项全部 PASS。 - [ ] **Step 6: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs git commit -m "feat(enemy): AiStateFragments 建态原语,新增 AbilityOnce 修死亡态重触发隐患" ``` --- ## Task 5: 三个模块契约 只有接口,无实现。单独一次提交,让契约本身可被审阅。 **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs` - [ ] **Step 1: 未发现层契约** 新建 `IUnawareModule.cs`: ```csharp using System.Collections.Generic; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 未发现层:玩家尚未被发现时的行为形状(单一站桩 / 单向降级 / 定时交替 …)。 /// /// 刻意拆成两阶段:AiRuntime 按声明顺序评估条件边,若本模块的内部边(如巡逻计时) /// 早于骨架的升级边声明,玩家进入追逐区那一帧若恰好计时到点,敌人会切去另一个待机态 /// 而不是扑上来。骨架的调用顺序固定为 Declare → 挂升级边 → Link。 /// public interface IUnawareModule { /// 只声明状态与行为回调,不挂任何转换。 void Declare(BrainBuilder b); /// 挂内部转换(如站立 ⇄ 巡逻的计时边)。没有内部边的模块留空实现。 void Link(BrainBuilder b); /// 图入口(出生态)。 string Entry { get; } /// 脱战 / 警觉丢失后回到的态。必须属于 States。 string Rest { get; } /// 需要挂升级边的所有未发现态。 IReadOnlyList States { get; } } } ``` - [ ] **Step 2: 交战层契约** 新建 `IEngagementModule.cs`: ```csharp using System; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 交战层:发现玩家之后怎么打。骨架只认这四件事——从哪个态进、什么条件进、 /// 边标签是什么、内部怎么打。新增一种打法 = 新增一个实现,骨架与其他模块不受影响。 /// /// CanEngage 归本模块所有是关键:像"冲锋冷却门"这种只对某一种打法有意义的条件, /// 若留在骨架上就会变成骨架的开关字段,并与其他开关互相耦合。 /// /// 实现注意:模块会经 [SerializeReference] 反序列化,构造函数不保证被调用。 /// 不要在构造函数里预计算缓存或做参数校验——缓存用惰性属性,校验放 Build()。 /// public interface IEngagementModule { /// 声明交战态与脱战边。 /// 脱战后回到的未发现态,由骨架传入。 void Build(BrainBuilder b, string rest); /// 骨架从未发现态 / 警觉态连过来的目标。 string EntryState { get; } /// 进入交战的条件。惰性构建并缓存。 Func CanEngage { get; } /// CanEngage 的可读标签(trace / 图导出用)。 string EngageLabel { get; } } } ``` - [ ] **Step 3: 死亡层契约** 新建 `IDeathModule.cs`: ```csharp using BaseGames.AI; namespace BaseGames.Enemies { /// /// 死亡层:单态终结(演出交给物理状态机)/ 单段死亡能力 / 两段演出(可带生成物)。 /// 骨架只挂一条 Global → EntryState 的 Died 事件边,链条内容全归本模块。 /// public interface IDeathModule { void Build(BrainBuilder b); /// 骨架全局死亡边指向的态。 string EntryState { get; } } } ``` - [ ] **Step 4: 确认编译通过** MCP 调 `mcp__unity__unity_get_compilation_errors`。期望:无错误。 - [ ] **Step 5: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs git commit -m "feat(enemy): 未发现层/交战层/死亡层三个模块契约" ``` --- ## Task 5b: `BrainBuilder.DeclareState` 防状态名冲突 > 本任务是执行 T5 后由契约质量审查提出并采纳的追加项,非原稿内容。 > > **风险来由**:`BrainBuilder.State(name)` 是「有则取、无则建」。改造前 `PerceptionStateMachine` > 是单个函数独占全部状态名,撞名不可能发生。改成「策划在下拉里自由组合三层独立模块」之后, > 两个不同作者的模块取了同一个状态名(或某模块内部态叫 `Alert`,撞上骨架保留的那个), > 后声明者的 `.OnEnter/.Tick/.OnExit` 会**静默覆盖**前者 —— 敌人跑错行为且零报错。 > > 这与 T1 的 `RequireState` 是同一类失败的两面:`RequireState` 守「挂边到未声明的态」, > `DeclareState` 守「重复声明同一个态」。趁只有 0 个模块实现时补,代价最低。 **Files:** - Modify: `Assets/_Game/Scripts/AI/BrainBuilder.cs` - Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs` - Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs` - Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs` - Test: `Assets/Tests/EditMode/AI/BrainBuilderTests.cs` - [ ] **Step 1: 写失败测试** 在 `BrainBuilderTests.cs` 类体内追加: ```csharp [Test] public void DeclareState_Throws_OnDuplicateName() { var b = new BrainBuilder(); b.DeclareState("Dup"); var ex = Assert.Throws(() => b.DeclareState("Dup")); StringAssert.Contains("Dup", ex.Message); } [Test] public void DeclareState_ReturnsUsableBuilder_OnFirstDeclare() { var b = new BrainBuilder(); b.Entry("A"); b.DeclareState("A").To("B").When(c => true, "go"); b.DeclareState("B"); Assert.DoesNotThrow(() => b.Build()); } [Test] public void State_StaysLenient_ForAttachingToDeclaredState() { // State() 仍是"有则取"——骨架要给模块已声明的态挂升级边,靠的就是这个。 var b = new BrainBuilder(); b.Entry("A"); b.DeclareState("A"); Assert.DoesNotThrow(() => b.State("A").To("A").When(c => false, "noop")); } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `BrainBuilderTests`。期望:编译错误 `'BrainBuilder' does not contain a definition for 'DeclareState'`。 - [ ] **Step 3: 实现 `DeclareState`** 在 `BrainBuilder.cs` 的 `RequireState` 之后插入: ```csharp /// /// 首次声明一个状态。名字已存在即抛——防止两个独立模块取了同名状态时, /// 后者的 OnEnter/Tick/OnExit 静默覆盖前者(敌人跑错行为且零报错)。 /// 只有"首次声明行为"的调用点用它;给已声明的态挂边仍用 State()。 /// public StateBuilder DeclareState(string name) { if (_states.ContainsKey(name)) throw new InvalidOperationException( $"BrainBuilder: 状态 '{name}' 已被声明过。两个模块取了同名状态会互相覆盖回调——" + "请给其中一个换个不冲突的名字。"); return State(name); } ``` - [ ] **Step 4: 让四个建态原语走 `DeclareState`** `AiStateFragments.cs` 中,把四个原语里的 `b.State(state)` 全部换成 `b.DeclareState(state)` (`Locomotion` / `Ability` / `AbilityOnce` / `Terminal` 各一处)。私有辅助方法不动。 - [ ] **Step 5: 给交战层 / 死亡层契约补一条不变量说明** `IEngagementModule.cs` 的接口 XML 注释末尾追加一段: ``` /// 为何本层只需单阶段 Build(而未发现层要拆 Declare/Link):骨架只把边**指向** /// EntryState,从不在本模块自己的状态上挂边,所以 Build 内声明的边不会被外部抢占。 /// 将来若修改骨架、使其向交战态挂边,必须先回来重新审视本契约。 ``` `IDeathModule.cs` 的接口 XML 注释末尾追加: ``` /// 同 IEngagementModule:骨架只把全局 Died 边指向 EntryState,不在死亡链内部挂边, /// 故单阶段 Build 足够。 ``` 并在 `IUnawareModule.cs` 的 `Entry` 成员注释上补一句区分: ``` /// 图入口(出生态)。注意与交战/死亡层的 EntryState 不同: /// 本属性会成为整张图唯一的 BrainBuilder.Entry,而 EntryState 只是别处 To() 的目标。 ``` - [ ] **Step 6: 运行测试确认通过** 运行 `BrainBuilderTests` 与 `AiStateFragmentsTests`,再跑全量 EditMode。 期望:新增 3 个测试,全量从 222 变为 225,0 失败。 > 若有既有测试因重复声明而失败,**停下报告** —— 那说明框架里本来就存在重复声明, > 是真问题,不要靠把 `DeclareState` 改宽松来绕过。 - [ ] **Step 7: 提交** ```bash git add Assets/_Game/Scripts/AI/BrainBuilder.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules Assets/Tests/EditMode/AI/BrainBuilderTests.cs git commit -m "feat(ai): BrainBuilder.DeclareState——重复声明状态即报错,防模块间同名静默覆盖" ``` --- ## Task 6: 未发现层三个模块 **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/SinglePost.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/DisguiseThenPatrol.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/AlternatingIdlePatrol.cs` - Test: `Assets/Tests/EditMode/AI/UnawareModuleTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/UnawareModuleTests.cs`: ```csharp using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class UnawareModuleTests { // 只跑未发现层自身(不挂骨架升级边),验证态、行为与内部边。 static AiRuntime RunAlone(IUnawareModule m, FakeAiContext ctx) { var b = new BrainBuilder(); m.Declare(b); m.Link(b); b.Entry(m.Entry); return new AiRuntime(b.Build(), ctx); } [Test] public void SinglePost_OneState_EntryEqualsRest() { var m = new SinglePost(LocomotionMode.Patrol); Assert.AreEqual(1, m.States.Count); Assert.AreEqual(m.Entry, m.Rest); var ctx = new FakeAiContext(); RunAlone(m, ctx); Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); } [Test] public void DisguiseThenPatrol_EntryIsDisguise_RestIsPatrol() { var m = new DisguiseThenPatrol(); Assert.AreEqual(DisguiseThenPatrol.Disguise, m.Entry); Assert.AreEqual(DisguiseThenPatrol.Patrol, m.Rest); CollectionAssert.AreEquivalent( new[] { DisguiseThenPatrol.Disguise, DisguiseThenPatrol.Patrol }, m.States); } [Test] public void DisguiseThenPatrol_HasNoInternalEdges_DisguiseIsOneWay() { var ctx = new FakeAiContext(); var rt = RunAlone(new DisguiseThenPatrol(), ctx); Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName); for (int i = 0; i < 20; i++) rt.Tick(1f); // 无内部边:伪装态自己不会跑掉,只能被骨架的升级边带走 Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName); Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode); } [Test] public void AlternatingIdlePatrol_SwitchesOnDwellTimers() { var m = new AlternatingIdlePatrol(idleDwell: 2f, patrolDwell: 3f); var ctx = new FakeAiContext(); var rt = RunAlone(m, ctx); Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); rt.Tick(1f); Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); // 未到 2s rt.Tick(1.5f); Assert.AreEqual(AlternatingIdlePatrol.Patrol, rt.CurrentStateName); // 累计 2.5s ≥ 2s Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); rt.Tick(3.5f); Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); // ≥ 3s 回站立 } } } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `UnawareModuleTests`。期望:编译错误,找不到 `SinglePost` 等类型。 - [ ] **Step 3: 实现 `SinglePost`** ```csharp using System; using System.Collections.Generic; using UnityEngine; using BaseGames.AI; namespace BaseGames.Enemies { /// 单一未发现态:站桩(Idle)或单一巡逻(Patrol)。出生态与脱战态是同一个。 [Serializable] public sealed class SinglePost : IUnawareModule { public const string Post = "Post"; static readonly string[] StatesArray = { Post }; [Tooltip("未发现时的移动模式:Idle=站着不动,Patrol=按配置策略游走")] [SerializeField] LocomotionMode _mode = LocomotionMode.Patrol; public SinglePost() { } public SinglePost(LocomotionMode mode) { _mode = mode; } public string Entry => Post; public string Rest => Post; public IReadOnlyList States => StatesArray; public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, Post, _mode); public void Link(BrainBuilder b) { } // 单态,无内部边 } } ``` - [ ] **Step 4: 实现 `DisguiseThenPatrol`** ```csharp using System; using System.Collections.Generic; using UnityEngine; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 单向降级:出生时伪装静止,一旦交战过就只回巡逻,伪装态不可逆。 /// 刻意不声明两态之间的内部边——伪装只在出生那一次可达。 /// [Serializable] public sealed class DisguiseThenPatrol : IUnawareModule { public const string Disguise = "Disguise"; public const string Patrol = "Patrol"; static readonly string[] StatesArray = { Disguise, Patrol }; [Tooltip("出生伪装态的移动模式(通常 Idle)")] [SerializeField] LocomotionMode _disguiseMode = LocomotionMode.Idle; [Tooltip("脱战后常态的移动模式(通常 Patrol)")] [SerializeField] LocomotionMode _patrolMode = LocomotionMode.Patrol; public DisguiseThenPatrol() { } public DisguiseThenPatrol(LocomotionMode disguiseMode, LocomotionMode patrolMode) { _disguiseMode = disguiseMode; _patrolMode = patrolMode; } public string Entry => Disguise; public string Rest => Patrol; public IReadOnlyList States => StatesArray; public void Declare(BrainBuilder b) { AiStateFragments.Locomotion(b, Disguise, _disguiseMode); AiStateFragments.Locomotion(b, Patrol, _patrolMode); } public void Link(BrainBuilder b) { } // 无内部边:伪装态单向不可逆 } } ``` - [ ] **Step 5: 实现 `AlternatingIdlePatrol`** ```csharp using System; using System.Collections.Generic; using UnityEngine; using BaseGames.AI; namespace BaseGames.Enemies { /// 站立 ⇄ 巡逻定时交替,脱战回站立。内部计时边经 Link 挂载,优先级低于骨架升级边。 [Serializable] public sealed class AlternatingIdlePatrol : IUnawareModule { public const string Idle = "Idle"; public const string Patrol = "Patrol"; static readonly string[] StatesArray = { Idle, Patrol }; [Tooltip("站立停留时长(秒),到点转巡逻")] [SerializeField, Min(0.1f)] float _idleDwell = 2f; [Tooltip("巡逻持续时长(秒),到点转站立")] [SerializeField, Min(0.1f)] float _patrolDwell = 4f; public AlternatingIdlePatrol() { } public AlternatingIdlePatrol(float idleDwell, float patrolDwell) { _idleDwell = idleDwell; _patrolDwell = patrolDwell; } public string Entry => Idle; public string Rest => Idle; public IReadOnlyList States => StatesArray; public void Declare(BrainBuilder b) { AiStateFragments.Locomotion(b, Idle, LocomotionMode.Idle); AiStateFragments.Locomotion(b, Patrol, LocomotionMode.Patrol); } public void Link(BrainBuilder b) { b.State(Idle) .To(Patrol).After(_idleDwell); b.State(Patrol).To(Idle) .After(_patrolDwell); } } } ``` - [ ] **Step 6: 运行测试确认通过** 运行 `UnawareModuleTests`。期望:4 项全部 PASS。 - [ ] **Step 7: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware Assets/Tests/EditMode/AI/UnawareModuleTests.cs git commit -m "feat(enemy): 未发现层三模块(单一态/单向降级/定时交替)" ``` --- ## Task 7: 交战层 `RushEngagement` `RushExit.Committed` 一个枚举值同时决定「脱战边怎么写」和「进入条件带不带冷却门」—— 这两件事本就是同一语义的两面,原设计拆成两个 flag 交给调用方自己配对,只配一半就会每帧抖动。 **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs` - Test: `Assets/Tests/EditMode/AI/RushEngagementTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/RushEngagementTests.cs`: ```csharp using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class RushEngagementTests { const string Rest = "Rest"; // 单独跑交战模块:手搓一个最小 rest 态,直接以交战入口为图入口。 static AiRuntime RunAlone(RushEngagement m, FakeAiContext ctx) { var b = new BrainBuilder(); AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol); m.Build(b, Rest); b.Entry(m.EntryState); return new AiRuntime(b.Build(), ctx); } [Test] public void OnLostTarget_CanEngage_IsChaseZoneOnly() { var m = new RushEngagement("rush", RushExit.OnLostTarget); var ctx = new FakeAiContext(); ctx.C.CanUse = false; // 冷却中 ctx.S.Chase = true; Assert.IsTrue(m.CanEngage(ctx)); // 不看冷却 Assert.AreEqual("InChaseZone", m.EngageLabel); } [Test] public void Committed_CanEngage_AlsoRequiresOffCooldown() { var m = new RushEngagement("rush", RushExit.Committed); var ctx = new FakeAiContext(); ctx.S.Chase = true; ctx.C.CanUse = false; Assert.IsFalse(m.CanEngage(ctx)); // CD 中不进 ctx.C.CanUse = true; Assert.IsTrue(m.CanEngage(ctx)); Assert.AreEqual("InChaseZone+offCD", m.EngageLabel); } [Test] public void OnLostTarget_ExitsWhenAllZonesLost() { var ctx = new FakeAiContext { }; ctx.S.Chase = true; var rt = RunAlone(new RushEngagement("rush", RushExit.OnLostTarget), ctx); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); // 还在追逐区 ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f); Assert.AreEqual(Rest, rt.CurrentStateName); } [Test] public void Committed_NotInterruptedByLosingTarget() { var ctx = new FakeAiContext(); ctx.S.Chase = true; var rt = RunAlone(new RushEngagement("rush", RushExit.Committed), ctx); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); ctx.S.Chase = false; ctx.S.Vision = false; // 中途丢失感知 rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); // 冲锋不被打断 ctx.C.Running = null; // 冲锋跑完 rt.Tick(0.1f); Assert.AreEqual(Rest, rt.CurrentStateName); } [Test] public void Build_Throws_WhenAbilityMissing() { var b = new BrainBuilder(); AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol); var m = new RushEngagement(); // 未配能力 Assert.Throws(() => m.Build(b, Rest)); } } } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `RushEngagementTests`。期望:编译错误,找不到 `RushEngagement` / `RushExit`。 - [ ] **Step 3: 实现 `RushExit`** 新建 `RushExit.cs`: ```csharp namespace BaseGames.Enemies { /// 冲锋的脱战语义。一个值同时决定"脱战边怎么写"与"进入条件带不带冷却门"。 public enum RushExit { /// 追不到就放弃:脱离全部感知区即停。 OnLostTarget, /// /// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。 /// 冷却门由模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲, /// 出现 冲锋态 ↔ 未发现态 每帧抖动。 /// Committed, } } ``` - [ ] **Step 4: 实现 `RushEngagement`** 新建 `RushEngagement.cs`: ```csharp using System; using UnityEngine; using BaseGames.AI; using BaseGames.Enemies.Abilities; namespace BaseGames.Enemies { /// 接触冲锋:一个能力包办追击与伤害。冲锋能力自己管方向、速度、动画与命中。 [Serializable] public sealed class RushEngagement : IEngagementModule { public const string Rush = "Rush"; [Tooltip("冲锋能力")] [SerializeField] AbilityRef _ability; [Tooltip("脱战语义:OnLostTarget=追不到就放弃;Committed=起手即锁定,打完才脱战")] [SerializeField] RushExit _exit = RushExit.OnLostTarget; // 惰性缓存:[SerializeReference] 反序列化不保证走构造函数,不能在 ctor 里预计算。 [NonSerialized] Func _canEngage; public RushEngagement() { } public RushEngagement(AbilityRef ability, RushExit exit = RushExit.OnLostTarget) { _ability = ability; _exit = exit; } public string EntryState => Rush; public string EngageLabel => _exit == RushExit.Committed ? "InChaseZone+offCD" : "InChaseZone"; public Func CanEngage { get { if (_canEngage == null) { string id = RequireAbilityId(); _canEngage = _exit == RushExit.Committed ? x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(id) : (Func)(x => x.Sensor.InChaseZone()); } return _canEngage; } } public void Build(BrainBuilder b, string rest) { string id = RequireAbilityId(); 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"); } // 校验放这里而非构造函数:反序列化路径不走 ctor,漏配必须在建图时暴露。 string RequireAbilityId() { string id = _ability.Id; if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("RushEngagement:未配置冲锋能力。"); return id; } } } ``` - [ ] **Step 5: 运行测试确认通过** 运行 `RushEngagementTests`。期望:5 项全部 PASS。 - [ ] **Step 6: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement Assets/Tests/EditMode/AI/RushEngagementTests.cs git commit -m "feat(enemy): RushEngagement——两个耦合 flag 合并为 RushExit 语义枚举" ``` --- ## Task 8: 交战层 `ApproachAttackEngagement` 含 spec §3.6 的行为变更:`Attack` 态去掉 `leftAllZones` 边,攻击一旦起手就完整打完。 **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs` - Test: `Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs`: ```csharp using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class ApproachAttackEngagementTests { const string Rest = "Rest"; static AiRuntime RunAlone(FakeAiContext ctx) { var m = new ApproachAttackEngagement(); var b = new BrainBuilder(); AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol); m.Build(b, Rest); b.Entry(m.EntryState); return new AiRuntime(b.Build(), ctx); } [Test] public void Approach_PursuesLastKnown() { var ctx = new FakeAiContext(); ctx.S.Chase = true; ctx.S.Last = new UnityEngine.Vector2(5f, 1f); RunAlone(ctx); Assert.AreEqual(new UnityEngine.Vector2(5f, 1f), ctx.L.PursuedTo); } [Test] public void Approach_ToAttack_WhenEligibleAttackExists() { var ctx = new FakeAiContext(); ctx.S.Chase = true; var rt = RunAlone(ctx); Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName); ctx.C.Eligible = true; rt.Tick(0.1f); Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName); CollectionAssert.Contains(ctx.C.Used, "best"); } [Test] public void Attack_BackToApproach_WhenAbilityDone() { var ctx = new FakeAiContext(); ctx.S.Chase = true; ctx.C.Eligible = true; var rt = RunAlone(ctx); rt.Tick(0.1f); Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName); ctx.C.Running = null; // 招式打完 rt.Tick(0.1f); Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName); } [Test] public void Approach_ExitsToRest_WhenAllZonesLost() { var ctx = new FakeAiContext(); ctx.S.Chase = true; var rt = RunAlone(ctx); ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f); Assert.AreEqual(Rest, rt.CurrentStateName); } // spec §3.6 行为变更 [Test] public void Attack_CompletesEvenWhenPlayerLeavesAllZones() { var ctx = new FakeAiContext(); ctx.S.Chase = true; ctx.C.Eligible = true; var rt = RunAlone(ctx); rt.Tick(0.1f); Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName); ctx.S.Chase = false; ctx.S.Vision = false; // 前摇中玩家跑掉 rt.Tick(0.1f); Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName); // 不中途收招 ctx.C.Running = null; // 招式打完 rt.Tick(0.1f); Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName); rt.Tick(0.1f); Assert.AreEqual(Rest, rt.CurrentStateName); // 再经 Approach 自然脱战 } } } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `ApproachAttackEngagementTests`。期望:编译错误,找不到 `ApproachAttackEngagement`。 - [ ] **Step 3: 实现** 新建 `ApproachAttackEngagement.cs`: ```csharp using System; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 寻路逼近 ↔ 到射程选招攻击。招式的射程 / 冷却 / 权重由 EnemyAttackSelector 负责, /// 本模块只决定"什么时候该逼近、什么时候该出手"。 /// [Serializable] public sealed class ApproachAttackEngagement : IEngagementModule { public const string Approach = "Approach"; public const string Attack = "Attack"; static readonly Func InChaseZone = x => x.Sensor.InChaseZone(); public string EntryState => Approach; public Func CanEngage => InChaseZone; public string EngageLabel => "InChaseZone"; public void Build(BrainBuilder b, string 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"); } } } ``` - [ ] **Step 4: 运行测试确认通过** 运行 `ApproachAttackEngagementTests`。期望:5 项全部 PASS。 - [ ] **Step 5: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs git commit -m "feat(enemy): ApproachAttackEngagement——攻击改为起手即打完,不中途收招" ``` --- ## Task 9: ~~死亡层三个模块~~ → 已作废,改为 Task 9R > **本任务已作废。** 三个死亡模块曾按下方原稿实现并提交(`3bc7eb1`、`5a9c1fd`), > 但质量审查发现 `TwoStageDeath` 在真实运行中必然卡死,追根后确认整个死亡层放错了层。 > 详见 spec 的「修订:死亡层取消」一节与 commit `023a4a4`。 > > **必然卡死的机制**:`EnemyBase.PerformDeath` 先 `ForceState(EnemyStateType.Dead)`(终态) > 再 `_brain.Send(AiSignal.Died)`。`IsControllable` 是 `CurrentState == Controlled`,此后永久为假。 > `AiRuntime.Tick` 的顺序是「事件转换(不受门限制)→ IsControllable 让位门(为假即 return)→ > 条件转换」,所以 `Died` 事件能切进死亡链,但**此后任何条件边都不再被求值**, > `TwoStageDeath` 的 `preDone` 边永不触发。 > > `AbilityDeath` 同样不成立:死亡态 `OnEnter` 执行时,`InterruptAll(Dead)` 已跑过、 > 碰撞体已关、`Dead` 动画已在播,此刻触发能力是在和 `PerformDeath` 的死亡演出打架。 > > **由 Task 9R 取代。** 下方原稿仅作历史记录保留,不要照它实施。
原稿(已作废,勿实施) **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs` - Test: `Assets/Tests/EditMode/AI/DeathModuleTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/DeathModuleTests.cs`: ```csharp using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class DeathModuleTests { // 从一个活着的态出发,发 Died 信号进入死亡链。 static AiRuntime RunWithDeath(IDeathModule m, FakeAiContext ctx) { var b = new BrainBuilder(); AiStateFragments.Locomotion(b, "Alive", LocomotionMode.Patrol); m.Build(b); b.Entry("Alive"); b.Global().To(m.EntryState).OnEvent(AiSignal.Died); return new AiRuntime(b.Build(), ctx); } [Test] public void TerminalDeath_EntersAndStays_NoAbility() { var ctx = new FakeAiContext(); var rt = RunWithDeath(new TerminalDeath(), ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); Assert.AreEqual(0, ctx.C.Used.Count); // 演出走物理状态机,AI 不触发能力 rt.Tick(0.1f); Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); } [Test] public void AbilityDeath_TriggersOnce_DoesNotLoop() { var ctx = new FakeAiContext(); var rt = RunWithDeath(new AbilityDeath("die"), ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual(1, ctx.C.Used.Count); ctx.C.Running = null; // 演出播完 rt.Tick(0.1f); rt.Tick(0.1f); Assert.AreEqual(1, ctx.C.Used.Count); // 不重播 } [Test] public void TwoStageDeath_PreThenFinal() { var ctx = new FakeAiContext(); var rt = RunWithDeath(new TwoStageDeath("die_pre", "die"), ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual(TwoStageDeath.DeathPre, rt.CurrentStateName); CollectionAssert.Contains(ctx.C.Used, "die_pre"); rt.Tick(0.1f); Assert.AreEqual(TwoStageDeath.DeathPre, rt.CurrentStateName); // 前段还在跑 ctx.C.Running = null; // 前段结束 rt.Tick(0.1f); Assert.AreEqual(TwoStageDeath.Death, rt.CurrentStateName); CollectionAssert.Contains(ctx.C.Used, "die"); } [Test] public void AbilityDeath_Build_Throws_WhenAbilityMissing() { var b = new BrainBuilder(); Assert.Throws(() => new AbilityDeath().Build(b)); } } } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `DeathModuleTests`。期望:编译错误,找不到 `TerminalDeath` 等类型。 - [ ] **Step 3: 实现 `TerminalDeath`** ```csharp using System; using BaseGames.AI; namespace BaseGames.Enemies { /// 死亡演出交给物理状态机(EnemyBase.PerformDeath):AI 只需要一个无行为终态。 [Serializable] public sealed class TerminalDeath : IDeathModule { public const string Death = "Death"; public string EntryState => Death; public void Build(BrainBuilder b) => AiStateFragments.Terminal(b, Death); } } ``` - [ ] **Step 4: 实现 `AbilityDeath`** ```csharp using System; using UnityEngine; using BaseGames.AI; using BaseGames.Enemies.Abilities; namespace BaseGames.Enemies { /// 单段死亡能力:进入即触发一次,不重播。 [Serializable] public sealed class AbilityDeath : IDeathModule { public const string Death = "Death"; [Tooltip("死亡演出能力")] [SerializeField] AbilityRef _ability; public AbilityDeath() { } public AbilityDeath(AbilityRef ability) { _ability = ability; } public string EntryState => Death; public void Build(BrainBuilder b) { string id = _ability.Id; if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("AbilityDeath:未配置死亡能力。若死亡演出走物理状态机,请改用 TerminalDeath。"); AiStateFragments.AbilityOnce(b, Death, id); } } } ``` - [ ] **Step 5: 实现 `TwoStageDeath`** ```csharp using System; using UnityEngine; using BaseGames.AI; using BaseGames.Enemies.Abilities; namespace BaseGames.Enemies { /// 两段死亡:前段(挣扎 / 膨胀)播完接后段(爆体,可带生成物)。 [Serializable] public sealed class TwoStageDeath : IDeathModule { public const string DeathPre = "Death_Pre"; public const string Death = "Death"; [Tooltip("前段演出能力(挣扎 / 膨胀)")] [SerializeField] AbilityRef _preAbility; [Tooltip("后段演出能力(爆体 / 生成物)")] [SerializeField] AbilityRef _ability; public TwoStageDeath() { } public TwoStageDeath(AbilityRef preAbility, AbilityRef ability) { _preAbility = preAbility; _ability = ability; } public string EntryState => DeathPre; public void Build(BrainBuilder b) { string preId = _preAbility.Id; string id = _ability.Id; if (string.IsNullOrEmpty(preId)) throw new InvalidOperationException("TwoStageDeath:未配置前段能力。"); if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("TwoStageDeath:未配置后段能力。"); AiStateFragments.AbilityOnce(b, DeathPre, preId) .To(Death).When(x => !x.Combat.IsAbilityRunning(preId), "preDone"); AiStateFragments.AbilityOnce(b, Death, id); } } } ``` - [ ] **Step 6: 运行测试确认通过** 运行 `DeathModuleTests`。期望:4 项全部 PASS。 - [ ] **Step 7: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death Assets/Tests/EditMode/AI/DeathModuleTests.cs git commit -m "feat(enemy): 死亡层三模块(终态/单段/两段)" ```
--- ## Task 9R: 收掉死亡层,死亡归物理层 **为什么**:见上方 Task 9 的作废说明与 spec 的「修订:死亡层取消」。要点是——死亡在本项目里 完全归物理层(`PerformDeath` 切终态/清效果/关碰撞/播死亡动画/归还池;`EnemyDeathSequence` 做死亡前摇无敌演出并在演出中经动画事件生成小怪;`EnemyAnimationConfigSO.Dead` 提供动画)。 **AI 图需要 `Death` 态只是为了让全局 `Died` 边有去处、并让决策停止。死亡不是一个决策。** **Files:** - Delete: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/`(整个目录,三个模块 + meta) - Delete: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs`(+ meta) - Delete: `Assets/Tests/EditMode/AI/DeathModuleTests.cs`(+ meta) - [ ] **Step 1: 删除三个死亡模块、契约与测试** ```bash git rm -r Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death.meta git rm Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs.meta git rm Assets/Tests/EditMode/AI/DeathModuleTests.cs Assets/Tests/EditMode/AI/DeathModuleTests.cs.meta ``` - [ ] **Step 2: 确认无残留引用** ```bash grep -rn "IDeathModule\|TerminalDeath\|AbilityDeath\|TwoStageDeath" Assets --include=*.cs ``` 期望:无结果。(`PerceptionSkeleton` 尚未实现,所以此时不该有引用。) - [ ] **Step 3: 确认编译 + 全量测试** MCP 查编译,期望 0 错误。跑全量 EditMode:**从 247 回落到 243**(删掉 4 个死亡层测试)。 - [ ] **Step 4: 提交** ```bash git commit -m "revert(enemy): 收掉死亡层——死亡归物理层,AI 只需一个终态" ``` 死亡终态改由 `PerceptionSkeleton` 自己声明,见 Task 10。 --- ## Task 10: `PerceptionSkeleton` **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs` - Test: `Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs`: ```csharp using System.Collections.Generic; using NUnit.Framework; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class PerceptionSkeletonTests { sealed class Ctx : IAiContext, IEnemyActor { public FakeSensor S = new FakeSensor(); public FakeLocomotion L = new FakeLocomotion(); public FakeCombat C = new FakeCombat(); public FakeVitals V = new FakeVitals(); public Blackboard BB = new Blackboard(); public bool HasAlert = true; public ISensor Sensor => S; public IEnemyLocomotion Locomotion => L; public ICombatant Combat => C; public IActorVitals Vitals => V; public Blackboard Blackboard => BB; public bool HasAlertState => HasAlert; } static AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null) { var b = new BrainBuilder(); PerceptionSkeleton.Add(b, unaware ?? new SinglePost(LocomotionMode.Patrol), engagement ?? new RushEngagement("rush", RushExit.OnLostTarget)); return new AiRuntime(b.Build(), ctx); } // ── 升级 ────────────────────────────────────────────────────────── [Test] public void Unaware_ToEngagement_WhenInChaseZone() { var ctx = new Ctx(); var rt = Build(ctx); ctx.S.Chase = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); } [Test] public void Unaware_ToAlert_WhenInVision_AndHasAlert() { var ctx = new Ctx { HasAlert = true }; var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName); } [Test] public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState() { var ctx = new Ctx { HasAlert = false }; var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); } [Test] public void ChaseZone_BeatsAlert_WhenBothActive() { var ctx = new Ctx { HasAlert = true }; var rt = Build(ctx); ctx.S.Chase = true; ctx.S.Vision = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); } // 漏洞 A 回归:升级边必须优先于未发现层内部计时边 [Test] public void UpgradeEdge_BeatsUnawareInternalDwellEdge() { var ctx = new Ctx(); var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f)); Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); ctx.S.Chase = true; rt.Tick(5f); // 计时早已到点,但追击边优先 Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); } // ── 警觉 ────────────────────────────────────────────────────────── [Test] public void Alert_ToEngagement_WhenEnteringChaseZone() { var ctx = new Ctx(); var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName); ctx.S.Chase = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); } [Test] public void Alert_ToRest_WhenLeavingVision() { var ctx = new Ctx(); var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); ctx.S.Vision = false; rt.Tick(0.1f); Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); } [Test] public void Alert_FacesLastKnown() { var ctx = new Ctx(); ctx.S.Last = new UnityEngine.Vector2(7f, 2f); var rt = Build(ctx); ctx.S.Vision = true; rt.Tick(0.1f); Assert.AreEqual(new UnityEngine.Vector2(7f, 2f), ctx.L.FacedAt); } // ── 降级:追击后永不回警觉 ───────────────────────────────────────── [Test] public void AfterEngagement_GoesToRest_NeverBackToAlert() { var ctx = new Ctx(); var rt = Build(ctx); ctx.S.Chase = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f); Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 不是 Alert } // ── 死亡 ────────────────────────────────────────────────────────── [Test] public void Died_TransitionsToDeath_FromAnyState() { var ctx = new Ctx(); var rt = Build(ctx); ctx.S.Chase = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); } [Test] public void Died_WorksEvenWhenNotControllable() { var ctx = new Ctx(); var rt = Build(ctx); ctx.V.Controllable = false; // 硬直中 rt.Send(AiSignal.Died); rt.Tick(0.1f); Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); } // ── 校验 ────────────────────────────────────────────────────────── sealed class BadUnaware : IUnawareModule { public string Entry => "X"; public string Rest => "NotInStates"; public IReadOnlyList States => new[] { "X" }; public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, "X", LocomotionMode.Idle); public void Link(BrainBuilder b) { } } sealed class UndeclaredUnaware : IUnawareModule { public string Entry => "Y"; public string Rest => "Y"; public IReadOnlyList States => new[] { "Y" }; public void Declare(BrainBuilder b) { } // 忘了声明行为 public void Link(BrainBuilder b) { } } [Test] public void Throws_WhenRestNotInStates() { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( b, new BadUnaware(), new RushEngagement("rush"))); } [Test] public void Throws_WhenUnawareStateNotDeclared() { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( b, new UndeclaredUnaware(), new RushEngagement("rush"))); } [Test] public void Throws_WhenModuleIsNull() { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( b, null, new RushEngagement("rush"))); } } } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `PerceptionSkeletonTests`。期望:编译错误,找不到 `PerceptionSkeleton`。 - [ ] **Step 3: 实现** 新建 `Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs`: ```csharp using System; using System.Collections.Generic; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到两个模块声明的态上。 /// 骨架永远只做这一件事——未发现态长什么样、交战怎么打,全部归模块。 /// /// 规则: /// 未发现:在追逐区 → 交战(优先);否则 有警觉态且在视野 → 警觉;否则保持。 /// 警觉: 在追逐区 → 交战;否则 脱离视野 → Rest;否则保持(朝向最后已知位置)。 /// 交战: 脱战条件由交战模块自定,脱战一律回 Rest —— 永不回警觉。 /// 死亡: 全局事件边,优先级最高,且不受 IsControllable 门阻挡。 /// /// 死亡态由骨架自己声明为无行为终态,不做成可插拔层——死亡在本项目里完全归物理层 /// (EnemyBase.PerformDeath 切终态/关碰撞/播死亡动画/归还池;EnemyDeathSequence 做 /// 死亡前摇演出并可经动画事件生成小怪)。且 PerformDeath 先 ForceState(Dead) 再发 /// Died 信号,此后 IsControllable 永久为假、AiRuntime 不再求值任何条件边, /// 所以死亡链里放条件转换必然卡住。AI 图需要这个态只是为了让全局边有去处、决策停止。 /// /// 入口默认取 unaware.Entry;需要前置态(掉落链 / 出场链)的敌人在调用本方法后 /// 再调 b.Entry("自己的态") 覆盖即可。 /// public static class PerceptionSkeleton { public const string Alert = "Alert"; public const string Death = "Death"; static readonly Func HasAlertAndInVision = x => x is IEnemyActor a && a.HasAlertState && x.Sensor.InVisionZone(); static readonly Func LeftVision = x => !x.Sensor.InVisionZone(); public static void Add(BrainBuilder b, IUnawareModule unaware, IEngagementModule engagement) { if (b == null) throw new ArgumentNullException(nameof(b)); if (unaware == null) throw new ArgumentNullException(nameof(unaware)); if (engagement == null) throw new ArgumentNullException(nameof(engagement)); // 1) 各模块先声明自己的态(未发现层此阶段只声明态,不挂内部边) unaware.Declare(b); engagement.Build(b, unaware.Rest); AiStateFragments.Terminal(b, Death); var states = unaware.States; if (states == null || states.Count == 0) throw new InvalidOperationException($"{unaware.GetType().Name}: States 不能为空。"); for (int i = 0; i < states.Count; i++) b.RequireState(states[i]); // Declare 漏声明的态在此暴露 if (!Contains(states, unaware.Rest)) throw new InvalidOperationException( $"{unaware.GetType().Name}: Rest='{unaware.Rest}' 必须属于 States。"); b.Entry(unaware.Entry); b.Global().To(Death).OnEvent(AiSignal.Died); // 2) 升级边先挂——AiRuntime 按声明顺序评估条件边,升级必须优先于 // 未发现层的内部边(如巡逻计时),否则玩家进追逐区那一帧可能被计时边抢走。 for (int i = 0; i < states.Count; i++) b.State(states[i]) .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(LeftVision, "leftVision"); // 3) 未发现层内部边最后挂,优先级低于升级边 unaware.Link(b); } static bool Contains(IReadOnlyList list, string value) { for (int i = 0; i < list.Count; i++) if (list[i] == value) return true; return false; } } } ``` - [ ] **Step 4: 运行测试确认通过** 运行 `PerceptionSkeletonTests`。期望:15 项全部 PASS。 - [ ] **Step 5: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs git commit -m "feat(enemy): PerceptionSkeleton——三层可插拔的感知骨架" ``` --- ## Task 11: 退役 `PerceptionStateMachine`,E001 切到新 API 先保留 `E001CaoZhiAi.cs`(Task 16 才迁成配方资产),本步只把它换成新 API,确保行为等价。 **Files:** - Delete: `Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs` - Delete: `Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs` - Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs` - [ ] **Step 1: 改写 E001** `E001CaoZhiAi.cs` 全文替换为: ```csharp using BaseGames.AI; namespace BaseGames.Enemies { /// /// E001(草蛭)AI —— 纯决策层,组装两层模块。 /// 出生伪装静止(伪装成石头),一旦交战过就只回巡逻;追击=带冷却的接触冲锋, /// 起手即锁定、打完回巡逻走冷却;死亡演出走物理状态机(EnemyBase.PerformDeath)。 /// AI 只决策;移动 / 朝向 / 速度 / 动画 / 伤害全部在能力与 EnemyLocomotion 里实现。 /// 依据 Docs/Game/敌人/小怪/E001_草蛭.md。 /// [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)); } } ``` > 顺带清理:原文件的 `Entry` 被临时改为巡逻态,用于验证寻路失效,注释标明「验证完成后必须改回」。 > 寻路库已整包移除(改为 `IEnemyNavigator` + 直接移动),该验证已失去对象,出生态恢复为伪装静止。 - [ ] **Step 2: 删除旧骨架与旧测试** ```bash git rm Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs \ Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs.meta \ Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs \ Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs.meta ``` - [ ] **Step 3: 确认编译通过** MCP 调 `mcp__unity__unity_get_compilation_errors`。期望:无错误。 若报「找不到 `PerceptionStateMachine`」,说明还有残留引用,用 `grep -rn "PerceptionStateMachine" Assets --include=*.cs` 定位并清理。 - [ ] **Step 4: 跑全部 AI 测试** 在 Test Runner 中运行 `BaseGames.Tests.EditMode.AI` 命名空间下的全部测试。 期望:全部 PASS(`AiFrameworkTests` / `AiRuntimeTests` / `BrainBuilderTests` / `AiScriptTests` / `AiDefinitionRegistryTests` / 本次新增的 6 个测试类)。 - [ ] **Step 5: 提交** ```bash git add -A Assets/_Game/Scripts/Enemies/AIBrain Assets/Tests/EditMode/AI git commit -m "refactor(enemy): 退役 PerceptionStateMachine,E001 切换到三层模块组装" ``` --- ## Task 12: `IAiDefinition` + `AiRecipeSO` 配方基类放 `BaseGames.AI` —— 它只需要 `BrainBuilder` / `AiGraph`,没有 Enemies 依赖, 放这里才能让两条定义源共用同一个抽象。 **Files:** - Create: `Assets/_Game/Scripts/AI/IAiDefinition.cs` - Create: `Assets/_Game/Scripts/AI/AiRecipeSO.cs` - Modify: `Assets/_Game/Scripts/AI/AiScript.cs` - [ ] **Step 1: 建 `IAiDefinition`** ```csharp namespace BaseGames.AI { /// /// 能产出共享 AiGraph 的东西。两个实现: /// AiScript(定制路径,写 C# 类)与 AiRecipeSO(配方路径,建资产)。 /// public interface IAiDefinition { /// 惰性构建并缓存共享图(flyweight:每个定义只建一次)。 AiGraph GetOrBuildGraph(); } } ``` - [ ] **Step 2: 建 `AiRecipeSO`** ```csharp using UnityEngine; using BaseGames.Core.Events; namespace BaseGames.AI { /// /// AI 配方资产基类。子类以序列化字段描述一张图,Build 里组装。 /// 一个资产 = 一种敌人 AI;所有引用该资产的实例共享同一张 AiGraph(flyweight)。 /// public abstract class AiRecipeSO : ScriptableObject, IAiDefinition { AiGraph _cached; void OnEnable() { // 项目已关闭 Domain Reload:SO 的运行时态在多次 Play 会话间会残留, // 改了配方再进 Play 仍用旧图。登记到统一的"进入 Play 前重置"通道。 PlayModeResetHook.Register(ClearCache); } void ClearCache() => _cached = null; public AiGraph GetOrBuildGraph() { if (_cached == null) { var b = new BrainBuilder(); Build(b); _cached = b.Build(); } return _cached; } protected abstract void Build(BrainBuilder b); } } ``` - [ ] **Step 3: 给 `BaseGames.AI` 加 `BaseGames.Core.Events` 引用** `AiRecipeSO` 用了 `PlayModeResetHook`。`BaseGames.Core.Events` 的 `references` 为空数组, 加这条引用不会形成循环。编辑 `Assets/_Game/Scripts/AI/BaseGames.AI.asmdef`,把 `references` 改为: ```json "references": [ "BaseGames.Core", "BaseGames.Core.Events" ], ``` - [ ] **Step 4: 让 `AiScript` 实现 `IAiDefinition`** `AiScript.cs` 中把类声明改为: ```csharp public abstract class AiScript : IAiDefinition ``` 其余不动(`GetOrBuildGraph` 签名已匹配)。 - [ ] **Step 5: 确认编译 + 现有测试通过** MCP 调 `mcp__unity__unity_get_compilation_errors`,期望无错误。 运行 `AiScriptTests`、`AiDefinitionRegistryTests`。期望:全部 PASS。 - [ ] **Step 6: 提交** ```bash git add Assets/_Game/Scripts/AI git commit -m "feat(ai): IAiDefinition + AiRecipeSO 配方基类(含关闭域重载的缓存重置)" ``` --- ## Task 13: `PerceptionRecipeSO` + `SubclassSelectorDrawer` **Files:** - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs` - Create: `Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs` - Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs` - Test: `Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs` - [ ] **Step 1: 写失败测试** 新建 `Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs`: ```csharp using NUnit.Framework; using UnityEngine; using BaseGames.AI; using BaseGames.Enemies; namespace BaseGames.Tests.EditMode.AI { public class PerceptionRecipeSoTests { static PerceptionRecipeSO MakeE001Recipe() { var so = ScriptableObject.CreateInstance(); so.SetModulesForTests( new DisguiseThenPatrol(), new RushEngagement("e001_chase", RushExit.Committed)); return so; } [Test] public void Graph_IsCachedAcrossCalls_Flyweight() { var so = MakeE001Recipe(); Assert.AreSame(so.GetOrBuildGraph(), so.GetOrBuildGraph()); Object.DestroyImmediate(so); } [Test] public void Graph_MatchesEquivalentHandWrittenAssembly() { var so = MakeE001Recipe(); var fromRecipe = so.GetOrBuildGraph(); var b = new BrainBuilder(); PerceptionSkeleton.Add(b, new DisguiseThenPatrol(), new RushEngagement("e001_chase", RushExit.Committed)); var handWritten = b.Build(); Assert.AreEqual(handWritten.EntryState, fromRecipe.EntryState); Object.DestroyImmediate(so); } [Test] public void UnconfiguredRecipe_ThrowsClearError_NotSilentlyInert() { // 新建的空配方默认用 RushEngagement 但尚未配能力。建图时必须**显式报错**, // 而不是静默产出一张"永远不会出手"的图——第 6 条:暴露问题,不掩盖。 // 注:策划建完资产到配好模块之间没有任何东西会调 GetOrBuildGraph(), // 它只在 EnemyAiBrain.Start() 运行时被调,所以这里报错不影响编辑期体验。 var so = ScriptableObject.CreateInstance(); var ex = Assert.Throws(() => so.GetOrBuildGraph()); StringAssert.Contains("RushEngagement", ex.Message); Object.DestroyImmediate(so); } [Test] public void Graph_BehavesLikeE001() { var so = MakeE001Recipe(); var ctx = new PerceptionSkeletonLikeCtx(); var rt = new AiRuntime(so.GetOrBuildGraph(), ctx); Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName); ctx.S.Chase = true; rt.Tick(0.1f); Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); Object.DestroyImmediate(so); } sealed class PerceptionSkeletonLikeCtx : IAiContext, IEnemyActor { public FakeSensor S = new FakeSensor(); public FakeLocomotion L = new FakeLocomotion(); public FakeCombat C = new FakeCombat(); public FakeVitals V = new FakeVitals(); public Blackboard BB = new Blackboard(); public ISensor Sensor => S; public IEnemyLocomotion Locomotion => L; public ICombatant Combat => C; public IActorVitals Vitals => V; public Blackboard Blackboard => BB; public bool HasAlertState => true; } } } ``` - [ ] **Step 2: 运行测试确认失败** 运行 `PerceptionRecipeSoTests`。期望:编译错误,找不到 `PerceptionRecipeSO`。 - [ ] **Step 3: 建 `SubclassSelectorAttribute`** 新建 `Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs`: ```csharp using UnityEngine; namespace BaseGames.Enemies { /// /// 标在 [SerializeReference] 字段上,Inspector 里给出该接口所有 [Serializable] 实现的下拉。 /// 意义:新增一个模块类,它自动出现在所有配方的下拉里,无需改枚举或任何已有文件。 /// public sealed class SubclassSelectorAttribute : PropertyAttribute { } } ``` - [ ] **Step 4: 实现 `PerceptionRecipeSO`** 新建 `Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs`: ```csharp using UnityEngine; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 感知型 AI 配方:一个 SO 类型覆盖所有走「未发现 → 警觉 → 交战」规则的敌人。 /// 三个下拉各选一个模块即可,无需写代码。有独门机制的敌人改写 AiScript 子类。 /// [CreateAssetMenu(menuName = "BaseGames/AI/感知型 AI 配方", fileName = "ENM_")] public sealed class PerceptionRecipeSO : AiRecipeSO { [Tooltip("未发现层:玩家尚未被发现时的行为形状")] [SerializeReference, SubclassSelector] IUnawareModule _unaware = new SinglePost(); [Tooltip("交战层:发现玩家之后怎么打")] [SerializeReference, SubclassSelector] IEngagementModule _engagement = new RushEngagement(); // 无「死亡层」字段:死亡归物理层,骨架自己声明 Death 终态。详见 spec 的 // 「修订:死亡层取消」——PerformDeath 先 ForceState(Dead) 再发 Died 信号, // 此后 IsControllable 永久为假,死亡链里的条件边永不被求值。 protected override void Build(BrainBuilder b) => PerceptionSkeleton.Add(b, _unaware, _engagement); /// 仅供 EditMode 测试与脚手架向导装配模块,运行时不使用。 public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement) { _unaware = unaware; _engagement = engagement; } } } ``` - [ ] **Step 5: 实现 `SubclassSelectorDrawer`** 新建 `Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs`: ```csharp using System; using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; using BaseGames.Enemies; namespace BaseGames.Editor.AI { /// /// [SerializeReference] + [SubclassSelector] 字段的下拉绘制器: /// 反射收集该接口的所有非抽象 [Serializable] 实现,选中即替换实例。 /// [CustomPropertyDrawer(typeof(SubclassSelectorAttribute))] public sealed class SubclassSelectorDrawer : PropertyDrawer { static readonly Dictionary _cache = new Dictionary(); public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { if (property.propertyType != SerializedPropertyType.ManagedReference) { EditorGUI.PropertyField(position, property, label, true); return; } var baseType = GetManagedReferenceFieldType(property); var options = GetImplementations(baseType); var line = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); string currentName = ShortName(property.managedReferenceFullTypename); int currentIndex = Array.FindIndex(options, t => t.Name == currentName); var labels = options.Select(t => new GUIContent(t.Name)).ToArray(); int picked = EditorGUI.Popup( new Rect(line.x + EditorGUIUtility.labelWidth + 2f, line.y, line.width - EditorGUIUtility.labelWidth - 2f, line.height), currentIndex, labels); EditorGUI.LabelField(line, label); if (picked >= 0 && picked != currentIndex) { property.managedReferenceValue = Activator.CreateInstance(options[picked]); property.serializedObject.ApplyModifiedProperties(); } EditorGUI.indentLevel++; var body = new Rect(position.x, position.y + EditorGUIUtility.singleLineHeight + 2f, position.width, position.height); foreach (var child in Children(property)) { float h = EditorGUI.GetPropertyHeight(child, true); EditorGUI.PropertyField(new Rect(body.x, body.y, body.width, h), child, true); body.y += h + 2f; } EditorGUI.indentLevel--; } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { float h = EditorGUIUtility.singleLineHeight + 2f; if (property.propertyType == SerializedPropertyType.ManagedReference) foreach (var child in Children(property)) h += EditorGUI.GetPropertyHeight(child, true) + 2f; return h; } static IEnumerable Children(SerializedProperty property) { var it = property.Copy(); var end = it.GetEndProperty(); if (!it.NextVisible(true)) yield break; while (!SerializedProperty.EqualContents(it, end)) { yield return it.Copy(); if (!it.NextVisible(false)) break; } } static string ShortName(string managedReferenceFullTypename) { if (string.IsNullOrEmpty(managedReferenceFullTypename)) return null; int dot = managedReferenceFullTypename.LastIndexOf('.'); return dot >= 0 ? managedReferenceFullTypename.Substring(dot + 1) : managedReferenceFullTypename.Split(' ').Last(); } static Type GetManagedReferenceFieldType(SerializedProperty property) { // 形如 "Assembly TypeFullName" string full = property.managedReferenceFieldTypename; var parts = full.Split(' '); if (parts.Length != 2) return typeof(object); return AppDomain.CurrentDomain.GetAssemblies() .FirstOrDefault(a => a.GetName().Name == parts[0])?.GetType(parts[1]) ?? typeof(object); } static Type[] GetImplementations(Type baseType) { if (_cache.TryGetValue(baseType, out var cached)) return cached; var found = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty(); } }) .Where(t => t != null && !t.IsAbstract && !t.IsInterface && baseType.IsAssignableFrom(t) && t.IsDefined(typeof(SerializableAttribute), false) && t.GetConstructor(Type.EmptyTypes) != null) .OrderBy(t => t.Name) .ToArray(); _cache[baseType] = found; return found; } } } ``` - [ ] **Step 6: 运行测试确认通过** 运行 `PerceptionRecipeSoTests`。期望:3 项全部 PASS。 - [ ] **Step 7: 人工检查 Inspector** 在 Unity 里 `Assets` 右键 → `Create` → `BaseGames` → `AI` → `感知型 AI 配方`, 临时建一个资产放在 `Assets/Temp/` 下,确认三个字段各显示一个下拉, 下拉里分别有 3 / 2 / 3 个选项,切换后字段内容随之变化。**确认后删除该临时资产。** - [ ] **Step 8: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/Recipes Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs git commit -m "feat(enemy): PerceptionRecipeSO 配方资产 + SubclassSelector 模块下拉" ``` --- ## Task 14: `EnemyAiBrain` 双路径解析 **Files:** - Modify: `Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs` - [ ] **Step 1: 改写字段与 Awake / Start** 把 `EnemyAiBrain.cs` 中从 `[Tooltip("对应 [AiDefinition(id)]...")]` 到 `Start()` 结束的部分替换为: ```csharp [Tooltip("配方路径(约 95% 的敌人):直接引用 AI 配方资产")] [SerializeField] AiRecipeSO _recipe; [Tooltip("定制路径(约 5% 的敌人):[AiDefinition(id)] 的 AiScript 子类 id。与配方二选一")] [SerializeField] string _definitionId; EnemyBase _enemy; EnemyBrainContext _context; AiRuntime _runtime; public string DefinitionId => _recipe != null ? _recipe.name : _definitionId; public string CurrentStateName => _runtime != null ? _runtime.CurrentStateName : "(none)"; public bool IsSuspended => _runtime != null && _runtime.IsSuspended; public AiRuntime Runtime => _runtime; void Awake() { _enemy = GetComponent(); bool hasRecipe = _recipe != null; bool hasId = !string.IsNullOrEmpty(_definitionId); if (hasRecipe == hasId) // 都配了 或 都没配 { // 根因暴露:配置歧义 / 漏配直接报错,不静默兜底。 Debug.LogError( $"EnemyAiBrain 必须且只能配置一项:_recipe(配方资产)或 _definitionId(AiScript id)。当前 recipe={(hasRecipe ? _recipe.name : "空")}, id='{_definitionId}':{name}", this); enabled = false; } } // 在 Start 构造 runtime:确保 EnemyBase.Awake 已发现 Locomotion/子系统后, // 再触发入口状态 OnEnter(其会声明 locomotion 意图)。所有 Awake 先于任一 Start 执行。 void Start() { IAiDefinition definition = _recipe != null ? (IAiDefinition)_recipe : AiDefinitionRegistry.GetDefinition(_definitionId); // 不存在则抛异常 var graph = definition.GetOrBuildGraph(); _context = new EnemyBrainContext(_enemy); _runtime = new AiRuntime(graph, _context); } ``` - [ ] **Step 2: 给 `AiDefinitionRegistry` 加 `GetDefinition`** 在 `AiDefinitionRegistry.cs` 的 `GetGraph` 之后追加(保留 `GetGraph` 不动,仍有测试依赖): ```csharp /// 取 id 对应的定义;不存在则显式抛错(根因暴露,不做兜底)。 public static IAiDefinition GetDefinition(string id) { EnsureBuilt(); if (!_byId.TryGetValue(id, out var script)) throw new InvalidOperationException( $"AiDefinitionRegistry: 未找到 AI 定义 id '{id}'。请确认存在 [AiDefinition(\"{id}\")] 的 AiScript 子类。"); return script; } ``` - [ ] **Step 3: 确认编译通过** MCP 调 `mcp__unity__unity_get_compilation_errors`。期望:无错误。 - [ ] **Step 4: 跑全部 AI 测试** Test Runner 运行 `BaseGames.Tests.EditMode.AI`。期望:全部 PASS。 - [ ] **Step 5: 提交** ```bash git add Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs git commit -m "feat(enemy): EnemyAiBrain 支持配方资产/AiScript id 二选一解析" ``` --- ## Task 15: `EnemyAiRecipeWizard` 脚手架 > **T13 审查遗留的两项,在本任务一并做**(当时推迟的理由:彼时只有测试调用,无活的隐患; > 本任务的向导是第二个调用方,改名与缓存失效在此落地最自然): > > 1. **`PerceptionRecipeSO.SetModulesForTests` 改名为 `AssignModules`** —— 它不再只服务测试, > 向导也要用它装配模块,原名误导。 > 2. **装配后必须让已缓存的图失效**。`AiRecipeSO` 持有 `_cached`(flyweight);若在 > `GetOrBuildGraph()` 之后改模块,改动不会生效直到下次 `PlayModeResetHook` 清理—— > 静默的陈旧图。为此在 `AiRecipeSO` 加: > ```csharp > /// 装配 / 修改模块后调用,丢弃已缓存的图。 > protected void InvalidateGraph() => _cached = null; > ``` > 并让 `AssignModules` 在末尾调它。 > > 另一条 Minor(drawer 按简单类型名匹配,跨命名空间同名会静默选错)不在本次做: > 当前 5 个模块名字唯一,不可达;若模块数增长到出现同名,再改成按 `FullName` 匹配。 CLAUDE.md 第 2 条:不裸建。配方资产必须由向导产出,保证路径、命名符合 `AssetFolderSpec`。 **Files:** - Create: `Assets/_Game/Scripts/Editor/AI/EnemyAiRecipeWizard.cs` - [ ] **Step 1: 实现向导** 新建 `Assets/_Game/Scripts/Editor/AI/EnemyAiRecipeWizard.cs`: ```csharp using System.IO; using UnityEditor; using UnityEngine; using BaseGames.Enemies; namespace BaseGames.Editor.AI { /// /// 敌人 AI 配方脚手架。按 AssetFolderSpec 定名定路径: /// Assets/_Game/Data/Enemies/{EnemyID}/ENM_{EnemyID}_Ai.asset /// public sealed class EnemyAiRecipeWizard : EditorWindow { const string DataRoot = "Assets/_Game/Data/Enemies"; string _enemyId = "E001"; [MenuItem("BaseGames/AI/Enemy AI Recipe Wizard")] static void Open() => GetWindow("敌人 AI 配方向导").minSize = new Vector2(420, 160); void OnGUI() { EditorGUILayout.HelpBox( "创建感知型 AI 配方资产。创建后在 Inspector 里选三层模块(未发现 / 交战 / 死亡)," + "再把资产拖到敌人预制体的 EnemyAiBrain._recipe 上。", MessageType.Info); _enemyId = EditorGUILayout.TextField("敌人 ID(如 E001)", _enemyId); string path = TargetPath(_enemyId); EditorGUILayout.LabelField("产出路径", path); using (new EditorGUI.DisabledScope(string.IsNullOrWhiteSpace(_enemyId))) { if (GUILayout.Button("创建配方资产", GUILayout.Height(28))) Create(_enemyId); } } static string TargetPath(string enemyId) => $"{DataRoot}/{enemyId}/ENM_{enemyId}_Ai.asset"; public static PerceptionRecipeSO Create(string enemyId) { string dir = $"{DataRoot}/{enemyId}"; if (!AssetDatabase.IsValidFolder(dir)) { Directory.CreateDirectory(dir); AssetDatabase.Refresh(); } string path = TargetPath(enemyId); var existing = AssetDatabase.LoadAssetAtPath(path); if (existing != null) { // 已存在就选中并返回,不覆盖用户已配好的内容。 Selection.activeObject = existing; EditorGUIUtility.PingObject(existing); Debug.Log($"配方已存在,已选中:{path}"); return existing; } var so = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(so, path); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Selection.activeObject = so; EditorGUIUtility.PingObject(so); Debug.Log($"✔ 已创建 AI 配方:{path}"); return so; } } } ``` - [ ] **Step 2: 确认编译通过** MCP 调 `mcp__unity__unity_get_compilation_errors`。期望:无错误。 - [ ] **Step 3: 人工验证菜单** Unity 菜单 `BaseGames` → `AI` → `Enemy AI Recipe Wizard`,窗口应打开并显示产出路径 `Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset`。**先不要点创建**(Task 16 才创建)。 - [ ] **Step 4: 提交** ```bash git add Assets/_Game/Scripts/Editor/AI/EnemyAiRecipeWizard.cs git commit -m "feat(editor): 敌人 AI 配方脚手架向导" ``` --- ## Task 16: E001 迁移为配方资产 **Files:** - Create: `Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset`(经向导) - Delete: `Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs` - Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs` - [ ] **Step 1: 经向导创建配方资产** Unity 菜单 `BaseGames` → `AI` → `Enemy AI Recipe Wizard`,敌人 ID 填 `E001`,点「创建配方资产」。 期望:Console 输出 `✔ 已创建 AI 配方:Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset`。 - [ ] **Step 2: 在 Inspector 里配三层模块** 选中 `ENM_E001_Ai.asset`,按下表设置: | 字段 | 值 | |---|---| | 未发现层 | `DisguiseThenPatrol`;伪装模式 `Idle`,巡逻模式 `Patrol` | | 交战层 | `RushEngagement`;能力 = `Assets/_Game/Data/Enemies/E001/` 下 `abilityId == "e001_chase"` 的 `EnemyAbilitySO` 资产;脱战语义 `Committed` | | 死亡层 | `TerminalDeath` | > 若找不到 `abilityId == "e001_chase"` 的能力资产,用 > `grep -rl "e001_chase" Assets/_Game/Data --include=*.asset` 定位。 > 找不到则**停下报告**——这是上游漏配,不要在配方里填字符串绕过(CLAUDE.md 第 6 条)。 - [ ] **Step 3: 把配方绑到 E001 预制体** 找到 E001 预制体(`Assets/_Game/Prefabs/Enemies/E001/` 下的 `ENM_*.prefab`)。 若预制体不存在(小怪预制体此前已删、延后统一产出),则跳过本步并在 Task 结束时记录。 若存在:在其 `EnemyAiBrain` 组件上,把 `_recipe` 指向 `ENM_E001_Ai.asset`, 并把 `_definitionId` 清空。 - [ ] **Step 4: 删除 E001 定制脚本** ```bash git rm Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs \ Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs.meta ``` - [ ] **Step 5: 更新 `SceneObjectPlacerTool` 的 AI 挂载提示** `SceneObjectPlacerTool.cs` 中有 7 处提示文案让使用者「写 `[AiDefinition]` 的 AiScript 子类」。 把这些文案统一改为配方路径。具体: 第 499–501 行附近(E001 分支)—— 复用同文件已有的 `AssignReference` / `AssignString`, 不要新写赋值辅助方法: ```csharp var brain = GetOrAddComponent(go); var recipe = AssetDatabase.LoadAssetAtPath( "Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset"); if (recipe == null) { // 根因暴露:配方缺失就报出来,不退回旧的字符串 id 路径掩盖。 report.Add("✘ 未找到 AI 配方 ENM_E001_Ai —— 请先用菜单 BaseGames/AI/Enemy AI Recipe Wizard 创建。"); } else { AssignReference(brain, "_recipe", recipe, report); AssignString(brain, "_definitionId", "", report); report.Add("✔ 已挂载 EnemyAiBrain 并绑定 AI 配方 ENM_E001_Ai(BrainGraph)。"); } ``` 其余 6 处提示文案(原第 341、405、601、688、819、929、1028 行附近,以 `grep -n "AI(BrainGraph)" Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs` 实际定位) 统一替换为: ```csharp report.Add("★ AI(BrainGraph):用菜单 BaseGames/AI/Enemy AI Recipe Wizard 建一个配方资产," + "在 Inspector 选三层模块(未发现/交战/死亡),再挂 EnemyAiBrain 组件并把 _recipe 指向该资产。" + "有独门机制的敌人才写 [AiDefinition] 的 AiScript 子类并改用 _definitionId。"); ``` - [ ] **Step 6: 确认编译通过** MCP 调 `mcp__unity__unity_get_compilation_errors`。期望:无错误。 - [ ] **Step 7: 跑全部 AI 测试** Test Runner 运行 `BaseGames.Tests.EditMode.AI`。期望:全部 PASS。 - [ ] **Step 8: 跑项目自检工具(CLAUDE.md 第 3 条)** 依次执行菜单: - `BaseGames/Tools/Validation/Validate All ScriptableObjects` - `BaseGames/Addressables/Validate Address Keys` - `BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check` 期望:三项均无新增错误。若出现与本次改动无关的既有告警,记录但不修。 - [ ] **Step 9: 提交** ```bash git add -A Assets/_Game/Data/Enemies/E001 Assets/_Game/Scripts/Enemies/AIBrain/Ai Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs git commit -m "refactor(enemy): E001 AI 迁移为配方资产,退役定制脚本" ``` --- ## Task 17: 作者指南文档 150 只敌人规模下,「新敌人 AI 怎么写」必须有一份长期文档,否则三条作者路径会各写各的。 **Files:** - Create: `Docs/Guides/06_EnemyAiAuthoring.md` - [ ] **Step 1: 写文档** 新建 `Docs/Guides/06_EnemyAiAuthoring.md`,内容需覆盖: 1. **三条作者路径与选择判据** - 配方资产(约 80%):三个下拉搞定,零代码零编译 - 配方 + 自定义前置态(约 15%):写一个 `AiScript` 调 `PerceptionSkeleton.Add` 后追加态 - 定制脚本(约 5%):完全自己写图,不用骨架(如陷阱型敌人) 2. **四层职责**:骨架固定,三层可插拔;每个构件只声明自己拥有的态、只挂自己负责的边 3. **现有模块清单**:8 个模块各自的语义、参数、适用敌人 4. **新增模块的方法**:实现对应接口 + 打 `[Serializable]` + 提供无参构造 → 自动出现在配方下拉; 强调不要在构造函数里做校验或缓存(`[SerializeReference]` 不保证走 ctor) 5. **六个敌人的组装范例**(E001–E006,含 E002 完全不用骨架的写法、E003 掉落前置链的写法) 6. **常见陷阱**: - 未发现层内部边必须放 `Link` 不能放 `Declare`(否则抢在升级边前) - 死亡态用 `AbilityOnce` 不用 `Ability`(否则演出无限重播) - 状态名只是 trace 标签,不驱动动画;动画归能力与 `EnemyLocomotion` - 受击 / 硬直不进 AI 图,由物理状态机 + `IsControllable` 门处理 - [ ] **Step 2: 提交** ```bash git add Docs/Guides/06_EnemyAiAuthoring.md git commit -m "docs(enemy): 新增敌人 AI 作者指南(三条路径 + 模块清单 + 六个范例)" ``` --- ## 收尾检查 - [ ] `grep -rn "PerceptionStateMachine" Assets Docs Docs_Dev --include=*.cs --include=*.md` 只在 spec / plan 的历史叙述中出现 - [ ] `grep -rn "PatrolBetweenChases\|EngagementStyle" Assets --include=*.cs` 无结果 - [ ] Test Runner 全量 EditMode 通过 - [ ] 三个自检工具无新增错误 - [ ] `git log --oneline` 有 17 次提交,每次一个逻辑单元(CLAUDE.md 第 5 条) ## 本次不做(已在 spec §8 定案) - `AiDefinitionValidator` 校验器、`_definitionId` 下拉 Drawer —— 紧接本次,单独一轮 - `AiSignal.Engaged` 战斗激活信号 —— 等 E003 / E004 落地时才加,现在加就是死枚举 - Boss 双轨统一(`BossSkillExecutor` vs `EnemyAttackSelector`)—— 单独立项 - `AiGraphExporter` 图导出 —— 路线图