Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2827 lines
107 KiB
Markdown
2827 lines
107 KiB
Markdown
# 敌人 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): 退役 PerceptionStateMachine,E001 切换到三层模块组装"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: `IAiDefinition` + `AiRecipeSO`
|
||
|
||
配方基类放 `BaseGames.AI` —— 它只需要 `BrainBuilder` / `AiGraph`,没有 Enemies 依赖,
|
||
放这里才能让两条定义源共用同一个抽象。
|
||
|
||
**Files:**
|
||
- Create: `Assets/_Game/Scripts/AI/IAiDefinition.cs`
|
||
- Create: `Assets/_Game/Scripts/AI/AiRecipeSO.cs`
|
||
- Modify: `Assets/_Game/Scripts/AI/AiScript.cs`
|
||
|
||
- [ ] **Step 1: 建 `IAiDefinition`**
|
||
|
||
```csharp
|
||
namespace BaseGames.AI
|
||
{
|
||
/// <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;所有引用该资产的实例共享同一张 AiGraph(flyweight)。
|
||
/// </summary>
|
||
public abstract class AiRecipeSO : ScriptableObject, IAiDefinition
|
||
{
|
||
AiGraph _cached;
|
||
|
||
void OnEnable()
|
||
{
|
||
// 项目已关闭 Domain Reload:SO 的运行时态在多次 Play 会话间会残留,
|
||
// 改了配方再进 Play 仍用旧图。登记到统一的"进入 Play 前重置"通道。
|
||
PlayModeResetHook.Register(ClearCache);
|
||
}
|
||
|
||
void ClearCache() => _cached = null;
|
||
|
||
public AiGraph GetOrBuildGraph()
|
||
{
|
||
if (_cached == null)
|
||
{
|
||
var b = new BrainBuilder();
|
||
Build(b);
|
||
_cached = b.Build();
|
||
}
|
||
return _cached;
|
||
}
|
||
|
||
protected abstract void Build(BrainBuilder b);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 给 `BaseGames.AI` 加 `BaseGames.Core.Events` 引用**
|
||
|
||
`AiRecipeSO` 用了 `PlayModeResetHook`。`BaseGames.Core.Events` 的 `references` 为空数组,
|
||
加这条引用不会形成循环。编辑 `Assets/_Game/Scripts/AI/BaseGames.AI.asmdef`,把 `references` 改为:
|
||
|
||
```json
|
||
"references": [
|
||
"BaseGames.Core",
|
||
"BaseGames.Core.Events"
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 4: 让 `AiScript` 实现 `IAiDefinition`**
|
||
|
||
`AiScript.cs` 中把类声明改为:
|
||
|
||
```csharp
|
||
public abstract class AiScript : IAiDefinition
|
||
```
|
||
|
||
其余不动(`GetOrBuildGraph` 签名已匹配)。
|
||
|
||
- [ ] **Step 5: 确认编译 + 现有测试通过**
|
||
|
||
MCP 调 `mcp__unity__unity_get_compilation_errors`,期望无错误。
|
||
运行 `AiScriptTests`、`AiDefinitionRegistryTests`。期望:全部 PASS。
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add Assets/_Game/Scripts/AI
|
||
git commit -m "feat(ai): IAiDefinition + AiRecipeSO 配方基类(含关闭域重载的缓存重置)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: `PerceptionRecipeSO` + `SubclassSelectorDrawer`
|
||
|
||
**Files:**
|
||
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs`
|
||
- Create: `Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs`
|
||
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs`
|
||
- Test: `Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs`
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
新建 `Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs`:
|
||
|
||
```csharp
|
||
using NUnit.Framework;
|
||
using UnityEngine;
|
||
using BaseGames.AI;
|
||
using BaseGames.Enemies;
|
||
|
||
namespace BaseGames.Tests.EditMode.AI
|
||
{
|
||
public class PerceptionRecipeSoTests
|
||
{
|
||
static PerceptionRecipeSO MakeE001Recipe()
|
||
{
|
||
var so = ScriptableObject.CreateInstance<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`:
|
||
|
||
```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(配方资产)或 _definitionId(AiScript id)。当前 recipe={(hasRecipe ? _recipe.name : "空")}, id='{_definitionId}':{name}",
|
||
this);
|
||
enabled = false;
|
||
}
|
||
}
|
||
|
||
// 在 Start 构造 runtime:确保 EnemyBase.Awake 已发现 Locomotion/子系统后,
|
||
// 再触发入口状态 OnEnter(其会声明 locomotion 意图)。所有 Awake 先于任一 Start 执行。
|
||
void Start()
|
||
{
|
||
IAiDefinition definition = _recipe != null
|
||
? (IAiDefinition)_recipe
|
||
: AiDefinitionRegistry.GetDefinition(_definitionId); // 不存在则抛异常
|
||
var graph = definition.GetOrBuildGraph();
|
||
_context = new EnemyBrainContext(_enemy);
|
||
_runtime = new AiRuntime(graph, _context);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 给 `AiDefinitionRegistry` 加 `GetDefinition`**
|
||
|
||
在 `AiDefinitionRegistry.cs` 的 `GetGraph` 之后追加(保留 `GetGraph` 不动,仍有测试依赖):
|
||
|
||
```csharp
|
||
/// <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` 脚手架
|
||
|
||
> **T13 审查遗留的两项,在本任务一并做**(当时推迟的理由:彼时只有测试调用,无活的隐患;
|
||
> 本任务的向导是第二个调用方,改名与缓存失效在此落地最自然):
|
||
>
|
||
> 1. **`PerceptionRecipeSO.SetModulesForTests` 改名为 `AssignModules`** —— 它不再只服务测试,
|
||
> 向导也要用它装配模块,原名误导。
|
||
> 2. **装配后必须让已缓存的图失效**。`AiRecipeSO` 持有 `_cached`(flyweight);若在
|
||
> `GetOrBuildGraph()` 之后改模块,改动不会生效直到下次 `PlayModeResetHook` 清理——
|
||
> 静默的陈旧图。为此在 `AiRecipeSO` 加:
|
||
> ```csharp
|
||
> /// <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`:
|
||
|
||
```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_Ai(BrainGraph)。");
|
||
}
|
||
```
|
||
|
||
其余 6 处提示文案(原第 341、405、601、688、819、929、1028 行附近,以
|
||
`grep -n "AI(BrainGraph)" Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs` 实际定位)
|
||
统一替换为:
|
||
|
||
```csharp
|
||
report.Add("★ AI(BrainGraph):用菜单 BaseGames/AI/Enemy AI Recipe Wizard 建一个配方资产," +
|
||
"在 Inspector 选三层模块(未发现/交战/死亡),再挂 EnemyAiBrain 组件并把 _recipe 指向该资产。" +
|
||
"有独门机制的敌人才写 [AiDefinition] 的 AiScript 子类并改用 _definitionId。");
|
||
```
|
||
|
||
- [ ] **Step 6: 确认编译通过**
|
||
|
||
MCP 调 `mcp__unity__unity_get_compilation_errors`。期望:无错误。
|
||
|
||
- [ ] **Step 7: 跑全部 AI 测试**
|
||
|
||
Test Runner 运行 `BaseGames.Tests.EditMode.AI`。期望:全部 PASS。
|
||
|
||
- [ ] **Step 8: 跑项目自检工具(CLAUDE.md 第 3 条)**
|
||
|
||
依次执行菜单:
|
||
- `BaseGames/Tools/Validation/Validate All ScriptableObjects`
|
||
- `BaseGames/Addressables/Validate Address Keys`
|
||
- `BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check`
|
||
|
||
期望:三项均无新增错误。若出现与本次改动无关的既有告警,记录但不修。
|
||
|
||
- [ ] **Step 9: 提交**
|
||
|
||
```bash
|
||
git add -A Assets/_Game/Data/Enemies/E001 Assets/_Game/Scripts/Enemies/AIBrain/Ai Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
|
||
git commit -m "refactor(enemy): E001 AI 迁移为配方资产,退役定制脚本"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: 作者指南文档
|
||
|
||
150 只敌人规模下,「新敌人 AI 怎么写」必须有一份长期文档,否则三条作者路径会各写各的。
|
||
|
||
**Files:**
|
||
- Create: `Docs/Guides/06_EnemyAiAuthoring.md`
|
||
|
||
- [ ] **Step 1: 写文档**
|
||
|
||
新建 `Docs/Guides/06_EnemyAiAuthoring.md`,内容需覆盖:
|
||
|
||
1. **三条作者路径与选择判据**
|
||
- 配方资产(约 80%):三个下拉搞定,零代码零编译
|
||
- 配方 + 自定义前置态(约 15%):写一个 `AiScript` 调 `PerceptionSkeleton.Add` 后追加态
|
||
- 定制脚本(约 5%):完全自己写图,不用骨架(如陷阱型敌人)
|
||
2. **四层职责**:骨架固定,三层可插拔;每个构件只声明自己拥有的态、只挂自己负责的边
|
||
3. **现有模块清单**:8 个模块各自的语义、参数、适用敌人
|
||
4. **新增模块的方法**:实现对应接口 + 打 `[Serializable]` + 提供无参构造 → 自动出现在配方下拉;
|
||
强调不要在构造函数里做校验或缓存(`[SerializeReference]` 不保证走 ctor)
|
||
5. **六个敌人的组装范例**(E001–E006,含 E002 完全不用骨架的写法、E003 掉落前置链的写法)
|
||
6. **常见陷阱**:
|
||
- 未发现层内部边必须放 `Link` 不能放 `Declare`(否则抢在升级边前)
|
||
- 死亡态用 `AbilityOnce` 不用 `Ability`(否则演出无限重播)
|
||
- 状态名只是 trace 标签,不驱动动画;动画归能力与 `EnemyLocomotion`
|
||
- 受击 / 硬直不进 AI 图,由物理状态机 + `IsControllable` 门处理
|
||
|
||
- [ ] **Step 2: 提交**
|
||
|
||
```bash
|
||
git add Docs/Guides/06_EnemyAiAuthoring.md
|
||
git commit -m "docs(enemy): 新增敌人 AI 作者指南(三条路径 + 模块清单 + 六个范例)"
|
||
```
|
||
|
||
---
|
||
|
||
## 收尾检查
|
||
|
||
- [ ] `grep -rn "PerceptionStateMachine" Assets Docs Docs_Dev --include=*.cs --include=*.md` 只在 spec / plan 的历史叙述中出现
|
||
- [ ] `grep -rn "PatrolBetweenChases\|EngagementStyle" Assets --include=*.cs` 无结果
|
||
- [ ] Test Runner 全量 EditMode 通过
|
||
- [ ] 三个自检工具无新增错误
|
||
- [ ] `git log --oneline` 有 17 次提交,每次一个逻辑单元(CLAUDE.md 第 5 条)
|
||
|
||
## 本次不做(已在 spec §8 定案)
|
||
|
||
- `AiDefinitionValidator` 校验器、`_definitionId` 下拉 Drawer —— 紧接本次,单独一轮
|
||
- `AiSignal.Engaged` 战斗激活信号 —— 等 E003 / E004 落地时才加,现在加就是死枚举
|
||
- Boss 双轨统一(`BossSkillExecutor` vs `EnemyAttackSelector`)—— 单独立项
|
||
- `AiGraphExporter` 图导出 —— 路线图
|