diff --git a/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md b/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md
new file mode 100644
index 00000000..03d8a61c
--- /dev/null
+++ b/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md
@@ -0,0 +1,2848 @@
+# Boss 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:** 把 Boss 的决策层与执行层一并归并到小怪轨(BrainGraph + `EnemyAbilitySO`),退役 `BossSkillSO` / `BossSkillExecutor` 整套旧轨及其两条脚手架创建链路,并让嘲风真正能打。
+
+**Architecture:** 决策归 AI 图(每 Boss 一个 `AiScript` 定制类),编排归动画时间轴(clip + 归一化时机 + 动画事件),"能不能用"归招式自己(`CanUse` + 组件启用),"选哪个"归 `EnemyAttackSelector`,演出归旁挂组件。Boss 专属仅一个 `IBossControl` facet(阶段)+ 几个旁挂 MonoBehaviour。
+
+**Tech Stack:** Unity 6 / C# 9、Animancer、UI Toolkit(编辑器)、NUnit(EditMode 测试)、自研 `BrainGraph`(`BrainBuilder` / `AiRuntime` / `AiGraph`)
+
+**Spec:** `Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md`
+
+---
+
+## 执行须知
+
+### 分两阶段,各自独立可提交
+
+- **阶段一(Task 1–15):只加不删。** 每个 Task 结束时工程编译通过、测试全绿。阶段一末尾嘲风已能用新轨打起来,旧轨仍在但已无人调用。
+- **阶段二(Task 16–21):删旧轨。** 顺序不可乱:先改引用方(`BossBase` / `BossResource` / 两个脚手架),再删被引用的类型;先删资产,再删脚本。
+
+**任何时刻工程都必须能编译。** 每个 Task 的最后一步都是提交。
+
+### 怎么跑测试与编译检查
+
+本项目无命令行测试入口,一律经 Unity Editor:
+
+- **编译检查**:MCP `mcp__unity__unity_get_compilation_errors`。期望 `0 errors, 0 warnings`。
+- **跑 EditMode 测试**:Unity 菜单 `Window > General > Test Runner` → `EditMode` 页 → 选中目标测试 → `Run Selected`。
+ 或经 MCP `mcp__unity__unity_execute_menu_item`。
+- **三件自检工具**(阶段末尾跑):
+ - `BaseGames/Tools/Validation/Validate All ScriptableObjects`
+ - `BaseGames/Addressables/Validate Address Keys`
+ - `BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check`
+
+⚠️ **MCP 卡死排查**:Unity 编辑器窗口失焦会让 MCP 队列无限期卡住(不是超时)。若某个 MCP 调用长时间无返回,把 Unity 窗口置前即可恢复,不要盲目重试。
+
+### 项目硬约束
+
+1. **禁止下游兜底**(CLAUDE.md 第 6 条):不要用 `?? 默认值` / `try-catch` / `if(null) return` 掩盖上游漏配。缺配置就 `Debug.LogError` 或抛异常。
+2. **代码里禁止出现其他游戏名**(CLAUDE.md 第 4 条):注释 / `[Tooltip]` / `[Header]` / 命名一律只描述功能。
+3. **已关闭 Domain Reload**:新增的 SO 运行时缓存态必须走 `PlayModeResetHook`;静态态走 `[RuntimeInitializeOnLoadMethod]`。本计划新增的都是 MonoBehaviour 运行时态,靠 `OnSpawn` 重置。
+4. **资产必须经脚手架创建**(第 2 条):不裸 `CreateAsset`。这正是 Task 13–15 存在的原因。
+
+---
+
+## 文件结构
+
+### 新建
+
+| 文件 | 责任 |
+|---|---|
+| `Assets/_Game/Scripts/AI/IBossControl.cs` | Boss 专属决策面(阶段 + 资源满值查询)。放 `BaseGames.AI`,不引用 Enemies |
+| `Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs` | 阶段=换招池:按阶段启停能力组件 |
+| `Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs` | 竞技场锚点集合(一组 Transform) |
+| `Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs` | 开战触发:发 `EVT_BossFightStarted` + `AiSignal.Engaged` |
+| `Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs` | Boss 图共享建态片段(阶段过渡 / 逼近选招 / 移动到锚点) |
+| `Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs` | 嘲风的 `AiScript` 定制图 |
+| `Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs` | 测试用 `IBossControl` 假实现 |
+| `Assets/Tests/EditMode/AI/ChaoFengAiTests.cs` | 嘲风图状态序列断言 |
+| `Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs` | 阶段门启停集断言 |
+| `Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs` | 非 Boss 访问 `Boss` facet 抛异常 |
+| `Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs` | 弱点倍率断言 |
+
+### 修改
+
+| 文件 | 改动 |
+|---|---|
+| `Assets/_Game/Scripts/AI/IAiContext.cs` | `+ IBossControl Boss { get; }` |
+| `Assets/_Game/Scripts/AI/AiSignal.cs` | `+ Engaged` |
+| `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs` | 实现 `Boss`(非 Boss 抛异常) |
+| `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs` | 实现 `IBossControl`;接阶段门;阶段二瘦身约 150 行 |
+| `Assets/_Game/Scripts/Enemies/Boss/BossResource.cs` | `+ IsFull`;阶段二删自动触发 |
+| `Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs` | 阶段二删自动触发三字段 |
+| `Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs` | `StopBehaviorTree()` 调用点改名 |
+| `Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs` | `+ WeightedRandomAntiRepeat` |
+| `Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs` | 防重复权重折扣 |
+| `Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs` | `CanUse += enabled`;阶段二删 `TelegraphRoutine` |
+| `Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs` | `+ designNote`;`fileName → ABL_`;阶段二删 telegraph 两字段 |
+| `Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs` | `+ attackAntiRepeatFactor` |
+| `Assets/_Game/Scripts/Enemies/EnemyBase.cs` | 选招器传系数;阶段二改名 `NotifyDecisionStop`、删 `ConsumeParryEvent` |
+| `Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs` | 调用点改名 |
+| `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs` | 建态逻辑提 `internal static` |
+| `Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs` | `+ 4 case`、`+ _hurtBox` / `_feedback` |
+| `Assets/_Game/Scripts/Combat/HurtBox.cs` | `+ _damageMultiplier` |
+| `Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs` | Boss 页改建 `EnemyAbilitySO` |
+| `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs` | `PlaceChaoFeng` 改挂新轨组件 |
+| `Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs` | `+ Boss` |
+| `Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs` | `+ 防重复测试` |
+
+### 删除(阶段二)
+
+```
+Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_{Boomerang,FanCombo,TornadoSmall,TornadoLarge,WindStone}.asset
+Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs
+Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs
+Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs
+Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs
+Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs
+Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs
+Assets/_Game/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs
+Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs
+Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs
+```
+
+---
+
+# 阶段一:加通(只加不删)
+
+## Task 1: `IBossControl` 接口与 `IAiContext.Boss` 接缝
+
+**Files:**
+- Create: `Assets/_Game/Scripts/AI/IBossControl.cs`
+- Modify: `Assets/_Game/Scripts/AI/IAiContext.cs`
+- Create: `Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs`
+- Modify: `Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs`
+
+- [ ] **Step 1: 建 `IBossControl`**
+
+`Assets/_Game/Scripts/AI/IBossControl.cs`:
+
+```csharp
+namespace BaseGames.AI
+{
+ ///
+ /// Boss 专属决策面。挂在 上供 Boss 图读取阶段状态、发起阶段过渡、
+ /// 查询竞技场锚点。只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。
+ /// 非 Boss 敌人访问 会抛异常——这是有意的显式失败。
+ ///
+ public interface IBossControl
+ {
+ /// 当前阶段索引(出生为 0)。
+ int CurrentPhase { get; }
+
+ /// 是否处于阶段过渡(无敌 + 过渡演出)期间。
+ bool IsPhaseTransitioning { get; }
+
+ /// Boss 资源是否已满(无资源组件时恒为 false)。
+ bool ResourceFull { get; }
+
+ /// 发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。
+ void BeginPhaseTransition(int targetPhase, float invincibleDuration);
+
+ // 竞技场锚点两成员(AnchorAt / DistanceToAnchor)在 Task 10 与
+ // BossArenaAnchors 组件一并加入——此刻加会让 Task 2–8 期间 BossBase 编译不过。
+ }
+}
+```
+
+> `using UnityEngine;` 此刻还用不上(`Vector2` 在 Task 10 才引入),先不写,Task 10 再加。
+
+- [ ] **Step 2: `IAiContext` 加 `Boss`**
+
+`Assets/_Game/Scripts/AI/IAiContext.cs`,在 `Blackboard Blackboard { get; }` 之后追加:
+
+```csharp
+ ///
+ /// Boss 专属决策面。**非 Boss 敌人访问本属性会抛 InvalidOperationException**——
+ /// 小怪图误用 Boss 边必须立刻暴露,不返回空对象把问题盖住。
+ ///
+ IBossControl Boss { get; }
+```
+
+- [ ] **Step 3: 建 `FakeBossControl`**
+
+`Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs`:
+
+```csharp
+using BaseGames.AI;
+
+namespace BaseGames.Tests.EditMode.AI
+{
+ public sealed class FakeBossControl : IBossControl
+ {
+ public int Phase;
+ public bool Transitioning;
+ public bool Full;
+
+ // 记录最近一次过渡请求,供断言"图确实发起了过渡"
+ public int LastTargetPhase = -1;
+ public float LastDuration = -1f;
+ public int BeginCallCount;
+
+ // 锚点:测试按下标直给坐标;距离由测试直接设定
+ public UnityEngine.Vector2[] Anchors = new UnityEngine.Vector2[0];
+ public float DistanceToAnchorValue;
+
+ public int CurrentPhase => Phase;
+ public bool IsPhaseTransitioning => Transitioning;
+ public bool ResourceFull => Full;
+
+ public void BeginPhaseTransition(int targetPhase, float invincibleDuration)
+ {
+ LastTargetPhase = targetPhase;
+ LastDuration = invincibleDuration;
+ BeginCallCount++;
+ Transitioning = true; // 由测试手工置回 false 模拟过渡结束
+ }
+
+ public UnityEngine.Vector2 AnchorAt(int index) => Anchors[index];
+ public float DistanceToAnchor(int index) => DistanceToAnchorValue;
+ }
+}
+```
+
+- [ ] **Step 4: `FakeAiContext` 暴露 `Boss`**
+
+`Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs`,在 `FakeAiContext` 类里追加字段与属性:
+
+```csharp
+ public FakeBossControl B = new FakeBossControl();
+ public IBossControl Boss => B;
+```
+
+- [ ] **Step 5: 编译检查**
+
+MCP `mcp__unity__unity_get_compilation_errors`。
+期望:因 `EnemyBrainContext` 未实现 `Boss` 而报 1 个错误
+(`'EnemyBrainContext' does not implement interface member 'IAiContext.Boss'`)——下一个 Task 修。
+
+- [ ] **Step 6: 暂不提交**
+
+本 Task 故意留下编译错误,与 Task 2 合并提交。跳过提交,直接进 Task 2。
+
+---
+
+## Task 2: `EnemyBrainContext` 实现 `Boss`,`BossBase` 实现 `IBossControl`
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/BossResource.cs`
+- Test: `Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+`Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs`:
+
+```csharp
+using System;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using BaseGames.Enemies;
+
+namespace BaseGames.Tests.EditMode.Enemies
+{
+ public class EnemyBrainContextBossFacetTests
+ {
+ // EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。
+ [SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true;
+ [TearDown] public void TearDown() => LogAssert.ignoreFailingMessages = false;
+
+ static T MakeEnemy(string name) where T : EnemyBase
+ {
+ var go = new GameObject(name);
+ return go.AddComponent();
+ }
+
+ [Test]
+ public void NonBoss_AccessingBossFacet_Throws()
+ {
+ var enemy = MakeEnemy("plain-enemy");
+ var ctx = new EnemyBrainContext(enemy);
+
+ Assert.Throws(() => { var _ = ctx.Boss; });
+
+ UnityEngine.Object.DestroyImmediate(enemy.gameObject);
+ }
+
+ [Test]
+ public void Boss_AccessingBossFacet_ReturnsTheBoss()
+ {
+ var boss = MakeEnemy("boss");
+ var ctx = new EnemyBrainContext(boss);
+
+ Assert.AreSame(boss, ctx.Boss);
+ Assert.AreEqual(0, ctx.Boss.CurrentPhase);
+ Assert.IsFalse(ctx.Boss.IsPhaseTransitioning);
+ Assert.IsFalse(ctx.Boss.ResourceFull); // 未挂 BossResource
+
+ UnityEngine.Object.DestroyImmediate(boss.gameObject);
+ }
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Test Runner → EditMode → `EnemyBrainContextBossFacetTests`。
+期望:编译失败(`EnemyBrainContext` 未实现 `Boss`、`BossBase` 未实现 `IBossControl`)。
+
+- [ ] **Step 3: `BossResource` 加 `IsFull`**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossResource.cs`,在 `NormalizedValue` 属性之后追加:
+
+```csharp
+ /// 资源是否已达上限(供 AI 图条件边判定"可放大招")。
+ public bool IsFull => _config != null && _config.maxValue > 0f
+ && _currentValue >= _config.maxValue;
+```
+
+- [ ] **Step 4: `BossBase` 实现 `IBossControl`**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossBase.cs`:
+
+1. 类声明改为:
+
+```csharp
+ public class BossBase : EnemyBase, BaseGames.AI.IBossControl
+```
+
+2. 在 `public int CurrentPhase => _currentPhase;` 之后追加:
+
+```csharp
+ /// Boss 资源是否已满(未挂资源组件时恒为 false)。IBossControl 实现。
+ public bool ResourceFull => _bossResource != null && _bossResource.IsFull;
+```
+
+`CurrentPhase` / `IsPhaseTransitioning` / `BeginPhaseTransition(int, float)` 三个成员已存在且签名匹配,
+无需改动(`BeginPhaseTransition` 的可选参数不影响接口实现)。
+
+3. 顺带把 `CurrentPhase` 与 `IsBossSkillExecuting` 的注释里残留的 `BD_UseBossSkill` / `BD Task` 字样删掉
+(行为树债,CLAUDE.md 第 4 条同款问题):
+
+```csharp
+ /// 当前是否有 Boss 技能正在执行。
+ public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting;
+```
+
+```csharp
+ /// 当前 Boss 阶段索引。IBossControl 实现。
+ public int CurrentPhase => _currentPhase;
+```
+
+- [ ] **Step 5: `EnemyBrainContext` 实现 `Boss`**
+
+`Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs`:
+
+1. 文件顶部加 `using System;`(现有 using 只有 `UnityEngine` 与 `BaseGames.AI`)。
+
+2. 字段与构造函数改为:
+
+```csharp
+ readonly EnemyBase _enemy;
+ readonly BossBase _boss;
+ readonly Blackboard _blackboard = new Blackboard();
+ Vector2 _lastKnown;
+ float _lostTimer;
+
+ public EnemyBrainContext(EnemyBase enemy)
+ {
+ _enemy = enemy;
+ _boss = enemy as BossBase; // 非 Boss 为 null,Boss facet 访问时显式抛
+ }
+```
+
+3. 在 `public Blackboard Blackboard => _blackboard;` 之后追加:
+
+```csharp
+ ///
+ /// Boss 专属决策面。非 Boss 敌人访问即抛——小怪图误挂了 Boss 边必须立刻暴露,
+ /// 不返回空对象让错误静默通过(CLAUDE.md 第 6 条)。
+ ///
+ public IBossControl Boss => _boss ?? throw new InvalidOperationException(
+ $"[EnemyBrainContext] '{_enemy.name}' 不是 Boss(未挂 BossBase 或其子类)," +
+ "但它的 AI 图访问了 IAiContext.Boss。请检查该敌人是否误用了 Boss 专属的图或图片段。");
+```
+
+- [ ] **Step 6: 跑测试确认通过**
+
+Test Runner → EditMode → `EnemyBrainContextBossFacetTests`。期望:2 passed。
+
+- [ ] **Step 7: 全量 EditMode 回归**
+
+Test Runner → EditMode → `Run All`。期望:全绿(`FakeAiContext` 已在 Task 1 补上 `Boss`)。
+
+- [ ] **Step 8: 提交**
+
+```bash
+git add Assets/_Game/Scripts/AI/IBossControl.cs Assets/_Game/Scripts/AI/IBossControl.cs.meta \
+ Assets/_Game/Scripts/AI/IAiContext.cs \
+ Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs \
+ Assets/_Game/Scripts/Enemies/Boss/BossBase.cs \
+ Assets/_Game/Scripts/Enemies/Boss/BossResource.cs \
+ Assets/Tests/EditMode/AI/Fakes/ \
+ Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs \
+ Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs.meta
+git commit -m "feat(ai): 新增 IBossControl facet——Boss 阶段状态经 IAiContext 供图读取
+
+非 Boss 敌人访问 IAiContext.Boss 显式抛 InvalidOperationException,
+不返回空对象掩盖小怪图误挂 Boss 边的错误。BossResource 补 IsFull 供图条件边判定。"
+```
+
+---
+
+## Task 3: 选招器防重复
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/EnemyBase.cs`
+- Test: `Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+`Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs`,在 `Weighted_NeverPicksIneligible` 之后追加:
+
+```csharp
+ // ── WeightedRandomAntiRepeat ─────────────────────────────────────────
+
+ static EnemyAttackSelector SelAntiRepeat(float factor, params IAttackCandidate[] cs)
+ => new EnemyAttackSelector(new List(cs), factor);
+
+ [Test]
+ public void AntiRepeat_ZeroFactor_NeverPicksSameTwiceWhenAlternativeExists()
+ {
+ var a = new FakeCandidate { WeightV = 1f };
+ var b = new FakeCandidate { WeightV = 1f };
+ var s = SelAntiRepeat(0f, a, b);
+
+ var prev = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
+ for (int i = 0; i < 100; i++)
+ {
+ var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
+ Assert.AreNotSame(prev, cur, "系数为 0 时不应连续选中同一招");
+ prev = cur;
+ }
+ }
+
+ [Test]
+ public void AntiRepeat_ZeroFactor_SingleCandidate_StillReturnsIt()
+ {
+ // 只有一招时折扣会把权重压到 0,WeightedPick 返回 -1;
+ // 必须退化为 Priority 路径把它选出来,否则敌人永远不出手。
+ var only = new FakeCandidate { WeightV = 1f };
+ var s = SelAntiRepeat(0f, only);
+
+ for (int i = 0; i < 10; i++)
+ Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat));
+ }
+
+ [Test]
+ public void AntiRepeat_DiscountsPreviousPick_ButDoesNotBanIt()
+ {
+ // 系数 0.5:a 被选过后权重折半,但仍有机会再被选中。
+ var a = new FakeCandidate { WeightV = 1f };
+ var b = new FakeCandidate { WeightV = 1f };
+ var s = SelAntiRepeat(0.5f, a, b);
+
+ s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
+ int repeats = 0;
+ var prev = s.LastPicked;
+ for (int i = 0; i < 400; i++)
+ {
+ var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
+ if (ReferenceEquals(cur, prev)) repeats++;
+ prev = cur;
+ }
+ Assert.Greater(repeats, 0, "折扣不应等于禁用");
+ Assert.Less (repeats, 200, "折扣后重复率应低于无折扣时的约 50%");
+ }
+
+ [Test]
+ public void AntiRepeat_IneligiblePrevious_DoesNotBlockOthers()
+ {
+ var a = new FakeCandidate { WeightV = 1f };
+ var b = new FakeCandidate { WeightV = 1f };
+ var s = SelAntiRepeat(0f, a, b);
+
+ var first = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
+ ((FakeCandidate)first).CanUseV = false; // 上一招进冷却
+
+ var second = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
+ Assert.IsNotNull(second);
+ Assert.AreNotSame(first, second);
+ }
+
+ [Test]
+ public void PlainWeighted_DoesNotApplyAntiRepeat()
+ {
+ // WeightedRandom 模式下唯一候选恒被选中(回归:折扣不得泄漏到旧模式)
+ var only = new FakeCandidate { WeightV = 1f };
+ var s = SelAntiRepeat(0f, only);
+ for (int i = 0; i < 10; i++)
+ Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandom));
+ }
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Test Runner → EditMode → `EnemyAttackSelectorTests`。
+期望:编译失败(`AttackSelectionMode.WeightedRandomAntiRepeat` 不存在、构造函数无第二参数、`LastPicked` 不存在)。
+
+- [ ] **Step 3: 加枚举值**
+
+`Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs` 全文替换为:
+
+```csharp
+namespace BaseGames.Enemies.Abilities
+{
+ /// 多攻击选招策略。
+ public enum AttackSelectionMode
+ {
+ WeightedRandom, // 合格集内按 weight 加权随机
+ Priority, // 合格集内取 priority 最高(并列取首个)
+ WeightedRandomAntiRepeat, // 同 WeightedRandom,但对上一次选中的招施加权重折扣,压低连续重复
+ }
+}
+```
+
+⚠️ 新值必须**追加在末尾**:枚举按整数序列化,插在中间会让已有资产的选招模式静默变成另一种。
+
+- [ ] **Step 4: 选招器实现折扣**
+
+`Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs` 全文替换为:
+
+```csharp
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace BaseGames.Enemies.Abilities
+{
+ ///
+ /// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤,
+ /// 再按 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。
+ ///
+ public sealed class EnemyAttackSelector
+ {
+ private readonly List _candidates;
+ // 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC
+ private readonly List _weightBuf;
+ // 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚
+ private readonly float _antiRepeatFactor;
+
+ private IAttackCandidate _lastPicked;
+
+ public EnemyAttackSelector(IEnumerable candidates, float antiRepeatFactor = 0.3f)
+ {
+ _candidates = new List(candidates);
+ _weightBuf = new List(_candidates.Count);
+ _antiRepeatFactor = Mathf.Clamp01(antiRepeatFactor);
+ }
+
+ public int Count => _candidates.Count;
+
+ /// 上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。
+ public IAttackCandidate LastPicked => _lastPicked;
+
+ /// 对象池复用 / 阶段切换时清除防重复记忆。
+ public void ResetRepeatMemory() => _lastPicked = null;
+
+ private static bool Eligible(IAttackCandidate c, bool hasLOS, bool grounded)
+ => c != null && c.CanUse && c.InAttackRange()
+ && (!c.RequiresLineOfSight || hasLOS)
+ && (!c.RequiresGrounded || grounded);
+
+ public bool HasEligible(bool hasLOS, bool grounded)
+ {
+ for (int i = 0; i < _candidates.Count; i++)
+ if (Eligible(_candidates[i], hasLOS, grounded)) return true;
+ return false;
+ }
+
+ public IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode)
+ {
+ switch (mode)
+ {
+ case AttackSelectionMode.Priority:
+ return Remember(SelectByPriority(hasLOS, grounded));
+ case AttackSelectionMode.WeightedRandomAntiRepeat:
+ return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: true));
+ default:
+ return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: false));
+ }
+ }
+
+ // 只在真选中了东西时更新记忆:选空时保留上一招,避免"空一帧就清掉防重复"。
+ private IAttackCandidate Remember(IAttackCandidate pick)
+ {
+ if (pick != null) _lastPicked = pick;
+ return pick;
+ }
+
+ private IAttackCandidate SelectByPriority(bool hasLOS, bool grounded)
+ {
+ IAttackCandidate best = null;
+ for (int i = 0; i < _candidates.Count; i++)
+ {
+ var c = _candidates[i];
+ if (!Eligible(c, hasLOS, grounded)) continue;
+ if (best == null || c.Priority > best.Priority) best = c; // 并列取首个
+ }
+ return best;
+ }
+
+ private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded, bool antiRepeat)
+ {
+ // 有效权重:不合格候选填 0(由共享原语保证零权重项绝不被选中)。缓冲区复用 → 选招零 GC。
+ _weightBuf.Clear();
+ for (int i = 0; i < _candidates.Count; i++)
+ {
+ var c = _candidates[i];
+ float w = Eligible(c, hasLOS, grounded) ? Mathf.Max(0f, c.Weight) : 0f;
+ // 防重复:上一招权重打折。折扣可把权重压到 0——此时若它是唯一候选,
+ // 下面的 Priority 退化路径会把它选出来,不会出现"敌人永远不出手"。
+ if (antiRepeat && w > 0f && ReferenceEquals(c, _lastPicked))
+ w *= _antiRepeatFactor;
+ _weightBuf.Add(w);
+ }
+
+ int idx = BaseGames.Core.WeightedPick.Index(_weightBuf);
+ if (idx >= 0) return _candidates[idx];
+
+ // 无正权重(合格候选权重全 0,或全被防重复折扣压平)→ 退化为按 Priority 选(并列取首个)
+ return SelectByPriority(hasLOS, grounded);
+ }
+ }
+}
+```
+
+- [ ] **Step 5: 跑测试确认通过**
+
+Test Runner → EditMode → `EnemyAttackSelectorTests`。期望:全部 passed(原 9 个 + 新 5 个 = 14)。
+
+- [ ] **Step 6: `EnemyStatsSO` 加系数字段**
+
+`Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs`,在 `attackSelectionMode` 字段之后追加:
+
+```csharp
+ [Tooltip("WeightedRandomAntiRepeat 模式下,上一次选中的招的权重折扣系数。" +
+ "0 = 有替代招时绝不连续重复;1 = 不惩罚。仅该模式生效")]
+ [Range(0f, 1f)] public float attackAntiRepeatFactor = 0.3f;
+```
+
+- [ ] **Step 7: `EnemyBase` 把系数传给选招器**
+
+`Assets/_Game/Scripts/Enemies/EnemyBase.cs` 的 `BuildAttackSelector()`,最后一行改为:
+
+```csharp
+ // 折扣系数是策划配置项,未配 StatsSO 的敌人用 0.3 这个合法业务缺省(非掩盖漏配:
+ // 该模式本身是可选的,没配就等于用默认折扣)。
+ float antiRepeat = _statsSO != null ? _statsSO.attackAntiRepeatFactor : 0.3f;
+ _attackSelector = new Abilities.EnemyAttackSelector(candidates, antiRepeat);
+```
+
+- [ ] **Step 8: 阶段切换清空防重复记忆**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossBase.cs` 的 `EnterPhase(int phase)`,把
+`LastUsedSkillId = null;` 那一行下面追加:
+
+```csharp
+ // 新阶段换招池,上一阶段的防重复记忆不应跨阶段影响选招
+ AttackSelector?.ResetRepeatMemory();
+```
+
+- [ ] **Step 9: 编译 + 全量回归**
+
+MCP `unity_get_compilation_errors` 期望 0 errors 0 warnings;Test Runner → EditMode → `Run All` 期望全绿。
+
+- [ ] **Step 10: 提交**
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs \
+ Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs \
+ Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs \
+ Assets/_Game/Scripts/Enemies/EnemyBase.cs \
+ Assets/_Game/Scripts/Enemies/Boss/BossBase.cs \
+ Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs
+git commit -m "feat(enemy): 选招器支持防重复折扣——WeightedRandomAntiRepeat
+
+上一次选中的招按 EnemyStatsSO.attackAntiRepeatFactor 折扣权重(默认 0.3)。
+折扣把唯一候选压到 0 时退化为 Priority 路径,不会出现敌人永不出手。
+阶段切换清空防重复记忆。取代 BossBase.UseBossSkillWeighted 里的同名逻辑。"
+```
+
+---
+
+## Task 4: 禁用的能力不可用
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs`
+- Test: `Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs`(本 Task 只建文件与第一个测试)
+
+- [ ] **Step 1: 写失败测试**
+
+`Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs`:
+
+```csharp
+using System.Collections;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using BaseGames.Enemies.Abilities;
+
+namespace BaseGames.Tests.EditMode.Enemies
+{
+ public class BossPhaseAbilityGateTests
+ {
+ /// 最小可实例化能力:不播动画、不碰 HitBox,只为验证启停与 CanUse。
+ private sealed class StubAbility : EnemyAbilityBase
+ {
+ protected override IEnumerator ExecuteCoroutine() { yield break; }
+ }
+
+ // EnemyAbilityBase.Awake 找不到 EnemyBase 会报错,与本测试无关。
+ [SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true;
+ [TearDown] public void TearDown() => LogAssert.ignoreFailingMessages = false;
+
+ [Test]
+ public void DisabledAbility_IsNotUsable()
+ {
+ var go = new GameObject("ability-host");
+ var ab = go.AddComponent();
+
+ ab.enabled = false;
+ Assert.IsFalse(ab.CanUse, "禁用的能力组件不可用——否则选招器会选中一个启不动协程的招");
+
+ Object.DestroyImmediate(go);
+ }
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Test Runner → EditMode → `BossPhaseAbilityGateTests.DisabledAbility_IsNotUsable`。
+期望:FAIL——`CanUse` 当前不看 `enabled`,返回 true。
+
+> 若因 `StubAbility` 上无 `EnemyBase` 导致 `_enemy == null` 使 `CanUse` 已为 false 而测试假通过:
+> 给 `go` 先 `AddComponent()`,再断言 `ab.enabled = true` 时 `CanUse` 为 true、
+> `false` 时为 false 的对比,确保断言真正测到 `enabled` 这一维。
+
+- [ ] **Step 3: `CanUse` 纳入 `enabled`**
+
+`Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs`,把 `CanUse` 改为:
+
+```csharp
+ ///
+ /// 统一可用性查询:组件启用 + 冷却完毕 + 未执行中 + 宿主存活。
+ /// enabled 这一维供阶段门使用——被阶段禁用的能力不得进入选招候选,
+ /// 否则选招器会选中它然后 StartCoroutine 在禁用组件上必然失败。
+ ///
+ public virtual bool CanUse => enabled && !_isRunning && !IsOnCooldown
+ && _enemy != null && _enemy.IsAlive;
+```
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Test Runner → EditMode → `BossPhaseAbilityGateTests`。期望:1 passed。
+
+- [ ] **Step 5: 全量回归**
+
+Test Runner → EditMode → `Run All`。期望全绿。
+⚠️ 若有测试因"能力默认 enabled=true 但断言了别的路径"而失败,说明该测试原本依赖了错误行为,按新语义修断言。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs \
+ Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs \
+ Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs.meta
+git commit -m "fix(enemy): 禁用的能力组件不再报告可用
+
+CanUse 纳入 enabled。此前禁用组件仍返回 true,选招器会选中一个
+StartCoroutine 必然失败的招。这也是阶段门(按阶段启停能力)的前提。"
+```
+
+---
+
+## Task 5: `BossPhaseAbilityGate` 阶段门
+
+**Files:**
+- Create: `Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs`
+- Test: `Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+`Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs`:
+
+先在文件顶部的 using 区补两行:
+
+```csharp
+using System.Reflection;
+using BaseGames.Enemies;
+```
+
+再在 `DisabledAbility_IsNotUsable` 之后追加:
+
+```csharp
+ // 阶段门的 _entries 是私有序列化字段——与放置脚手架同样用反射写入。
+ static void SetEntries(BossPhaseAbilityGate gate, params (EnemyAbilityBase ab, int[] phases)[] items)
+ {
+ var arr = new BossPhaseAbilityGate.PhaseEntry[items.Length];
+ for (int i = 0; i < items.Length; i++)
+ arr[i] = new BossPhaseAbilityGate.PhaseEntry { ability = items[i].ab, phases = items[i].phases };
+ typeof(BossPhaseAbilityGate)
+ .GetField("_entries", BindingFlags.NonPublic | BindingFlags.Instance)
+ .SetValue(gate, arr);
+ }
+
+ [Test]
+ public void ApplyPhase_EnablesOnlyAbilitiesAllowedInThatPhase()
+ {
+ var go = new GameObject("boss");
+ var gate = go.AddComponent();
+ var p0 = go.AddComponent();
+ var p1Go = new GameObject("p1"); p1Go.transform.SetParent(go.transform);
+ var p1 = p1Go.AddComponent();
+
+ SetEntries(gate, (p0, new[] { 0 }), (p1, new[] { 1 }));
+
+ gate.ApplyPhase(0);
+ Assert.IsTrue (p0.enabled);
+ Assert.IsFalse(p1.enabled);
+
+ gate.ApplyPhase(1);
+ Assert.IsFalse(p0.enabled);
+ Assert.IsTrue (p1.enabled);
+
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ApplyPhase_EmptyPhaseArrayMeansAllPhases()
+ {
+ var go = new GameObject("boss");
+ var gate = go.AddComponent();
+ var any = go.AddComponent();
+
+ SetEntries(gate, (any, new int[0]));
+
+ gate.ApplyPhase(0); Assert.IsTrue(any.enabled);
+ gate.ApplyPhase(7); Assert.IsTrue(any.enabled);
+
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ApplyPhase_UnlistedAbilityIsUntouched()
+ {
+ var go = new GameObject("boss");
+ var gate = go.AddComponent();
+ var listed = go.AddComponent();
+ var otherGo = new GameObject("unlisted"); otherGo.transform.SetParent(go.transform);
+ var unlisted = otherGo.AddComponent();
+
+ SetEntries(gate, (listed, new[] { 1 }));
+
+ gate.ApplyPhase(0);
+ Assert.IsFalse(listed.enabled, "登记的能力应被阶段门管控");
+ Assert.IsTrue (unlisted.enabled, "未登记的能力不受阶段门影响");
+
+ Object.DestroyImmediate(go);
+ }
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Test Runner → EditMode → `BossPhaseAbilityGateTests`。期望:编译失败(`BossPhaseAbilityGate` 不存在)。
+
+- [ ] **Step 3: 建阶段门组件**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs`:
+
+```csharp
+using System;
+using UnityEngine;
+using BaseGames.Enemies.Abilities;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// 阶段 = 换招池:按当前阶段启用 / 禁用能力组件。
+ /// 被禁用的能力其 为 false,
+ /// 因此自动从 的候选里消失——阶段门不需要碰选招器。
+ ///
+ /// 由 与 直接调用
+ /// (而非订阅阶段频道),保证与阶段切换严格同序。
+ ///
+ public sealed class BossPhaseAbilityGate : MonoBehaviour
+ {
+ [Serializable]
+ public struct PhaseEntry
+ {
+ [Tooltip("受阶段管控的能力组件")]
+ public EnemyAbilityBase ability;
+ [Tooltip("该能力可用的阶段索引;空数组 = 全阶段可用")]
+ public int[] phases;
+ }
+
+ [Tooltip("阶段可用性表。未登记的能力不受本组件影响(保持其自身启用状态)")]
+ [SerializeField] private PhaseEntry[] _entries;
+
+ /// 把指定阶段的启用集应用到所有登记的能力。
+ public void ApplyPhase(int phase)
+ {
+ if (_entries == null) return;
+ for (int i = 0; i < _entries.Length; i++)
+ {
+ var e = _entries[i];
+ if (e.ability == null) continue;
+ e.ability.enabled = IsAllowed(e.phases, phase);
+ }
+ }
+
+ private static bool IsAllowed(int[] phases, int phase)
+ {
+ if (phases == null || phases.Length == 0) return true; // 空 = 全阶段
+ for (int i = 0; i < phases.Length; i++)
+ if (phases[i] == phase) return true;
+ return false;
+ }
+
+#if UNITY_EDITOR
+ // 漏配显式报错,不静默跳过(CLAUDE.md 第 6 条)
+ private void OnValidate()
+ {
+ if (_entries == null) return;
+ for (int i = 0; i < _entries.Length; i++)
+ if (_entries[i].ability == null)
+ Debug.LogError(
+ $"[BossPhaseAbilityGate] {name} 第 {i} 项未指定能力组件,该行不会生效。", this);
+ }
+#endif
+ }
+}
+```
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Test Runner → EditMode → `BossPhaseAbilityGateTests`。期望:4 passed。
+
+- [ ] **Step 5: `BossBase` 接上阶段门**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossBase.cs`:
+
+1. 在 `[SerializeField] private BossResource _bossResource;` 之后追加:
+
+```csharp
+ [Header("阶段招池门(可选)")]
+ [Tooltip("按阶段启用 / 禁用能力组件;未挂载则所有能力全阶段可用")]
+ [SerializeField] private BossPhaseAbilityGate _phaseGate;
+```
+
+2. `Awake()` 里,在 `if (_bossResource == null) ...` 之后追加:
+
+```csharp
+ if (_phaseGate == null) _phaseGate = GetComponentInChildren(true);
+```
+
+3. `EnterPhase(int phase)` 里,在 `AttackSelector?.ResetRepeatMemory();`(Task 3 加的)之后追加:
+
+```csharp
+ // 阶段 = 换招池:先换池,再广播阶段事件,保证订阅方看到的是新池
+ _phaseGate?.ApplyPhase(phase);
+```
+
+⚠️ 顺序:必须在 `_onBossPhaseChanged?.Raise(...)` **之前**。
+
+4. `OnSpawn()` 里,在 `_currentPhase = 0;` 之后追加:
+
+```csharp
+ _phaseGate?.ApplyPhase(0); // 对象池复用:回到阶段 0 的启用集
+ AttackSelector?.ResetRepeatMemory();
+```
+
+- [ ] **Step 6: 编译 + 全量回归**
+
+MCP `unity_get_compilation_errors` 期望 0/0;`Run All` 期望全绿。
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs \
+ Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs.meta \
+ Assets/_Game/Scripts/Enemies/Boss/BossBase.cs \
+ Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs
+git commit -m "feat(enemy): BossPhaseAbilityGate——阶段即换招池
+
+按阶段启停能力组件,被禁用的招经 CanUse 自动退出选招候选,
+阶段门不需要碰选招器。取代 BossSkillSO.availablePhaseIndices。
+EnterPhase 先换池后广播;OnSpawn 回阶段 0(对象池复用)。"
+```
+
+---
+
+## Task 6: `HurtBox` 弱点伤害倍率
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Combat/HurtBox.cs`
+- Test: `Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+`Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs`:
+
+```csharp
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using BaseGames.Combat;
+
+namespace BaseGames.Tests.EditMode.Combat
+{
+ public class HurtBoxMultiplierTests
+ {
+ [SetUp] public void SetUp() => LogAssert.ignoreFailingMessages = true;
+ [TearDown] public void TearDown() => LogAssert.ignoreFailingMessages = false;
+
+ [Test]
+ public void DefaultMultiplier_IsOne()
+ {
+ var go = new GameObject("hurtbox");
+ var hb = go.AddComponent();
+ Assert.AreEqual(1f, hb.DamageMultiplier);
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void SetDamageMultiplier_ClampsToNonNegative()
+ {
+ var go = new GameObject("hurtbox");
+ var hb = go.AddComponent();
+
+ hb.SetDamageMultiplier(2.5f);
+ Assert.AreEqual(2.5f, hb.DamageMultiplier);
+
+ hb.SetDamageMultiplier(-3f);
+ Assert.AreEqual(0f, hb.DamageMultiplier, "负倍率无意义,钳到 0");
+
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ApplyMultiplier_ScalesRawAmount_AndFloorsAtOne()
+ {
+ var go = new GameObject("hurtbox");
+ var hb = go.AddComponent();
+
+ hb.SetDamageMultiplier(2f);
+ Assert.AreEqual(20, hb.ApplyDamageMultiplier(10));
+
+ hb.SetDamageMultiplier(0f);
+ Assert.AreEqual(1, hb.ApplyDamageMultiplier(10), "倍率 0 也至少造成 1 点,与防御减免同规则");
+
+ hb.SetDamageMultiplier(1f);
+ Assert.AreEqual(10, hb.ApplyDamageMultiplier(10), "倍率 1 时数值原样通过");
+
+ Object.DestroyImmediate(go);
+ }
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Test Runner → EditMode → `HurtBoxMultiplierTests`。
+期望:编译失败(`DamageMultiplier` / `SetDamageMultiplier` / `ApplyDamageMultiplier` 不存在)。
+
+- [ ] **Step 3: 实现倍率**
+
+`Assets/_Game/Scripts/Combat/HurtBox.cs`:
+
+1. 在 `public void SetInvincible(bool value) => _isHurtBoxInvincible = value;` 附近追加字段与 API:
+
+```csharp
+ // 本 HurtBox 专属的受击倍率(弱点部位 > 1;默认 1 = 无影响)。
+ // 由能力在弱点窗口期间开关,语义与"这个部位更脆弱"一致。
+ private float _damageMultiplier = 1f;
+
+ /// 本 HurtBox 当前的受击伤害倍率。
+ public float DamageMultiplier => _damageMultiplier;
+
+ /// 设置受击倍率(弱点窗口开合时调用)。负值钳到 0。
+ public void SetDamageMultiplier(float value)
+ => _damageMultiplier = UnityEngine.Mathf.Max(0f, value);
+
+ ///
+ /// 按本 HurtBox 的倍率缩放伤害。最低 1 点,与防御减免同规则
+ /// (倍率为 0 也不该让攻击完全无效——那属于无敌帧的语义,不是弱点倍率的)。
+ ///
+ public int ApplyDamageMultiplier(int raw)
+ => _damageMultiplier == 1f
+ ? raw
+ : UnityEngine.Mathf.Max(1, UnityEngine.Mathf.RoundToInt(raw * _damageMultiplier));
+```
+
+2. 在 `ReceiveDamage` 的伤害管线里插入倍率步骤——找到现有的第 5 步:
+
+```csharp
+ // 5. 计算 FinalDamage(防御减免,最低 1)
+ int finalDamage = UnityEngine.Mathf.Max(1, info.Amount - _owner.Defense);
+```
+
+改为:
+
+```csharp
+ // 5. 本 HurtBox 受击倍率(弱点部位;默认 1 时无影响)
+ info.Amount = ApplyDamageMultiplier(info.Amount);
+
+ // 6. 计算 FinalDamage(防御减免,最低 1)
+ int finalDamage = UnityEngine.Mathf.Max(1, info.Amount - _owner.Defense);
+```
+
+并把该行之后原本的注释编号 `// 6.` `// 7.` `// 8.` 依次改为 `// 7.` `// 8.` `// 9.`。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Test Runner → EditMode → `HurtBoxMultiplierTests`。期望:3 passed。
+
+- [ ] **Step 5: 全量回归 + 提交**
+
+`Run All` 期望全绿。
+
+```bash
+git add Assets/_Game/Scripts/Combat/HurtBox.cs \
+ Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs \
+ Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs.meta
+git commit -m "feat(combat): HurtBox 自带受击伤害倍率
+
+倍率归 HurtBox 自己(弱点部位就是一个倍率 > 1 的 HurtBox),
+取代 WeakPointSystem 那份写进私有字段却零调用者的倍率。
+默认 1 时数值原样通过,现有行为不变。"
+```
+
+---
+
+## Task 7: 敌人侧动画事件补接四件
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs`
+
+本 Task 无自动化测试:动画事件路由需要真实 Animancer 时间轴驱动,属手动验证范畴。
+验证方式写在 Step 4。
+
+- [ ] **Step 1: 补两个子系统引用**
+
+`Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs`:
+
+1. 顶部 using 区追加:
+
+```csharp
+using BaseGames.Feedback;
+```
+
+2. `[Header("子系统引用")]` 区块改为:
+
+```csharp
+ [Header("子系统引用")]
+ [SerializeField] private HitBox[] _hitBoxes;
+ [SerializeField] private HurtBox _hurtBox;
+ [SerializeField] private EnemyBase _enemy;
+```
+
+3. 在 `_bindings` 字段之后追加私有字段:
+
+```csharp
+ private IFeedbackPlayer _feedback;
+```
+
+4. `Awake()` 改为:
+
+```csharp
+ private void Awake()
+ {
+ if (_enemy == null)
+ _enemy = GetComponentInParent();
+
+ // IFeedbackPlayer 由父层或同层实现(EnemyFeedback / NullFeedbackPlayer),与玩家侧同写法
+ _feedback = GetComponentInParent() ?? NullFeedbackPlayer.Instance;
+
+ foreach (var b in _bindings)
+ AnimationEventBinder.Bind(b.clip, b.config, this);
+ }
+```
+
+- [ ] **Step 2: 补四个 case**
+
+`HandleEvent` 的 switch,在 `case AnimationEventType.DisableHitBox:` 之后插入:
+
+```csharp
+ // ── 无敌帧 ────────────────────────────────────────────────
+ // 时长由动画时间轴上两个事件的间距表达,不在代码里写常量。
+ case AnimationEventType.EnableIFrame:
+ _hurtBox?.SetInvincible(true);
+ break;
+
+ case AnimationEventType.DisableIFrame:
+ _hurtBox?.SetInvincible(false);
+ break;
+```
+
+在 `case AnimationEventType.AnimationComplete:` 之前插入:
+
+```csharp
+ // ── 通用反馈 ──────────────────────────────────────────────
+ // 音效预警:在起手帧播一段可辨识的音,让玩家提前缩小招式可能性。
+ case AnimationEventType.TriggerFeedback:
+ if (!string.IsNullOrEmpty(payload))
+ _feedback.TriggerPreset(payload);
+ break;
+
+ case AnimationEventType.PlaySFX:
+ if (!string.IsNullOrEmpty(payload))
+ _feedback.PlaySFXById(payload);
+ break;
+```
+
+- [ ] **Step 3: 编译检查**
+
+MCP `unity_get_compilation_errors`。期望 0 errors 0 warnings。
+
+- [ ] **Step 4: 手动验证(记录到提交说明)**
+
+在 Unity 里检查:任一挂有 `EnemyAnimationEvents` 的敌人 Inspector 上出现新的 `_hurtBox` 槽位。
+若项目当前无敌人预制体(E001–E006 已删,见 `Docs_Dev` 中的预制体延后决策),仅确认编译通过与
+Inspector 字段出现即可;实际路由在 Task 15 放置嘲风后连同弱点窗口一起验证。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs
+git commit -m "feat(animation): 敌人侧动画事件补接无敌帧与反馈四件
+
+EnableIFrame/DisableIFrame → HurtBox.SetInvincible(与玩家侧同形态,
+无敌时长由动画时间轴上两事件间距表达);TriggerFeedback/PlaySFX →
+IFeedbackPlayer,用于音效预警。此前敌人侧只接了 7/20 个事件类型。"
+```
+
+---
+
+## Task 8: `EnemyAbilitySO` 设计备注与命名对齐
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs`
+
+- [ ] **Step 1: 加 `designNote`,改 `fileName`,清行为树残留**
+
+`Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs`:
+
+1. `CreateAssetMenu` 改为(与 `AssetFolderSpec.md` 的 `ABL_` 及两个向导的实际命名对齐;
+ 同目录的 `PlayClipAbilitySO` 等子类已在用 `ABL_`):
+
+```csharp
+ [CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "ABL_")]
+```
+
+2. `[Header("标识")]` 区块改为(顺带删掉 `abilityId` 注释里的行为树残留字样):
+
+```csharp
+ [Header("标识")]
+ [Tooltip("能力唯一 id(全小写英文 + 下划线,如 \"melee_combo\" / \"blink_strike\")。" +
+ "AI 图与选招器通过它引用本能力")]
+ public string abilityId = "ability_id";
+
+ [TextArea(1, 4)]
+ [Tooltip("设计备注(仅供编辑器参考,不影响运行)")]
+ public string designNote;
+```
+
+- [ ] **Step 2: 编译 + 回归 + 提交**
+
+MCP `unity_get_compilation_errors` 期望 0/0;`Run All` 期望全绿。
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs
+git commit -m "feat(enemy): EnemyAbilitySO 补设计备注,命名前缀对齐规范
+
++ designNote(目标体量下能力数量大,设计意图需就近记录)。
+fileName 由 EAB_ 改为 ABL_——AssetFolderSpec 第 298 行、两个向导
+以及 PlayClipAbilitySO 等子类都用 ABL_,此前只有基类是 EAB_。
+顺带清掉 abilityId 注释里的行为树残留字样。"
+```
+
+---
+
+## Task 9: `AiSignal.Engaged` 与 `BossFragments`
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/AI/AiSignal.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs`
+- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs`
+
+- [ ] **Step 1: 加 `Engaged` 信号**
+
+`Assets/_Game/Scripts/AI/AiSignal.cs` 全文替换为:
+
+```csharp
+namespace BaseGames.AI
+{
+ ///
+ /// 外部 → 决策层的事件信号。事件边不受 IsControllable 让位门阻挡。
+ /// 只保留真有发送方与消费方的值——无发送方的占位枚举只会变成死值。
+ ///
+ public enum AiSignal
+ {
+ /// 敌人已死亡:由 EnemyBase.PerformDeath / NotifyDecisionStop 发出,让决策停止。
+ Died,
+
+ /// 开战:由竞技场触发器(BossFightTrigger)发出,让 Boss 从静置态进入入场演出。
+ Engaged,
+ }
+}
+```
+
+- [ ] **Step 2: `ApproachAttackEngagement` 建态逻辑提为静态**
+
+`Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs`,
+把 `Build` 方法替换为下面两段(态名参数化,使 Boss 图能建多组逼近↔攻击):
+
+```csharp
+ public void Build(BrainBuilder b, string rest)
+ => Declare(b, Approach, Attack, rest);
+
+ ///
+ /// 建一组「逼近 ↔ 到射程选招攻击」。态名参数化,供本模块与 Boss 图共用同一份实现
+ /// (Boss 可能有多个阶段各一组,如地面组与空中组)。
+ /// 返回 Approach 态的 builder,调用方可在其上继续挂自己的出边(如阶段过渡)。
+ ///
+ internal static BrainBuilder.StateBuilder Declare(
+ BrainBuilder b, string approach, string attack, string rest)
+ {
+ var approachBuilder = b.DeclareState(approach)
+ .OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
+ .Tick (x => x.Locomotion.Pursue(x.Sensor.LastKnown))
+ .OnExit(x => x.Locomotion.Stop())
+ // 边序:attackInRange 先于 leftAllZones。前提是攻击射程恒小于感知区
+ // (EnemyAttackSelector.Eligible 的 InAttackRange 门),故"够得着"不会在
+ // "已脱离全部感知"之后仍成立,两条件不会相争。若将来出现射程大于追击区的招式,
+ // 那属于射程/感知区配置错误,由 AI 配方校验器报出,不要靠调整此处边序绕开。
+ .To(attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange")
+ .To(rest) .When(AiStateFragments.LostAllZones, "leftAllZones");
+
+ // 攻击一旦起手就打完:刻意不挂 leftAllZones 边。
+ // 玩家在前摇中跑出感知区时招式仍完整打完,之后经逼近态自然脱战——
+ // 只多一帧,且消除了"起手到一半凭空收招"的观感缺陷。
+ b.DeclareState(attack)
+ .OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
+ .OnExit (x => x.Combat.InterruptAbilities())
+ .To(approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
+
+ return approachBuilder;
+ }
+```
+
+- [ ] **Step 3: 建 `BossFragments`**
+
+`Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs`:
+
+```csharp
+using System;
+using UnityEngine;
+using BaseGames.AI;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// Boss 图的共享建态片段。与 同级——是静态建态原语,
+ /// 不是可插拔模块:Boss 图手写,重复的形状抽成片段即可,不需要 SO 下拉与多态序列化。
+ ///
+ public static class BossFragments
+ {
+ ///
+ /// 阶段过渡态:进入时发起过渡(无敌 + 演出由 BossBase 负责),过渡结束后由调用方挂出边。
+ /// 只发起一次——OnEnter 而非 Tick,因为 BossBase.BeginPhaseTransition 对重入会告警。
+ ///
+ public static BrainBuilder.StateBuilder PhaseTransition(
+ BrainBuilder b, string state, int targetPhase, float invincibleDuration)
+ {
+ if (targetPhase < 0)
+ throw new ArgumentOutOfRangeException(nameof(targetPhase),
+ $"BossFragments.PhaseTransition: 状态 '{state}' 的目标阶段不能为负。");
+ if (invincibleDuration <= 0f)
+ throw new ArgumentOutOfRangeException(nameof(invincibleDuration),
+ $"BossFragments.PhaseTransition: 状态 '{state}' 的无敌时长必须 > 0——" +
+ "为 0 意味着过渡演出期间可被打断,这不是阶段过渡的语义。");
+
+ return b.DeclareState(state)
+ .OnEnter(x =>
+ {
+ x.Locomotion.Stop();
+ x.Combat.InterruptAbilities();
+ x.Boss.BeginPhaseTransition(targetPhase, invincibleDuration);
+ });
+ }
+
+ ///
+ /// 一组「逼近 ↔ 到射程选招攻击」。转发到 ApproachAttackEngagement 的共享实现,
+ /// 使模块路径与 Boss 图路径永远是同一份行为。
+ /// 返回逼近态的 builder,调用方可继续挂阶段过渡等出边。
+ ///
+ public static BrainBuilder.StateBuilder ApproachAttack(
+ BrainBuilder b, string approach, string attack, string rest)
+ => ApproachAttackEngagement.Declare(b, approach, attack, rest);
+
+ ///
+ /// 移动到第 index 个竞技场锚点(),到位后由调用方挂出边。
+ /// 坐标经 IBossControl 取得——声明层的 lambda 不得闭包捕获具体实例。
+ ///
+ public static BrainBuilder.StateBuilder MoveToAnchor(BrainBuilder b, string state, int index)
+ {
+ if (index < 0)
+ throw new ArgumentOutOfRangeException(nameof(index),
+ $"BossFragments.MoveToAnchor: 状态 '{state}' 的锚点下标不能为负。");
+
+ return b.DeclareState(state)
+ .OnEnter(x => x.Locomotion.MoveTo(x.Boss.AnchorAt(index)))
+ .OnExit (x => x.Locomotion.Stop());
+ }
+
+ /// 共享条件:已到达第 index 个锚点(容差内)。
+ public static Func AtAnchor(int index, float tolerance = 0.2f)
+ => x => x.Boss.DistanceToAnchor(index) <= tolerance;
+ }
+}
+```
+
+- [ ] **Step 4: 编译检查**
+
+MCP `unity_get_compilation_errors`。
+期望:`BossFragments.MoveToAnchor` / `AtAnchor` 引用了 `IBossControl.AnchorAt` /
+`DistanceToAnchor` 而它们尚未声明,报 2 个错误——由 Task 10 Step 0 补上。
+**本 Task 不单独提交,与 Task 10 合并提交。**
+
+---
+
+## Task 10: `BossArenaAnchors` 竞技场锚点(与 Task 9 合并提交)
+
+**Files:**
+- Create: `Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs`
+- Modify: `Assets/_Game/Scripts/AI/IBossControl.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs`
+
+- [ ] **Step 0: `IBossControl` 补锚点两成员**
+
+`Assets/_Game/Scripts/AI/IBossControl.cs`:文件顶部加 `using UnityEngine;`,
+并在 `BeginPhaseTransition` 之后(替换 Task 1 留下的那两行说明注释)追加:
+
+```csharp
+ /// 第 index 个竞技场锚点的世界坐标。未挂锚点组件 / 下标越界即抛。
+ Vector2 AnchorAt(int index);
+
+ /// Boss 当前位置到第 index 个锚点的距离。未挂锚点组件 / 下标越界即抛。
+ float DistanceToAnchor(int index);
+```
+
+同步更新 `Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs`——Task 1 已把
+`Anchors` / `DistanceToAnchorValue` 两个字段与两个实现写好,此处只需确认它们存在。
+
+- [ ] **Step 1: 建组件**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs`:
+
+```csharp
+using UnityEngine;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// 竞技场锚点集合:Boss 定点走位(跳到悬空平台、传送到房间固定角落)的坐标来源。
+ /// 挂在 Boss 上;锚点物体本身放在场景里(随房间布局走,不进 SO)。
+ ///
+ /// AI 图不直接持有本组件——坐标经 取得
+ /// (图的 lambda 不得闭包捕获实例)。BossBase 在 Awake 解析本组件并转发。
+ ///
+ public sealed class BossArenaAnchors : MonoBehaviour
+ {
+ [Tooltip("锚点物体(顺序即下标,AI 图按下标引用)")]
+ [SerializeField] private Transform[] _anchors;
+
+ public int Count => _anchors != null ? _anchors.Length : 0;
+
+ /// 取第 index 个锚点坐标。下标越界或漏配即抛——不静默回退到自身位置。
+ public Vector2 At(int index)
+ {
+ if (_anchors == null || index < 0 || index >= _anchors.Length)
+ throw new System.IndexOutOfRangeException(
+ $"[BossArenaAnchors] {name} 请求锚点下标 {index},但只配了 {Count} 个。" +
+ "请在 Inspector 补齐锚点,或修正 AI 图里的下标。");
+ var t = _anchors[index];
+ if (t == null)
+ throw new System.InvalidOperationException(
+ $"[BossArenaAnchors] {name} 第 {index} 个锚点为空引用。请在 Inspector 指定。");
+ return t.position;
+ }
+
+#if UNITY_EDITOR
+ private void OnValidate()
+ {
+ if (_anchors == null) return;
+ for (int i = 0; i < _anchors.Length; i++)
+ if (_anchors[i] == null)
+ Debug.LogError($"[BossArenaAnchors] {name} 第 {i} 个锚点未指定。", this);
+ }
+
+ private void OnDrawGizmosSelected()
+ {
+ if (_anchors == null) return;
+ Gizmos.color = new Color(1f, 0.8f, 0.2f, 0.9f);
+ for (int i = 0; i < _anchors.Length; i++)
+ {
+ if (_anchors[i] == null) continue;
+ Gizmos.DrawWireSphere(_anchors[i].position, 0.35f);
+ UnityEditor.Handles.Label(_anchors[i].position, $"anchor {i}");
+ }
+ }
+#endif
+ }
+}
+```
+
+- [ ] **Step 2: `BossBase` 解析锚点组件并实现两个 facet 成员**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossBase.cs`:
+
+1. 在 `_phaseGate` 字段之后追加:
+
+```csharp
+ [Header("竞技场锚点(可选)")]
+ [Tooltip("定点走位用的锚点集合;AI 图未用到锚点片段时可留空")]
+ [SerializeField] private BossArenaAnchors _arenaAnchors;
+```
+
+2. `Awake()` 里,在 `_phaseGate` 解析之后追加:
+
+```csharp
+ if (_arenaAnchors == null) _arenaAnchors = GetComponentInChildren(true);
+```
+
+3. 在 `ResourceFull` 属性之后追加两个 facet 成员:
+
+```csharp
+ /// 第 index 个竞技场锚点坐标。IBossControl 实现。未挂锚点组件即抛——
+ /// AI 图用了锚点片段却没配锚点是配置错误,必须暴露而非回退到自身位置。
+ public Vector2 AnchorAt(int index) => RequireAnchors().At(index);
+
+ /// 当前位置到第 index 个锚点的距离。IBossControl 实现。
+ public float DistanceToAnchor(int index)
+ => Vector2.Distance(transform.position, RequireAnchors().At(index));
+
+ private BossArenaAnchors RequireAnchors()
+ => _arenaAnchors != null ? _arenaAnchors : throw new System.InvalidOperationException(
+ $"[BossBase] '{name}' 的 AI 图使用了竞技场锚点,但未挂 BossArenaAnchors 组件。" +
+ "请挂上该组件并配好锚点,或从 AI 图里移除锚点片段。");
+```
+
+4. 顶部 using 区确认已有 `using UnityEngine;`(现有代码已用 `SerializeField`,必然已有)。
+
+> `BossBase` 已在 Task 2 声明实现 `BaseGames.AI.IBossControl`,本步补齐新增的两个成员。
+
+- [ ] **Step 4: 编译检查**
+
+MCP `unity_get_compilation_errors`。期望 0 errors 0 warnings(Task 9 的报错此时消解)。
+
+- [ ] **Step 5: 全量回归 + 提交(含 Task 9)**
+
+`Run All` 期望全绿。
+
+```bash
+git add Assets/_Game/Scripts/AI/AiSignal.cs \
+ Assets/_Game/Scripts/AI/IBossControl.cs \
+ Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs \
+ Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs \
+ Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta \
+ Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs \
+ Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta \
+ Assets/_Game/Scripts/Enemies/Boss/BossBase.cs \
+ Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs
+git commit -m "feat(ai): Boss 图共享片段 + 竞技场锚点 + AiSignal.Engaged
+
+BossFragments:阶段过渡 / 逼近选招 / 移动到锚点三个静态建态片段。
+ApproachAttackEngagement 的建态逻辑提为 internal static Declare(态名参数化),
+模块路径与 Boss 图路径共用同一份行为,Boss 可建多组(地面组/空中组)。
+
+锚点坐标经 IBossControl.AnchorAt / DistanceToAnchor 暴露,不走黑板——
+EnemyAiBrain._context 是私有的(BossBase 无从写入),且 ResetScratch 会清黑板
+带来隐式时序约束。锚点本就是 Boss 专属知识,归 Boss facet 更直。
+锚点漏配 / 下标越界显式抛,不回退到自身位置。"
+```
+
+---
+
+## Task 11: `ChaoFengAi` 定制图
+
+**Files:**
+- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs`
+- Test: `Assets/Tests/EditMode/AI/ChaoFengAiTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+`Assets/Tests/EditMode/AI/ChaoFengAiTests.cs`:
+
+```csharp
+using NUnit.Framework;
+using BaseGames.AI;
+using BaseGames.Enemies;
+
+namespace BaseGames.Tests.EditMode.AI
+{
+ public class ChaoFengAiTests
+ {
+ static AiRuntime Run(FakeAiContext ctx)
+ => new AiRuntime(new ChaoFengAi().GetOrBuildGraph(), ctx);
+
+ static FakeAiContext Ctx()
+ {
+ var c = new FakeAiContext();
+ c.S.Chase = true; // 竞技场内玩家恒在追逐区
+ c.S.Vision = true;
+ return c;
+ }
+
+ [Test]
+ public void StartsInWait_UntilEngaged()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+
+ Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName);
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName, "未开战前不应自行进入战斗");
+ }
+
+ [Test]
+ public void Engaged_EntersIntro_ThenGround()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+
+ rt.Send(AiSignal.Engaged);
+ Assert.AreEqual(ChaoFengAi.Intro, rt.CurrentStateName);
+ CollectionAssert.Contains(ctx.C.Used, ChaoFengAi.IntroAbility);
+
+ ctx.C.Running = null; // 入场演出播完
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void Ground_ApproachAttackLoop()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+ rt.Send(AiSignal.Engaged);
+ ctx.C.Running = null; rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
+
+ ctx.C.Eligible = true;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.GroundAttack, rt.CurrentStateName);
+ CollectionAssert.Contains(ctx.C.Used, "best");
+
+ ctx.C.Running = null; // 招式打完
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void HpBelowHalf_TransitionsToAirPhase()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+ rt.Send(AiSignal.Engaged);
+ ctx.C.Running = null; rt.Tick(0.1f);
+
+ ctx.V.Hp = 0.4f;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName);
+ Assert.AreEqual(1, ctx.B.BeginCallCount, "阶段过渡只应发起一次");
+ Assert.AreEqual(1, ctx.B.LastTargetPhase);
+
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName, "过渡期间不得提前转出");
+ Assert.AreEqual(1, ctx.B.BeginCallCount, "过渡期间不得重复发起");
+
+ ctx.B.Transitioning = false; // 过渡结束
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void Air_HoverAttackLoop()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+ rt.Send(AiSignal.Engaged);
+ ctx.C.Running = null; rt.Tick(0.1f);
+ ctx.V.Hp = 0.4f; rt.Tick(0.1f);
+ ctx.B.Transitioning = false; rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
+
+ ctx.C.Eligible = true;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.AirAttack, rt.CurrentStateName);
+
+ ctx.C.Running = null;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void Died_GoesToDeathFromAnyState()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+ rt.Send(AiSignal.Engaged);
+
+ rt.Send(AiSignal.Died);
+ Assert.AreEqual(ChaoFengAi.Death, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void AirPhase_DoesNotFallBackToGround()
+ {
+ // 阶段单向推进:进了空中阶段就不再回地面阶段(招池已换)
+ var ctx = Ctx();
+ var rt = Run(ctx);
+ rt.Send(AiSignal.Engaged);
+ ctx.C.Running = null; rt.Tick(0.1f);
+ ctx.V.Hp = 0.4f; rt.Tick(0.1f);
+ ctx.B.Transitioning = false; rt.Tick(0.1f);
+
+ ctx.V.Hp = 0.9f; // 即使血量回升
+ for (int i = 0; i < 5; i++) rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
+ }
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Test Runner → EditMode → `ChaoFengAiTests`。期望:编译失败(`ChaoFengAi` 不存在)。
+
+> 若 `AiRuntime` 的事件入口不是 `Send(AiSignal)`,打开 `Assets/_Game/Scripts/AI/AiRuntime.cs`
+> 确认实际方法名并统一改测试(`EnemyAiBrain.Send` 是对它的转发,方法名应一致)。
+
+- [ ] **Step 3: 写 `ChaoFengAi`**
+
+`Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs`:
+
+```csharp
+using BaseGames.AI;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// 嘲风的 AI 图(定制路径:Boss 数量少、彼此差异大,手写图而非配方)。
+ ///
+ /// 阶段 0(地面):逼近 ↔ 选招。四个地面招由 BossPhaseAbilityGate 在阶段 0 放行。
+ /// 阶段 1(空中):悬停 ↔ 选招。风石由阶段门在阶段 1 放行,地面四招被禁用。
+ ///
+ /// 招式的射程 / 冷却 / 权重全归各能力自己的 EnemyAbilitySO;本图只决定
+ /// 「什么时候该逼近、什么时候该出手、什么时候该换阶段」。
+ /// 死亡不在本图:EnemyBase.PerformDeath 发 Died 事件,全局边把决策停在 Death 终态。
+ ///
+ [AiDefinition("ChaoFeng")]
+ public sealed class ChaoFengAi : AiScript
+ {
+ public const string Wait = "Wait";
+ public const string Intro = "Intro";
+ public const string Ground = "Ground";
+ public const string GroundAttack = "GroundAttack";
+ public const string PhaseTx = "PhaseTransition";
+ public const string Air = "Air";
+ public const string AirAttack = "AirAttack";
+ public const string Death = "Death";
+
+ /// 入场演出能力 id(对应 ABL_ChaoFeng_Intro 资产的 abilityId)。
+ public const string IntroAbility = "chaofeng_intro";
+
+ /// 空中阶段的血量阈值。
+ private const float AirPhaseHpRatio = 0.5f;
+
+ /// 阶段过渡无敌时长,须 ≥ 浮空上升时长 + 缓冲。
+ private const float PhaseTxInvincible = 2f;
+
+ protected override void Build(BrainBuilder b)
+ {
+ b.Entry(Wait);
+
+ // 竞技场触发前静置:不巡逻、不警觉(Boss 不搜敌,等玩家进场)
+ AiStateFragments.Locomotion(b, Wait, LocomotionMode.Idle)
+ .To(Intro).OnEvent(AiSignal.Engaged);
+
+ // 入场演出:一次性能力,播完进战
+ AiStateFragments.AbilityOnce(b, Intro, IntroAbility)
+ .To(Ground).When(x => !x.Combat.IsAbilityRunning(IntroAbility), "introDone");
+
+ // 阶段 0(地面):逼近 ↔ 选招。rest 指回自身——Boss 不脱战。
+ BossFragments.ApproachAttack(b, Ground, GroundAttack, rest: Ground)
+ .To(PhaseTx).When(x => x.Vitals.HpBelow(AirPhaseHpRatio), "hp<50%");
+
+ // 阶段过渡:无敌 + 浮空演出由 ChaoFengBoss.OnBeginPhaseTransition 承担
+ BossFragments.PhaseTransition(b, PhaseTx, targetPhase: 1, invincibleDuration: PhaseTxInvincible)
+ .To(Air).When(x => !x.Boss.IsPhaseTransitioning, "txDone");
+
+ // 阶段 1(空中):悬停朝向玩家 ↔ 选招。阶段单向推进,不回地面阶段。
+ AiStateFragments.Locomotion(b, Air, LocomotionMode.Face)
+ .To(AirAttack).When(x => x.Combat.HasEligibleAttack(), "attackInRange");
+
+ // 与地面攻击态同语义:起手就打完,不挂脱战边
+ b.DeclareState(AirAttack)
+ .OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
+ .OnExit (x => x.Combat.InterruptAbilities())
+ .To(Air).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
+
+ // 死亡终态:演出走物理层(ChaoFengBoss.Die 的击败序列)
+ AiStateFragments.Terminal(b, Death);
+ b.Global().To(Death).OnEvent(AiSignal.Died);
+ }
+ }
+}
+```
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Test Runner → EditMode → `ChaoFengAiTests`。期望:7 passed。
+
+- [ ] **Step 5: 确认注册表能发现它**
+
+Test Runner → EditMode → `AiDefinitionRegistryTests`。期望全绿。
+另在 Unity Console 里确认 `AiDefinitionRegistry` 的 id 列表包含 `ChaoFeng`
+(若注册表有调试打印入口则用它;否则在 Task 15 放置嘲风时由 `_definitionId` 下拉验证)。
+
+- [ ] **Step 6: 全量回归 + 提交**
+
+`Run All` 期望全绿。
+
+```bash
+git add Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs \
+ Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs.meta \
+ Assets/Tests/EditMode/AI/ChaoFengAiTests.cs \
+ Assets/Tests/EditMode/AI/ChaoFengAiTests.cs.meta
+git commit -m "feat(enemy): 嘲风 AI 图(BrainGraph 定制路径)
+
+Wait →(Engaged) Intro → Ground⇄GroundAttack →(hp<50%) PhaseTx → Air⇄AirAttack。
+Boss 不脱战(rest 指回自身)、阶段单向推进、起手就打完。
+死亡不在图上:全局 Died 边进 Death 终态,演出归物理层。
+7 条 EditMode 断言覆盖状态序列、过渡只发起一次、血量回升不回退阶段。"
+```
+
+---
+
+## Task 12: `BossFightTrigger` 开战触发
+
+**Files:**
+- Create: `Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs`
+
+- [ ] **Step 1: 建触发器**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs`:
+
+```csharp
+using UnityEngine;
+using BaseGames.AI;
+using BaseGames.Core.Events;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// Boss 开战触发:玩家进入竞技场触发区时,广播开战事件并让 Boss 的决策层从静置态进战。
+ /// 此前 EVT_BossFightStarted 全项目零发送方,Boss 永远不会开战。
+ ///
+ /// 放在 Boss 房间入口的触发区上(Collider2D + isTrigger)。只触发一次。
+ ///
+ [RequireComponent(typeof(Collider2D))]
+ public sealed class BossFightTrigger : MonoBehaviour
+ {
+ [Header("目标 Boss")]
+ [Tooltip("要唤醒的 Boss 的决策层组件")]
+ [SerializeField] private EnemyAiBrain _bossBrain;
+
+ [Header("事件频道")]
+ [Tooltip("EVT_BossFightStarted — payload 为 bossId,驱动 BGM / 状态机 / HUD")]
+ [SerializeField] private StringEventChannelSO _onBossFightStarted;
+ [Tooltip("EVT_BossFightToggled — true 表示开始,驱动 Boss 血条与 BGM 切换")]
+ [SerializeField] private BoolEventChannelSO _onBossFightToggled;
+
+ [Header("标识")]
+ [Tooltip("Boss id,与 BossBase._bossId 一致")]
+ [SerializeField] private string _bossId;
+
+ [Header("触发条件")]
+ [Tooltip("玩家所在的层")]
+ [SerializeField] private LayerMask _playerLayers;
+
+ private bool _fired;
+
+ private void Awake()
+ {
+ var col = GetComponent();
+ if (!col.isTrigger)
+ Debug.LogError($"[BossFightTrigger] {name} 的 Collider2D 未勾选 Is Trigger,无法触发开战。", this);
+ if (_bossBrain == null)
+ Debug.LogError($"[BossFightTrigger] {name} 未指定目标 Boss 的 EnemyAiBrain,开战不会生效。", this);
+ if (string.IsNullOrEmpty(_bossId))
+ Debug.LogError($"[BossFightTrigger] {name} 未填 bossId,BGM / 血条无法定位该 Boss。", this);
+ }
+
+ private void OnTriggerEnter2D(Collider2D other)
+ {
+ if (_fired) return;
+ if ((_playerLayers.value & (1 << other.gameObject.layer)) == 0) return;
+
+ _fired = true;
+ _onBossFightStarted?.Raise(_bossId);
+ _onBossFightToggled?.Raise(true);
+ _bossBrain.Send(AiSignal.Engaged);
+ }
+
+ /// 供存档回载 / 重试时复位(Boss 未被击败则重新可触发)。
+ public void ResetTrigger() => _fired = false;
+ }
+}
+```
+
+- [ ] **Step 2: 确认 `EnemyAiBrain.Send` 存在且为 public**
+
+打开 `Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs`,确认 `public void Send(AiSignal)`。
+若不存在,按 `EnemyBase.StopBehaviorTree` 里 `_brain?.Send(...)` 的调用形态补一个 public 转发。
+
+- [ ] **Step 3: 编译检查 + 提交**
+
+MCP `unity_get_compilation_errors` 期望 0/0。
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs \
+ Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs.meta
+git commit -m "feat(enemy): BossFightTrigger——补上缺失的开战触发
+
+EVT_BossFightStarted 此前全项目零发送方,Boss 永远不开战。
+触发时广播开战事件并向 Boss 决策层发 AiSignal.Engaged。
+漏配目标 Brain / bossId / isTrigger 一律显式报错。"
+```
+
+---
+
+## Task 13: `CharacterWizardWindow` Boss 页改造
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs`
+
+**为什么必须改**:它是 5 个空 `BossSkillSO` 资产的创建链路。不改则改完代码仍会源源不断产出旧轨资产(CLAUDE.md 第 2、6 条)。
+
+- [ ] **Step 1: 删死引用**
+
+1. 删第 8 行 `using BaseGames.Boss;`(仅为 `BossSkillSO` 而存在)。
+2. 删第 80 行的 `_bossId` 字段及其 `// kept for legacy SkillSequenceSO queries if any` 注释
+ (死字段 + 行为树/旧轨残留注释)。若编译报有其他引用,一并清掉。
+
+- [ ] **Step 2: 改招式定义表**
+
+把 `ChaoFengSkillDefs`(约第 709–716 行)替换为:
+
+```csharp
+ ///
+ /// 嘲风能力集。阶段可用性不在 SO 上——由 BossPhaseAbilityGate 承载
+ /// (见放置工具 PlaceChaoFeng),这里只记录供说明文本使用。
+ /// rangeRadius 必须 > 0:category==Attack 且射程为 0 的招永远够不着玩家,
+ /// EnemyAbilitySO.Validate 与 EnemyBase.BuildAttackSelector 都会显式报错。
+ ///
+ private static readonly (string name, string id, System.Type soType,
+ float rangeRadius, float cooldown, float weight, int phase)[]
+ ChaoFengAbilityDefs =
+ {
+ ("Intro", "chaofeng_intro", typeof(PlayClipAbilitySO), 0f, 0f, 0f, -1),
+ ("Boomerang", "boomerang", typeof(PlayClipAbilitySO), 9f, 4.0f, 1.0f, 0),
+ ("FanCombo", "fan_combo", typeof(EnemyAbilitySO), 2.5f, 2.5f, 1.5f, 0),
+ ("TornadoSmall", "tornado_small", typeof(PlayClipAbilitySO), 8f, 3.5f, 1.2f, 0),
+ ("TornadoLarge", "tornado_large", typeof(PlayClipAbilitySO), 12f, 6.0f, 0.8f, 0),
+ ("WindStone", "wind_stone", typeof(PlayClipAbilitySO), 12f, 3.0f, 1.0f, 1),
+ };
+```
+
+说明:
+- `Intro` 是入场演出,`phase = -1` 表示不进选招池(`category` 保持 `None`、`rangeRadius = 0`)。
+- `FanCombo` 是多段近战,用基类 `EnemyAbilitySO` 配 `attackSequence`(`MeleeAttackAbility` 消费)。
+- 其余四招是「播 clip,判定与生成靠动画事件」,用 `PlayClipAbilitySO`。
+
+- [ ] **Step 3: 改资产工厂**
+
+把 `CreateChaoFengSkillSO`(约第 692–706 行)替换为:
+
+```csharp
+ private static void CreateChaoFengAbilitySO(
+ string abilityName, string abilityId, System.Type soType,
+ float rangeRadius, float cooldown, float weight, int phase)
+ {
+ const string dir = "Assets/_Game/Data/Enemies/ChaoFeng/Abilities";
+ string name = $"ABL_ChaoFeng_{abilityName}";
+
+ var so = EditorScaffoldUtils.CreateSOAsset(soType, dir, name) as EnemyAbilitySO;
+ if (so == null) return;
+
+ so.abilityId = abilityId;
+ so.designNote = phase < 0
+ ? "入场演出,不进选招池"
+ : $"阶段 {phase} 招式;阶段可用性由 BossPhaseAbilityGate 配置";
+ so.cooldown = cooldown;
+
+ if (phase < 0)
+ {
+ so.category = AbilityCategory.None;
+ so.rangeRadius = 0f;
+ so.weight = 0f;
+ }
+ else
+ {
+ so.category = AbilityCategory.Attack;
+ so.rangeRadius = rangeRadius; // 必须 > 0,否则选招器永远选不中
+ so.weight = weight;
+ }
+
+ EditorUtility.SetDirty(so);
+ AssetDatabase.SaveAssets();
+ }
+```
+
+✔ 无需新增辅助:`EditorScaffoldUtils.CreateSOAsset(System.Type soType, string folder, string assetName)`
+**已存在**(`Assets/_Game/Scripts/Editor/Shared/EditorScaffoldUtils.cs:178`,返回 `ScriptableObject`,
+与泛型版同样是"已存在则返回 null 不覆盖")。上面代码里的 `as EnemyAbilitySO` 强转即依赖它。
+
+- [ ] **Step 4: 改一键创建与提示文案**
+
+把 `CreateAllChaoFengSOs`(约第 718–727 行)替换为:
+
+```csharp
+ private static void CreateAllChaoFengSOs()
+ {
+ CreateChaoFengStatsSO();
+ CreateChaoFengAnimConfigSO();
+ foreach (var (n, id, t, r, cd, w, ph) in ChaoFengAbilityDefs)
+ CreateChaoFengAbilitySO(n, id, t, r, cd, w, ph);
+ AssetDatabase.SaveAssets();
+ EditorUtility.DisplayDialog("创建完成",
+ "全部嘲风 SO 已创建(已存在的跳过)。\n" +
+ "接下来:\n" +
+ "1. 用「放置嘲风到场景并绑定 SO」生成组件树;\n" +
+ "2. 检查 EnemyAiBrain._definitionId = ChaoFeng;\n" +
+ "3. 检查各能力组件的 _config 已绑定对应 ABL_ 资产;\n" +
+ "4. 在 BossPhaseAbilityGate 上确认阶段表(地面四招=阶段0,风石=阶段1)。",
+ "确定");
+ }
+```
+
+- [ ] **Step 5: 改工厂按钮与状态面板**
+
+1. `BuildBossTab` 里的 factory 循环(约第 333–338 行)改为:
+
+```csharp
+ foreach (var (abName, abId, soType, r, cd, w, ph) in ChaoFengAbilityDefs)
+ {
+ string cName = abName; string cId = abId; System.Type cType = soType;
+ float cR = r, cCd = cd, cW = w; int cPh = ph;
+ factory.Add(MakeFactoryButton($"ABL_ChaoFeng_{cName}.asset",
+ () => { CreateChaoFengAbilitySO(cName, cId, cType, cR, cCd, cW, cPh); RefreshSOStatus(); }));
+ }
+```
+
+2. `BuildBossStatus`(约第 785–801 行)的 `foreach` 改为:
+
+```csharp
+ foreach (var (abName, _, _, _, _, _, _) in ChaoFengAbilityDefs)
+ checks.Add(($"ABL_ChaoFeng_{abName}",
+ FindAtPath($"{ablDir}/ABL_ChaoFeng_{abName}.asset")));
+```
+
+3. 在该 `foreach` 之后追加一行 AI 图状态检查:
+
+```csharp
+ // AI 图是代码定义的(定制路径),没有资产可查——用注册表确认它被反射收集到了
+ bool hasAiGraph = System.Array.IndexOf(BaseGames.AI.AiDefinitionRegistry.Ids, "ChaoFeng") >= 0;
+ _bossStatusPanel.Add(MakeStatusGrid(checks.ToArray()));
+ _bossStatusPanel.Add(new UnityEngine.UIElements.Label(
+ hasAiGraph ? "✔ AI 图 ChaoFengAi 已注册(定制路径 _definitionId = ChaoFeng)"
+ : "✘ AI 图 ChaoFengAi 未注册——检查 [AiDefinition(\"ChaoFeng\")] 特性"));
+```
+
+并把原来那行单独的 `_bossStatusPanel.Add(MakeStatusGrid(checks.ToArray()));` 删掉(已并入上面)。
+
+⚠️ `AiDefinitionRegistry.Ids` 的实际成员名以 `Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs` 为准,
+按实际签名调整;语义是「注册表里有没有 ChaoFeng」。
+
+- [ ] **Step 6: 改跳转按钮与放置说明**
+
+1. 删第 365 行 `jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow));`
+ (该窗口将在 Task 20 删除)。
+2. 第 367 行标签改为 `"Data Hub(敌人 / 能力)"`。
+3. 第 348 行的 `MakeHelpBox` 文案改为:
+
+```csharp
+ root.Add(MakeHelpBox("放置嘲风完整组件树(ChaoFengBoss + 浮空控制器 + 击倒计数 + " +
+ "EnemyAiBrain + BossPhaseAbilityGate + 5 个能力组件 + " +
+ "Phase1 HitBox × 4 + 炮口 × 3)。"));
+```
+
+- [ ] **Step 7: 编译检查**
+
+MCP `unity_get_compilation_errors`。期望 0 errors 0 warnings。
+⚠️ 此时状态面板会显示 5 个 `ABL_ChaoFeng_*` 缺失——**这是正确的**:磁盘上那 5 个是 `BossSkillSO`,
+不是 `EnemyAbilitySO`。Task 15 会删旧建新。
+
+- [ ] **Step 8: 提交**
+
+```bash
+git add Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs
+git commit -m "refactor(editor): 角色向导 Boss 页改产出 EnemyAbilitySO
+
+该向导是 5 个空 BossSkillSO 资产的创建链路——不改则会持续产出旧轨资产。
+招式表扩为带 soType / rangeRadius / cooldown / weight 的形式(与
+GetEnemyAbilityDefs 对齐),阶段可用性移交 BossPhaseAbilityGate。
+产出时强制 rangeRadius > 0,不把责任推给下游作者。
+状态面板改查 EnemyAbilitySO,并新增 AI 图注册状态一行。"
+```
+
+---
+
+## Task 14: `SceneObjectPlacerTool.PlaceChaoFeng` 改造
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`
+
+- [ ] **Step 1: 换组件:执行器 → 决策层 + 阶段门 + 能力组件**
+
+把第 1089 行:
+
+```csharp
+ BossSkillExecutor skillExec = GetOrAddComponent(go);
+```
+
+替换为:
+
+```csharp
+ EnemyAiBrain brain = GetOrAddComponent(go);
+ BossPhaseAbilityGate phaseGate = GetOrAddComponent(go);
+ // 五个招式各一个能力组件(Intro 与四个地面/空中招)
+ var abIntro = GetOrAddComponent(GetOrCreateChild(go.transform, "ABL_Intro").gameObject);
+ var abFan = GetOrAddComponent(GetOrCreateChild(go.transform, "ABL_FanCombo").gameObject);
+ var abBoomer = GetOrAddComponent(GetOrCreateChild(go.transform, "ABL_Boomerang").gameObject);
+ var abTornadoS = GetOrAddComponent(GetOrCreateChild(go.transform, "ABL_TornadoSmall").gameObject);
+ var abTornadoL = GetOrAddComponent(GetOrCreateChild(go.transform, "ABL_TornadoLarge").gameObject);
+ var abWind = GetOrAddComponent(GetOrCreateChild(go.transform, "ABL_WindStone").gameObject);
+```
+
+> 一招一个子物体:`EnemyAbilityBase` 带 `[DisallowMultipleComponent]`,同一物体上放不了两个
+> 同类型能力(四个 `PlayClipAbility`)。`EnemyAbilityRegistry.CollectFrom(gameObject)` 是
+> 递归收集,子物体上的能力同样会被注册。
+
+- [ ] **Step 2: 改 HitBox 注释**
+
+第 1098 行注释改为:
+
+```csharp
+ // Phase1 攻击 HitBox(默认禁用;由能力按 EnemyAttackSO.hitBoxSlot 归一化时机开关)。
+```
+
+- [ ] **Step 3: 删执行器接线,改绑能力 `_config`**
+
+删第 1125 行 `AssignReference(skillExec, "_animancer", animancer, report);`。
+
+把第 1161–1175 行(收集 `BossSkillSO` 赋给执行器那一整段,含 `AssignString(skillExec, "_bossId", ...)`
+与 `AssignObjectArray(skillExec, "_hitBoxes", ...)`)替换为:
+
+```csharp
+ // 决策层:Boss 走定制路径(AiScript),不用配方
+ AssignString(brain, "_definitionId", "ChaoFeng", report);
+
+ // 能力 SO 绑定(一招一组件)
+ var abilityWiring = new (Object comp, string assetName)[]
+ {
+ (abIntro, "ABL_ChaoFeng_Intro"),
+ (abFan, "ABL_ChaoFeng_FanCombo"),
+ (abBoomer, "ABL_ChaoFeng_Boomerang"),
+ (abTornadoS, "ABL_ChaoFeng_TornadoSmall"),
+ (abTornadoL, "ABL_ChaoFeng_TornadoLarge"),
+ (abWind, "ABL_ChaoFeng_WindStone"),
+ };
+ foreach (var (comp, assetName) in abilityWiring)
+ {
+ Object so = FindFirstAsset(assetName);
+ if (so != null) AssignReference(comp, "_config", so, report);
+ else report.Add($"未找到 EnemyAbilitySO:{assetName}," +
+ "请先在角色向导 Boss 页「一键创建全部 ChaoFeng SO」后重新运行本放置操作。");
+ }
+
+ // 近战三段 HitBox 走命名槽位(EnemyAttackSO.hitBoxSlot 按名索引)
+ AssignHitBoxSlots(abFan, new[] { ("fan_1", fan1), ("fan_2", fan2), ("fan_3", fan3) }, report);
+
+ // 阶段招池:地面四招 = 阶段 0,风石 = 阶段 1,Intro 不受阶段管控(全阶段,但 category=None 不进选招池)
+ AssignPhaseGate(phaseGate, new (Object ability, int[] phases)[]
+ {
+ (abFan, new[] { 0 }),
+ (abBoomer, new[] { 0 }),
+ (abTornadoS, new[] { 0 }),
+ (abTornadoL, new[] { 0 }),
+ (abWind, new[] { 1 }),
+ }, report);
+```
+
+- [ ] **Step 4: 加两个反射写入辅助**
+
+在 `SceneObjectPlacerTool` 的「敌人放置辅助方法」区(第 1199 行之后)追加:
+
+```csharp
+ /// 写入 MeleeAttackAbility 的命名 HitBox 槽位数组(私有序列化字段,经反射)。
+ private static void AssignHitBoxSlots(
+ BaseGames.Enemies.Abilities.MeleeAttackAbility ability,
+ (string slotName, BaseGames.Combat.HitBox hitBox)[] slots,
+ System.Collections.Generic.List report)
+ {
+ if (ability == null) { report.Add("AssignHitBoxSlots:能力组件为空,跳过。"); return; }
+
+ var arr = new BaseGames.Enemies.Abilities.MeleeAttackAbility.HitBoxSlot[slots.Length];
+ for (int i = 0; i < slots.Length; i++)
+ arr[i] = new BaseGames.Enemies.Abilities.MeleeAttackAbility.HitBoxSlot
+ { slotName = slots[i].slotName, hitBox = slots[i].hitBox };
+
+ var f = typeof(BaseGames.Enemies.Abilities.MeleeAttackAbility).GetField(
+ "_hitBoxSlots", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ if (f == null) { report.Add("AssignHitBoxSlots:找不到 _hitBoxSlots 字段,接线失败。"); return; }
+
+ Undo.RecordObject(ability, "Assign HitBox Slots");
+ f.SetValue(ability, arr);
+ EditorUtility.SetDirty(ability);
+ report.Add($"✔ 已绑定 {slots.Length} 个命名 HitBox 槽位到 {ability.GetType().Name}。");
+ }
+
+ /// 写入 BossPhaseAbilityGate 的阶段表(私有序列化字段,经反射)。
+ private static void AssignPhaseGate(
+ BaseGames.Enemies.BossPhaseAbilityGate gate,
+ (Object ability, int[] phases)[] entries,
+ System.Collections.Generic.List report)
+ {
+ if (gate == null) { report.Add("AssignPhaseGate:阶段门组件为空,跳过。"); return; }
+
+ var arr = new BaseGames.Enemies.BossPhaseAbilityGate.PhaseEntry[entries.Length];
+ for (int i = 0; i < entries.Length; i++)
+ arr[i] = new BaseGames.Enemies.BossPhaseAbilityGate.PhaseEntry
+ {
+ ability = entries[i].ability as BaseGames.Enemies.Abilities.EnemyAbilityBase,
+ phases = entries[i].phases,
+ };
+
+ var f = typeof(BaseGames.Enemies.BossPhaseAbilityGate).GetField(
+ "_entries", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ if (f == null) { report.Add("AssignPhaseGate:找不到 _entries 字段,接线失败。"); return; }
+
+ Undo.RecordObject(gate, "Assign Phase Gate");
+ f.SetValue(gate, arr);
+ EditorUtility.SetDirty(gate);
+ report.Add($"✔ 已写入阶段招池表({entries.Length} 项)。");
+ }
+```
+
+- [ ] **Step 5: 接阶段门与锚点到 `BossBase`**
+
+在第 1132 行(`AssignReference(bossBase, "_windStoneMuzzle", ...)`)之后追加:
+
+```csharp
+ AssignReference(bossBase, "_phaseGate", phaseGate, report);
+```
+
+- [ ] **Step 6: 改报告文案**
+
+把第 1186–1191 行的四条 `report.Add` 替换为:
+
+```csharp
+ report.Add("★ FanCombo 三段 HitBox 已绑为命名槽位 fan_1 / fan_2 / fan_3," +
+ "在各 EATK_ 资产的 hitBoxSlot 里填对应名字。");
+ report.Add("★ 回旋扇 / 龙卷 / 风石的生成由技能 clip 上的 SpawnProjectile 动画事件驱动," +
+ "payload 填 boomerang / tornado_small / tornado_large / wind_stone;" +
+ "炮口引用留在 ChaoFengBoss 上,无需再拖到 SO。");
+ report.Add("★ 回旋扇收招 / 阶段过渡 / 击败演出等动画 Clip 待美术接入后在 ChaoFengBoss Inspector 指定。");
+ report.Add("★ AI:Boss 走定制路径,EnemyAiBrain._definitionId 已设为 ChaoFeng(对应 ChaoFengAi)。" +
+ "小怪才用配方(_recipe)。");
+ report.Add("★ 开战触发:Boss 房间入口需另放一个带 BossFightTrigger 的触发区," +
+ "指向本 Boss 的 EnemyAiBrain 并填 bossId=ChaoFeng,否则 Boss 不会开战。");
+```
+
+- [ ] **Step 7: 清 E004 的旧轨提示**
+
+删第 405–407 行里那条 `report.Add("★ 挂载 BossSkillExecutor 并指定 BossSkillSO 列表(技能执行层)。");`
+(E004 归属已定为精英怪,走 `ApproachAttackEngagement`,不再是 Boss 轨)。
+
+- [ ] **Step 8: 确认 using 与编译**
+
+文件顶部按需补 `using BaseGames.Enemies.Abilities;`(若尚未引入)。
+MCP `unity_get_compilation_errors`。期望 0 errors 0 warnings。
+
+- [ ] **Step 9: 提交**
+
+```bash
+git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
+git commit -m "refactor(editor): PlaceChaoFeng 改挂新轨——决策层 + 阶段门 + 能力组件
+
+BossSkillExecutor 换为 EnemyAiBrain(_definitionId=ChaoFeng) +
+BossPhaseAbilityGate + 6 个能力组件(一招一子物体,绕开
+DisallowMultipleComponent)。HitBox 改绑命名槽位,阶段招池经反射写入。
+顺带删掉 E004 上的 BossSkillExecutor 旧轨提示(已定为精英怪)。"
+```
+
+---
+
+## Task 15: 重建嘲风资产与对象树,跑通并自检
+
+**Files:**
+- Delete: `Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_*.asset`(5 个 + `.meta`)
+- Create(经向导): 同目录 6 个新 `ABL_ChaoFeng_*.asset`
+- Modify: `Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab`
+
+- [ ] **Step 1: 删 5 个旧资产**
+
+这 5 个是空 `BossSkillSO`,且新资产要占用同名路径(`CreateSOAsset` 遇同名会跳过),必须先删。
+
+```bash
+cd "E:/Works/Unity/Projects/zeling_v2"
+rm Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Boomerang.asset* \
+ Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_FanCombo.asset* \
+ Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoSmall.asset* \
+ Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoLarge.asset* \
+ Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_WindStone.asset*
+```
+
+回 Unity 让它刷新资产库(`Assets > Refresh` 或切窗口聚焦)。
+
+- [ ] **Step 2: 用向导重建 6 个能力资产**
+
+Unity 菜单打开角色向导(`CharacterWizardWindow`)→ `Boss` 页 → `★ 一键创建全部 ChaoFeng SO`。
+
+期望:`Abilities/` 下出现 6 个资产
+(`Intro` / `Boomerang` / `FanCombo` / `TornadoSmall` / `TornadoLarge` / `WindStone`),
+状态面板 6 项全为 ✔,且底部显示 `✔ AI 图 ChaoFengAi 已注册`。
+
+抽查 `ABL_ChaoFeng_FanCombo.asset`:`abilityId = fan_combo`、`category = Attack`、
+`rangeRadius = 2.5`、`cooldown = 2.5`、`weight = 1.5`。
+
+- [ ] **Step 3: 确认活动场景,放置新的嘲风对象树**
+
+⚠️ **先确认活动场景**——场景脚手架会写入当前活动场景,跑错场景会污染错误场景。
+打开 `Assets/_Game/Scenes/Testings/TestRoomA.unity` 并确保它是活动场景。
+
+向导 `Boss` 页 → `放置嘲风到场景并绑定 SO`。
+
+期望报告里出现:
+- `✔ 已绑定 6 个能力 SO`(或 6 条 `_config` 接线成功)
+- `✔ 已绑定 3 个命名 HitBox 槽位`
+- `✔ 已写入阶段招池表(5 项)`
+- 五条 `★` 提示(槽位 / 动画事件 payload / 待接 Clip / 定制路径 / 开战触发)
+
+Inspector 检查新对象:`EnemyAiBrain._definitionId = ChaoFeng`、`_recipe` 为空;
+`BossPhaseAbilityGate` 5 行阶段表齐全;6 个 `ABL_*` 子物体各自 `_config` 已绑。
+
+- [ ] **Step 4: 用新对象覆盖预制体**
+
+选中场景里新生成的嘲风根物体 → 拖到 `Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab` 上覆盖
+(或 `右键 > Prefab > Replace`)。
+
+覆盖的理由:旧预制体上带 `BossSkillExecutor` 与指向已删资产的 `_skills`,
+不覆盖则 Task 20 删脚本后它会变成 missing script。这也与「敌人预制体由脚手架统一产出」的既有决策一致。
+
+- [ ] **Step 5: 放一个开战触发区**
+
+场景里新建一个空物体 `BossFightTrigger_ChaoFeng`,加 `BoxCollider2D`(勾 `Is Trigger`)+ `BossFightTrigger`:
+- `_bossBrain` → 嘲风的 `EnemyAiBrain`
+- `_bossId` → `ChaoFeng`
+- `_playerLayers` → `Player`
+- `_onBossFightStarted` → `EVT_BossFightStarted`
+- `_onBossFightToggled` → `EVT_BossFightToggled`
+
+保存场景。
+
+- [ ] **Step 6: 进播放模式验证**
+
+进 Play,走进触发区。期望:
+1. Console 无报错(尤其无 `IAiContext.Boss` 异常、无阶段门漏配报错)。
+2. 嘲风从静置转入入场态(若 Intro clip 未接,`PlayClipAbility` 会立刻结束,直接进 `Ground`——正常)。
+3. 打到 50% 血量以下时进入阶段过渡(`ChaoFengFloatController.FloatUp` 起效),过渡结束进空中阶段。
+4. 用 `EnemyDebugOverlay` 观察 `CurrentStateName` 序列符合 `ChaoFengAiTests` 的断言。
+
+⚠️ 动画 Clip 未接入时招式判定不会真正生效,这属于美术资源缺口,不是本次范围。
+**只要状态机流转正确、无报错即视为通过**,并把观察到的状态序列记进提交说明。
+
+- [ ] **Step 7: 跑三件自检**
+
+- `BaseGames/Tools/Validation/Validate All ScriptableObjects` → 期望 0 error
+ (尤其 `EnemyAbilitySO.Validate` 对 `category==Attack && rangeRadius<=0` 的报错不应出现)
+- `BaseGames/Addressables/Validate Address Keys` → 期望 0 error
+- `BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check` → 期望通过
+
+- [ ] **Step 8: 提交**
+
+```bash
+git add -A Assets/_Game/Data/Enemies/ChaoFeng/ \
+ Assets/_Game/Prefabs/Enemies/ChaoFeng/ \
+ Assets/_Game/Scenes/Testings/TestRoomA.unity
+git commit -m "feat(enemy): 嘲风迁移到新轨——能力资产 + 对象树 + 开战触发
+
+删 5 个空 BossSkillSO,用向导重建 6 个 EnemyAbilitySO(含入场演出)。
+预制体经放置工具重新产出:EnemyAiBrain(_definitionId=ChaoFeng) +
+BossPhaseAbilityGate + 6 个能力组件,不再有 BossSkillExecutor。
+TestRoomA 加开战触发区。三件自检 0 error。
+
+验证:状态序列 Wait →(进触发区) Intro → Ground⇄GroundAttack →
+(hp<50%) PhaseTransition → Air⇄AirAttack,无报错。
+动画 Clip 待美术接入,判定时序届时再校。"
+```
+
+---
+
+# 阶段二:删旧轨
+
+> ⚠️ 顺序不可乱:先改引用方(Task 16–18),再删被引用的类型(Task 19)。
+
+## Task 16: `BossBase` 瘦身
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs`
+
+- [ ] **Step 1: 删技能执行相关成员**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossBase.cs` 删除以下全部内容:
+
+1. 字段:`_skillExecutor`(含 `[Header("技能执行器")]`)、`_onParrySuccess`(含其 `[Header]` 与 `[Tooltip]`)、
+ `_weightedCandidates`、`_candidateWeights`、`_singleSkillBuf`、`_counterStaggerCoroutine`
+2. 属性:`IsBossSkillExecuting`、`LastUsedSkillId`
+3. 方法:`UseBossSkill`、`UseBossSkillWeighted`、`IsSkillAvailableInPhase`、`CheckResourceCost`、
+ `HandleParrySuccess`、`ApplyCounterResponse`、`ExecuteCounterEffect`、`CounterStaggerCoroutine`
+4. `Awake()` 里 `if (_skillExecutor == null) ...` 那一行
+5. `OnEnable()` 里 `_onParrySuccess?.Subscribe(HandleParrySuccess).AddTo(_subs);` 那一行
+ (若 `OnEnable` 因此只剩 `base.OnEnable();`,整个 override 一并删掉)
+6. `EnterPhase()` 里 `_skillExecutor?.InterruptCurrentSkill();` 改为 `_abilities.InterruptAll(Abilities.InterruptReason.ExternalRequest);`
+ —— 阶段切换仍须打断在跑的招,只是打断对象从执行器换成能力注册表
+7. `PhaseTransitionCoroutine()` 里 `_skillExecutor?.InterruptCurrentSkill();` 同样替换
+8. `OnSpawn()` 里 `_skillExecutor?.ResetAllCooldowns();` 删掉
+ (能力冷却随 `EnemyAbilityBase` 实例,对象池复用时另有机制;若发现冷却未重置,
+ 在 `EnemyAbilityBase` 上补一个 `ResetCooldown()` 并由 `EnemyBase.OnSpawn` 遍历调用——
+ **这属于既有缺陷,若出现请单独记录,不要在本 Task 顺手改**)
+9. `LastUsedSkillId = null;` 两处(`EnterPhase` 与 `OnSpawn`)删掉——防重复记忆已归选招器
+10. using 区里因此不再需要的 `using BaseGames.Boss;`、`using BaseGames.Parry;`
+
+- [ ] **Step 2: 编译检查**
+
+MCP `unity_get_compilation_errors`。
+期望:`EnemyDebugOverlay.cs:113` 报错(引用了已删的 `IsBossSkillExecuting`)。下一步修。
+
+- [ ] **Step 3: 修 `EnemyDebugOverlay`**
+
+`Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs` 第 113 行那条 `BossSkillExec` 显示,替换为阶段信息
+(Boss 现在没有独立执行器,有价值的是阶段与过渡状态):
+
+```csharp
+ list.Add($"Phase: {_boss.CurrentPhase}" +
+ (_boss.IsPhaseTransitioning ? " (transitioning)" : ""));
+```
+
+- [ ] **Step 4: 编译 + 全量回归 + 提交**
+
+MCP `unity_get_compilation_errors` 期望 0/0;`Run All` 期望全绿。
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Boss/BossBase.cs \
+ Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs
+git commit -m "refactor(enemy): BossBase 瘦身——删技能执行与弹反反制两套
+
+技能选取/执行归 EnemyAttackSelector + EnemyAbilityBase(阶段一已就位),
+BossBase 不再持有 BossSkillExecutor。阶段切换改用能力注册表打断在跑的招。
+
+弹反反制整套删除:它订阅的是全局弹反成功频道,无来源过滤——
+玩家弹反任意小怪都会让场上 Boss 硬直。正确形态是 EnemyBase.ReceiveParry
+(谁被弹反谁硬直),其接线为独立后续任务。
+
+调试面板的 BossSkillExec 行换为阶段与过渡状态。"
+```
+
+---
+
+## Task 17: `EnemyBase` 清行为树债
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/EnemyBase.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs`
+
+- [ ] **Step 1: 删 `ConsumeParryEvent` 及其状态**
+
+`Assets/_Game/Scripts/Enemies/EnemyBase.cs`(约第 405–425 行)删除:
+- 字段 `_wasParried`、`_parryTimestamp`、常量 `ParryEventTTL` 及其注释
+- 方法 `ConsumeParryEvent()` 整个(零调用者)
+
+并把 `ReceiveParry` 里对 `_wasParried` / `_parryTimestamp` 的两行赋值删掉,注释更新为:
+
+```csharp
+ ///
+ /// 被弹反时调用:强制进入 Stagger 并在 staggerDuration 秒后恢复。
+ /// Stagger 期间 IsControllable 为 false,AI 决策自动让位——不需要额外的 AI 信号。
+ /// ⚠️ 目前**零调用者**:弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用,
+ /// 接线是一件独立任务(DamageInfo 加 attacker,或弹反成功时回调发起攻击的 HitBox)。
+ ///
+ public virtual void ReceiveParry(float staggerDuration = 0.5f)
+ {
+ if (!IsAlive) return;
+ ForceState(EnemyStateType.Stagger);
+ _abilities.InterruptAll(InterruptReason.Stagger);
+ ScheduleStateRecovery(EnemyStateType.Stagger, staggerDuration);
+ }
+```
+
+- [ ] **Step 2: `StopBehaviorTree` 改名**
+
+`Assets/_Game/Scripts/Enemies/EnemyBase.cs` 第 656–664 行改为:
+
+```csharp
+ ///
+ /// 通知决策层终止(死亡演出 / 出场演出期间调用,防止决策继续覆盖演出逻辑)。
+ /// 与 PerformDeath 发的 Died 信号幂等(重复 Died 无害)。
+ /// 供配置型行为组件(如 EnemyDeathSequence)调用,故为 public。
+ ///
+ public void NotifyDecisionStop()
+ {
+ _brain?.Send(BaseGames.AI.AiSignal.Died);
+ }
+```
+
+- [ ] **Step 3: 改两个调用点**
+
+1. `Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs` 第 63 行:
+
+```csharp
+ if (_stopDecisionLayer) _enemy?.NotifyDecisionStop();
+```
+
+并把该组件上的序列化字段 `_stopBehaviorTree` 重命名为 `_stopDecisionLayer`,加迁移特性保住已有资产的值:
+
+```csharp
+ [UnityEngine.Serialization.FormerlySerializedAs("_stopBehaviorTree")]
+ [Tooltip("死亡演出开始时通知决策层终止,防止 AI 继续覆盖演出")]
+ [SerializeField] private bool _stopDecisionLayer = true;
+```
+
+⚠️ 若该字段的默认值原本不是 `true`,保持原默认值不变——本 Task 只改名,不改行为。
+
+2. `Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs` 第 188 行:
+
+```csharp
+ NotifyDecisionStop();
+```
+
+- [ ] **Step 4: 编译 + 全量回归 + 提交**
+
+MCP `unity_get_compilation_errors` 期望 0/0;`Run All` 期望全绿。
+
+```bash
+git add Assets/_Game/Scripts/Enemies/EnemyBase.cs \
+ Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs \
+ Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs
+git commit -m "refactor(enemy): 清行为树债——StopBehaviorTree 改名,删 ConsumeParryEvent
+
+StopBehaviorTree → NotifyDecisionStop(方法体早已只是发 Died 信号)。
+EnemyDeathSequence 的 _stopBehaviorTree 字段同步改名,带 FormerlySerializedAs 保值。
+ConsumeParryEvent 及其 TTL 状态删除:零调用者,且 ForceState(Stagger) +
+IsControllable 让位门已是更优表达,不需要第二条受击通道。"
+```
+
+---
+
+## Task 18: `BossResource` 去自动触发
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Boss/BossResource.cs`
+
+- [ ] **Step 1: 删配置里的自动触发三字段**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs` 删除:
+`autoTriggerOnFull`、`fullTriggerSkill`(类型是即将删除的 `BossSkillSO`)、`resetValueAfterTrigger`
+三个字段及相关 `[Header]` / `[Tooltip]`。
+
+在类注释里追加一句说明去向:
+
+```csharp
+ ///
+ /// 资源满值不再由本组件自动放招——那是绕过决策层直接执行,违反「AI 只决策、能力负责实现」。
+ /// 现在的形态是:本组件只维护数值,AI 图挂一条 When(x => x.Boss.ResourceFull) 边进专属招态。
+ ///
+```
+
+- [ ] **Step 2: 删运行时的自动触发**
+
+`Assets/_Game/Scripts/Enemies/Boss/BossResource.cs`:
+
+1. 删字段 `_fullTriggered`
+2. 删方法 `OnReachFull()` 整个
+3. `AddValue(float delta)` 简化为:
+
+```csharp
+ private void AddValue(float delta)
+ {
+ _currentValue = Mathf.Clamp(_currentValue + delta, 0f, _config.maxValue);
+ }
+```
+
+4. 类注释里 `- 满值时若 autoTriggerOnFull,自动让 BossBase 执行配置的技能。` 那一行,改为:
+
+```csharp
+ /// - 满值时 为真,由 AI 图的条件边决定放什么招(决策不在本组件)。
+```
+
+5. `OnBossUseSkill()` 的注释里 `由 BossBase.UseBossSkill 触发` 改为
+ `由使用了资源的能力触发(Boss 的招在自己的 EnemyAbilityBase 里调用)`。
+
+- [ ] **Step 3: 编译 + 提交**
+
+MCP `unity_get_compilation_errors` 期望 0/0。
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Boss/BossResource.cs \
+ Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs
+git commit -m "refactor(enemy): BossResource 去掉自动放招,触发权交回 AI 图
+
+满值时由 BossResource 直接调 UseBossSkill 是绕过决策层执行,
+违反「AI 只决策、能力负责实现」。现在只维护数值并暴露 IsFull,
+AI 图挂 When(x => x.Boss.ResourceFull) 边进专属招态。
+顺带解除对即将删除的 BossSkillSO 的依赖。"
+```
+
+---
+
+## Task 19: 删旧轨 9 个脚本
+
+**Files:**
+- Delete: 9 个脚本(见下)
+
+- [ ] **Step 1: 确认零引用**
+
+```bash
+cd "E:/Works/Unity/Projects/zeling_v2"
+grep -rn "BossSkillSO\|BossSkillExecutor\|AttackPatternSO\|SkillSequenceSO\|WeakPointSystem\|TelegraphSystem\|BossSkillModule\|BossSkillSequenceWindow\|InteractionTag\|VulnerabilityWindow\|PlayerCounterResponse\|CounterType\|ArenaEvent\|IArenaInteractable\|BossResourceCost\|BossSkillCategory\|BossSkillType\|VulnTriggerType\|WeakPointType" \
+ Assets --include=*.cs
+```
+
+期望:**只命中将被删除的那 9 个文件自身**。若命中其他文件,先修那些引用再继续。
+
+- [ ] **Step 2: 删文件(含 `.meta`)**
+
+```bash
+cd "E:/Works/Unity/Projects/zeling_v2"
+rm Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs* \
+ Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs* \
+ Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs* \
+ Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs* \
+ Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs* \
+ Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs* \
+ Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs* \
+ Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs*
+rm -r Assets/_Game/Scripts/Enemies/Boss/Patterns
+rm -f Assets/_Game/Scripts/Enemies/Boss/Patterns.meta
+```
+
+- [ ] **Step 3: 编译 + 全量回归**
+
+回 Unity 刷新。MCP `unity_get_compilation_errors` 期望 0 errors 0 warnings;`Run All` 期望全绿。
+
+⚠️ 若 Console 出现 missing script 警告,检查是哪个预制体/场景仍引用旧组件——
+`ENM_ChaoFeng.prefab` 已在 Task 15 重建,`TestRoomA` 里可能还有旧的嘲风实例,删掉它并保存场景。
+
+- [ ] **Step 4: 打开 Data Hub 与角色向导确认无异常**
+
+- `DataHubWindow`:`BossSkillModule` 已删,其他模块应正常列出(该模块经反射发现,无显式注册点)。
+- 角色向导 `Boss` 页:状态面板 6 个 ✔ + AI 图 ✔,跳转按钮不再有"Boss 技能序列查看器"。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add -A Assets/_Game/Scripts/Enemies/Boss/ Assets/_Game/Scripts/Editor/
+git commit -m "refactor(enemy)!: 删除旧 Boss 轨 9 个脚本
+
+BossSkillSO / AttackPatternSO / SkillSequenceSO / BossSkillExecutor /
+BossSkillTypes / WeakPointSystem / TelegraphSystem / BossSkillModule /
+BossSkillSequenceWindow。
+
+判据「有更优替代才删」逐项对应:
+- 编排 → EnemyAbilitySO + EnemyAttackSO(归一化时机 / 槽位 HitBox)
+- 阶段门 → BossPhaseAbilityGate
+- 选招 → EnemyAttackSelector + WeightedRandomAntiRepeat
+- 可弹反 → DamageSourceSO.Flags(CanBeParried),HurtBox 已在判定
+- 弱点倍率 → HurtBox 自带倍率(原实现的 GetDamageMultiplier 零调用者)
+- 预警 → clip 姿态 + 动画事件(TriggerFeedback / PlaySFX 已补接)
+- 竞技场联动 → EnemySpawnerOnEvent;BossSkillEventChannelSO 本就零订阅方
+- 霸体窗口 → EnemyAttackSO.hasPoiseWindow 四字段
+
+能力资产的编辑器总览视图随 BossSkillModule 一并消失,
+后续补 EnemyAbilityModule(优先级低于 AiDefinitionValidator)。"
+```
+
+---
+
+## Task 20: 删预警旧路径
+
+**Files:**
+- Modify: `Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs`
+- Modify: `Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs`
+
+- [ ] **Step 1: 确认无资产在用**
+
+```bash
+cd "E:/Works/Unity/Projects/zeling_v2"
+grep -rn "telegraphVfxKey\|telegraphDuration" Assets --include=*.asset --include=*.prefab --include=*.unity
+```
+
+期望:无命中(若有命中,说明有资产配了预警 VFX——先把该效果迁到 clip 的
+`TriggerFeedback` 动画事件上,再继续)。
+
+- [ ] **Step 2: 删 SO 上的两个字段**
+
+`Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs` 删除整个:
+
+```csharp
+ [Header("预警(Telegraph)")]
+ [Tooltip("预警 VFX key(IObjectPoolService.Spawn 的池 key),为空则跳过")]
+ public string telegraphVfxKey = "";
+ [Min(0f)] public float telegraphDuration = 0f;
+```
+
+- [ ] **Step 3: 删 `TelegraphRoutine` 与其调用**
+
+`Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs`:
+
+1. 删 `TelegraphRoutine()` 整个方法
+2. `RunInternal()` 里删掉这两行:
+
+```csharp
+ if (_config != null && _config.telegraphDuration > 0f)
+ yield return TelegraphRoutine();
+```
+
+3. `Phase = AbilityRunState.Telegraph;` 那行删掉(不再有 Telegraph 阶段),
+ `RunInternal` 开头改为:
+
+```csharp
+ _isRunning = true;
+ Phase = AbilityRunState.Windup;
+ try
+ {
+ yield return ExecuteCoroutine();
+ Phase = AbilityRunState.Recovery;
+ }
+```
+
+4. 若 `AbilityRunState.Telegraph` 枚举值因此零消费者,一并从
+ `Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs` 删掉。
+ ⚠️ 先 grep 确认:`grep -rn "AbilityRunState.Telegraph" Assets --include=*.cs`。
+ 有其他消费者就保留枚举值,只删预警协程。
+
+5. 顶部 using 区里因此不再需要的 `using BaseGames.Core;` / `using BaseGames.Core.Pool;`
+ —— **先 grep 确认文件里没有其他用途再删**。
+
+- [ ] **Step 4: 编译 + 全量回归 + 提交**
+
+MCP `unity_get_compilation_errors` 期望 0/0;`Run All` 期望全绿。
+
+```bash
+git add Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs \
+ Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs \
+ Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs
+git commit -m "refactor(enemy): 删预警旧路径,预警统一走动画
+
+telegraphVfxKey / telegraphDuration / TelegraphRoutine 删除。
+预警的正确载体是 clip 本身——姿态在动画里,音效与特效在动画事件上
+(PlaySFX / TriggerFeedback 已于阶段一补接)。旧路径会在动画之前
+插一段无动画的静止等待,是同一件事的第二条平行路。"
+```
+
+---
+
+## Task 21: 收尾自检与文档
+
+**Files:**
+- Modify: `Docs/Guides/` 下的敌人 / Boss 指导手册
+- Modify: `Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md`
+
+- [ ] **Step 1: 全量自检**
+
+- MCP `unity_get_compilation_errors` → 0 errors 0 warnings
+- Test Runner → EditMode → `Run All` → 全绿
+- `BaseGames/Tools/Validation/Validate All ScriptableObjects` → 0 error
+- `BaseGames/Addressables/Validate Address Keys` → 0 error
+- `BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check` → 通过
+- 进 Play 走一遍 TestRoomA 的嘲风战:状态序列与 Task 15 Step 6 一致,无报错
+
+- [ ] **Step 2: 全库残留扫描**
+
+```bash
+cd "E:/Works/Unity/Projects/zeling_v2"
+grep -rn "BossSkill\|WeakPoint\|Telegraph\|AttackPattern\|SkillSequence\|StopBehaviorTree\|BD_\|行为树" \
+ Assets/_Game/Scripts --include=*.cs
+```
+
+期望:无命中。若有注释残留,一并清掉(CLAUDE.md 第 4 条)。
+
+- [ ] **Step 3: 更新指导手册**
+
+在 `Docs/Guides/` 的敌人 / Boss 章节新增一节「Boss AI 怎么写」,内容覆盖:
+1. Boss 走定制路径:写 `[AiDefinition("")] class XxxAi : AiScript`,`EnemyAiBrain._definitionId` 填该 id(不用 `_recipe`)
+2. 可用的建态原语:`AiStateFragments`(Locomotion / Ability / AbilityOnce / Terminal)+ `BossFragments`(PhaseTransition / ApproachAttack / MoveToAnchor / AtAnchor)
+3. 阶段 = 换招池:`BossPhaseAbilityGate` 配阶段表,不在 SO 上写阶段
+4. 一招怎么配:`EnemyAbilitySO`(+ 类型化子类)+ `EnemyAttackSO`;判定 / 生成 / 音效 / 无敌帧全挂动画事件
+5. 开战:`BossFightTrigger` 发 `AiSignal.Engaged`
+6. 死亡不在 AI 层:`Die` / `PerformDeath` + `NotifyDecisionStop`
+7. 以 `ChaoFengAi` 为完整范例逐行讲解
+
+- [ ] **Step 4: spec 标记为已实施**
+
+`Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md` 顶部状态行改为:
+
+```markdown
+> 日期:2026-07-30 状态:**已实施**(阶段一 Task 1–15、阶段二 Task 16–20)
+```
+
+并在 §13 注记末尾追加实施期发现的偏差(若有),例如实际选用的黑板写入位置、
+`AbilityRunState.Telegraph` 是否保留、`ReceiveParry` 接线仍为待办等。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add Docs/Guides/ Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md
+git commit -m "docs(enemy): Boss AI 单轨落地——新增作者指南,spec 标记已实施
+
+指南覆盖定制路径写法、建态原语、阶段门、招式配置、开战触发、
+死亡归属,以 ChaoFengAi 为完整范例。
+记录仍为待办的两项:ReceiveParry 接线(需 DamageInfo 带攻击者)、
+EnemyAbilityModule(能力资产编辑器总览)。"
+```
+
+---
+
+## 后续独立任务(不在本计划范围)
+
+| 任务 | 缘由 |
+|---|---|
+| `ReceiveParry` 接线 | `DamageInfo` 无攻击者引用,需在 Combat 层补(加 attacker 或弹反成功时回调 `HitBox`)。见 spec §6.2 |
+| `EnemyAbilityModule` 编辑器总览 | `BossSkillModule` 删除后能力资产无编辑器视图。见 spec §6.4 |
+| `AiDefinitionValidator` | 防 450+ 能力引用出错,优先级高于上一项。见组合式模块 spec §6 |
+| `BossEncounterCoordinator` | 多体 Boss 交替出招令牌。等真设计双体 Boss 时再做。见 spec §7.2 |
+| 嘲风动画 Clip 接入 | 美术资源缺口。Clip 到位后校招式判定时序与预警节奏 |
diff --git a/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md b/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md
index 9ab4129e..7fc5fa69 100644
--- a/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md
+++ b/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md
@@ -193,16 +193,21 @@ public sealed class ChaoFengAi : AiScript
---
-## 5. 新增的六件
+## 5. 新增的五件
| # | 内容 | 规模 |
|---|---|---|
| 1 | `IBossControl` facet + `IAiContext.Boss` | 小接口 |
| 2 | `BossPhaseAbilityGate` 组件 | 小组件 |
| 3 | `AttackSelectionMode.WeightedRandomAntiRepeat` + 选招器防重复 | 改两个文件 |
-| 4 | `EnemyAnimationEvents` 补 4 个 case | 各 2–3 行 |
-| 5 | `EnemyBase.SetInvincible(float)` | 约 6 行 |
-| 6 | `BossArenaAnchors` 组件 + 「移动到第 N 锚点」图片段 | 小组件 |
+| 4 | `EnemyAnimationEvents` 补 4 个 case + 两个子系统引用字段 | 各 2–3 行 |
+| 5 | `BossArenaAnchors` 组件 + 「移动到第 N 锚点」图片段 | 小组件 |
+
+> 修订(2026-07-30,实施计划阶段):初稿列的第 5 件「`EnemyBase.SetInvincible(float)`」**已取消**。
+> 核查 `PlayerAnimationEvents` 后确认玩家侧的 `EnableIFrame` / `DisableIFrame` 走的是
+> `HurtBox.SetInvincible(bool)`——该 API **已存在**,无敌时长由动画时间轴上两个事件的间距表达。
+> 敌人侧照同一形态接线即可,不需要在 `EnemyBase` 上新造带时长的无敌源。
+> 这也更正确:时长归动画,不归代码常量。
### 5.1 `IBossControl`
@@ -216,10 +221,24 @@ namespace BaseGames.AI
bool IsPhaseTransitioning { get; }
bool ResourceFull { get; } // BossResource 满值;无资源组件时恒 false
void BeginPhaseTransition(int targetPhase, float invincibleDuration);
+
+ // 竞技场锚点(§5.5)。归本 facet 而非黑板:见下方修订说明。
+ Vector2 AnchorAt(int index);
+ float DistanceToAnchor(int index);
}
}
```
+> 修订(2026-07-30,实施计划阶段):锚点坐标初稿设计为经 `IAiContext.Blackboard` 传递
+> (`BossBase` 在 `Awake` 写入黑板键)。该方案有两处硬阻塞,已废弃:
+> 1. **拿不到黑板** —— `EnemyAiBrain._context` 是私有字段且无公开访问器,`BossBase` 无从写入。
+> 2. **时序坑** —— `EnemyBrainContext.ResetScratch()` 会 `_blackboard.Clear()`(对象池复用时调用),
+> 写入点必须排在它之后,是个易错的隐式顺序依赖。
+>
+> 改为挂在 `IBossControl` 上:锚点本就是 Boss 专属知识,facet 已存在,`BossBase` 在 `Awake`
+> 解析组件即可。净收益是少一个失败模式(黑板键缺失)、少一条隐式时序约束,且
+> `DistanceToAnchor` 把"我离锚点多远"封在 Boss 内部,不需要给 AI 层引入新的位置概念。
+
`EnemyBrainContext.Boss => _boss ?? throw new InvalidOperationException(...)`。
按 CLAUDE.md 第 6 条:小怪图误用它必须崩,不能返回一个空对象把问题盖住。
@@ -261,8 +280,11 @@ C# 单继承直接堵死。
| `PlaySFX` / `TriggerFeedback` | **音效预警**——竞品有 Boss 用两句语音区分物理招与远程招,预警的是招式**类别**而非具体招式,让玩家缩小可能性但不能确定。这是最经济的 telegraph 手法,现在写不出来 |
| `EnableIFrame` / `DisableIFrame` | 招式中途无敌:突进穿过玩家时不吃伤害、传送消失期间无敌 |
-配套 §5 第 5 项:`EnemyBase.IsInvincible` 是只读计算属性(`Dead || _deathSequenceActive`),
-没有带时长的无敌源,需新增 `SetInvincible(float)` 并纳入 `IsInvincible`。
+四个 case 的实现与 `PlayerAnimationEvents` 逐字对称:反馈两件走 `IFeedbackPlayer.TriggerPreset` /
+`PlaySFXById`(`EnemyFeedback` 已实现该接口),无敌两件走 `HurtBox.SetInvincible(bool)`(API 已存在)。
+`EnemyAnimationEvents` 因此需补两个子系统引用:`HurtBox _hurtBox` 与 `IFeedbackPlayer _feedback`
+(后者在 `Awake` 用 `GetComponentInParent() ?? NullFeedbackPlayer.Instance` 解析,
+与玩家侧同写法)。
### 5.5 `BossArenaAnchors`
@@ -270,6 +292,10 @@ C# 单继承直接堵死。
`IEnemyLocomotion.MoveTo(Vector2)` 能到点,但锚点集合无处安放。
一个持有 `Transform[]` 的组件 + 一个「移动到第 N 个锚点」的图片段即可。
+坐标经 `IBossControl.AnchorAt(int)` / `DistanceToAnchor(int)` 暴露给图(不走黑板,见 §5.1 修订)。
+锚点物体放在场景里而非 SO 里——它随房间布局走。下标越界或引用为空**显式抛**,
+不回退到 Boss 自身位置(那会让"锚点漏配"表现为"Boss 站着不动",是典型的下游兜底掩盖问题)。
+
---
## 6. 退役清单
@@ -469,8 +495,8 @@ C# 单继承直接堵死。
改 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs CanUse += enabled;删 TelegraphRoutine
改 Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs + designNote;− telegraph 两字段;fileName → ABL_
改 Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs 建态逻辑提静态
-改 Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs + 4 个 case
-改 Assets/_Game/Scripts/Enemies/EnemyBase.cs + SetInvincible;改名 NotifyDecisionStop;− ConsumeParryEvent
+改 Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs + 4 个 case;+ _hurtBox / _feedback 引用
+改 Assets/_Game/Scripts/Enemies/EnemyBase.cs 改名 NotifyDecisionStop;− ConsumeParryEvent
改 Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs 调用点改名
改 Assets/_Game/Scripts/Combat/HurtBox.cs + 伤害倍率
改 Assets/_Game/Scripts/Enemies/Boss/BossBase.cs 瘦身约 150 行;实现 IBossControl