Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md
T
joywayer 2c569dcf0d docs(enemy): Task 21 补 Step 2.5——处理描述已删除系统的长期文档
Task 19 发现约 20 份文档仍在描述旧 Boss 轨,含两份整份失效的架构/设计文档。
按第 7 条,Docs/ 是长期文档,留着描述不存在的类型比没有更糟。
分类处理:两份整份删除、AssetFolderSpec 局部修订、Guides 随新章节改写、
Docs_Dev 的历史记录保留不动(那是过程记录,改了反而失真)。
2026-07-30 16:47:51 +08:00

131 KiB
Raw Blame History

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 2022.3.62f3c1 / C# 9、Animancer、UI Toolkit(编辑器)、NUnitEditMode 测试)、自研 BrainGraphBrainBuilder / 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 RunnerEditMode 页 → 选中目标测试 → 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 窗口置前即可恢复,不要盲目重试。

两个实测踩过的坑(Task 3 执行时发现)

1. EnemyBase.Abilities 属性遮蔽 BaseGames.Enemies.Abilities 命名空间。 EnemyBasepublic EnemyAbilityRegistry Abilities { get; }。在类型位置 new Abilities.EnemyAttackSelector(...) 能编译(解析为命名空间),但在成员访问位置 Abilities.EnemyAttackSelector.SomeConst 编译失败(解析为那个属性)。 在 EnemyBase 内引用该命名空间下的静态成员时必须写全 BaseGames.Enemies.Abilities.…。 Task 16 / 17 都要改 EnemyBase,会再遇到。

2. unity_get_compilation_errors 在程序集编译成功时不报警告。 Unity 只对编译失败的程序集给出完整诊断集。ChargeAbility.cs:29-30 有两个既存 CS0414[SerializeField] 但从未读取),在绿色构建下完全不可见。 所以"0 errors 0 warnings"是这个上报口径下的结果,不等于真的零警告—— 后续任务若在构建一度失败时看到这两条,那是既存问题,不是自己引入的。

3. MCP 测试运行器忽略过滤参数。 unity_testing_run_teststestFilter / filter 无效, 每次都跑整个 EditMode 套件。这不影响正确性(等于免费拿到全量回归),但别把全量结果误读成过滤结果。

4. ⚠️ EditMode 下 AddComponent 不触发 AwakeTask 4 实测证实) 普通 MonoBehaviour 在编辑模式下 AddComponentAwake 不会被调用。 后果:任何依赖 Awake 解析引用的成员在 EditMode 测试里都是未初始化状态。 具体地,EnemyAbilityBase._enemy 会留空,使 CanUse 恒为 false—— 断言"禁用后不可用"会在修复前也通过,是个永远不会失败的测试。

做法:给测试用的子类加一个 public void RunAwake() => Awake(); 显式触发, 不要用反射直写字段——前者跑的是真实的 GetComponentInParent 解析路径,后者是伪造结果。 只检查 enabled 标志本身(如阶段门的启停断言)则不需要 Awake

5. ⚠️ [RequireComponent] 指向抽象类型时 AddComponent 返回 null。Task 6 实测证实) HurtBox 标了 [RequireComponent(typeof(Collider2D))],而 Collider2D 是抽象类, Unity 无法自动补齐 → AddComponent<HurtBox>() 返回真正的 null(不是 fake-null)。 建测试对象时必须先挂一个具体碰撞体:

_host.AddComponent<BoxCollider2D>();   // 必须在 HurtBox 之前
var hb = _host.AddComponent<HurtBox>();

同样带该特性的还有 HitBoxColliderProxy / PerceptionTriggerProxy / LethalTrapHitBox 本身没有);Task 12 新建的 BossFightTrigger 也标了它, 给它建测试或裸建物体时同理。 旁证:HurtBoxEditor.cs:97 早就为此写了「先加 Collider2D 以满足 HurtBox 的 RequireComponent」—— 这个坑项目里踩过一次,只是没写进规范。

项目硬约束

  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 + designNotefileName → 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

namespace BaseGames.AI
{
    /// <summary>
    /// Boss 专属决策面。挂在 <see cref="IAiContext"/> 上供 Boss 图读取阶段状态、发起阶段过渡、
    /// 查询竞技场锚点。只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。
    /// 非 Boss 敌人访问 <see cref="IAiContext.Boss"/> 会抛异常——这是有意的显式失败。
    /// </summary>
    public interface IBossControl
    {
        /// <summary>当前阶段索引(出生为 0)。</summary>
        int CurrentPhase { get; }

        /// <summary>是否处于阶段过渡(无敌 + 过渡演出)期间。</summary>
        bool IsPhaseTransitioning { get; }

        /// <summary>Boss 资源是否已满(无资源组件时恒为 false)。</summary>
        bool ResourceFull { get; }

        /// <summary>发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。</summary>
        void BeginPhaseTransition(int targetPhase, float invincibleDuration);

        // 竞技场锚点两成员(AnchorAt / DistanceToAnchor)在 Task 10 与
        // BossArenaAnchors 组件一并加入——此刻加会让 Task 2–8 期间 BossBase 编译不过。
    }
}

using UnityEngine; 此刻还用不上(Vector2 在 Task 10 才引入),先不写,Task 10 再加。

  • Step 2: IAiContextBoss

Assets/_Game/Scripts/AI/IAiContext.cs,在 Blackboard Blackboard { get; } 之后追加:

        /// <summary>
        /// Boss 专属决策面。**非 Boss 敌人访问本属性会抛 InvalidOperationException**——
        /// 小怪图误用 Boss 边必须立刻暴露,不返回空对象把问题盖住。
        /// </summary>
        IBossControl Boss { get; }
  • Step 3: 建 FakeBossControl

Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs

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 类里追加字段与属性:

        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 实现 BossBossBase 实现 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

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<T>(string name) where T : EnemyBase
        {
            var go = new GameObject(name);
            return go.AddComponent<T>();
        }

        [Test]
        public void NonBoss_AccessingBossFacet_Throws()
        {
            var enemy = MakeEnemy<EnemyBase>("plain-enemy");
            var ctx   = new EnemyBrainContext(enemy);

            Assert.Throws<InvalidOperationException>(() => { var _ = ctx.Boss; });

            UnityEngine.Object.DestroyImmediate(enemy.gameObject);
        }

        [Test]
        public void Boss_AccessingBossFacet_ReturnsTheBoss()
        {
            var boss = MakeEnemy<BossBase>("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 未实现 BossBossBase 未实现 IBossControl)。

  • Step 3: BossResourceIsFull

Assets/_Game/Scripts/Enemies/Boss/BossResource.cs,在 NormalizedValue 属性之后追加:

        /// <summary>资源是否已达上限(供 AI 图条件边判定"可放大招")。</summary>
        public bool IsFull => _config != null && _config.maxValue > 0f
                              && _currentValue >= _config.maxValue;
  • Step 4: BossBase 实现 IBossControl

Assets/_Game/Scripts/Enemies/Boss/BossBase.cs

  1. 类声明改为:
    public class BossBase : EnemyBase, BaseGames.AI.IBossControl
  1. public int CurrentPhase => _currentPhase; 之后追加:
        /// <summary>Boss 资源是否已满(未挂资源组件时恒为 false)。IBossControl 实现。</summary>
        public bool ResourceFull => _bossResource != null && _bossResource.IsFull;

CurrentPhase / IsPhaseTransitioning / BeginPhaseTransition(int, float) 三个成员已存在且签名匹配, 无需改动(BeginPhaseTransition 的可选参数不影响接口实现)。

  1. 顺带把 CurrentPhaseIsBossSkillExecuting 的注释里残留的 BD_UseBossSkill / BD Task 字样删掉 (行为树债,CLAUDE.md 第 4 条同款问题):
        /// <summary>当前是否有 Boss 技能正在执行。</summary>
        public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting;
        /// <summary>当前 Boss 阶段索引。IBossControl 实现。</summary>
        public int CurrentPhase => _currentPhase;
  • Step 5: EnemyBrainContext 实现 Boss

Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs

  1. 文件顶部加 using System;(现有 using 只有 UnityEngineBaseGames.AI)。

  2. 字段与构造函数改为:

        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 为 nullBoss facet 访问时显式抛
        }
  1. public Blackboard Blackboard => _blackboard; 之后追加:
        /// <summary>
        /// Boss 专属决策面。非 Boss 敌人访问即抛——小怪图误挂了 Boss 边必须立刻暴露,
        /// 不返回空对象让错误静默通过(CLAUDE.md 第 6 条)。
        /// </summary>
        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: 提交
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 之后追加:

        // ── WeightedRandomAntiRepeat ─────────────────────────────────────────

        static EnemyAttackSelector SelAntiRepeat(float factor, params IAttackCandidate[] cs)
            => new EnemyAttackSelector(new List<IAttackCandidate>(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 全文替换为:

namespace BaseGames.Enemies.Abilities
{
    /// <summary>多攻击选招策略。</summary>
    public enum AttackSelectionMode
    {
        WeightedRandom,           // 合格集内按 weight 加权随机
        Priority,                 // 合格集内取 priority 最高(并列取首个)
        WeightedRandomAntiRepeat, // 同 WeightedRandom,但对上一次选中的招施加权重折扣,压低连续重复
    }
}

⚠️ 新值必须追加在末尾:枚举按整数序列化,插在中间会让已有资产的选招模式静默变成另一种。

  • Step 4: 选招器实现折扣

Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs 全文替换为:

using System.Collections.Generic;
using UnityEngine;

namespace BaseGames.Enemies.Abilities
{
    /// <summary>
    /// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤,
    /// 再按 <see cref="AttackSelectionMode"/> 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。
    /// </summary>
    public sealed class EnemyAttackSelector
    {
        private readonly List<IAttackCandidate> _candidates;
        // 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC
        private readonly List<float> _weightBuf;
        // 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚
        private readonly float _antiRepeatFactor;

        private IAttackCandidate _lastPicked;

        public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates, float antiRepeatFactor = 0.3f)
        {
            _candidates       = new List<IAttackCandidate>(candidates);
            _weightBuf        = new List<float>(_candidates.Count);
            _antiRepeatFactor = Mathf.Clamp01(antiRepeatFactor);
        }

        public int Count => _candidates.Count;

        /// <summary>上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。</summary>
        public IAttackCandidate LastPicked => _lastPicked;

        /// <summary>对象池复用 / 阶段切换时清除防重复记忆。</summary>
        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(原 10 个 + 新 5 个 = 15)。

  • Step 6: EnemyStatsSO 加系数字段

Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs,在 attackSelectionMode 字段之后追加:

        [Tooltip("WeightedRandomAntiRepeat 模式下,上一次选中的招的权重折扣系数。" +
                 "0 = 有替代招时绝不连续重复;1 = 不惩罚。仅该模式生效")]
        [Range(0f, 1f)] public float attackAntiRepeatFactor = 0.3f;
  • Step 7: EnemyBase 把系数传给选招器

Assets/_Game/Scripts/Enemies/EnemyBase.csBuildAttackSelector(),最后一行改为:

            // 折扣系数是策划配置项,未配 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.csEnterPhase(int phase),把 LastUsedSkillId = null; 那一行下面追加:

            // 新阶段换招池,上一阶段的防重复记忆不应跨阶段影响选招
            AttackSelector?.ResetRepeatMemory();
  • Step 9: 编译 + 全量回归

MCP unity_get_compilation_errors 期望 0 errors 0 warningsTest Runner → EditMode → Run All 期望全绿。

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

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()
        {
            // 必须先挂 EnemyBase:否则 _enemy==null 会让 CanUse 恒假,
            // 这条断言在修复前也会"通过",成为一个永远不会失败的测试。
            var go    = new GameObject("ability-host");
            go.AddComponent<EnemyBase>();          // 裸 EnemyBase 的 _currentState=Controlled → IsAlive 为真
            var ab    = go.AddComponent<StubAbility>();

            Assert.IsTrue(ab.CanUse,
                "前提:启用且宿主存活时应可用——否则下一条断言分不清是 enabled 还是别的门在起作用");

            ab.enabled = false;
            Assert.IsFalse(ab.CanUse, "禁用的能力组件不可用——否则选招器会选中一个启不动协程的招");

            Object.DestroyImmediate(go);
        }
    }
}

using BaseGames.Enemies; 需一并加入 using 区(EnemyBase 在该命名空间)。 EnemyBase.Awake 末尾的 _stats.Initialize(_statsSO) 会在裸物体上 NRE, 但那发生在所有组件解析之后,不影响本测试;LogAssert.ignoreFailingMessages 已覆盖噪声。

  • Step 2: 跑测试确认失败

Test Runner → EditMode → BossPhaseAbilityGateTests.DisabledAbility_IsNotUsable。 期望:第一条断言通过、第二条 FAIL——CanUse 当前不看 enabled,禁用后仍返回 true。 若两条都过,说明测试没测到 enabled 这一维,停下来修测试再继续。

  • Step 3: CanUse 纳入 enabled

Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs,把 CanUse 改为:

        /// <summary>
        /// 统一可用性查询:组件启用 + 冷却完毕 + 未执行中 + 宿主存活。
        /// enabled 这一维供阶段门使用——被阶段禁用的能力不得进入选招候选,
        /// 否则选招器会选中它然后 StartCoroutine 在禁用组件上必然失败。
        /// </summary>
        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: 提交
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
  • Modify: Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs(一行文档修正,见下)
  • Test: Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.csTask 4 已建,本 Task 追加三条)

前置说明StubAbility_host / TearDown 骨架在 Task 4 已经建好,含 public void RunAwake() => Awake();EditMode 不自动跑 Awake,见执行须知第 4 条)。 本 Task 的三条测试只断言 enabled 标志本身,不需要 RunAwake

顺带修正IAttackCandidate.cs 第 9 行把 CanUse 注释为 // 未冷却 + 未运行 + 存活 Task 4 加了 enabled 这一维后该注释已不完整。改为 // 组件启用 + 未冷却 + 未运行 + 存活

  • Step 1: 写失败测试

Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs

先在文件顶部的 using 区补两行:

using System.Reflection;
using BaseGames.Enemies;

再在 DisabledAbility_IsNotUsable 之后追加:

        // 阶段门的 _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<BossPhaseAbilityGate>();
            var p0    = go.AddComponent<StubAbility>();
            var p1Go  = new GameObject("p1"); p1Go.transform.SetParent(go.transform);
            var p1    = p1Go.AddComponent<StubAbility>();

            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<BossPhaseAbilityGate>();
            var any  = go.AddComponent<StubAbility>();

            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<BossPhaseAbilityGate>();
            var listed   = go.AddComponent<StubAbility>();
            var otherGo  = new GameObject("unlisted"); otherGo.transform.SetParent(go.transform);
            var unlisted = otherGo.AddComponent<StubAbility>();

            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

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

namespace BaseGames.Enemies
{
    /// <summary>
    /// 阶段 = 换招池:按当前阶段启用 / 禁用能力组件。
    /// 被禁用的能力其 <see cref="EnemyAbilityBase.CanUse"/> 为 false
    /// 因此自动从 <see cref="EnemyAttackSelector"/> 的候选里消失——阶段门不需要碰选招器。
    ///
    /// 由 <see cref="BossBase.EnterPhase"/> 与 <see cref="BossBase.OnSpawn"/> 直接调用
    /// (而非订阅阶段频道),保证与阶段切换严格同序。
    /// </summary>
    public sealed class BossPhaseAbilityGate : MonoBehaviour
    {
        [Serializable]
        public struct PhaseEntry
        {
            [Tooltip("受阶段管控的能力组件")]
            public EnemyAbilityBase ability;
            [Tooltip("该能力可用的阶段索引;空数组 = 全阶段可用")]
            public int[] phases;
        }

        [Tooltip("阶段可用性表。未登记的能力不受本组件影响(保持其自身启用状态)")]
        [SerializeField] private PhaseEntry[] _entries;

        /// <summary>把指定阶段的启用集应用到所有登记的能力。</summary>
        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; 之后追加:
        [Header("阶段招池门(可选)")]
        [Tooltip("按阶段启用 / 禁用能力组件;未挂载则所有能力全阶段可用")]
        [SerializeField] private BossPhaseAbilityGate _phaseGate;
  1. Awake() 里,在 if (_bossResource == null) ... 之后追加:
            if (_phaseGate == null) _phaseGate = GetComponentInChildren<BossPhaseAbilityGate>(true);
  1. EnterPhase(int phase) 里,在 AttackSelector?.ResetRepeatMemory();Task 3 加的)之后追加:
            // 阶段 = 换招池:先换池,再广播阶段事件,保证订阅方看到的是新池
            _phaseGate?.ApplyPhase(phase);

⚠️ 顺序:必须在 _onBossPhaseChanged?.Raise(...) 之前

  1. OnSpawn() 里,在 _currentPhase = 0; 之后追加:
            _phaseGate?.ApplyPhase(0);        // 对象池复用:回到阶段 0 的启用集

选招器的防重复记忆不在这里重置——Task 3 的代码审查发现它该归 EnemyBase.OnSpawn() (覆盖全部池化敌人,而非只有 Boss),已在 Task 3 的修正里落地。此处只管阶段门。

  • Step 6: 编译 + 全量回归

MCP unity_get_compilation_errors 期望 0/0Run All 期望全绿。

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

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<HurtBox>();
            Assert.AreEqual(1f, hb.DamageMultiplier);
            Object.DestroyImmediate(go);
        }

        [Test]
        public void SetDamageMultiplier_ClampsToNonNegative()
        {
            var go = new GameObject("hurtbox");
            var hb = go.AddComponent<HurtBox>();

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

            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
        // 本 HurtBox 专属的受击倍率(弱点部位 > 1;默认 1 = 无影响)。
        // 由能力在弱点窗口期间开关,语义与"这个部位更脆弱"一致。
        private float _damageMultiplier = 1f;

        /// <summary>本 HurtBox 当前的受击伤害倍率。</summary>
        public float DamageMultiplier => _damageMultiplier;

        /// <summary>设置受击倍率(弱点窗口开合时调用)。负值钳到 0。</summary>
        public void SetDamageMultiplier(float value)
            => _damageMultiplier = UnityEngine.Mathf.Max(0f, value);

        /// <summary>
        /// 按本 HurtBox 的倍率缩放伤害。最低 1 点,与防御减免同规则
        /// (倍率为 0 也不该让攻击完全无效——那属于无敌帧的语义,不是弱点倍率的)。
        /// </summary>
        public int ApplyDamageMultiplier(int raw)
            => _damageMultiplier == 1f
                ? raw
                : UnityEngine.Mathf.Max(1, UnityEngine.Mathf.RoundToInt(raw * _damageMultiplier));
  1. ReceiveDamage 的伤害管线里插入倍率步骤——找到现有的第 5 步:
            // 5. 计算 FinalDamage(防御减免,最低 1
            int finalDamage  = UnityEngine.Mathf.Max(1, info.Amount - _owner.Defense);

改为:

            // 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 期望全绿。

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 区追加:
using BaseGames.Feedback;
  1. [Header("子系统引用")] 区块改为:
        [Header("子系统引用")]
        [SerializeField] private HitBox[]    _hitBoxes;
        [SerializeField] private HurtBox     _hurtBox;
        [SerializeField] private EnemyBase   _enemy;
  1. _bindings 字段之后追加私有字段:
        private IFeedbackPlayer _feedback;
  1. Awake() 改为:
        private void Awake()
        {
            if (_enemy == null)
                _enemy = GetComponentInParent<EnemyBase>();

            // IFeedbackPlayer 由父层或同层实现(EnemyFeedback / NullFeedbackPlayer),与玩家侧同写法
            _feedback = GetComponentInParent<IFeedbackPlayer>() ?? NullFeedbackPlayer.Instance;

            foreach (var b in _bindings)
                AnimationEventBinder.Bind(b.clip, b.config, this);
        }
  • Step 2: 补四个 case

HandleEvent 的 switch,在 case AnimationEventType.DisableHitBox: 之后插入:

                // ── 无敌帧 ────────────────────────────────────────────────
                // 时长由动画时间轴上两个事件的间距表达,不在代码里写常量。
                case AnimationEventType.EnableIFrame:
                    _hurtBox?.SetInvincible(true);
                    break;

                case AnimationEventType.DisableIFrame:
                    _hurtBox?.SetInvincible(false);
                    break;

case AnimationEventType.AnimationComplete: 之前插入:

                // ── 通用反馈 ──────────────────────────────────────────────
                // 音效预警:在起手帧播一段可辨识的音,让玩家提前缩小招式可能性。
                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: 提交
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.mdABL_ 及两个向导的实际命名对齐; 同目录的 PlayClipAbilitySO 等子类已在用 ABL_):
    [CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "ABL_")]
  1. [Header("标识")] 区块改为(顺带删掉 abilityId 注释里的行为树残留字样):
        [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/0Run All 期望全绿。

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.EngagedBossFragments

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 全文替换为:

namespace BaseGames.AI
{
    /// <summary>
    /// 外部 → 决策层的事件信号。事件边不受 IsControllable 让位门阻挡。
    /// 只保留真有发送方与消费方的值——无发送方的占位枚举只会变成死值。
    /// </summary>
    public enum AiSignal
    {
        /// <summary>敌人已死亡:由 EnemyBase.PerformDeath / NotifyDecisionStop 发出,让决策停止。</summary>
        Died,

        /// <summary>开战:由竞技场触发器(BossFightTrigger)发出,让 Boss 从静置态进入入场演出。</summary>
        Engaged,
    }
}
  • Step 2: ApproachAttackEngagement 建态逻辑提为静态

Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.csBuild 方法替换为下面两段(态名参数化,使 Boss 图能建多组逼近↔攻击):

        public void Build(BrainBuilder b, string rest)
            => Declare(b, Approach, Attack, rest);

        /// <summary>
        /// 建一组「逼近 ↔ 到射程选招攻击」。态名参数化,供本模块与 Boss 图共用同一份实现
        /// (Boss 可能有多个阶段各一组,如地面组与空中组)。
        /// 返回 Approach 态的 builder,调用方可在其上继续挂自己的出边(如阶段过渡)。
        /// </summary>
        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

using System;
using UnityEngine;
using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// Boss 图的共享建态片段。与 <see cref="AiStateFragments"/> 同级——是静态建态原语,
    /// 不是可插拔模块:Boss 图手写,重复的形状抽成片段即可,不需要 SO 下拉与多态序列化。
    /// </summary>
    public static class BossFragments
    {
        /// <summary>
        /// 阶段过渡态:进入时发起过渡(无敌 + 演出由 BossBase 负责),过渡结束后由调用方挂出边。
        /// 只发起一次——OnEnter 而非 Tick,因为 BossBase.BeginPhaseTransition 对重入会告警。
        /// </summary>
        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);
                });
        }

        /// <summary>
        /// 一组「逼近 ↔ 到射程选招攻击」。转发到 ApproachAttackEngagement 的共享实现,
        /// 使模块路径与 Boss 图路径永远是同一份行为。
        /// 返回逼近态的 builder,调用方可继续挂阶段过渡等出边。
        /// </summary>
        public static BrainBuilder.StateBuilder ApproachAttack(
            BrainBuilder b, string approach, string attack, string rest)
            => ApproachAttackEngagement.Declare(b, approach, attack, rest);

        /// <summary>
        /// 移动到第 index 个竞技场锚点(<see cref="BossArenaAnchors"/>),到位后由调用方挂出边。
        /// 坐标经 IBossControl 取得——声明层的 lambda 不得闭包捕获具体实例。
        /// </summary>
        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());
        }

        /// <summary>共享条件:已到达第 index 个锚点(容差内)。</summary>
        public static Func<IAiContext, bool> 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 留下的那两行说明注释)追加:

        /// <summary>第 index 个竞技场锚点的世界坐标。未挂锚点组件 / 下标越界即抛。</summary>
        Vector2 AnchorAt(int index);

        /// <summary>Boss 当前位置到第 index 个锚点的距离。未挂锚点组件 / 下标越界即抛。</summary>
        float DistanceToAnchor(int index);

同步更新 Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs——Task 1 已把 Anchors / DistanceToAnchorValue 两个字段与两个实现写好,此处只需确认它们存在。

  • Step 1: 建组件

Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs

using UnityEngine;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 竞技场锚点集合:Boss 定点走位(跳到悬空平台、传送到房间固定角落)的坐标来源。
    /// 挂在 Boss 上;锚点物体本身放在场景里(随房间布局走,不进 SO)。
    ///
    /// AI 图不直接持有本组件——坐标经 <see cref="BaseGames.AI.IBossControl.AnchorAt"/> 取得
    /// (图的 lambda 不得闭包捕获实例)。BossBase 在 Awake 解析本组件并转发。
    /// </summary>
    public sealed class BossArenaAnchors : MonoBehaviour
    {
        [Tooltip("锚点物体(顺序即下标,AI 图按下标引用)")]
        [SerializeField] private Transform[] _anchors;

        public int Count => _anchors != null ? _anchors.Length : 0;

        /// <summary>取第 index 个锚点坐标。下标越界或漏配即抛——不静默回退到自身位置。</summary>
        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 字段之后追加:
        [Header("竞技场锚点(可选)")]
        [Tooltip("定点走位用的锚点集合;AI 图未用到锚点片段时可留空")]
        [SerializeField] private BossArenaAnchors _arenaAnchors;
  1. Awake() 里,在 _phaseGate 解析之后追加:
            if (_arenaAnchors == null) _arenaAnchors = GetComponentInChildren<BossArenaAnchors>(true);
  1. ResourceFull 属性之后追加两个 facet 成员:
        /// <summary>第 index 个竞技场锚点坐标。IBossControl 实现。未挂锚点组件即抛——
        /// AI 图用了锚点片段却没配锚点是配置错误,必须暴露而非回退到自身位置。</summary>
        public Vector2 AnchorAt(int index) => RequireAnchors().At(index);

        /// <summary>当前位置到第 index 个锚点的距离。IBossControl 实现。</summary>
        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 图里移除锚点片段。");
  1. 顶部 using 区确认已有 using UnityEngine;(现有代码已用 SerializeField,必然已有)。

BossBase 已在 Task 2 声明实现 BaseGames.AI.IBossControl,本步补齐新增的两个成员。

  • Step 4: 编译检查

MCP unity_get_compilation_errors。期望 0 errors 0 warningsTask 9 的报错此时消解)。

  • Step 5: 全量回归 + 提交(含 Task 9)

Run All 期望全绿。

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

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

using BaseGames.AI;

namespace BaseGames.Enemies
{
    /// <summary>
    /// 嘲风的 AI 图(定制路径:Boss 数量少、彼此差异大,手写图而非配方)。
    ///
    /// 阶段 0(地面):逼近 ↔ 选招。四个地面招由 BossPhaseAbilityGate 在阶段 0 放行。
    /// 阶段 1(空中):悬停 ↔ 选招。风石由阶段门在阶段 1 放行,地面四招被禁用。
    ///
    /// 招式的射程 / 冷却 / 权重全归各能力自己的 EnemyAbilitySO;本图只决定
    /// 「什么时候该逼近、什么时候该出手、什么时候该换阶段」。
    /// 死亡不在本图:EnemyBase.PerformDeath 发 Died 事件,全局边把决策停在 Death 终态。
    /// </summary>
    [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";

        /// <summary>入场演出能力 id(对应 ABL_ChaoFeng_Intro 资产的 abilityId)。</summary>
        public const string IntroAbility = "chaofeng_intro";

        /// <summary>空中阶段的血量阈值。</summary>
        private const float AirPhaseHpRatio = 0.5f;

        /// <summary>阶段过渡无敌时长,须 ≥ 浮空上升时长 + 缓冲。</summary>
        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 期望全绿。

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

using UnityEngine;
using BaseGames.AI;
using BaseGames.Core.Events;

namespace BaseGames.Enemies
{
    /// <summary>
    /// Boss 开战触发:玩家进入竞技场触发区时,广播开战事件并让 Boss 的决策层从静置态进战。
    /// 此前 EVT_BossFightStarted 全项目零发送方,Boss 永远不会开战。
    ///
    /// 放在 Boss 房间入口的触发区上(Collider2D + isTrigger)。只触发一次。
    /// </summary>
    [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<Collider2D>();
            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} 未填 bossIdBGM / 血条无法定位该 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);
        }

        /// <summary>供存档回载 / 重试时复位(Boss 未被击败则重新可触发)。</summary>
        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。

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(约第 709716 行)替换为:

        /// <summary>
        /// 嘲风能力集。阶段可用性不在 SO 上——由 BossPhaseAbilityGate 承载
        /// (见放置工具 PlaceChaoFeng),这里只记录供说明文本使用。
        /// rangeRadius 必须 > 0category==Attack 且射程为 0 的招永远够不着玩家,
        /// EnemyAbilitySO.Validate 与 EnemyBase.BuildAttackSelector 都会显式报错。
        /// </summary>
        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 保持 NonerangeRadius = 0)。

  • FanCombo 是多段近战,用基类 EnemyAbilitySOattackSequenceMeleeAttackAbility 消费)。

  • 其余四招是「播 clip,判定与生成靠动画事件」,用 PlayClipAbilitySO

  • Step 3: 改资产工厂

CreateChaoFengSkillSO(约第 692706 行)替换为:

        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(约第 718727 行)替换为:

        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 行)改为:
            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(); }));
            }
  1. BuildBossStatus(约第 785801 行)的 foreach 改为:
            foreach (var (abName, _, _, _, _, _, _) in ChaoFengAbilityDefs)
                checks.Add(($"ABL_ChaoFeng_{abName}",
                            FindAtPath<EnemyAbilitySO>($"{ablDir}/ABL_ChaoFeng_{abName}.asset")));
  1. 在该 foreach 之后追加一行 AI 图状态检查:
            // 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 文案改为:
            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: 提交
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 行:

            BossSkillExecutor         skillExec    = GetOrAddComponent<BossSkillExecutor>(go);

替换为:

            EnemyAiBrain              brain        = GetOrAddComponent<EnemyAiBrain>(go);
            BossPhaseAbilityGate      phaseGate    = GetOrAddComponent<BossPhaseAbilityGate>(go);
            // 五个招式各一个能力组件(Intro 与四个地面/空中招)
            var abIntro    = GetOrAddComponent<PlayClipAbility>(GetOrCreateChild(go.transform, "ABL_Intro").gameObject);
            var abFan      = GetOrAddComponent<MeleeAttackAbility>(GetOrCreateChild(go.transform, "ABL_FanCombo").gameObject);
            var abBoomer   = GetOrAddComponent<PlayClipAbility>(GetOrCreateChild(go.transform, "ABL_Boomerang").gameObject);
            var abTornadoS = GetOrAddComponent<PlayClipAbility>(GetOrCreateChild(go.transform, "ABL_TornadoSmall").gameObject);
            var abTornadoL = GetOrAddComponent<PlayClipAbility>(GetOrCreateChild(go.transform, "ABL_TornadoLarge").gameObject);
            var abWind     = GetOrAddComponent<PlayClipAbility>(GetOrCreateChild(go.transform, "ABL_WindStone").gameObject);

一招一个子物体:EnemyAbilityBase[DisallowMultipleComponent],同一物体上放不了两个 同类型能力(四个 PlayClipAbility)。EnemyAbilityRegistry.CollectFrom(gameObject) 是 递归收集,子物体上的能力同样会被注册。

  • Step 2: 改 HitBox 注释

第 1098 行注释改为:

            // Phase1 攻击 HitBox(默认禁用;由能力按 EnemyAttackSO.hitBoxSlot 归一化时机开关)。
  • Step 3: 删执行器接线,改绑能力 _config

删第 1125 行 AssignReference(skillExec, "_animancer", animancer, report);

把第 11611175 行(收集 BossSkillSO 赋给执行器那一整段,含 AssignString(skillExec, "_bossId", ...)AssignObjectArray(skillExec, "_hitBoxes", ...))替换为:

            // 决策层: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 行之后)追加:

        /// <summary>写入 MeleeAttackAbility 的命名 HitBox 槽位数组(私有序列化字段,经反射)。</summary>
        private static void AssignHitBoxSlots(
            BaseGames.Enemies.Abilities.MeleeAttackAbility ability,
            (string slotName, BaseGames.Combat.HitBox hitBox)[] slots,
            System.Collections.Generic.List<string> 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}。");
        }

        /// <summary>写入 BossPhaseAbilityGate 的阶段表(私有序列化字段,经反射)。</summary>
        private static void AssignPhaseGate(
            BaseGames.Enemies.BossPhaseAbilityGate gate,
            (Object ability, int[] phases)[] entries,
            System.Collections.Generic.List<string> 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", ...))之后追加:

            AssignReference(bossBase, "_phaseGate", phaseGate, report);
  • Step 6: 改报告文案

把第 11861191 行的四条 report.Add 替换为:

            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("★ AIBoss 走定制路径,EnemyAiBrain._definitionId 已设为 ChaoFeng(对应 ChaoFengAi)。" +
                       "小怪才用配方(_recipe)。");
            report.Add("★ 开战触发:Boss 房间入口需另放一个带 BossFightTrigger 的触发区," +
                       "指向本 Boss 的 EnemyAiBrain 并填 bossId=ChaoFeng,否则 Boss 不会开战。");
  • Step 7: 清 E004 的旧轨提示

删第 405407 行里那条 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: 提交
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_*.asset5 个 + .meta

  • Create(经向导): 同目录 6 个新 ABL_ChaoFeng_*.asset

  • Modify: Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab

  • Step 1: 删 5 个旧资产

这 5 个是空 BossSkillSO,且新资产要占用同名路径(CreateSOAsset 遇同名会跳过),必须先删。

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.assetabilityId = fan_combocategory = AttackrangeRadius = 2.5cooldown = 2.5weight = 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 3.5: 判定 Phase1_Tornado_HitBox 的去留(Task 14 发现)

放置工具仍会创建 Phase1_Tornado_HitBox 并给它绑伤害源,但旧的 _hitBoxes 数组已随 BossSkillExecutor 一起移除,现在没有任何东西引用它。龙卷是由技能 clip 上的 SpawnProjectile 动画事件生成弹体、由弹体自带判定,Boss 身上这个 HitBox 很可能本就多余。

在真实对象树上确认:若确无消费者,从放置工具里删掉它的创建与伤害源绑定 (留着会让作者以为龙卷判定挂在 Boss 身上,是误导)。若发现某招确实需要 Boss 自身的 近身判定,则把它绑进对应能力的 HitBox 槽位——不要让它以无引用状态留在对象树上

  • Step 4.5: 修 EnemyBrainContext.UseAbility 的静默失败(Task 11 发现,此处正是它变可观测的时点)

EnemyBrainContext.UseAbility(id)找不到的能力 id 静默返回 false。后果: chaofeng_intro 资产若漏配,Intro 态进入后什么都不做,IsAbilityRunning 恒假, introDone 立刻成立——入场演出被静默跳过,零报错。它"向前失败"(战斗仍能打), 但正是第 6 条禁止的那种静默通过。

AiStateFragments.Ability/AbilityOnce 已在建图期挡掉空 id,所以运行期解析不到的 id 一定是配置错误,没有合法情形。改为显式报错:

        public bool UseAbility(string abilityId)
        {
            var a = _enemy.Abilities?.Get(abilityId);
            if (a == null)
            {
                Debug.LogError($"[EnemyBrainContext] '{_enemy.name}' 的 AI 图请求能力 " +
                               $"'{abilityId}',但该敌人身上没有这个能力组件。" +
                               "请检查能力组件是否挂载、其 EnemyAbilitySO 的 abilityId 是否一致。", _enemy);
                return false;
            }
            return a.Execute();
        }

LogError 而非抛异常:这是每帧路径,抛异常会打断整个 AiRuntime.Tick LogError 足以让问题在 Console 里立刻可见,且不会让一个漏配的敌人拖垮整场。 ⚠️ 会影响所有走配方路径的小怪——放在本 Task 是因为此刻嘲风的能力资产刚建好, 是第一次能真正观察到"配齐 vs 漏配"两种表现的时点。改完跑全量回归确认无既有敌人踩中。

  • Step 5: 放一个开战触发区

场景里新建一个空物体 BossFightTrigger_ChaoFeng,加 BoxCollider2D(勾 Is Trigger+ BossFightTrigger

  • _bossBrain → 嘲风的 EnemyAiBrain
  • _bossIdChaoFeng
  • _playerLayersPlayer
  • _onBossFightStartedEVT_BossFightStarted
  • _onBossFightToggledEVT_BossFightEnded⚠️ Task 12 实测:不存在名为 EVT_BossFightToggled 的资产。那个 BoolEventChannelSO 实际叫 EVT_BossFightEnded 但被当作开关用——BGMController._onBossFightToggledBossHPBar._onBossFightToggled 两个字段绑的都是它。资产名与用途不符是既存问题,本次不改名,只是别再去找那个不存在的资产。)

保存场景。

  • 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.Validatecategory==Attack && rangeRadius<=0 的报错不应出现)

  • BaseGames/Addressables/Validate Address Keys → 期望 0 error

  • BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check → 期望通过

  • Step 8: 提交

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

⚠️ 不要删掉 Task 15 新加的 BossBase.Start() 它调 _phaseGate?.ApplyPhase(_currentPhase), 是场景直接放置的 Boss(非对象池生成)唯一一次应用阶段招池的时机—— EnterPhase 只在换阶段时触发、OnSpawn 只在池化复用时触发,两者开局都不会跑。 缺了它,阶段 1 的招在阶段 0 就是启用状态。Task 15 实测确认过这一点。

  • Step 1: 删技能执行相关成员

Assets/_Game/Scripts/Enemies/Boss/BossBase.cs 删除以下全部内容:

  1. 字段:_skillExecutor(含 [Header("技能执行器")])、_onParrySuccess(含其 [Header][Tooltip])、 _weightedCandidates_candidateWeights_singleSkillBuf_counterStaggerCoroutine
  2. 属性:IsBossSkillExecutingLastUsedSkillId
  3. 方法:UseBossSkillUseBossSkillWeightedIsSkillAvailableInPhaseCheckResourceCostHandleParrySuccessApplyCounterResponseExecuteCounterEffectCounterStaggerCoroutine
  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; 两处(EnterPhaseOnSpawn)删掉——防重复记忆已归选招器
  10. using 区里因此不再需要的 using BaseGames.Boss;using BaseGames.Parry;
  11. 清掉本文件残余的三处行为树残留 —— 已在 Task 5 顺带完成(该任务本就在编辑 BossBase.cs,按"清理你正在编辑的注释"规则一并做了)。三处均已改为"决策层"表述, 含那条运行时告警字符串。本 Task 无需再动,保留原文仅作记录:
    • 第 76 行附近的区段注释 // ── 技能执行(BD Task 调用入口) —— 随该区段一并删除
    • BeginPhaseTransition 文档里的 BD_BossPhaseTransition 检查 IsPhaseTransitioning 来等待过渡完成。 —— 改为 AI 图挂 When(x => !x.Boss.IsPhaseTransitioning) 边等待过渡完成。
    • BeginPhaseTransition 重入告警里的运行时字符串 请检查行为树逻辑是否重复触发阶段切换。 —— 改为 请检查 AI 图是否重复触发阶段切换。(这条是开发期真的会看到的日志,不能留)
  • Step 2: 编译检查

MCP unity_get_compilation_errors。 期望:EnemyDebugOverlay.cs:113 报错(引用了已删的 IsBossSkillExecuting)。下一步修。

  • Step 3: 修 EnemyDebugOverlay

Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs 第 113 行那条 BossSkillExec 显示,替换为阶段信息 (Boss 现在没有独立执行器,有价值的是阶段与过渡状态):

                list.Add($"Phase: {_boss.CurrentPhase}" +
                         (_boss.IsPhaseTransitioning ? " <color=#ffcc44>(transitioning)</color>" : ""));
  • Step 4: 编译 + 全量回归 + 提交

MCP unity_get_compilation_errors 期望 0/0Run All 期望全绿。

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(约第 405425 行)删除:

  • 字段 _wasParried_parryTimestamp、常量 ParryEventTTL 及其注释
  • 方法 ConsumeParryEvent() 整个(零调用者)

并把 ReceiveParry 里对 _wasParried / _parryTimestamp 的两行赋值删掉,注释更新为:

        /// <summary>
        /// 被弹反时调用:强制进入 Stagger 并在 staggerDuration 秒后恢复。
        /// Stagger 期间 IsControllable 为 falseAI 决策自动让位——不需要额外的 AI 信号。
        /// ⚠️ 目前**零调用者**:弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用,
        /// 接线是一件独立任务(DamageInfo 加 attacker,或弹反成功时回调发起攻击的 HitBox)。
        /// </summary>
        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 第 656664 行改为:

        /// <summary>
        /// 通知决策层终止(死亡演出 / 出场演出期间调用,防止决策继续覆盖演出逻辑)。
        /// 与 PerformDeath 发的 Died 信号幂等(重复 Died 无害)。
        /// 供配置型行为组件(如 EnemyDeathSequence)调用,故为 public。
        /// </summary>
        public void NotifyDecisionStop()
        {
            _brain?.Send(BaseGames.AI.AiSignal.Died);
        }
  • Step 3: 改两个调用点
  1. Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs 第 63 行:
            if (_stopDecisionLayer) _enemy?.NotifyDecisionStop();

并把该组件上的序列化字段 _stopBehaviorTree 重命名为 _stopDecisionLayer,加迁移特性保住已有资产的值:

        [UnityEngine.Serialization.FormerlySerializedAs("_stopBehaviorTree")]
        [Tooltip("死亡演出开始时通知决策层终止,防止 AI 继续覆盖演出")]
        [SerializeField] private bool _stopDecisionLayer = true;

⚠️ 若该字段的默认值原本不是 true,保持原默认值不变——本 Task 只改名,不改行为。

  1. Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs 第 188 行:
            NotifyDecisionStop();
  • Step 4: 编译 + 全量回归 + 提交

MCP unity_get_compilation_errors 期望 0/0Run All 期望全绿。

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 删除: autoTriggerOnFullfullTriggerSkill(类型是即将删除的 BossSkillSO)、resetValueAfterTrigger 三个字段及相关 [Header] / [Tooltip]

在类注释里追加一句说明去向:

    /// <remarks>
    /// 资源满值不再由本组件自动放招——那是绕过决策层直接执行,违反「AI 只决策、能力负责实现」。
    /// 现在的形态是:本组件只维护数值,AI 图挂一条 When(x => x.Boss.ResourceFull) 边进专属招态。
    /// </remarks>
  • Step 2: 删运行时的自动触发

Assets/_Game/Scripts/Enemies/Boss/BossResource.cs

  1. 删字段 _fullTriggered
  2. 删方法 OnReachFull() 整个
  3. AddValue(float delta) 简化为:
        private void AddValue(float delta)
        {
            _currentValue = Mathf.Clamp(_currentValue + delta, 0f, _config.maxValue);
        }
  1. 类注释里 - 满值时若 autoTriggerOnFull,自动让 BossBase 执行配置的技能。 那一行,改为:
    /// - 满值时 <see cref="IsFull"/> 为真,由 AI 图的条件边决定放什么招(决策不在本组件)。
  1. OnBossUseSkill() 的注释里 由 BossBase.UseBossSkill 触发 改为 由使用了资源的能力触发(Boss 的招在自己的 EnemyAbilityBase 里调用)
  • Step 3: 编译 + 提交

MCP unity_get_compilation_errors 期望 0/0。

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 0: 先清掉两处计划遗漏的引用(Task 16–18 执行时发现,不清则本 Task 必然编译失败)

0a. ChaoFengKnockdownCounter.cs:82 —— 编译期类型引用,必须改:

// 改前
_boss.GetComponentInChildren<BossSkillExecutor>()?.InterruptCurrentSkill();
// 改后(与 BossBase 瘦身时同一形状)
_boss.Abilities.InterruptAll(InterruptReason.ExternalRequest);

该文件不在删除清单里且仍被 ChaoFengBoss._knockdownCounter 使用(DefeatSequenceOnDamageTaken 都会调)。按需补 using BaseGames.Enemies.Abilities;⚠️ 注意 EnemyBase.Abilities 属性遮蔽同名命名空间(执行须知第 1 条),写法参照 BossBase 里已落地的那处。

0b. EditorScaffoldUtils.cs:21-22 —— 命名前缀表里的死条目:

{ "BossSkillSO",     ("SKL_", "SKL_{Name},例:SKL_BossRage") },
{ "SkillSequenceSO", ("SKL_", "SKL_Seq_{Name},例:SKL_Seq_RageCombo") },

字符串字面量,不影响编译,但类型删除后是误导性死配置(还与 AssetFolderSpecABL_ 冲突)。删掉这两行。

  • Step 1: 确认零引用
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
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

⚠️ Patterns/独立程序集BaseGames.Enemies.Boss.Patterns.asmdefautoReferenced: true)。 已核实零外部引用(无任何 asmdef 的 references 含它),因此可随目录整包删除。 删除后无需改动任何其他 asmdef。

  • Step 3: 编译 + 全量回归

回 Unity 刷新。MCP unity_get_compilation_errors 期望 0 errors 0 warningsRun All 期望全绿。

⚠️ 若 Console 出现 missing script 警告,检查是哪个预制体/场景仍引用旧组件—— ENM_ChaoFeng.prefab 已在 Task 15 重建,TestRoomA 里可能还有旧的嘲风实例,删掉它并保存场景。

  • Step 4: 打开 Data Hub 与角色向导确认无异常

  • DataHubWindowBossSkillModule 已删,其他模块应正常列出(该模块经反射发现,无显式注册点)。

  • 角色向导 Boss 页:状态面板 6 个 ✔ + AI 图 ✔,跳转按钮不再有"Boss 技能序列查看器"。

  • Step 5: 提交

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 已补接)
- 竞技场联动 → EnemySpawnerOnEventBossSkillEventChannelSO 本就零订阅方
- 霸体窗口 → 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: 确认无资产在用

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 删除整个:

        [Header("预警(Telegraph")]
        [Tooltip("预警 VFX keyIObjectPoolService.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() 里删掉这两行:
                if (_config != null && _config.telegraphDuration > 0f)
                    yield return TelegraphRoutine();
  1. Phase = AbilityRunState.Telegraph; 那行删掉(不再有 Telegraph 阶段), RunInternal 开头改为:
            _isRunning = true;
            Phase      = AbilityRunState.Windup;
            try
            {
                yield return ExecuteCoroutine();
                Phase = AbilityRunState.Recovery;
            }
  1. AbilityRunState.Telegraph 枚举值因此零消费者,一并从 Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs 删掉。 ⚠️ 先 grep 确认:grep -rn "AbilityRunState.Telegraph" Assets --include=*.cs。 有其他消费者就保留枚举值,只删预警协程。

  2. 顶部 using 区里因此不再需要的 using BaseGames.Core; / using BaseGames.Core.Pool; —— 先 grep 确认文件里没有其他用途再删

  • Step 4: 编译 + 全量回归 + 提交

MCP unity_get_compilation_errors 期望 0/0Run All 期望全绿。

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: 全库残留扫描

cd "E:/Works/Unity/Projects/zeling_v2"
grep -rn "BossSkill\|WeakPoint\|Telegraph\|AttackPattern\|SkillSequence\|StopBehaviorTree\|BD_\|行为树" \
     Assets/_Game/Scripts --include=*.cs

BossSkill|WeakPoint|Telegraph|AttackPattern|SkillSequence|StopBehaviorTree 期望无命中(相关类型已删)。

BD_|行为树 预期仍有命中——Task 3 执行时实测 Assets/_Game/Scripts/Enemies/ 下约 20 处, 散落在 EnemyAnimationConfigSO / SensorSlotNames / EnemyAbilityBase / EnemyDeathSequence / ChaoFengBoss 等文件的注释与 [Tooltip] 里。其中一部分会随 Task 16/17/19/20 自然消失 BossSkillSOTelegraphSystem 整文件删除;BossBaseEnemyBaseEnemyDeathSequence 在各自任务里改到)。本步负责清掉剩余的:逐个改为只描述功能的表述,不删除功能性内容。 清完后再跑一次 grep,此时才应无命中。

已点名的一处需特别处理EnemyQuotaManager 有序列化字段 _maxActiveBehaviorTrees 与注释 等价于旧的禁用行为树(Task 4 执行时发现)。字段改名会丢已有场景/预制体上的配置值, 必须加 [UnityEngine.Serialization.FormerlySerializedAs("_maxActiveBehaviorTrees")] 再改为 _maxActiveBrains(该管理器实际裁剪的是 EnemyAiBrain,不是能力组件)。

  • Step 2.5: 处理描述已删除系统的长期文档(Task 19 发现)

约 20 份 Docs/Docs_Dev/ 文档仍在描述旧 Boss 轨。按 CLAUDE.md 第 7 条, Docs/ 是需要长期维护、对项目持续有效的文档——留着整份描述已不存在系统的架构文档, 比没有文档更糟(新人会照着它去找不存在的类型)。逐类处理:

文档 处理
Docs/Architecture/23_BossSkillModule.md 整份描述已删除的 BossSkillSO/BossSkillExecutor 体系 → 删除,其内容已被本次 spec 取代
Docs/Design/47_BossSkillSystem.md 同上 → 删除
Docs/Standards/AssetFolderSpec.md 只需删掉旧 Boss 技能资产的条目,其余规范仍有效 → 局部修订
Docs/Guides/ 下提到旧轨的章节 随 Step 3 的新章节一并改写
Docs_Dev/ 下的历史计划与验证清单 保留不动——那是开发过程记录,反映当时状态,改了反而失真

删除前确认没有其他文档以链接方式引用它们;若有,把链接指向本次的 spec。

  • Step 3: 更新指导手册

Docs/Guides/ 的敌人 / Boss 章节新增一节「Boss AI 怎么写」,内容覆盖:

  1. Boss 走定制路径:写 [AiDefinition("<BossId>")] class XxxAi : AiScriptEnemyAiBrain._definitionId 填该 id(不用 _recipe
  2. 可用的建态原语:AiStateFragmentsLocomotion / Ability / AbilityOnce / Terminal+ BossFragmentsPhaseTransition / ApproachAttack / MoveToAnchor / AtAnchor
  3. 阶段 = 换招池:BossPhaseAbilityGate 配阶段表,不在 SO 上写阶段
  4. 一招怎么配:EnemyAbilitySO+ 类型化子类)+ EnemyAttackSO;判定 / 生成 / 音效 / 无敌帧全挂动画事件
  5. 开战:BossFightTriggerAiSignal.Engaged
  6. 死亡不在 AI 层:Die / PerformDeath + NotifyDecisionStop
  7. ChaoFengAi 为完整范例逐行讲解
  • Step 4: spec 标记为已实施

Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md 顶部状态行改为:

> 日期:2026-07-30  状态:**已实施**(阶段一 Task 115、阶段二 Task 1620

并在 §13 注记末尾追加实施期发现的偏差(若有),例如实际选用的黑板写入位置、 AbilityRunState.Telegraph 是否保留、ReceiveParry 接线仍为待办等。

  • Step 5: 提交
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 到位后校招式判定时序与预警节奏
BrainBuilder.Build() 校验"态是否有出边" Task 9+10 执行时发现:Build() 只校验"边指向的态是否已声明"(RequireState 的正向),不校验反向——一个已声明却没有任何出边的非终态会让敌人永久卡住且零报错。BossFragments.PhaseTransition 按设计不自带出边,正是这类风险点。做法是给 AiStateFragments.Terminal 加显式终态标记,Build() 对"非终态且无出边"抛异常
池化敌人复活时带着上一条命的冷却 Task 16 执行时确认:EnemyBase.OnSpawn_abilities.InterruptAll(InterruptReason.Dead) 的注释写着「重置能力冷却」,但 EnemyAbilityBase.Interrupt!_isRunning提前返回,真跑到时又是设置 _cooldownEndTime = Time.time + cooldown*0.5f 而非清零。所以冷却从未被重置。修法:给 EnemyAbilityBaseResetCooldown(),在 EnemyBase.OnSpawn 遍历调用。影响全体池化敌人
EnemyHurtState 无受击动画时永久卡死(优先级最高) Task 15 实测发现:EnemyHurtState.EnterAnimConfig.Hurt 为空时提前返回且不安排回到 Controlled。任何没有受击动画的敌人被打一次就永久停在 Hurt 态,IsControllable 恒假、AI 永久停摆。当前美术未接入,等于大部分敌人一挨打就废。典型的规则 6 静默提前返回。与 Boss 轨无关,影响全体敌人,应尽快单独立项
MoveToAnchor 只在 OnEnter 采样锚点 锚点 Transform 若会移动则不会重新取点。与"锚点是固定点"的设定一致,但这是个隐含假设,将来若出现移动锚点需显式处理