Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-24-enemy-nav-approach-attack.md
T

37 KiB
Raw Blame History

敌人寻路逼近 + 攻击选择器 实现计划

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: 新增"寻路逼近(Approach)+ 到攻击范围内按策略选招攻击、攻击时暂停逼近"的敌人交战范式,与现有冲锋(Chase)范式并存。

Architecture: 感知系统(PhysicsPerceptionSystem)继续管敌人级交战状态(脱战/警戒/追逐);每个攻击能力自管圆形攻击触发范围AI 状态机(PerceptionStateMachine)新增 opt-in 的 Approach↔Attack 两态子机做决策;纯逻辑的 EnemyAttackSelector 从"够得着+未冷却+LOS+着地"的候选里按 WeightedRandom/Priority 选招。

Tech Stack: Unity 2D、C#、PathBerserker2d 导航、自研 BrainGraph HFSM、NUnit EditMode 测试(Unity Test Runner)。

设计文档: Docs_Dev/superpowers/specs/2026-07-24-enemy-nav-approach-attack-design.md

通用验证方式:

  • 编译门:改动 C# 后,用 MCP unity_get_compilation_errors 确认 0 错误。
  • EditMode 测试Unity 菜单 Window > General > Test Runner > EditMode > Run All(或运行指定类)。
  • 提交:每个 Task 末尾 git add <改动文件> && git commit。提交信息用中文 + 类型前缀(feat(enemy):/test(enemy):)。

文件结构

新建:

  • Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs — 能力调度分类枚举。
  • Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs — 选招模式枚举。
  • Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs — 选择器候选契约。
  • Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs — 选招器(纯逻辑)。
  • Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs — 选招器单测。

修改:

  • Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs — +category/weight/rangeRadius/rangeOffset。
  • Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs — +attackSelectionMode。
  • Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs — 实现 IAttackCandidate + InAttackRange()
  • Assets/_Game/Scripts/AI/ICombatant.cs — +HasEligibleAttack/UseBestAttack。
  • Assets/_Game/Scripts/AI/IEnemyLocomotion.cs — +Pursue。
  • Assets/_Game/Scripts/Enemies/EnemyLocomotion.cs — 实现 Pursue。
  • Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs — 实现 HasEligibleAttack/UseBestAttack。
  • Assets/_Game/Scripts/Enemies/EnemyBase.cs — 构建 selector + 校验。
  • Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs — +ApproachAttack 拓扑。
  • Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs — FakeCombat +HasEligibleAttack/UseBestAttack。
  • Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs — +Pursue。
  • Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs — +ApproachAttack 拓扑测试。

Task 1: 枚举与 SO 字段(选招/射程元数据)

Files:

  • Create: Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs

  • Create: Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs

  • Modify: Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs

  • Modify: Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs

  • Step 1: 建 AbilityCategory 枚举

AbilityCategory.cs

namespace BaseGames.Enemies.Abilities
{
    /// <summary>能力调度分类:攻击选择器只从 <see cref="Attack"/> 类里选招。</summary>
    public enum AbilityCategory
    {
        None,     // 未分类(默认,不参与选招)
        Attack,   // 攻击招式(选择器候选)
        Movement, // 位移/机动
        Utility   // 辅助/增益
    }
}
  • Step 2: 建 AttackSelectionMode 枚举

AttackSelectionMode.cs

namespace BaseGames.Enemies.Abilities
{
    /// <summary>多攻击选招策略。</summary>
    public enum AttackSelectionMode
    {
        WeightedRandom, // 合格集内按 weight 加权随机
        Priority        // 合格集内取 priority 最高(并列取首个)
    }
}
  • Step 3: EnemyAbilitySO 加字段

EnemyAbilitySO.cspriority 字段之后(exclusionGroup/priority 所在 Header 块末尾)追加:

        [Header("选招(攻击选择器用)")]
        [Tooltip("能力调度分类;攻击选择器只从 Attack 类里选招")]
        public AbilityCategory category = AbilityCategory.None;
        [Tooltip("WeightedRandom 模式下的相对权重(越大越易被选)")]
        [Min(0f)] public float weight = 1f;

        [Header("攻击触发范围(招式自管,圆形)")]
        [Tooltip("触发半径(m)category==Attack 时必须 > 0,否则永远够不着(Awake 报错)")]
        [Min(0f)] public float rangeRadius = 0f;
        [Tooltip("范围圆心相对敌人的偏移(m),X 随朝向翻转")]
        public Vector2 rangeOffset = Vector2.zero;

EnemyAbilitySOusing UnityEngine; 且与 AbilityCategory 同命名空间,无需额外 using。旧的 preferredMinRange/preferredMaxRange 保留不动(降级为编辑器提示)。

  • Step 4: EnemyStatsSO 加字段

EnemyStatsSO.csAttackCooldown 字段附近(战斗参数区)追加:

        [Header("攻击选招策略")]
        [Tooltip("到攻击范围内如何从多个候选招里选一个")]
        public BaseGames.Enemies.Abilities.AttackSelectionMode attackSelectionMode
            = BaseGames.Enemies.Abilities.AttackSelectionMode.WeightedRandom;
  • Step 5: 编译门

用 MCP unity_get_compilation_errors,预期:0 错误。

  • Step 6: 提交
git add Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs \
        Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs \
        Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs \
        Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs
git commit -m "feat(enemy): 攻击选招元数据(category/weight/圆形射程)与选招模式入 SO"

Task 2: 攻击候选契约与 InAttackRange

Files:

  • Create: Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs

  • Modify: Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs

  • Step 1: 建 IAttackCandidate 接口

IAttackCandidate.cs

namespace BaseGames.Enemies.Abilities
{
    /// <summary>
    /// 攻击选择器的候选契约。攻击类 <see cref="EnemyAbilityBase"/> 实现之;
    /// 抽象出来使 <see cref="EnemyAttackSelector"/> 可脱离 MonoBehaviour 单测。
    /// </summary>
    public interface IAttackCandidate
    {
        bool  CanUse { get; }              // 未冷却 + 未运行 + 存活
        bool  RequiresLineOfSight { get; } // 是否需要视线(由敌人级 LOS 提供)
        bool  RequiresGrounded { get; }    // 是否需要着地
        float Weight { get; }              // WeightedRandom 权重
        int   Priority { get; }            // Priority 优先级
        bool  InAttackRange();             // 招式自管圆形射程内是否有玩家
        bool  Execute();                   // 触发本招
    }
}
  • Step 2: EnemyAbilityBase 实现 IAttackCandidate

EnemyAbilityBase.cs,把类声明改为实现接口:

    public abstract class EnemyAbilityBase : MonoBehaviour, IAttackCandidate

并在类内(CanUse 属性附近)追加以下成员(CanUse 已存在,接口自动匹配):

        // ── IAttackCandidate(供 EnemyAttackSelector 选招)──────────────────
        public bool  RequiresLineOfSight => _config != null && _config.requiresLineOfSight;
        public bool  RequiresGrounded    => _config != null && _config.requiresGrounded;
        public float Weight              => _config != null ? _config.weight : 0f;
        public int   Priority            => _config != null ? _config.priority : 0;

        /// <summary>本招圆形攻击范围内是否有玩家(招式自管其射程;LOS 复用敌人级 IsPlayerVisible)。</summary>
        public virtual bool InAttackRange()
        {
            if (_config == null || _config.rangeRadius <= 0f
                || _enemy == null || _enemy.PlayerTransform == null) return false;
            float   sign   = _transform.localScale.x < 0f ? -1f : 1f;
            Vector2 origin = (Vector2)_transform.position
                           + new Vector2(_config.rangeOffset.x * sign, _config.rangeOffset.y);
            float r = _config.rangeRadius;
            return ((Vector2)_enemy.PlayerTransform.position - origin).sqrMagnitude <= r * r;
        }

Execute()CanUse 已在基类存在,直接满足接口。_config/_enemy/_transform 均为已有受保护字段。

  • Step 3: 编译门

MCP unity_get_compilation_errors,预期 0 错误。

  • Step 4: 提交
git add Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs \
        Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs
git commit -m "feat(enemy): EnemyAbilityBase 实现 IAttackCandidate 契约(自管圆形射程)"

Task 3: EnemyAttackSelector(选招器)+ 单元测试(TDD)

Files:

  • Test: Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs

  • Create: Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs

  • Step 1: 写失败测试

EnemyAttackSelectorTests.cs

using System.Collections.Generic;
using NUnit.Framework;
using BaseGames.Enemies.Abilities;

namespace BaseGames.Tests.EditMode.Enemies
{
    public class EnemyAttackSelectorTests
    {
        sealed class FakeCandidate : IAttackCandidate
        {
            public bool  CanUseV = true, InRange = true, ReqLOS = false, ReqGround = false, Executed;
            public float WeightV = 1f;
            public int   PriorityV = 0;
            public bool  CanUse => CanUseV;
            public bool  RequiresLineOfSight => ReqLOS;
            public bool  RequiresGrounded => ReqGround;
            public float Weight => WeightV;
            public int   Priority => PriorityV;
            public bool  InAttackRange() => InRange;
            public bool  Execute() { Executed = true; return true; }
        }

        static EnemyAttackSelector Sel(params IAttackCandidate[] cs)
            => new EnemyAttackSelector(new List<IAttackCandidate>(cs));

        [Test]
        public void Empty_NoEligible()
        {
            var s = Sel();
            Assert.IsFalse(s.HasEligible(true, true));
            Assert.IsNull(s.Select(true, true, AttackSelectionMode.Priority));
        }

        [Test]
        public void ExcludesOnCooldown()
        {
            var c = new FakeCandidate { CanUseV = false };
            Assert.IsFalse(Sel(c).HasEligible(true, true));
        }

        [Test]
        public void ExcludesOutOfRange()
        {
            var c = new FakeCandidate { InRange = false };
            Assert.IsFalse(Sel(c).HasEligible(true, true));
        }

        [Test]
        public void ExcludesWhenNeedsLOS_ButNoLOS()
        {
            var c = new FakeCandidate { ReqLOS = true };
            Assert.IsFalse(Sel(c).HasEligible(false, true)); // hasLOS=false
            Assert.IsTrue (Sel(c).HasEligible(true,  true));
        }

        [Test]
        public void ExcludesWhenNeedsGrounded_ButAirborne()
        {
            var c = new FakeCandidate { ReqGround = true };
            Assert.IsFalse(Sel(c).HasEligible(true, false)); // grounded=false
            Assert.IsTrue (Sel(c).HasEligible(true, true));
        }

        [Test]
        public void Priority_PicksHighest()
        {
            var lo = new FakeCandidate { PriorityV = 1 };
            var hi = new FakeCandidate { PriorityV = 5 };
            var pick = Sel(lo, hi).Select(true, true, AttackSelectionMode.Priority);
            Assert.AreSame(hi, pick);
        }

        [Test]
        public void Priority_TieTakesFirstInList()
        {
            var a = new FakeCandidate { PriorityV = 3 };
            var b = new FakeCandidate { PriorityV = 3 };
            var pick = Sel(a, b).Select(true, true, AttackSelectionMode.Priority);
            Assert.AreSame(a, pick);
        }

        [Test]
        public void Priority_SkipsIneligible()
        {
            var blocked = new FakeCandidate { PriorityV = 9, InRange = false }; // 高优但够不着
            var ok      = new FakeCandidate { PriorityV = 1 };
            var pick = Sel(blocked, ok).Select(true, true, AttackSelectionMode.Priority);
            Assert.AreSame(ok, pick);
        }

        [Test]
        public void Weighted_SingleEligible_AlwaysThatOne()
        {
            var only = new FakeCandidate { WeightV = 2f };
            for (int i = 0; i < 20; i++)
                Assert.AreSame(only, Sel(only).Select(true, true, AttackSelectionMode.WeightedRandom));
        }

        [Test]
        public void Weighted_NeverPicksIneligible()
        {
            var bad = new FakeCandidate { WeightV = 100f, InRange = false };
            var ok  = new FakeCandidate { WeightV = 1f };
            for (int i = 0; i < 50; i++)
                Assert.AreSame(ok, Sel(bad, ok).Select(true, true, AttackSelectionMode.WeightedRandom));
        }
    }
}
  • Step 2: 运行确认失败

Test Runner 运行 EnemyAttackSelectorTests,预期:编译失败EnemyAttackSelector 未定义)。

  • Step 3: 实现 EnemyAttackSelector

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;

        public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates)
            => _candidates = new List<IAttackCandidate>(candidates);

        public int Count => _candidates.Count;

        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)
            => mode == AttackSelectionMode.Priority
                ? SelectByPriority(hasLOS, grounded)
                : SelectByWeight(hasLOS, grounded);

        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)
        {
            float total = 0f;
            for (int i = 0; i < _candidates.Count; i++)
            {
                var c = _candidates[i];
                if (Eligible(c, hasLOS, grounded)) total += Mathf.Max(0f, c.Weight);
            }
            if (total <= 0f) return SelectByPriority(hasLOS, grounded); // 权重全 0 → 退化为确定性取首个合格
            float roll = Random.value * total;
            for (int i = 0; i < _candidates.Count; i++)
            {
                var c = _candidates[i];
                if (!Eligible(c, hasLOS, grounded)) continue;
                roll -= Mathf.Max(0f, c.Weight);
                if (roll <= 0f) return c;
            }
            return null; // 理论不达(浮点边界兜底)
        }
    }
}
  • Step 4: 运行确认通过

Test Runner 运行 EnemyAttackSelectorTests,预期:全部 PASS。若 Assets/Tests/EditMode/Enemies/ 需要 .asmdef——不需要,EditMode 测试同属 BaseGames.Tests.EditMode 程序集(已引用 BaseGames.Enemies)。

  • Step 5: 提交
git add Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs \
        Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs
git commit -m "feat(enemy): EnemyAttackSelector 选招器(射程+冷却+LOS+着地过滤,两模式选招)+单测"

Task 4: ICombatant 扩展 + EnemyBrainContext 实现 + FakeCombat

Files:

  • Modify: Assets/_Game/Scripts/AI/ICombatant.cs

  • Modify: Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs

  • Modify: Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs

  • Step 1: ICombatant 加两方法

ICombatant.cs 接口内追加:

        /// <summary>攻击选择器中是否有"够得着且未冷却"的招(供 Approach→Attack 转换)。</summary>
        bool HasEligibleAttack();

        /// <summary>按敌人配置的选招模式选一个攻击并触发;返回是否成功触发。</summary>
        bool UseBestAttack();
  • Step 2: EnemyBrainContext 实现

EnemyBrainContext.cs// ---- ICombatant ---- 区块末尾(InterruptAbilities 之后)追加:

        public bool HasEligibleAttack()
        {
            var sel = _enemy.AttackSelector;
            if (sel == null) return false;
            return sel.HasEligible(_enemy.IsPlayerVisible(), _enemy.Movement != null && _enemy.Movement.IsGrounded);
        }

        public bool UseBestAttack()
        {
            var sel = _enemy.AttackSelector;
            if (sel == null) return false;
            var mode = _enemy.StatsSO != null
                ? _enemy.StatsSO.attackSelectionMode
                : Abilities.AttackSelectionMode.WeightedRandom;
            bool grounded = _enemy.Movement != null && _enemy.Movement.IsGrounded;
            var pick = sel.Select(_enemy.IsPlayerVisible(), grounded, mode);
            return pick != null && pick.Execute();
        }

_enemy.AttackSelector 在 Task 6 添加。此处引用即可(同一提交在 Task 6 前无法编译——所以 Task 4 的编译门与 Task 6 合并验证;见 Step 5)。为避免"引用未定义成员",先做 Task 6 的 Step 1(加 AttackSelector 属性桩)再回本任务——或按下方 Step 顺序:本任务与 Task 6 连续实现、统一编译门。

  • Step 3: FakeCombat 实现新方法

FakeAiContext.csFakeCombat 类内追加(供状态机测试控制):

        public bool Eligible;                    // 测试控制:是否有合格攻击
        public bool HasEligibleAttack() => Eligible;
        public bool UseBestAttack()
        {
            if (!Eligible) return false;
            Used.Add("best"); Running = "best"; return true;
        }
  • Step 4: 检查其它 ICombatant 实现者

用 Grep 搜 : ICombatant / ICombatant, 确认只有 EnemyBrainContextFakeCombat 两处实现;若有其它实现者,一并补齐两方法(否则编译失败)。

  • Step 5: 编译门(与 Task 6 合并)

_enemy.AttackSelector 依赖 Task 6先完成 Task 6 再统一跑编译门。届时 MCP unity_get_compilation_errors 预期 0 错误。

  • Step 6: 提交(与 Task 6 合并提交,或本任务单独提交待 Task 6 后编译通过再提交)
git add Assets/_Game/Scripts/AI/ICombatant.cs \
        Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs \
        Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs
git commit -m "feat(enemy): ICombatant 增加 HasEligibleAttack/UseBestAttack + Context/Fake 实现"

Task 5: IEnemyLocomotion.Pursue + EnemyLocomotion 实现 + FakeLocomotion

Files:

  • Modify: Assets/_Game/Scripts/AI/IEnemyLocomotion.cs

  • Modify: Assets/_Game/Scripts/Enemies/EnemyLocomotion.cs

  • Modify: Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs

  • Step 1: 接口加 Pursue

IEnemyLocomotion.cs 接口内 Approach 之后追加:

        void Pursue(Vector2 target);         // 寻路逼近(RunSpeed):每帧朝 target 重寻路(底层防抖)
  • Step 2: EnemyLocomotion 实现 Pursue

EnemyLocomotion.csMoveTo 方法之后追加:

        /// <summary>
        /// 寻路逼近:设 Approach 模式 + RunSpeed,每帧朝 target 发一次寻路请求(底层 Nav 自带防抖/受阻/NavLink)。
        /// 与一次性 <see cref="MoveTo"/> 的区别:设跑速、语义为"持续追向移动目标"。供 AI 追击态每帧调用。
        /// </summary>
        public void Pursue(Vector2 target)
        {
            _mode = LocomotionMode.Approach;
            _approachTarget = null;
            if (_enemy?.Stats != null) _enemy.Nav?.SetSpeed(_enemy.Stats.RunSpeed);
            _enemy?.MoveTo(target);
        }
  • Step 3: FakeLocomotion 实现 Pursue

FakeLocomotion.cs 类内追加:

        public void Pursue(Vector2 p) { CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); }
  • Step 4: 编译门

MCP unity_get_compilation_errors,预期 0 错误。

  • Step 5: 提交
git add Assets/_Game/Scripts/AI/IEnemyLocomotion.cs \
        Assets/_Game/Scripts/Enemies/EnemyLocomotion.cs \
        Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs
git commit -m "feat(enemy): IEnemyLocomotion 增加 Pursue 寻路逼近原语(RunSpeed)"

Task 6: EnemyBase 构建选择器 + 根因校验

Files:

  • Modify: Assets/_Game/Scripts/Enemies/EnemyBase.cs

  • Step 1: 加 AttackSelector 属性与字段

EnemyBase.csAbilities 属性附近追加:

        public Abilities.EnemyAttackSelector AttackSelector => _attackSelector;
        private Abilities.EnemyAttackSelector _attackSelector;
  • Step 2: 在 Abilities 收集后构建选择器

EnemyBase.Awake(或调用 _abilities.CollectFrom(gameObject) 之处)紧随其后追加:

            BuildAttackSelector();

并在类内新增方法:

        /// <summary>
        /// 从已注册能力里收集 category==Attack 的候选,构建攻击选择器。
        /// 根因校验:攻击招 rangeRadius<=0(永远够不着)显式报错,不静默兜底(CLAUDE.md §6)。
        /// </summary>
        private void BuildAttackSelector()
        {
            var candidates = new System.Collections.Generic.List<Abilities.IAttackCandidate>();
            var all = _abilities?.All;
            if (all != null)
            {
                for (int i = 0; i < all.Count; i++)
                {
                    var ab = all[i];
                    if (ab == null || ab.Config == null) continue;
                    if (ab.Config.category != Abilities.AbilityCategory.Attack) continue;
                    if (ab.Config.rangeRadius <= 0f)
                        Debug.LogError($"[EnemyBase] 攻击招 '{ab.Config.abilityId}' 的 rangeRadius<=0" +
                                       "永远够不着玩家。请在其 EnemyAbilitySO 上配置攻击触发半径。", ab);
                    candidates.Add(ab); // EnemyAbilityBase 实现 IAttackCandidate
                }
            }
            _attackSelector = new Abilities.EnemyAttackSelector(candidates);
        }

是否报"配了 ApproachAttack 却无候选"由使用该风格的敌人在其 AI/配置侧校验(本期以 rangeRadius 校验为主,够暴露最常见漏配)。_abilities 为已有字段(Abilities 属性的后备)。

  • Step 3: 编译门(含 Task 4 改动)

MCP unity_get_compilation_errors,预期 0 错误(此时 Task 4 的 _enemy.AttackSelector 引用已可解析)。

  • Step 4: 运行既有 EditMode 测试确认无回归

Test Runner 运行 EnemyAttackSelectorTests + PerceptionStateMachineTests(既有),预期全 PASS。

  • Step 5: 提交(可与 Task 4 合并)
git add Assets/_Game/Scripts/Enemies/EnemyBase.cs
git commit -m "feat(enemy): EnemyBase 构建攻击选择器(收集 Attack 类+rangeRadius 根因校验)"

Task 7: PerceptionStateMachine 的 ApproachAttack 拓扑 + 测试(TDD

Files:

  • Test: Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs

  • Modify: Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs

  • Step 1: 写失败测试

PerceptionStateMachineTests.cs 类内追加 ApproachAttack 构图工厂与测试:

        // ── ApproachAttack 交战风格 ───────────────────────────────────────
        static AiGraph GraphApproach(string entry = "Idle")
        {
            var b = new BrainBuilder();
            PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
            {
                Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Death = "Death",
                Entry = entry, Rest = "Patrol",
                IdleMode = LocomotionMode.Idle, PatrolMode = LocomotionMode.Patrol, AlertMode = LocomotionMode.Face,
                Engagement = PerceptionStateMachine.EngagementStyle.ApproachAttack,
                ApproachState = "Approach", AttackState = "Attack",
            });
            return b.Build();
        }

        [Test]
        public void Approach_EnteredFromChaseZone_UsesPursue()
        {
            var ctx = new Ctx();
            var rt = new AiRuntime(GraphApproach(), ctx);
            ctx.S.Chase = true;
            rt.Tick(0.1f);
            Assert.AreEqual("Approach", rt.CurrentStateName);
            CollectionAssert.Contains(ctx.L.Calls, "Pursue"); // 寻路逼近意图
        }

        [Test]
        public void Approach_ToAttack_WhenEligibleAttack()
        {
            var ctx = new Ctx();
            var rt = new AiRuntime(GraphApproach(), ctx);
            ctx.S.Chase = true; rt.Tick(0.1f);        // → Approach
            ctx.C.Eligible = true;                     // 有招够得着
            rt.Tick(0.1f);
            Assert.AreEqual("Attack", rt.CurrentStateName);
            CollectionAssert.Contains(ctx.C.Used, "best"); // 选招触发
        }

        [Test]
        public void Attack_StopsLocomotion_OnEnter()
        {
            var ctx = new Ctx();
            var rt = new AiRuntime(GraphApproach(), ctx);
            ctx.S.Chase = true; rt.Tick(0.1f);
            ctx.C.Eligible = true; rt.Tick(0.1f);      // → Attack
            CollectionAssert.Contains(ctx.L.Calls, "Stop"); // 攻击时暂停逼近
        }

        [Test]
        public void Attack_ToApproach_WhenAttackDone()
        {
            var ctx = new Ctx();
            var rt = new AiRuntime(GraphApproach(), ctx);
            ctx.S.Chase = true; rt.Tick(0.1f);
            ctx.C.Eligible = true; rt.Tick(0.1f);      // → Attack, Running="best"
            Assert.AreEqual("Attack", rt.CurrentStateName);
            ctx.C.Running = null;                       // 攻击打完
            rt.Tick(0.1f);
            Assert.AreEqual("Approach", rt.CurrentStateName); // 回逼近重判
        }

        [Test]
        public void Approach_ToRest_WhenLeftAllZones()
        {
            var ctx = new Ctx();
            var rt = new AiRuntime(GraphApproach(), ctx);
            ctx.S.Chase = true; rt.Tick(0.1f);          // → Approach
            ctx.S.Chase = false; ctx.S.Vision = false;  // 脱离全部感知
            rt.Tick(0.1f);
            Assert.AreEqual("Patrol", rt.CurrentStateName);
        }

        [Test]
        public void ApproachAttack_Death_IsGlobal()
        {
            var ctx = new Ctx();
            var rt = new AiRuntime(GraphApproach(), ctx);
            ctx.S.Chase = true; rt.Tick(0.1f);
            rt.Send(AiSignal.Died); rt.Tick(0.1f);
            Assert.AreEqual("Death", rt.CurrentStateName);
        }
  • Step 2: 运行确认失败

Test Runner 运行 PerceptionStateMachineTests,预期:编译失败EngagementStyle/ApproachState/AttackState 未定义)。

  • Step 3: PerceptionStateMachine 加 EngagementStyle + Config 字段

PerceptionStateMachine 类内、Config 之上加枚举:

        /// <summary>交战风格:单一冲锋能力,或"寻路逼近 + 选招攻击"两态子机。</summary>
        public enum EngagementStyle { ChaseAbility, ApproachAttack }

Config 内追加字段:

            // 交战风格(默认冲锋能力,保持现状);ApproachAttack 时启用 Approach↔Attack 两态子机。
            public EngagementStyle Engagement = EngagementStyle.ChaseAbility;
            public string ApproachState = "Approach";
            public string AttackState   = "Attack";
  • Step 4: 改 Add 方法:交战入口与子机分支

Add 中"进追击条件"与"追击状态构建"两段改为如下(其余不变)。

先把 canChase 计算改为仅在 ChaseAbility 风格下应用冷却门:

            string chaseId = c.ChaseAbilityId;
            System.Func<IAiContext, bool> canChase;
            string chaseLabel;
            if (c.Engagement == EngagementStyle.ChaseAbility && c.PatrolBetweenChases)
            {
                canChase   = x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(chaseId);
                chaseLabel = "InChaseZone+offCD";
            }
            else
            {
                canChase   = x => x.Sensor.InChaseZone();
                chaseLabel = "InChaseZone";
            }

            // 交战入口态:冲锋风格=Chase;逼近风格=Approach
            string combatEntry = c.Engagement == EngagementStyle.ApproachAttack ? c.ApproachState : c.Chase;

把 Idle/Patrol/Alert 三处的 .To(c.Chase) 改为 .To(combatEntry)

            AddLocomotionState(b, c.Idle, c.IdleMode)
                .To(combatEntry).When(canChase, chaseLabel)
                .To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");
            AddLocomotionState(b, c.Patrol, c.PatrolMode)
                .To(combatEntry).When(canChase, chaseLabel)
                .To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");

            AddLocomotionState(b, c.Alert, c.AlertMode)
                .To(combatEntry).When(canChase, chaseLabel)
                .To(c.Rest).When(x => !x.Sensor.InVisionZone(), "leftVision");

把原"追击退出"整段(var chase = AddAbilityState(...) 那段)替换为按风格分支:

            if (c.Engagement == EngagementStyle.ApproachAttack)
            {
                // Approach:寻路逼近;有招够得着→Attack;脱离全部感知→R
                b.State(c.ApproachState)
                    .OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
                    .Tick(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
                    .OnExit(x => x.Locomotion.Stop())
                    .To(c.AttackState).When(x => x.Combat.HasEligibleAttack(), "attackInRange")
                    .To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");

                // Attack:停逼近 + 选招攻击;打完回 Approach;脱离感知→R
                b.State(c.AttackState)
                    .OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
                    .OnExit(x => x.Combat.InterruptAbilities())
                    .To(c.ApproachState).When(x => !x.Combat.IsAbilityRunning(), "attackDone")
                    .To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");
            }
            else
            {
                var chase = AddAbilityState(b, c.Chase, chaseId);
                if (c.PatrolBetweenChases)
                    chase.To(c.Rest).When(x => !x.Combat.IsAbilityRunning(chaseId), "dashDone→CD");
                else
                    chase.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");
            }

保留 AddAbilityState(b, c.Death, c.DeathAbilityId); 与全局 Died 转换不变。combatEntry 用于三处升级转换;ApproachAttack 风格下 c.Chase 不建态。

  • Step 5: 运行确认通过

Test Runner 运行 PerceptionStateMachineTests(含既有 ChaseAbility 用例 + 新 ApproachAttack 用例),预期:全部 PASS。既有冲锋用例应不受影响(默认 Engagement=ChaseAbility)。

  • Step 6: 编译门

MCP unity_get_compilation_errors,预期 0 错误。

  • Step 7: 提交
git add Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs \
        Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs
git commit -m "feat(enemy): PerceptionStateMachine 增加 ApproachAttack 风格(寻路逼近↔选招攻击)+测试"

Task 8: 编辑器 —— 攻击射程 Gizmo 与自检校验

Files:

  • Modify: Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs#if UNITY_EDITOR Gizmo

  • Modify: 自检工具(SOValidationRunner 所在文件,用 Grep 定位)

  • Step 1: 攻击范围 Gizmo(选中敌人可见)

EnemyAbilityBase.cs 末尾追加(类内,#if UNITY_EDITOR):

#if UNITY_EDITOR
        private void OnDrawGizmosSelected()
        {
            if (_config == null || _config.category != AbilityCategory.Attack || _config.rangeRadius <= 0f) return;
            float   sign   = transform.localScale.x < 0f ? -1f : 1f;
            Vector3 origin = transform.position
                           + new Vector3(_config.rangeOffset.x * sign, _config.rangeOffset.y, 0f);
            UnityEditor.Handles.color = new Color(1f, 0.4f, 0.2f, 0.9f);
            UnityEditor.Handles.DrawWireDisc(origin, Vector3.forward, _config.rangeRadius);
            UnityEditor.Handles.Label(origin, $"{_config.abilityId} range");
        }
#endif

_config 在编辑期未 Awake 也可用(SerializeField);_enemy/_transform 不依赖,用 transform 直接取。

  • Step 2: 编译门

MCP unity_get_compilation_errors,预期 0 错误。

  • Step 3: SOValidationRunner 加校验项(若存在该扩展点)

用 Grep 定位 SOValidationRunner。若其对 EnemyAbilitySO 有逐资产校验钩子,追加:category==AttackrangeRadius<=0 → 记为校验失败(附资产路径与 abilityId)。若该工具无按类型钩子,则跳过本步(运行时 BuildAttackSelectorLogError 已兜住根因暴露),并在提交信息注明"运行时已校验,SO 静态校验待工具支持"。

  • Step 4: 提交
git add Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs
# 若改了自检工具,一并 add 其文件
git commit -m "feat(enemy): 攻击能力射程 Gizmo + category==Attack 的 rangeRadius 自检"

Task 9: 手动 / PlayMode 验收

Files: 无(场景内验证)

  • Step 1: 准备测试敌人

TestRoomA(或用 ChaoFeng):给一个敌人挂 ≥2 个攻击能力(各配 category=Attack、不同 rangeRadius/rangeOffsetweight/priority),其 EnemyStatsSO.attackSelectionMode 先设 WeightedRandom。其 AI 定义(AiScript)改用 Engagement=ApproachAttack(参照 E001CaoZhiAiPerceptionStateMachine.Add 配置,去掉 ChaseAbilityId、加 Engagement)。确保敌人身上有寻路组件(EnemyNavAgent)与场景已烘焙 PB2d 导航面(含隔沟处的 Jump/Fall NavLink)。

  • Step 2: 验证寻路逼近

进 Play,让玩家站在与敌人隔着平台缺口的位置且在 aggro 范围内。预期:敌人沿寻路路径(走到跳点→跳/落 NavLink→过去)逼近玩家,而非直线卡在缺口边。

  • Step 3: 验证到范围选招攻击 + 暂停逼近

玩家进入某招 rangeRadius。预期:敌人停下逼近、播该招攻击;打完回逼近;若仍在范围且招就绪→再次选招(WeightedRandom 下多次交战招式有变化)。切 attackSelectionMode=Priority 复验:稳定优先高 priority 招。

  • Step 4: 验证脱战回退

玩家跑出全部感知区。预期:敌人退出交战、回巡逻(Rest=Patrol)。

  • Step 5: 验证根因校验

临时把某攻击招 rangeRadius 设 0,进 Play。预期:Console 出现 [EnemyBase] 攻击招 '...' 的 rangeRadius<=0 报错(不静默)。验证后改回。

  • Step 6: 记录结果

把验收结论(通过/问题)记入 Docs_Dev/Verification/(沿用现有验证文档风格)。若全通过,本功能完成。


自检记录(写计划时)

  • Spec 覆盖:§2 职责边界→Task 1/2/6/7;§4.1 SO 字段→Task 1;§4.2 InAttackRange→Task 2;§4.3 选择器→Task 3;§4.4 StatsSO→Task 1;§4.5 ICombatant→Task 4;§4.6 Pursue→Task 5;§4.7 状态机→Task 7;§5 边界/校验→Task 6/9;§6 测试→Task 3/7/9;§7 脚手架/Gizmo/自检→Task 8。全部有对应任务。
  • 类型一致性EnemyAttackSelector(IEnumerable<IAttackCandidate>)HasEligible(bool,bool)Select(bool,bool,AttackSelectionMode)ICombatant.HasEligibleAttack()/UseBestAttack()IEnemyLocomotion.Pursue(Vector2)EnemyBase.AttackSelectorConfig.Engagement/ApproachState/AttackState —— 各任务引用一致。
  • 顺序依赖Task 4(引用 EnemyBase.AttackSelector)需与 Task 6 连续实现、统一编译门(已在 Task 4 Step 2/5 注明)。