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>
This commit is contained in:
2026-07-29 13:59:54 +08:00
co-authored by Claude Opus 5
parent 023a4a4ec6
commit 6a885ec105
@@ -4,7 +4,7 @@
**Goal:** 用「固定感知骨架 + 可插拔的 未发现层 / 交战层 / 死亡层」替换 `PerceptionStateMachine`,并让约 95% 的敌人 AI 成为配方资产而非 C# 类。
**Architecture:** 个模块契约(`IUnawareModule` / `IEngagementModule` / `IDeathModule`)各自声明自己的状态与边;`PerceptionSkeleton` 只负责把「未发现 → 警觉 → 交战」的升降级边挂到模块声明的态上。模块是 `[Serializable]` 普通类,既能在 C# 里 `new`,也能经 `[SerializeReference]` 存进 `PerceptionRecipeSO` 配方资产。`EnemyAiBrain` 上「配方资产引用」与「AiScript 定义 id」二选一。
**Architecture:** 个模块契约(`IUnawareModule` / `IEngagementModule`)各自声明自己的状态与边;`PerceptionSkeleton` 只负责把「未发现 → 警觉 → 交战」的升降级边挂到模块声明的态上,并自行声明 `Death` 终态(死亡归物理层,不做成可插拔层——见 Task 9R)。模块是 `[Serializable]` 普通类,既能在 C# 里 `new`,也能经 `[SerializeReference]` 存进 `PerceptionRecipeSO` 配方资产。`EnemyAiBrain` 上「配方资产引用」与「AiScript 定义 id」二选一。
**Tech Stack:** Unity / C# 9 / NUnit EditMode / 自研 BrainGraph`BaseGames.AI`
@@ -60,17 +60,15 @@ Assets/_Game/Scripts/AI/ 框架层(只依赖 Base
Assets/_Game/Scripts/Enemies/AIBrain/Modules/ 敌人决策组合层
AiStateFragments.cs 建态原语(4 个)
IUnawareModule.cs / IEngagementModule.cs / IDeathModule.cs
PerceptionSkeleton.cs 感知三层规则
IUnawareModule.cs / IEngagementModule.cs
PerceptionSkeleton.cs 感知规则 + 自声明 Death 终态
Unaware/SinglePost.cs
Unaware/DisguiseThenPatrol.cs
Unaware/AlternatingIdlePatrol.cs
Engagement/RushExit.cs
Engagement/RushEngagement.cs
Engagement/ApproachAttackEngagement.cs
Death/TerminalDeath.cs
Death/AbilityDeath.cs
Death/TwoStageDeath.cs
(无 Death/ 目录:死亡归物理层,见 Task 9R)
Assets/_Game/Scripts/Enemies/AIBrain/Recipes/
PerceptionRecipeSO.cs 一个 SO 类型覆盖所有感知型敌人
@@ -1408,7 +1406,24 @@ git commit -m "feat(enemy): ApproachAttackEngagement——攻击改为起手即
---
## Task 9: 死亡层三个模块
## Task 9: ~~死亡层三个模块~~ → 已作废,改为 Task 9R
> **本任务已作废。** 三个死亡模块曾按下方原稿实现并提交(`3bc7eb1`、`5a9c1fd`),
> 但质量审查发现 `TwoStageDeath` 在真实运行中必然卡死,追根后确认整个死亡层放错了层。
> 详见 spec 的「修订:死亡层取消」一节与 commit `023a4a4`。
>
> **必然卡死的机制**`EnemyBase.PerformDeath` 先 `ForceState(EnemyStateType.Dead)`(终态)
> 再 `_brain.Send(AiSignal.Died)`。`IsControllable` 是 `CurrentState == Controlled`,此后永久为假。
> `AiRuntime.Tick` 的顺序是「事件转换(不受门限制)→ IsControllable 让位门(为假即 return)→
> 条件转换」,所以 `Died` 事件能切进死亡链,但**此后任何条件边都不再被求值**,
> `TwoStageDeath` 的 `preDone` 边永不触发。
>
> `AbilityDeath` 同样不成立:死亡态 `OnEnter` 执行时,`InterruptAll(Dead)` 已跑过、
> 碰撞体已关、`Dead` 动画已在播,此刻触发能力是在和 `PerformDeath` 的死亡演出打架。
>
> **由 Task 9R 取代。** 下方原稿仅作历史记录保留,不要照它实施。
<details><summary>原稿(已作废,勿实施)</summary>
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/TerminalDeath.cs`
@@ -1447,10 +1462,10 @@ namespace BaseGames.Tests.EditMode.AI
var rt = RunWithDeath(new TerminalDeath(), ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
Assert.AreEqual(0, ctx.C.Used.Count); // 演出走物理状态机,AI 不触发能力
rt.Tick(0.1f);
Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
[Test]
@@ -1607,6 +1622,49 @@ git add Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death Assets/Tests/EditMode
git commit -m "feat(enemy): 死亡层三模块(终态/单段/两段)"
```
</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`
@@ -1651,8 +1709,7 @@ namespace BaseGames.Tests.EditMode.AI
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
unaware ?? new SinglePost(LocomotionMode.Patrol),
engagement ?? new RushEngagement("rush", RushExit.OnLostTarget),
new TerminalDeath());
engagement ?? new RushEngagement("rush", RushExit.OnLostTarget));
return new AiRuntime(b.Build(), ctx);
}
@@ -1767,7 +1824,7 @@ namespace BaseGames.Tests.EditMode.AI
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
[Test]
@@ -1778,7 +1835,7 @@ namespace BaseGames.Tests.EditMode.AI
ctx.V.Controllable = false; // 硬直中
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(TerminalDeath.Death, rt.CurrentStateName);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
// ── 校验 ──────────────────────────────────────────────────────────
@@ -1806,7 +1863,7 @@ namespace BaseGames.Tests.EditMode.AI
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new BadUnaware(), new RushEngagement("rush"), new TerminalDeath()));
b, new BadUnaware(), new RushEngagement("rush")));
}
[Test]
@@ -1814,7 +1871,7 @@ namespace BaseGames.Tests.EditMode.AI
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new UndeclaredUnaware(), new RushEngagement("rush"), new TerminalDeath()));
b, new UndeclaredUnaware(), new RushEngagement("rush")));
}
[Test]
@@ -1822,7 +1879,7 @@ namespace BaseGames.Tests.EditMode.AI
{
var b = new BrainBuilder();
Assert.Throws<System.ArgumentNullException>(() => PerceptionSkeleton.Add(
b, null, new RushEngagement("rush"), new TerminalDeath()));
b, null, new RushEngagement("rush")));
}
}
}
@@ -1844,8 +1901,8 @@ using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到个模块声明的态上。
/// 骨架永远只做这一件事——未发现态长什么样、交战怎么打、怎么死,全部归模块。
/// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到个模块声明的态上。
/// 骨架永远只做这一件事——未发现态长什么样、交战怎么打,全部归模块。
///
/// 规则:
/// 未发现:在追逐区 → 交战(优先);否则 有警觉态且在视野 → 警觉;否则保持。
@@ -1853,12 +1910,19 @@ namespace BaseGames.Enemies
/// 交战: 脱战条件由交战模块自定,脱战一律回 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();
@@ -1867,18 +1931,16 @@ namespace BaseGames.Enemies
public static void Add(BrainBuilder b,
IUnawareModule unaware,
IEngagementModule engagement,
IDeathModule death)
IEngagementModule engagement)
{
if (b == null) throw new ArgumentNullException(nameof(b));
if (unaware == null) throw new ArgumentNullException(nameof(unaware));
if (engagement == null) throw new ArgumentNullException(nameof(engagement));
if (death == null) throw new ArgumentNullException(nameof(death));
// 1) 各模块先声明自己的态(未发现层此阶段只声明态,不挂内部边)
unaware.Declare(b);
engagement.Build(b, unaware.Rest);
death.Build(b);
AiStateFragments.Terminal(b, Death);
var states = unaware.States;
if (states == null || states.Count == 0)
@@ -1890,7 +1952,7 @@ namespace BaseGames.Enemies
$"{unaware.GetType().Name}: Rest='{unaware.Rest}' 必须属于 States。");
b.Entry(unaware.Entry);
b.Global().To(death.EntryState).OnEvent(AiSignal.Died);
b.Global().To(Death).OnEvent(AiSignal.Died);
// 2) 升级边先挂——AiRuntime 按声明顺序评估条件边,升级必须优先于
// 未发现层的内部边(如巡逻计时),否则玩家进追逐区那一帧可能被计时边抢走。
@@ -1949,7 +2011,7 @@ using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// E001(草蛭)AI —— 纯决策层,组装层模块。
/// E001(草蛭)AI —— 纯决策层,组装层模块。
/// 出生伪装静止(伪装成石头),一旦交战过就只回巡逻;追击=带冷却的接触冲锋,
/// 起手即锁定、打完回巡逻走冷却;死亡演出走物理状态机(EnemyBase.PerformDeath)。
/// AI 只决策;移动 / 朝向 / 速度 / 动画 / 伤害全部在能力与 EnemyLocomotion 里实现。
@@ -1960,8 +2022,7 @@ namespace BaseGames.Enemies
{
protected override void Build(BrainBuilder b) => PerceptionSkeleton.Add(b,
unaware: new DisguiseThenPatrol(),
engagement: new RushEngagement("e001_chase", RushExit.Committed),
death: new TerminalDeath());
engagement: new RushEngagement("e001_chase", RushExit.Committed));
}
}
```
@@ -2130,8 +2191,7 @@ namespace BaseGames.Tests.EditMode.AI
var so = ScriptableObject.CreateInstance<PerceptionRecipeSO>();
so.SetModulesForTests(
new DisguiseThenPatrol(),
new RushEngagement("e001_chase", RushExit.Committed),
new TerminalDeath());
new RushEngagement("e001_chase", RushExit.Committed));
return so;
}
@@ -2152,8 +2212,7 @@ namespace BaseGames.Tests.EditMode.AI
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
new DisguiseThenPatrol(),
new RushEngagement("e001_chase", RushExit.Committed),
new TerminalDeath());
new RushEngagement("e001_chase", RushExit.Committed));
var handWritten = b.Build();
Assert.AreEqual(handWritten.EntryState, fromRecipe.EntryState);
@@ -2235,16 +2294,17 @@ namespace BaseGames.Enemies
[Tooltip("交战层:发现玩家之后怎么打")]
[SerializeReference, SubclassSelector] IEngagementModule _engagement = new RushEngagement();
[Tooltip("死亡层:怎么死")]
[SerializeReference, SubclassSelector] IDeathModule _death = new TerminalDeath();
// 无「死亡层」字段:死亡归物理层,骨架自己声明 Death 终态。详见 spec 的
// 「修订:死亡层取消」——PerformDeath 先 ForceState(Dead) 再发 Died 信号,
// 此后 IsControllable 永久为假,死亡链里的条件边永不被求值。
protected override void Build(BrainBuilder b)
=> PerceptionSkeleton.Add(b, _unaware, _engagement, _death);
=> PerceptionSkeleton.Add(b, _unaware, _engagement);
/// <summary>仅供 EditMode 测试与脚手架向导装配模块,运行时不使用。</summary>
public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement, IDeathModule death)
public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement)
{
_unaware = unaware; _engagement = engagement; _death = death;
_unaware = unaware; _engagement = engagement;
}
}
}