Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-29-enemy-ai-composable-modules.md
T
joywayerandClaude Opus 5 6a885ec105 docs(enemy): 计划同步取消死亡层——T9 作废、新增 T9R,T10/T11/T13 去掉 death 参数
T9 原稿折叠保留作历史记录,附必然卡死的机制说明。
PerceptionSkeleton.Add 由三参收为两参并自声明 Death 终态;
PerceptionRecipeSO 去掉死亡层字段。T5 段落保持原样(它记录的是已发生的事实,
IDeathModule 由 T9R 删除)。

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

2795 lines
105 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 敌人 AI 组合式模块架构 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 用「固定感知骨架 + 可插拔的 未发现层 / 交战层 / 死亡层」替换 `PerceptionStateMachine`,并让约 95% 的敌人 AI 成为配方资产而非 C# 类。
**Architecture:** 两个模块契约(`IUnawareModule` / `IEngagementModule`)各自声明自己的状态与边;`PerceptionSkeleton` 只负责把「未发现 → 警觉 → 交战」的升降级边挂到模块声明的态上,并自行声明 `Death` 终态(死亡归物理层,不做成可插拔层——见 Task 9R)。模块是 `[Serializable]` 普通类,既能在 C# 里 `new`,也能经 `[SerializeReference]` 存进 `PerceptionRecipeSO` 配方资产。`EnemyAiBrain` 上「配方资产引用」与「AiScript 定义 id」二选一。
**Tech Stack:** Unity / C# 9 / NUnit EditMode / 自研 BrainGraph`BaseGames.AI`
**Spec:** `Docs_Dev/superpowers/specs/2026-07-29-enemy-ai-composable-modules-design.md`
---
## 计划阶段发现的两处设计漏洞(已纳入本计划修正)
写计划时核对 `AiRuntime` 求值顺序与 Unity 序列化行为,发现 spec 的接口形态有两处会产生**运行时错误**,本计划按修正后的形态实施:
### 漏洞 A:未发现层内部边会抢在升级边前面
`AiRuntime.TryTransition` 按**声明顺序**评估条件边。若 `IUnawareModule.Build` 一次性声明「态 + 内部边」,则 `AlternatingIdlePatrol` 的巡逻计时边会先于骨架的追击边被评估 —— 玩家进入追逐区的那一帧若恰好计时到点,敌人会切去另一个待机态而不是扑上来。
**修正**`IUnawareModule` 拆成两阶段 —— `Declare(b)` 只声明态与行为,`Link(b)` 挂内部边。骨架的调用顺序为 `Declare` → 挂升级边 → `Link`,保证升级边优先级最高。Task 5 定义、Task 6 实现、Task 10 有专门的优先级回归测试。
### 漏洞 B`[SerializeReference]` 反序列化不保证走构造函数
spec 里 `RushEngagement` 在构造函数中预计算并缓存 `CanEngage` 委托。Unity 反序列化 `[SerializeReference]` 实例时不保证调用构造函数,配方资产加载出来的模块 `_canEngage` 会是 `null`
**修正**`CanEngage` 改为**惰性构建并缓存的属性**;所有参数校验从构造函数移到 `Build()`(图构建时仍然快速失败);所有 `States` 数组改为 `static readonly`(静态初始化器一定执行)。Task 5 / 7 落实。
### 顺带修正:死亡态每帧重触发能力
现有 `PerceptionStateMachine.AddAbilityState``Tick` 会在能力结束后重新触发它。用在死亡态上意味着死亡演出无限循环。本计划新增 `AiStateFragments.AbilityOnce`(只在 `OnEnter` 触发一次),死亡层专用。
---
## 如何运行测试
**编译检查(每次改完代码先做):** MCP 调 `mcp__unity__unity_get_compilation_errors`,期望返回无错误。
**跑 EditMode 测试:** Unity Editor → `Window``General``Test Runner``EditMode` 标签 → 在树中定位到指定测试类 → 右键 `Run`
**批量跑:** Test Runner 顶部 `Run All`,或 MCP `mcp__unity__unity_execute_code` 调用 `UnityEditor.TestTools.TestRunner.Api.TestRunnerApi`
**本计划中每个「运行测试」步骤给出的是测试类名**,在 Test Runner 中按该类名过滤运行。
---
## 文件结构
新代码集中在两处,各自职责单一:
```
Assets/_Game/Scripts/AI/ 框架层(只依赖 BaseGames.Core
IAiDefinition.cs 统一「能产出 AiGraph 的东西」
AiRecipeSO.cs 配方基类(抽象,无 Enemies 依赖)
BrainBuilder.cs + RequireState
AiSignal.cs 8 → 1
AiScript.cs 实现 IAiDefinition
Assets/_Game/Scripts/Enemies/AIBrain/Modules/ 敌人决策组合层
AiStateFragments.cs 建态原语(4 个)
IUnawareModule.cs / IEngagementModule.cs
PerceptionSkeleton.cs 感知规则 + 自声明 Death 终态
Unaware/SinglePost.cs
Unaware/DisguiseThenPatrol.cs
Unaware/AlternatingIdlePatrol.cs
Engagement/RushExit.cs
Engagement/RushEngagement.cs
Engagement/ApproachAttackEngagement.cs
(无 Death/ 目录:死亡归物理层,见 Task 9R)
Assets/_Game/Scripts/Enemies/AIBrain/Recipes/
PerceptionRecipeSO.cs 一个 SO 类型覆盖所有感知型敌人
Assets/_Game/Scripts/Enemies/Abilities/
AbilityRef.cs 能力引用(资产优先,字符串兜底)
Assets/_Game/Scripts/Editor/AI/
SubclassSelectorDrawer.cs [SerializeReference] 子类下拉
EnemyAiRecipeWizard.cs 配方资产脚手架
```
每个模块一个文件 —— 模块是本架构的增长点(预计长到 15–20 个),一文件一模块保证新增时不触碰任何已有文件。
---
## Task 1: `BrainBuilder.RequireState`
模块要给「别人声明的态」挂边。`b.State(name)` 是幂等新建的 —— 若那个态其实没人声明行为,会静默新建一个空态,敌人杵着不动且无任何报错。这个方法把静默错误变成构建期异常。
**Files:**
- Modify: `Assets/_Game/Scripts/AI/BrainBuilder.cs`
- Test: `Assets/Tests/EditMode/AI/BrainBuilderTests.cs`
- [ ] **Step 1: 写失败测试**
`BrainBuilderTests.cs` 的类体内追加:
```csharp
[Test]
public void RequireState_Throws_WhenStateNotDeclared()
{
var b = new BrainBuilder();
var ex = Assert.Throws<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.cs``public GlobalBuilder Global() => new GlobalBuilder(this);` 之后插入:
```csharp
/// <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: 提交**
```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
{
/// <summary>
/// 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。
///
/// 只保留真实存在发送方与消费方的信号。受击 / 硬直 / 击飞 / 弹反**不在此列**——
/// 它们由敌人物理状态机(EnemyStateType)处理,决策层经 IActorVitals.IsControllable
/// 让位门自动挂起。在这里重复定义会诱导写出与物理层打架的第二套受击逻辑。
///
/// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留。
/// </summary>
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<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: 运行测试确认通过**
运行 `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<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`
```csharp
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: 提交**
```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<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`,把这两行:
```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
{
/// <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: 提交**
```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
{
/// <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`
```csharp
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`
```csharp
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: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs
git commit -m "feat(enemy): 未发现层/交战层/死亡层三个模块契约"
```
---
## Task 5b: `BrainBuilder.DeclareState` 防状态名冲突
> 本任务是执行 T5 后由契约质量审查提出并采纳的追加项,非原稿内容。
>
> **风险来由**`BrainBuilder.State(name)` 是「有则取、无则建」。改造前 `PerceptionStateMachine`
> 是单个函数独占全部状态名,撞名不可能发生。改成「策划在下拉里自由组合三层独立模块」之后,
> 两个不同作者的模块取了同一个状态名(或某模块内部态叫 `Alert`,撞上骨架保留的那个),
> 后声明者的 `.OnEnter/.Tick/.OnExit` 会**静默覆盖**前者 —— 敌人跑错行为且零报错。
>
> 这与 T1 的 `RequireState` 是同一类失败的两面:`RequireState` 守「挂边到未声明的态」,
> `DeclareState` 守「重复声明同一个态」。趁只有 0 个模块实现时补,代价最低。
**Files:**
- Modify: `Assets/_Game/Scripts/AI/BrainBuilder.cs`
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs`
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs`
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs`
- Test: `Assets/Tests/EditMode/AI/BrainBuilderTests.cs`
- [ ] **Step 1: 写失败测试**
`BrainBuilderTests.cs` 类体内追加:
```csharp
[Test]
public void DeclareState_Throws_OnDuplicateName()
{
var b = new BrainBuilder();
b.DeclareState("Dup");
var ex = Assert.Throws<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.cs``RequireState` 之后插入:
```csharp
/// <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.cs``Entry` 成员注释上补一句区分:
```
/// <summary>图入口(出生态)。注意与交战/死亡层的 EntryState 不同:
/// 本属性会成为整张图唯一的 BrainBuilder.Entry,而 EntryState 只是别处 To() 的目标。</summary>
```
- [ ] **Step 6: 运行测试确认通过**
运行 `BrainBuilderTests``AiStateFragmentsTests`,再跑全量 EditMode。
期望:新增 3 个测试,全量从 222 变为 225,0 失败。
> 若有既有测试因重复声明而失败,**停下报告** —— 那说明框架里本来就存在重复声明,
> 是真问题,不要靠把 `DeclareState` 改宽松来绕过。
- [ ] **Step 7: 提交**
```bash
git add Assets/_Game/Scripts/AI/BrainBuilder.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules Assets/Tests/EditMode/AI/BrainBuilderTests.cs
git commit -m "feat(ai): BrainBuilder.DeclareState——重复声明状态即报错,防模块间同名静默覆盖"
```
---
## Task 6: 未发现层三个模块
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/SinglePost.cs`
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/DisguiseThenPatrol.cs`
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/AlternatingIdlePatrol.cs`
- Test: `Assets/Tests/EditMode/AI/UnawareModuleTests.cs`
- [ ] **Step 1: 写失败测试**
新建 `Assets/Tests/EditMode/AI/UnawareModuleTests.cs`
```csharp
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
public class UnawareModuleTests
{
// 只跑未发现层自身(不挂骨架升级边),验证态、行为与内部边。
static AiRuntime RunAlone(IUnawareModule m, FakeAiContext ctx)
{
var b = new BrainBuilder();
m.Declare(b);
m.Link(b);
b.Entry(m.Entry);
return new AiRuntime(b.Build(), ctx);
}
[Test]
public void SinglePost_OneState_EntryEqualsRest()
{
var m = new SinglePost(LocomotionMode.Patrol);
Assert.AreEqual(1, m.States.Count);
Assert.AreEqual(m.Entry, m.Rest);
var ctx = new FakeAiContext();
RunAlone(m, ctx);
Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode);
}
[Test]
public void DisguiseThenPatrol_EntryIsDisguise_RestIsPatrol()
{
var m = new DisguiseThenPatrol();
Assert.AreEqual(DisguiseThenPatrol.Disguise, m.Entry);
Assert.AreEqual(DisguiseThenPatrol.Patrol, m.Rest);
CollectionAssert.AreEquivalent(
new[] { DisguiseThenPatrol.Disguise, DisguiseThenPatrol.Patrol }, m.States);
}
[Test]
public void DisguiseThenPatrol_HasNoInternalEdges_DisguiseIsOneWay()
{
var ctx = new FakeAiContext();
var rt = RunAlone(new DisguiseThenPatrol(), ctx);
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
for (int i = 0; i < 20; i++) rt.Tick(1f);
// 无内部边:伪装态自己不会跑掉,只能被骨架的升级边带走
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode);
}
[Test]
public void AlternatingIdlePatrol_SwitchesOnDwellTimers()
{
var m = new AlternatingIdlePatrol(idleDwell: 2f, patrolDwell: 3f);
var ctx = new FakeAiContext();
var rt = RunAlone(m, ctx);
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
rt.Tick(1f);
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); // 未到 2s
rt.Tick(1.5f);
Assert.AreEqual(AlternatingIdlePatrol.Patrol, rt.CurrentStateName); // 累计 2.5s ≥ 2s
Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode);
rt.Tick(3.5f);
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName); // ≥ 3s 回站立
}
}
}
```
- [ ] **Step 2: 运行测试确认失败**
运行 `UnawareModuleTests`。期望:编译错误,找不到 `SinglePost` 等类型。
- [ ] **Step 3: 实现 `SinglePost`**
```csharp
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <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`**
```csharp
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`**
```csharp
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: 提交**
```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<System.InvalidOperationException>(() => m.Build(b, Rest));
}
}
}
```
- [ ] **Step 2: 运行测试确认失败**
运行 `RushEngagementTests`。期望:编译错误,找不到 `RushEngagement` / `RushExit`
- [ ] **Step 3: 实现 `RushExit`**
新建 `RushExit.cs`
```csharp
namespace BaseGames.Enemies
{
/// <summary>冲锋的脱战语义。一个值同时决定"脱战边怎么写"与"进入条件带不带冷却门"。</summary>
public enum RushExit
{
/// <summary>追不到就放弃:脱离全部感知区即停。</summary>
OnLostTarget,
/// <summary>
/// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。
/// 冷却门由模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲,
/// 出现 冲锋态 ↔ 未发现态 每帧抖动。
/// </summary>
Committed,
}
}
```
- [ ] **Step 4: 实现 `RushEngagement`**
新建 `RushEngagement.cs`
```csharp
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: 提交**
```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
{
/// <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: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs
git commit -m "feat(enemy): ApproachAttackEngagement——攻击改为起手即打完,不中途收招"
```
---
## Task 9: ~~死亡层三个模块~~ → 已作废,改为 Task 9R
> **本任务已作废。** 三个死亡模块曾按下方原稿实现并提交(`3bc7eb1`、`5a9c1fd`),
> 但质量审查发现 `TwoStageDeath` 在真实运行中必然卡死,追根后确认整个死亡层放错了层。
> 详见 spec 的「修订:死亡层取消」一节与 commit `023a4a4`。
>
> **必然卡死的机制**`EnemyBase.PerformDeath` 先 `ForceState(EnemyStateType.Dead)`(终态)
> 再 `_brain.Send(AiSignal.Died)`。`IsControllable` 是 `CurrentState == Controlled`,此后永久为假。
> `AiRuntime.Tick` 的顺序是「事件转换(不受门限制)→ IsControllable 让位门(为假即 return)→
> 条件转换」,所以 `Died` 事件能切进死亡链,但**此后任何条件边都不再被求值**,
> `TwoStageDeath` 的 `preDone` 边永不触发。
>
> `AbilityDeath` 同样不成立:死亡态 `OnEnter` 执行时,`InterruptAll(Dead)` 已跑过、
> 碰撞体已关、`Dead` 动画已在播,此刻触发能力是在和 `PerformDeath` 的死亡演出打架。
>
> **由 Task 9R 取代。** 下方原稿仅作历史记录保留,不要照它实施。
<details><summary>原稿(已作废,勿实施)</summary>
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs`
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/AbilityDeath.cs`
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TwoStageDeath.cs`
- Test: `Assets/Tests/EditMode/AI/DeathModuleTests.cs`
- [ ] **Step 1: 写失败测试**
新建 `Assets/Tests/EditMode/AI/DeathModuleTests.cs`
```csharp
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
public class DeathModuleTests
{
// 从一个活着的态出发,发 Died 信号进入死亡链。
static AiRuntime RunWithDeath(IDeathModule m, FakeAiContext ctx)
{
var b = new BrainBuilder();
AiStateFragments.Locomotion(b, "Alive", LocomotionMode.Patrol);
m.Build(b);
b.Entry("Alive");
b.Global().To(m.EntryState).OnEvent(AiSignal.Died);
return new AiRuntime(b.Build(), ctx);
}
[Test]
public void TerminalDeath_EntersAndStays_NoAbility()
{
var ctx = new FakeAiContext();
var rt = RunWithDeath(new TerminalDeath(), ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
Assert.AreEqual(0, ctx.C.Used.Count); // 演出走物理状态机,AI 不触发能力
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
[Test]
public void AbilityDeath_TriggersOnce_DoesNotLoop()
{
var ctx = new FakeAiContext();
var rt = RunWithDeath(new AbilityDeath("die"), ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(1, ctx.C.Used.Count);
ctx.C.Running = null; // 演出播完
rt.Tick(0.1f); rt.Tick(0.1f);
Assert.AreEqual(1, ctx.C.Used.Count); // 不重播
}
[Test]
public void TwoStageDeath_PreThenFinal()
{
var ctx = new FakeAiContext();
var rt = RunWithDeath(new TwoStageDeath("die_pre", "die"), ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(TwoStageDeath.DeathPre, rt.CurrentStateName);
CollectionAssert.Contains(ctx.C.Used, "die_pre");
rt.Tick(0.1f);
Assert.AreEqual(TwoStageDeath.DeathPre, rt.CurrentStateName); // 前段还在跑
ctx.C.Running = null; // 前段结束
rt.Tick(0.1f);
Assert.AreEqual(TwoStageDeath.Death, rt.CurrentStateName);
CollectionAssert.Contains(ctx.C.Used, "die");
}
[Test]
public void AbilityDeath_Build_Throws_WhenAbilityMissing()
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => new AbilityDeath().Build(b));
}
}
}
```
- [ ] **Step 2: 运行测试确认失败**
运行 `DeathModuleTests`。期望:编译错误,找不到 `TerminalDeath` 等类型。
- [ ] **Step 3: 实现 `TerminalDeath`**
```csharp
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`**
```csharp
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`**
```csharp
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: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death Assets/Tests/EditMode/AI/DeathModuleTests.cs
git commit -m "feat(enemy): 死亡层三模块(终态/单段/两段)"
```
</details>
---
## Task 9R: 收掉死亡层,死亡归物理层
**为什么**:见上方 Task 9 的作废说明与 spec 的「修订:死亡层取消」。要点是——死亡在本项目里
完全归物理层(`PerformDeath` 切终态/清效果/关碰撞/播死亡动画/归还池;`EnemyDeathSequence`
做死亡前摇无敌演出并在演出中经动画事件生成小怪;`EnemyAnimationConfigSO.Dead` 提供动画)。
**AI 图需要 `Death` 态只是为了让全局 `Died` 边有去处、并让决策停止。死亡不是一个决策。**
**Files:**
- Delete: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/`(整个目录,三个模块 + meta
- Delete: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs`+ meta
- Delete: `Assets/Tests/EditMode/AI/DeathModuleTests.cs`+ meta
- [ ] **Step 1: 删除三个死亡模块、契约与测试**
```bash
git rm -r Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death.meta
git rm Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs.meta
git rm Assets/Tests/EditMode/AI/DeathModuleTests.cs Assets/Tests/EditMode/AI/DeathModuleTests.cs.meta
```
- [ ] **Step 2: 确认无残留引用**
```bash
grep -rn "IDeathModule\|TerminalDeath\|AbilityDeath\|TwoStageDeath" Assets --include=*.cs
```
期望:无结果。(`PerceptionSkeleton` 尚未实现,所以此时不该有引用。)
- [ ] **Step 3: 确认编译 + 全量测试**
MCP 查编译,期望 0 错误。跑全量 EditMode**从 247 回落到 243**(删掉 4 个死亡层测试)。
- [ ] **Step 4: 提交**
```bash
git commit -m "revert(enemy): 收掉死亡层——死亡归物理层,AI 只需一个终态"
```
死亡终态改由 `PerceptionSkeleton` 自己声明,见 Task 10。
---
## Task 10: `PerceptionSkeleton`
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs`
- Test: `Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs`
- [ ] **Step 1: 写失败测试**
新建 `Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs`
```csharp
using System.Collections.Generic;
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
public class PerceptionSkeletonTests
{
sealed class Ctx : IAiContext, IEnemyActor
{
public FakeSensor S = new FakeSensor();
public FakeLocomotion L = new FakeLocomotion();
public FakeCombat C = new FakeCombat();
public FakeVitals V = new FakeVitals();
public Blackboard BB = new Blackboard();
public bool HasAlert = true;
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public bool HasAlertState => HasAlert;
}
static AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
{
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
unaware ?? new SinglePost(LocomotionMode.Patrol),
engagement ?? new RushEngagement("rush", RushExit.OnLostTarget));
return new AiRuntime(b.Build(), ctx);
}
// ── 升级 ──────────────────────────────────────────────────────────
[Test]
public void Unaware_ToEngagement_WhenInChaseZone()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Unaware_ToAlert_WhenInVision_AndHasAlert()
{
var ctx = new Ctx { HasAlert = true };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
}
[Test]
public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState()
{
var ctx = new Ctx { HasAlert = false };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void ChaseZone_BeatsAlert_WhenBothActive()
{
var ctx = new Ctx { HasAlert = true };
var rt = Build(ctx);
ctx.S.Chase = true; ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// 漏洞 A 回归:升级边必须优先于未发现层内部计时边
[Test]
public void UpgradeEdge_BeatsUnawareInternalDwellEdge()
{
var ctx = new Ctx();
var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f));
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
ctx.S.Chase = true;
rt.Tick(5f); // 计时早已到点,但追击边优先
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// ── 警觉 ──────────────────────────────────────────────────────────
[Test]
public void Alert_ToEngagement_WhenEnteringChaseZone()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Alert_ToRest_WhenLeavingVision()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void Alert_FacesLastKnown()
{
var ctx = new Ctx();
ctx.S.Last = new UnityEngine.Vector2(7f, 2f);
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(new UnityEngine.Vector2(7f, 2f), ctx.L.FacedAt);
}
// ── 降级:追击后永不回警觉 ─────────────────────────────────────────
[Test]
public void AfterEngagement_GoesToRest_NeverBackToAlert()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 不是 Alert
}
// ── 死亡 ──────────────────────────────────────────────────────────
[Test]
public void Died_TransitionsToDeath_FromAnyState()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
[Test]
public void Died_WorksEvenWhenNotControllable()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.V.Controllable = false; // 硬直中
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
// ── 校验 ──────────────────────────────────────────────────────────
sealed class BadUnaware : IUnawareModule
{
public string Entry => "X";
public string Rest => "NotInStates";
public IReadOnlyList<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`
```csharp
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: 提交**
```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
{
/// <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: 删除旧骨架与旧测试**
```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): 退役 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`**
```csharp
namespace BaseGames.AI
{
/// <summary>
/// 能产出共享 AiGraph 的东西。两个实现:
/// AiScript(定制路径,写 C# 类)与 AiRecipeSO(配方路径,建资产)。
/// </summary>
public interface IAiDefinition
{
/// <summary>惰性构建并缓存共享图(flyweight:每个定义只建一次)。</summary>
AiGraph GetOrBuildGraph();
}
}
```
- [ ] **Step 2: 建 `AiRecipeSO`**
```csharp
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.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<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 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
{
/// <summary>
/// 标在 [SerializeReference] 字段上,Inspector 里给出该接口所有 [Serializable] 实现的下拉。
/// 意义:新增一个模块类,它自动出现在所有配方的下拉里,无需改枚举或任何已有文件。
/// </summary>
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
{
/// <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`
```csharp
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` 右键 → `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<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: 给 `AiDefinitionRegistry` 加 `GetDefinition`**
`AiDefinitionRegistry.cs``GetGraph` 之后追加(保留 `GetGraph` 不动,仍有测试依赖):
```csharp
/// <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: 提交**
```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
{
/// <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 菜单 `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<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` 实际定位)
统一替换为:
```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. **六个敌人的组装范例**E001E006,含 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` 图导出 —— 路线图