Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md
T

107 KiB
Raw Blame History

敌人 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 / 自研 BrainGraphBaseGames.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.AddAbilityStateTick 会在能力结束后重新触发它。用在死亡态上意味着死亡演出无限循环。本计划新增 AiStateFragments.AbilityOnce(只在 OnEnter 触发一次),死亡层专用。


如何运行测试

编译检查(每次改完代码先做): MCP 调 mcp__unity__unity_get_compilation_errors,期望返回无错误。

跑 EditMode 测试: Unity Editor → WindowGeneralTest RunnerEditMode 标签 → 在树中定位到指定测试类 → 右键 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 的类体内追加:

        [Test]
        public void RequireState_Throws_WhenStateNotDeclared()
        {
            var b = new BrainBuilder();
            var ex = Assert.Throws<System.InvalidOperationException>(() => 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.cspublic GlobalBuilder Global() => new GlobalBuilder(this); 之后插入:

        /// <summary>
        /// 要求状态已被声明(含行为回调)。模块给"别人声明的态"挂边前调用。
        /// 未声明即抛——否则 State() 会静默新建一个空态,敌人杵着不动且无任何报错。
        /// </summary>
        public void RequireState(string name)
        {
            if (!_states.ContainsKey(name))
                throw new InvalidOperationException(
                    $"BrainBuilder: 状态 '{name}' 尚未声明。请先声明它的行为(AiStateFragments.* 或 State(name)),再挂转换。");
        }
  • Step 4: 运行测试确认通过

运行 BrainBuilderTests。期望:全部 PASS。

  • Step 5: 提交
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} + AiRuntimeIsControllable 让位门独占处理;留着这些值会诱导在 AI 图里写第二套受击逻辑并与物理层打架。

Parried / Staggered 目前仅被三个框架测试当作「任意信号」使用,需一并改写。

Files:

  • Modify: Assets/_Game/Scripts/AI/AiSignal.cs

  • Modify: Assets/Tests/EditMode/AI/AiFrameworkTests.cs

  • Step 1: 改枚举

AiSignal.cs 全文替换为:

namespace BaseGames.AI
{
    /// <summary>
    /// 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。
    ///
    /// 只保留真实存在发送方与消费方的信号。受击 / 硬直 / 击飞 / 弹反**不在此列**——
    /// 它们由敌人物理状态机(EnemyStateType)处理,决策层经 IActorVitals.IsControllable
    /// 让位门自动挂起。在这里重复定义会诱导写出与物理层打架的第二套受击逻辑。
    ///
    /// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留。
    /// </summary>
    public enum AiSignal
    {
        Died,
    }
}
  • Step 2: 改写三个使用 Parried/Staggered 的框架测试

AiFrameworkTests.cs 中,把 EventTransition_TakesPriorityOverCondition_SameTickUnmatchedSignal_ConsumedThenConditionEvaluated 两个方法里的 AiSignal.Parried 全部替换为 AiSignal.Died(这两个测试只需要一个信号)。

然后把 MultipleQueuedSignals_FirstMatchWins_RemainingConsumed 整个方法替换为:

        [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<int>("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<int>("enters")); // 残留信号被消费,未重入 B
        }
  • Step 3: 运行测试确认通过

运行 AiFrameworkTestsAiRuntimeTestsPerceptionStateMachineTests。 期望:全部 PASSPerceptionStateMachine 只用 Died,不受影响)。

  • Step 4: 提交
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

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<EnemyAbilitySO>();
            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<EnemyAbilitySO>();
            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

using System;
using UnityEngine;

namespace BaseGames.Enemies.Abilities
{
    /// <summary>
    /// 能力引用。配方资产侧引用 EnemyAbilitySO(Unity 引用系统保证拼不错、改名不断链);
    /// 定制 AiScript 侧仍可直接写字符串 id。资产优先。
    /// </summary>
    [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; }

        /// <summary>解析出的能力 id;资产优先,都没有则 null。</summary>
        public string Id => _asset != null ? _asset.abilityId : _literal;

        public bool IsEmpty => string.IsNullOrEmpty(Id);

        /// <summary>资产与字符串同时非空——歧义配置,供校验器报警告。</summary>
        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: 提交
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

using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;

namespace BaseGames.Tests.EditMode.AI
{
    public class AiStateFragmentsTests
    {
        static AiRuntime Run(System.Action<BrainBuilder> 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,把这两行:

        public void Pursue(Vector2 p) { CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); }
        public void Face(Vector2 p) { CurrentMode = LocomotionMode.Face; Calls.Add("Face"); }

分别替换为:

        public Vector2 PursuedTo;
        public void Pursue(Vector2 p) { PursuedTo = p; CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); }
        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

using System;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 建态原语。骨架、各模块、以及定制 AiScript 写自定义态时共用这几个形状,
    /// 保证"进入声明意图 / 离开收尾"的契约在全项目一致。
    /// </summary>
    public static class AiStateFragments
    {
        /// <summary>共享条件:脱离全部感知(追逐区与视野区都不在)。</summary>
        public static readonly Func<IAiContext, bool> LostAllZones =
            x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone();

        /// <summary>由 EnemyLocomotion 驱动的态:进入 / 每帧声明移动意图,离开时停。</summary>
        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());

        /// <summary>
        /// 由能力驱动的持续态:进入 / 每帧确保能力在跑(受击打断后自动重触发),离开中断。
        /// 用于追击、冲锋这类"只要还在这个状态就该一直在做"的能力。
        /// </summary>
        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());
        }

        /// <summary>
        /// 由能力驱动的一次性态:只在进入时触发,不每帧重触发。
        /// 用于死亡等一次性演出——若用 Ability(),演出播完会被 Tick 无限重播。
        /// </summary>
        public static BrainBuilder.StateBuilder AbilityOnce(BrainBuilder b, string state, string abilityId)
        {
            RequireAbilityId(state, abilityId);
            return b.State(state).OnEnter(x => EnsureAbility(x, abilityId));
        }

        /// <summary>
        /// 无行为的终态。不需要在这里停移动——转入本态时,上一个态的 OnExit 已经收尾。
        /// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath"的敌人。
        /// </summary>
        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: 提交
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

using System.Collections.Generic;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 未发现层:玩家尚未被发现时的行为形状(单一站桩 / 单向降级 / 定时交替 …)。
    ///
    /// 刻意拆成两阶段:AiRuntime 按声明顺序评估条件边,若本模块的内部边(如巡逻计时)
    /// 早于骨架的升级边声明,玩家进入追逐区那一帧若恰好计时到点,敌人会切去另一个待机态
    /// 而不是扑上来。骨架的调用顺序固定为 Declare → 挂升级边 → Link。
    /// </summary>
    public interface IUnawareModule
    {
        /// <summary>只声明状态与行为回调,不挂任何转换。</summary>
        void Declare(BrainBuilder b);

        /// <summary>挂内部转换(如站立 ⇄ 巡逻的计时边)。没有内部边的模块留空实现。</summary>
        void Link(BrainBuilder b);

        /// <summary>图入口(出生态)。</summary>
        string Entry { get; }

        /// <summary>脱战 / 警觉丢失后回到的态。必须属于 States。</summary>
        string Rest { get; }

        /// <summary>需要挂升级边的所有未发现态。</summary>
        IReadOnlyList<string> States { get; }
    }
}
  • Step 2: 交战层契约

新建 IEngagementModule.cs

using System;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 交战层:发现玩家之后怎么打。骨架只认这四件事——从哪个态进、什么条件进、
    /// 边标签是什么、内部怎么打。新增一种打法 = 新增一个实现,骨架与其他模块不受影响。
    ///
    /// CanEngage 归本模块所有是关键:像"冲锋冷却门"这种只对某一种打法有意义的条件,
    /// 若留在骨架上就会变成骨架的开关字段,并与其他开关互相耦合。
    ///
    /// 实现注意:模块会经 [SerializeReference] 反序列化,构造函数不保证被调用。
    /// 不要在构造函数里预计算缓存或做参数校验——缓存用惰性属性,校验放 Build()。
    /// </summary>
    public interface IEngagementModule
    {
        /// <summary>声明交战态与脱战边。</summary>
        /// <param name="rest">脱战后回到的未发现态,由骨架传入。</param>
        void Build(BrainBuilder b, string rest);

        /// <summary>骨架从未发现态 / 警觉态连过来的目标。</summary>
        string EntryState { get; }

        /// <summary>进入交战的条件。惰性构建并缓存。</summary>
        Func<IAiContext, bool> CanEngage { get; }

        /// <summary>CanEngage 的可读标签(trace / 图导出用)。</summary>
        string EngageLabel { get; }
    }
}
  • Step 3: 死亡层契约

新建 IDeathModule.cs

using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 死亡层:单态终结(演出交给物理状态机)/ 单段死亡能力 / 两段演出(可带生成物)。
    /// 骨架只挂一条 Global → EntryState 的 Died 事件边,链条内容全归本模块。
    /// </summary>
    public interface IDeathModule
    {
        void Build(BrainBuilder b);

        /// <summary>骨架全局死亡边指向的态。</summary>
        string EntryState { get; }
    }
}
  • Step 4: 确认编译通过

MCP 调 mcp__unity__unity_get_compilation_errors。期望:无错误。

  • Step 5: 提交
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 类体内追加:

        [Test]
        public void DeclareState_Throws_OnDuplicateName()
        {
            var b = new BrainBuilder();
            b.DeclareState("Dup");
            var ex = Assert.Throws<System.InvalidOperationException>(() => 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.csRequireState 之后插入:

        /// <summary>
        /// 首次声明一个状态。名字已存在即抛——防止两个独立模块取了同名状态时,
        /// 后者的 OnEnter/Tick/OnExit 静默覆盖前者(敌人跑错行为且零报错)。
        /// 只有"首次声明行为"的调用点用它;给已声明的态挂边仍用 State()。
        /// </summary>
        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.csEntry 成员注释上补一句区分:

        /// <summary>图入口(出生态)。注意与交战/死亡层的 EntryState 不同:
        /// 本属性会成为整张图唯一的 BrainBuilder.Entry,而 EntryState 只是别处 To() 的目标。</summary>
  • Step 6: 运行测试确认通过

运行 BrainBuilderTestsAiStateFragmentsTests,再跑全量 EditMode。 期望:新增 3 个测试,全量从 222 变为 225,0 失败。

若有既有测试因重复声明而失败,停下报告 —— 那说明框架里本来就存在重复声明, 是真问题,不要靠把 DeclareState 改宽松来绕过。

  • Step 7: 提交
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

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
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>单一未发现态:站桩(Idle)或单一巡逻(Patrol)。出生态与脱战态是同一个。</summary>
    [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<string> States => StatesArray;

        public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, Post, _mode);
        public void Link(BrainBuilder b) { }   // 单态,无内部边
    }
}
  • Step 4: 实现 DisguiseThenPatrol
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 单向降级:出生时伪装静止,一旦交战过就只回巡逻,伪装态不可逆。
    /// 刻意不声明两态之间的内部边——伪装只在出生那一次可达。
    /// </summary>
    [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<string> 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
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>站立 ⇄ 巡逻定时交替,脱战回站立。内部计时边经 Link 挂载,优先级低于骨架升级边。</summary>
    [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<string> 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: 提交
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

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<System.InvalidOperationException>(() => m.Build(b, Rest));
        }
    }
}
  • Step 2: 运行测试确认失败

运行 RushEngagementTests。期望:编译错误,找不到 RushEngagement / RushExit

  • Step 3: 实现 RushExit

新建 RushExit.cs

namespace BaseGames.Enemies
{
    /// <summary>冲锋的脱战语义。一个值同时决定"脱战边怎么写"与"进入条件带不带冷却门"。</summary>
    public enum RushExit
    {
        /// <summary>追不到就放弃:脱离全部感知区即停。</summary>
        OnLostTarget,

        /// <summary>
        /// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。
        /// 冷却门由模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲,
        /// 出现 冲锋态 ↔ 未发现态 每帧抖动。
        /// </summary>
        Committed,
    }
}
  • Step 4: 实现 RushEngagement

新建 RushEngagement.cs

using System;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies.Abilities;

namespace BaseGames.Enemies
{
    /// <summary>接触冲锋:一个能力包办追击与伤害。冲锋能力自己管方向、速度、动画与命中。</summary>
    [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<IAiContext, bool> _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<IAiContext, bool> CanEngage
        {
            get
            {
                if (_canEngage == null)
                {
                    string id = RequireAbilityId();
                    _canEngage = _exit == RushExit.Committed
                        ? x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(id)
                        : (Func<IAiContext, bool>)(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: 提交
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

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

using System;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 寻路逼近 ↔ 到射程选招攻击。招式的射程 / 冷却 / 权重由 EnemyAttackSelector 负责,
    /// 本模块只决定"什么时候该逼近、什么时候该出手"。
    /// </summary>
    [Serializable]
    public sealed class ApproachAttackEngagement : IEngagementModule
    {
        public const string Approach = "Approach";
        public const string Attack   = "Attack";

        static readonly Func<IAiContext, bool> InChaseZone = x => x.Sensor.InChaseZone();

        public string EntryState => Approach;
        public Func<IAiContext, bool> 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: 提交
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

本任务已作废。 三个死亡模块曾按下方原稿实现并提交(3bc7eb15a9c1fd), 但质量审查发现 TwoStageDeath 在真实运行中必然卡死,追根后确认整个死亡层放错了层。 详见 spec 的「修订:死亡层取消」一节与 commit 023a4a4

必然卡死的机制EnemyBase.PerformDeathForceState(EnemyStateType.Dead)(终态) 再 _brain.Send(AiSignal.Died)IsControllableCurrentState == Controlled,此后永久为假。 AiRuntime.Tick 的顺序是「事件转换(不受门限制)→ IsControllable 让位门(为假即 return)→ 条件转换」,所以 Died 事件能切进死亡链,但此后任何条件边都不再被求值 TwoStageDeathpreDone 边永不触发。

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

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<System.InvalidOperationException>(() => new AbilityDeath().Build(b));
        }
    }
}
  • Step 2: 运行测试确认失败

运行 DeathModuleTests。期望:编译错误,找不到 TerminalDeath 等类型。

  • Step 3: 实现 TerminalDeath
using System;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>死亡演出交给物理状态机(EnemyBase.PerformDeath):AI 只需要一个无行为终态。</summary>
    [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
using System;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies.Abilities;

namespace BaseGames.Enemies
{
    /// <summary>单段死亡能力:进入即触发一次,不重播。</summary>
    [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
using System;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies.Abilities;

namespace BaseGames.Enemies
{
    /// <summary>两段死亡:前段(挣扎 / 膨胀)播完接后段(爆体,可带生成物)。</summary>
    [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: 提交
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: 删除三个死亡模块、契约与测试

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: 确认无残留引用
grep -rn "IDeathModule\|TerminalDeath\|AbilityDeath\|TwoStageDeath" Assets --include=*.cs

期望:无结果。(PerceptionSkeleton 尚未实现,所以此时不该有引用。)

  • Step 3: 确认编译 + 全量测试

MCP 查编译,期望 0 错误。跑全量 EditMode:从 247 回落到 243(删掉 4 个死亡层测试)。

  • Step 4: 提交
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

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<string> 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<string> States => new[] { "Y" };
            public void Declare(BrainBuilder b) { }   // 忘了声明行为
            public void Link(BrainBuilder b) { }
        }

        [Test]
        public void Throws_WhenRestNotInStates()
        {
            var b = new BrainBuilder();
            Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
                b, new BadUnaware(), new RushEngagement("rush")));
        }

        [Test]
        public void Throws_WhenUnawareStateNotDeclared()
        {
            var b = new BrainBuilder();
            Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
                b, new UndeclaredUnaware(), new RushEngagement("rush")));
        }

        [Test]
        public void Throws_WhenModuleIsNull()
        {
            var b = new BrainBuilder();
            Assert.Throws<System.ArgumentNullException>(() => PerceptionSkeleton.Add(
                b, null, new RushEngagement("rush")));
        }
    }
}
  • Step 2: 运行测试确认失败

运行 PerceptionSkeletonTests。期望:编译错误,找不到 PerceptionSkeleton

  • Step 3: 实现

新建 Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs

using System;
using System.Collections.Generic;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到两个模块声明的态上。
    /// 骨架永远只做这一件事——未发现态长什么样、交战怎么打,全部归模块。
    ///
    /// 规则:
    ///   未发现:在追逐区 → 交战(优先);否则 有警觉态且在视野 → 警觉;否则保持。
    ///   警觉:  在追逐区 → 交战;否则 脱离视野 → Rest;否则保持(朝向最后已知位置)。
    ///   交战:  脱战条件由交战模块自定,脱战一律回 Rest —— 永不回警觉。
    ///   死亡:  全局事件边,优先级最高,且不受 IsControllable 门阻挡。
    ///
    /// 死亡态由骨架自己声明为无行为终态,不做成可插拔层——死亡在本项目里完全归物理层
    /// EnemyBase.PerformDeath 切终态/关碰撞/播死亡动画/归还池;EnemyDeathSequence 做
    /// 死亡前摇演出并可经动画事件生成小怪)。且 PerformDeath 先 ForceState(Dead) 再发
    /// Died 信号,此后 IsControllable 永久为假、AiRuntime 不再求值任何条件边,
    /// 所以死亡链里放条件转换必然卡住。AI 图需要这个态只是为了让全局边有去处、决策停止。
    ///
    /// 入口默认取 unaware.Entry;需要前置态(掉落链 / 出场链)的敌人在调用本方法后
    /// 再调 b.Entry("自己的态") 覆盖即可。
    /// </summary>
    public static class PerceptionSkeleton
    {
        public const string Alert = "Alert";
        public const string Death = "Death";

        static readonly Func<IAiContext, bool> HasAlertAndInVision =
            x => x is IEnemyActor a && a.HasAlertState && x.Sensor.InVisionZone();

        static readonly Func<IAiContext, bool> 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<string> 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: 提交
git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs
git commit -m "feat(enemy): PerceptionSkeleton——三层可插拔的感知骨架"

Task 11: 退役 PerceptionStateMachineE001 切到新 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 全文替换为:

using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// E001(草蛭)AI —— 纯决策层,组装两层模块。
    /// 出生伪装静止(伪装成石头),一旦交战过就只回巡逻;追击=带冷却的接触冲锋,
    /// 起手即锁定、打完回巡逻走冷却;死亡演出走物理状态机(EnemyBase.PerformDeath)。
    /// AI 只决策;移动 / 朝向 / 速度 / 动画 / 伤害全部在能力与 EnemyLocomotion 里实现。
    /// 依据 Docs/Game/敌人/小怪/E001_草蛭.md。
    /// </summary>
    [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: 删除旧骨架与旧测试
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: 提交
git add -A Assets/_Game/Scripts/Enemies/AIBrain Assets/Tests/EditMode/AI
git commit -m "refactor(enemy): 退役 PerceptionStateMachineE001 切换到三层模块组装"

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

namespace BaseGames.AI
{
    /// <summary>
    /// 能产出共享 AiGraph 的东西。两个实现:
    /// AiScript(定制路径,写 C# 类)与 AiRecipeSO(配方路径,建资产)。
    /// </summary>
    public interface IAiDefinition
    {
        /// <summary>惰性构建并缓存共享图(flyweight:每个定义只建一次)。</summary>
        AiGraph GetOrBuildGraph();
    }
}
  • Step 2: 建 AiRecipeSO
using UnityEngine;
using BaseGames.Core.Events;

namespace BaseGames.AI
{
    /// <summary>
    /// AI 配方资产基类。子类以序列化字段描述一张图,Build 里组装。
    /// 一个资产 = 一种敌人 AI;所有引用该资产的实例共享同一张 AiGraphflyweight)。
    /// </summary>
    public abstract class AiRecipeSO : ScriptableObject, IAiDefinition
    {
        AiGraph _cached;

        void OnEnable()
        {
            // 项目已关闭 Domain ReloadSO 的运行时态在多次 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.AIBaseGames.Core.Events 引用

AiRecipeSO 用了 PlayModeResetHookBaseGames.Core.Eventsreferences 为空数组, 加这条引用不会形成循环。编辑 Assets/_Game/Scripts/AI/BaseGames.AI.asmdef,把 references 改为:

  "references": [
    "BaseGames.Core",
    "BaseGames.Core.Events"
  ],
  • Step 4: 让 AiScript 实现 IAiDefinition

AiScript.cs 中把类声明改为:

    public abstract class AiScript : IAiDefinition

其余不动(GetOrBuildGraph 签名已匹配)。

  • Step 5: 确认编译 + 现有测试通过

MCP 调 mcp__unity__unity_get_compilation_errors,期望无错误。 运行 AiScriptTestsAiDefinitionRegistryTests。期望:全部 PASS。

  • Step 6: 提交
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

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<PerceptionRecipeSO>();
            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<PerceptionRecipeSO>();
            var ex = Assert.Throws<System.InvalidOperationException>(() => 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

using UnityEngine;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 标在 [SerializeReference] 字段上,Inspector 里给出该接口所有 [Serializable] 实现的下拉。
    /// 意义:新增一个模块类,它自动出现在所有配方的下拉里,无需改枚举或任何已有文件。
    /// </summary>
    public sealed class SubclassSelectorAttribute : PropertyAttribute { }
}
  • Step 4: 实现 PerceptionRecipeSO

新建 Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs

using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 感知型 AI 配方:一个 SO 类型覆盖所有走「未发现 → 警觉 → 交战」规则的敌人。
    /// 三个下拉各选一个模块即可,无需写代码。有独门机制的敌人改写 AiScript 子类。
    /// </summary>
    [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);

        /// <summary>仅供 EditMode 测试与脚手架向导装配模块,运行时不使用。</summary>
        public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement)
        {
            _unaware = unaware; _engagement = engagement;
        }
    }
}
  • Step 5: 实现 SubclassSelectorDrawer

新建 Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using BaseGames.Enemies;

namespace BaseGames.Editor.AI
{
    /// <summary>
    /// [SerializeReference] + [SubclassSelector] 字段的下拉绘制器:
    /// 反射收集该接口的所有非抽象 [Serializable] 实现,选中即替换实例。
    /// </summary>
    [CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
    public sealed class SubclassSelectorDrawer : PropertyDrawer
    {
        static readonly Dictionary<Type, Type[]> _cache = new Dictionary<Type, Type[]>();

        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<SerializedProperty> 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<Type>(); } })
                .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 右键 → CreateBaseGamesAI感知型 AI 配方 临时建一个资产放在 Assets/Temp/ 下,确认三个字段各显示一个下拉, 下拉里分别有 3 / 2 / 3 个选项,切换后字段内容随之变化。确认后删除该临时资产。

  • Step 8: 提交
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() 结束的部分替换为:

        [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<EnemyBase>();

            bool hasRecipe = _recipe != null;
            bool hasId     = !string.IsNullOrEmpty(_definitionId);
            if (hasRecipe == hasId)   // 都配了 或 都没配
            {
                // 根因暴露:配置歧义 / 漏配直接报错,不静默兜底。
                Debug.LogError(
                    $"EnemyAiBrain 必须且只能配置一项:_recipe(配方资产)或 _definitionIdAiScript 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: 给 AiDefinitionRegistryGetDefinition

AiDefinitionRegistry.csGetGraph 之后追加(保留 GetGraph 不动,仍有测试依赖):

        /// <summary>取 id 对应的定义;不存在则显式抛错(根因暴露,不做兜底)。</summary>
        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: 提交
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 持有 _cachedflyweight);若在 GetOrBuildGraph() 之后改模块,改动不会生效直到下次 PlayModeResetHook 清理—— 静默的陈旧图。为此在 AiRecipeSO 加:
    /// <summary>装配 / 修改模块后调用,丢弃已缓存的图。</summary>
    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

using System.IO;
using UnityEditor;
using UnityEngine;
using BaseGames.Enemies;

namespace BaseGames.Editor.AI
{
    /// <summary>
    /// 敌人 AI 配方脚手架。按 AssetFolderSpec 定名定路径:
    /// Assets/_Game/Data/Enemies/{EnemyID}/ENM_{EnemyID}_Ai.asset
    /// </summary>
    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<EnemyAiRecipeWizard>("敌人 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<PerceptionRecipeSO>(path);
            if (existing != null)
            {
                // 已存在就选中并返回,不覆盖用户已配好的内容。
                Selection.activeObject = existing;
                EditorGUIUtility.PingObject(existing);
                Debug.Log($"配方已存在,已选中:{path}");
                return existing;
            }

            var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
            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 菜单 BaseGamesAIEnemy AI Recipe Wizard,窗口应打开并显示产出路径 Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset先不要点创建Task 16 才创建)。

  • Step 4: 提交
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 菜单 BaseGamesAIEnemy 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 定制脚本
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 不要新写赋值辅助方法:

            var brain = GetOrAddComponent<EnemyAiBrain>(go);
            var recipe = AssetDatabase.LoadAssetAtPath<PerceptionRecipeSO>(
                "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_AiBrainGraph)。");
            }

其余 6 处提示文案(原第 341、405、601、688、819、929、1028 行附近,以 grep -n "AI(BrainGraph)" Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs 实际定位) 统一替换为:

            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: 提交
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%):写一个 AiScriptPerceptionSkeleton.Add 后追加态
    • 定制脚本(约 5%):完全自己写图,不用骨架(如陷阱型敌人)
  2. 四层职责:骨架固定,三层可插拔;每个构件只声明自己拥有的态、只挂自己负责的边
  3. 现有模块清单8 个模块各自的语义、参数、适用敌人
  4. 新增模块的方法:实现对应接口 + 打 [Serializable] + 提供无参构造 → 自动出现在配方下拉; 强调不要在构造函数里做校验或缓存([SerializeReference] 不保证走 ctor
  5. 六个敌人的组装范例E001E006,含 E002 完全不用骨架的写法、E003 掉落前置链的写法)
  6. 常见陷阱
    • 未发现层内部边必须放 Link 不能放 Declare(否则抢在升级边前)
    • 死亡态用 AbilityOnce 不用 Ability(否则演出无限重播)
    • 状态名只是 trace 标签,不驱动动画;动画归能力与 EnemyLocomotion
    • 受击 / 硬直不进 AI 图,由物理状态机 + IsControllable 门处理
  • Step 2: 提交
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 图导出 —— 路线图