diff --git a/Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md b/Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md index f754b696..a5c17c3f 100644 --- a/Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md +++ b/Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md @@ -4,7 +4,7 @@ **Goal:** 用「固定感知骨架 + 可插拔的 未发现层 / 交战层 / 死亡层」替换 `PerceptionStateMachine`,并让约 95% 的敌人 AI 成为配方资产而非 C# 类。 -**Architecture:** 三个模块契约(`IUnawareModule` / `IEngagementModule` / `IDeathModule`)各自声明自己的状态与边;`PerceptionSkeleton` 只负责把「未发现 → 警觉 → 交战」的升降级边挂到模块声明的态上。模块是 `[Serializable]` 普通类,既能在 C# 里 `new`,也能经 `[SerializeReference]` 存进 `PerceptionRecipeSO` 配方资产。`EnemyAiBrain` 上「配方资产引用」与「AiScript 定义 id」二选一。 +**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`) @@ -60,17 +60,15 @@ Assets/_Game/Scripts/AI/ 框架层(只依赖 Base Assets/_Game/Scripts/Enemies/AIBrain/Modules/ 敌人决策组合层 AiStateFragments.cs 建态原语(4 个) - IUnawareModule.cs / IEngagementModule.cs / IDeathModule.cs - PerceptionSkeleton.cs 感知三层规则 + 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/TerminalDeath.cs - Death/AbilityDeath.cs - Death/TwoStageDeath.cs + (无 Death/ 目录:死亡归物理层,见 Task 9R) Assets/_Game/Scripts/Enemies/AIBrain/Recipes/ PerceptionRecipeSO.cs 一个 SO 类型覆盖所有感知型敌人 @@ -1408,7 +1406,24 @@ git commit -m "feat(enemy): ApproachAttackEngagement——攻击改为起手即 --- -## Task 9: 死亡层三个模块 +## 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` @@ -1447,10 +1462,10 @@ namespace BaseGames.Tests.EditMode.AI var rt = RunWithDeath(new TerminalDeath(), ctx); rt.Send(AiSignal.Died); rt.Tick(0.1f); - Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName); + Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); Assert.AreEqual(0, ctx.C.Used.Count); // 演出走物理状态机,AI 不触发能力 rt.Tick(0.1f); - Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName); + Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); } [Test] @@ -1607,6 +1622,49 @@ git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death Assets/Tests/EditMode 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` @@ -1651,8 +1709,7 @@ namespace BaseGames.Tests.EditMode.AI var b = new BrainBuilder(); PerceptionSkeleton.Add(b, unaware ?? new SinglePost(LocomotionMode.Patrol), - engagement ?? new RushEngagement("rush", RushExit.OnLostTarget), - new TerminalDeath()); + engagement ?? new RushEngagement("rush", RushExit.OnLostTarget)); return new AiRuntime(b.Build(), ctx); } @@ -1767,7 +1824,7 @@ namespace BaseGames.Tests.EditMode.AI Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); rt.Send(AiSignal.Died); rt.Tick(0.1f); - Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName); + Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); } [Test] @@ -1778,7 +1835,7 @@ namespace BaseGames.Tests.EditMode.AI ctx.V.Controllable = false; // 硬直中 rt.Send(AiSignal.Died); rt.Tick(0.1f); - Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName); + Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); } // ── 校验 ────────────────────────────────────────────────────────── @@ -1806,7 +1863,7 @@ namespace BaseGames.Tests.EditMode.AI { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( - b, new BadUnaware(), new RushEngagement("rush"), new TerminalDeath())); + b, new BadUnaware(), new RushEngagement("rush"))); } [Test] @@ -1814,7 +1871,7 @@ namespace BaseGames.Tests.EditMode.AI { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( - b, new UndeclaredUnaware(), new RushEngagement("rush"), new TerminalDeath())); + b, new UndeclaredUnaware(), new RushEngagement("rush"))); } [Test] @@ -1822,7 +1879,7 @@ namespace BaseGames.Tests.EditMode.AI { var b = new BrainBuilder(); Assert.Throws(() => PerceptionSkeleton.Add( - b, null, new RushEngagement("rush"), new TerminalDeath())); + b, null, new RushEngagement("rush"))); } } } @@ -1844,8 +1901,8 @@ using BaseGames.AI; namespace BaseGames.Enemies { /// - /// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到三个模块声明的态上。 - /// 骨架永远只做这一件事——未发现态长什么样、交战怎么打、怎么死,全部归模块。 + /// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到两个模块声明的态上。 + /// 骨架永远只做这一件事——未发现态长什么样、交战怎么打,全部归模块。 /// /// 规则: /// 未发现:在追逐区 → 交战(优先);否则 有警觉态且在视野 → 警觉;否则保持。 @@ -1853,12 +1910,19 @@ namespace BaseGames.Enemies /// 交战: 脱战条件由交战模块自定,脱战一律回 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(); @@ -1867,18 +1931,16 @@ namespace BaseGames.Enemies public static void Add(BrainBuilder b, IUnawareModule unaware, - IEngagementModule engagement, - IDeathModule death) + 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)); - if (death == null) throw new ArgumentNullException(nameof(death)); // 1) 各模块先声明自己的态(未发现层此阶段只声明态,不挂内部边) unaware.Declare(b); engagement.Build(b, unaware.Rest); - death.Build(b); + AiStateFragments.Terminal(b, Death); var states = unaware.States; if (states == null || states.Count == 0) @@ -1890,7 +1952,7 @@ namespace BaseGames.Enemies $"{unaware.GetType().Name}: Rest='{unaware.Rest}' 必须属于 States。"); b.Entry(unaware.Entry); - b.Global().To(death.EntryState).OnEvent(AiSignal.Died); + b.Global().To(Death).OnEvent(AiSignal.Died); // 2) 升级边先挂——AiRuntime 按声明顺序评估条件边,升级必须优先于 // 未发现层的内部边(如巡逻计时),否则玩家进追逐区那一帧可能被计时边抢走。 @@ -1949,7 +2011,7 @@ using BaseGames.AI; namespace BaseGames.Enemies { /// - /// E001(草蛭)AI —— 纯决策层,组装三层模块。 + /// E001(草蛭)AI —— 纯决策层,组装两层模块。 /// 出生伪装静止(伪装成石头),一旦交战过就只回巡逻;追击=带冷却的接触冲锋, /// 起手即锁定、打完回巡逻走冷却;死亡演出走物理状态机(EnemyBase.PerformDeath)。 /// AI 只决策;移动 / 朝向 / 速度 / 动画 / 伤害全部在能力与 EnemyLocomotion 里实现。 @@ -1960,8 +2022,7 @@ namespace BaseGames.Enemies { protected override void Build(BrainBuilder b) => PerceptionSkeleton.Add(b, unaware: new DisguiseThenPatrol(), - engagement: new RushEngagement("e001_chase", RushExit.Committed), - death: new TerminalDeath()); + engagement: new RushEngagement("e001_chase", RushExit.Committed)); } } ``` @@ -2130,8 +2191,7 @@ namespace BaseGames.Tests.EditMode.AI var so = ScriptableObject.CreateInstance(); so.SetModulesForTests( new DisguiseThenPatrol(), - new RushEngagement("e001_chase", RushExit.Committed), - new TerminalDeath()); + new RushEngagement("e001_chase", RushExit.Committed)); return so; } @@ -2152,8 +2212,7 @@ namespace BaseGames.Tests.EditMode.AI var b = new BrainBuilder(); PerceptionSkeleton.Add(b, new DisguiseThenPatrol(), - new RushEngagement("e001_chase", RushExit.Committed), - new TerminalDeath()); + new RushEngagement("e001_chase", RushExit.Committed)); var handWritten = b.Build(); Assert.AreEqual(handWritten.EntryState, fromRecipe.EntryState); @@ -2235,16 +2294,17 @@ namespace BaseGames.Enemies [Tooltip("交战层:发现玩家之后怎么打")] [SerializeReference, SubclassSelector] IEngagementModule _engagement = new RushEngagement(); - [Tooltip("死亡层:怎么死")] - [SerializeReference, SubclassSelector] IDeathModule _death = new TerminalDeath(); + // 无「死亡层」字段:死亡归物理层,骨架自己声明 Death 终态。详见 spec 的 + // 「修订:死亡层取消」——PerformDeath 先 ForceState(Dead) 再发 Died 信号, + // 此后 IsControllable 永久为假,死亡链里的条件边永不被求值。 protected override void Build(BrainBuilder b) - => PerceptionSkeleton.Add(b, _unaware, _engagement, _death); + => PerceptionSkeleton.Add(b, _unaware, _engagement); /// 仅供 EditMode 测试与脚手架向导装配模块,运行时不使用。 - public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement, IDeathModule death) + public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement) { - _unaware = unaware; _engagement = engagement; _death = death; + _unaware = unaware; _engagement = engagement; } } }