From f3c17de2237143d3e29932ea8649e17d55e6b7fc Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 29 Jul 2026 10:51:07 +0800 Subject: [PATCH] =?UTF-8?q?docs(enemy):=20=E6=95=8C=E4=BA=BA=20AI=20?= =?UTF-8?q?=E7=BB=84=E5=90=88=E5=BC=8F=E6=A8=A1=E5=9D=97=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=8817=20=E4=B8=AA?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=EF=BC=8CTDD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 含计划阶段发现的三处修正: - 未发现层拆 Declare/Link 两阶段,保证骨架升级边优先于内部计时边; - 模块参数校验与委托缓存移出构造函数([SerializeReference] 不保证走 ctor); - 新增 AiStateFragments.AbilityOnce,修死亡态演出被 Tick 无限重播的隐患。 Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-07-29-enemy-ai-composable-modules.md | 2589 +++++++++++++++++ 1 file changed, 2589 insertions(+) create mode 100644 Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md 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 new file mode 100644 index 00000000..f20643fd --- /dev/null +++ b/Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md @@ -0,0 +1,2589 @@ +# 敌人 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` / `IDeathModule`)各自声明自己的状态与边;`PerceptionSkeleton` 只负责把「未发现 → 警觉 → 交战」的升降级边挂到模块声明的态上。模块是 `[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 / IDeathModule.cs + PerceptionSkeleton.cs 感知三层规则 + 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 + +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) + => 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) + => 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); + } + + static void EnsureAbility(IAiContext x, string abilityId) + { + if (!string.IsNullOrEmpty(abilityId) && !x.Combat.IsAbilityRunning(abilityId)) + x.Combat.UseAbility(abilityId); + } + } +} +``` + +- [ ] **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 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: 死亡层三个模块 + +**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(TerminalDeath.Death, rt.CurrentStateName); + Assert.AreEqual(0, ctx.C.Used.Count); // 演出走物理状态机,AI 不触发能力 + rt.Tick(0.1f); + Assert.AreEqual(TerminalDeath.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 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), + new TerminalDeath()); + 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(TerminalDeath.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(TerminalDeath.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"), new TerminalDeath())); + } + + [Test] + public void Throws_WhenUnawareStateNotDeclared() + { + var b = new BrainBuilder(); + Assert.Throws(() => PerceptionSkeleton.Add( + b, new UndeclaredUnaware(), new RushEngagement("rush"), new TerminalDeath())); + } + + [Test] + public void Throws_WhenModuleIsNull() + { + var b = new BrainBuilder(); + Assert.Throws(() => PerceptionSkeleton.Add( + b, null, new RushEngagement("rush"), new TerminalDeath())); + } + } +} +``` + +- [ ] **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 门阻挡。 + /// + /// 入口默认取 unaware.Entry;需要前置态(掉落链 / 出场链)的敌人在调用本方法后 + /// 再调 b.Entry("自己的态") 覆盖即可。 + /// + public static class PerceptionSkeleton + { + public const string Alert = "Alert"; + + 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, + IDeathModule death) + { + 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); + + 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.EntryState).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), + death: new TerminalDeath()); + } +} +``` + +> 顺带清理:原文件的 `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), + new TerminalDeath()); + 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), + new TerminalDeath()); + var handWritten = b.Build(); + + Assert.AreEqual(handWritten.EntryState, fromRecipe.EntryState); + 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(); + + [Tooltip("死亡层:怎么死")] + [SerializeReference, SubclassSelector] IDeathModule _death = new TerminalDeath(); + + protected override void Build(BrainBuilder b) + => PerceptionSkeleton.Add(b, _unaware, _engagement, _death); + + /// 仅供 EditMode 测试与脚手架向导装配模块,运行时不使用。 + public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement, IDeathModule death) + { + _unaware = unaware; _engagement = engagement; _death = death; + } + } +} +``` + +- [ ] **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` 脚手架 + +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` 图导出 —— 路线图