From f15f0a6876890f821942dc2a8c2d69f003f7d7eb Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 11:50:20 +0800 Subject: [PATCH] =?UTF-8?q?docs(enemy):=20=E5=AF=BB=E8=B7=AF=E9=80=BC?= =?UTF-8?q?=E8=BF=91+=E6=94=BB=E5=87=BB=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92(9=E4=BB=BB=E5=8A=A1,TDD?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8+=E7=8A=B6=E6=80=81=E6=9C=BA,?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E9=97=A8+Play=E9=AA=8C=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-24-enemy-nav-approach-attack.md | 877 ++++++++++++++++++ 1 file changed, 877 insertions(+) create mode 100644 Docs_Dev/superpowers/plans/2026-07-24-enemy-nav-approach-attack.md diff --git a/Docs_Dev/superpowers/plans/2026-07-24-enemy-nav-approach-attack.md b/Docs_Dev/superpowers/plans/2026-07-24-enemy-nav-approach-attack.md new file mode 100644 index 00000000..ad291215 --- /dev/null +++ b/Docs_Dev/superpowers/plans/2026-07-24-enemy-nav-approach-attack.md @@ -0,0 +1,877 @@ +# 敌人寻路逼近 + 攻击选择器 实现计划 + +> **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`: +```csharp +namespace BaseGames.Enemies.Abilities +{ + /// 能力调度分类:攻击选择器只从 类里选招。 + public enum AbilityCategory + { + None, // 未分类(默认,不参与选招) + Attack, // 攻击招式(选择器候选) + Movement, // 位移/机动 + Utility // 辅助/增益 + } +} +``` + +- [ ] **Step 2: 建 AttackSelectionMode 枚举** + +`AttackSelectionMode.cs`: +```csharp +namespace BaseGames.Enemies.Abilities +{ + /// 多攻击选招策略。 + public enum AttackSelectionMode + { + WeightedRandom, // 合格集内按 weight 加权随机 + Priority // 合格集内取 priority 最高(并列取首个) + } +} +``` + +- [ ] **Step 3: EnemyAbilitySO 加字段** + +在 `EnemyAbilitySO.cs` 的 `priority` 字段之后(`exclusionGroup`/`priority` 所在 Header 块末尾)追加: +```csharp + [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; +``` +> `EnemyAbilitySO` 已 `using UnityEngine;` 且与 `AbilityCategory` 同命名空间,无需额外 using。旧的 `preferredMinRange/preferredMaxRange` 保留不动(降级为编辑器提示)。 + +- [ ] **Step 4: EnemyStatsSO 加字段** + +在 `EnemyStatsSO.cs` 的 `AttackCooldown` 字段附近(战斗参数区)追加: +```csharp + [Header("攻击选招策略")] + [Tooltip("到攻击范围内如何从多个候选招里选一个")] + public BaseGames.Enemies.Abilities.AttackSelectionMode attackSelectionMode + = BaseGames.Enemies.Abilities.AttackSelectionMode.WeightedRandom; +``` + +- [ ] **Step 5: 编译门** + +用 MCP `unity_get_compilation_errors`,预期:0 错误。 + +- [ ] **Step 6: 提交** + +```bash +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`: +```csharp +namespace BaseGames.Enemies.Abilities +{ + /// + /// 攻击选择器的候选契约。攻击类 实现之; + /// 抽象出来使 可脱离 MonoBehaviour 单测。 + /// + 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`,把类声明改为实现接口: +```csharp + public abstract class EnemyAbilityBase : MonoBehaviour, IAttackCandidate +``` +并在类内(`CanUse` 属性附近)追加以下成员(`CanUse` 已存在,接口自动匹配): +```csharp + // ── 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; + + /// 本招圆形攻击范围内是否有玩家(招式自管其射程;LOS 复用敌人级 IsPlayerVisible)。 + 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: 提交** + +```bash +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`: +```csharp +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(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`: +```csharp +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤, + /// 再按 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。 + /// + public sealed class EnemyAttackSelector + { + private readonly List _candidates; + + public EnemyAttackSelector(IEnumerable candidates) + => _candidates = new List(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: 提交** + +```bash +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` 接口内追加: +```csharp + /// 攻击选择器中是否有"够得着且未冷却"的招(供 Approach→Attack 转换)。 + bool HasEligibleAttack(); + + /// 按敌人配置的选招模式选一个攻击并触发;返回是否成功触发。 + bool UseBestAttack(); +``` + +- [ ] **Step 2: EnemyBrainContext 实现** + +在 `EnemyBrainContext.cs` 的 `// ---- ICombatant ----` 区块末尾(`InterruptAbilities` 之后)追加: +```csharp + 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.cs` 的 `FakeCombat` 类内追加(供状态机测试控制): +```csharp + 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,` 确认只有 `EnemyBrainContext` 与 `FakeCombat` 两处实现;若有其它实现者,一并补齐两方法(否则编译失败)。 + +- [ ] **Step 5: 编译门(与 Task 6 合并)** + +因 `_enemy.AttackSelector` 依赖 Task 6,**先完成 Task 6 再统一跑编译门**。届时 MCP `unity_get_compilation_errors` 预期 0 错误。 + +- [ ] **Step 6: 提交(与 Task 6 合并提交,或本任务单独提交待 Task 6 后编译通过再提交)** + +```bash +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` 之后追加: +```csharp + void Pursue(Vector2 target); // 寻路逼近(RunSpeed):每帧朝 target 重寻路(底层防抖) +``` + +- [ ] **Step 2: EnemyLocomotion 实现 Pursue** + +在 `EnemyLocomotion.cs` 的 `MoveTo` 方法之后追加: +```csharp + /// + /// 寻路逼近:设 Approach 模式 + RunSpeed,每帧朝 target 发一次寻路请求(底层 Nav 自带防抖/受阻/NavLink)。 + /// 与一次性 的区别:设跑速、语义为"持续追向移动目标"。供 AI 追击态每帧调用。 + /// + 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` 类内追加: +```csharp + public void Pursue(Vector2 p) { CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); } +``` + +- [ ] **Step 4: 编译门** + +MCP `unity_get_compilation_errors`,预期 0 错误。 + +- [ ] **Step 5: 提交** + +```bash +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.cs` 的 `Abilities` 属性附近追加: +```csharp + public Abilities.EnemyAttackSelector AttackSelector => _attackSelector; + private Abilities.EnemyAttackSelector _attackSelector; +``` + +- [ ] **Step 2: 在 Abilities 收集后构建选择器** + +在 `EnemyBase.Awake`(或调用 `_abilities.CollectFrom(gameObject)` 之处)**紧随其后**追加: +```csharp + BuildAttackSelector(); +``` +并在类内新增方法: +```csharp + /// + /// 从已注册能力里收集 category==Attack 的候选,构建攻击选择器。 + /// 根因校验:攻击招 rangeRadius<=0(永远够不着)显式报错,不静默兜底(CLAUDE.md §6)。 + /// + private void BuildAttackSelector() + { + var candidates = new System.Collections.Generic.List(); + 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 合并)** + +```bash +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 构图工厂与测试: +```csharp + // ── 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` 之上加枚举: +```csharp + /// 交战风格:单一冲锋能力,或"寻路逼近 + 选招攻击"两态子机。 + public enum EngagementStyle { ChaseAbility, ApproachAttack } +``` +在 `Config` 内追加字段: +```csharp + // 交战风格(默认冲锋能力,保持现状);ApproachAttack 时启用 Approach↔Attack 两态子机。 + public EngagementStyle Engagement = EngagementStyle.ChaseAbility; + public string ApproachState = "Approach"; + public string AttackState = "Attack"; +``` + +- [ ] **Step 4: 改 Add 方法:交战入口与子机分支** + +将 `Add` 中"进追击条件"与"追击状态构建"两段改为如下(其余不变)。 + +先把 `canChase` 计算改为仅在 `ChaseAbility` 风格下应用冷却门: +```csharp + string chaseId = c.ChaseAbilityId; + System.Func 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)`: +```csharp + 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(...)` 那段)替换为按风格分支: +```csharp + 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: 提交** + +```bash +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`): +```csharp +#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==Attack` 且 `rangeRadius<=0` → 记为校验失败(附资产路径与 `abilityId`)。若该工具无按类型钩子,则跳过本步(运行时 `BuildAttackSelector` 的 `LogError` 已兜住根因暴露),并在提交信息注明"运行时已校验,SO 静态校验待工具支持"。 + +- [ ] **Step 4: 提交** + +```bash +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`/`rangeOffset`、`weight`/`priority`),其 `EnemyStatsSO.attackSelectionMode` 先设 `WeightedRandom`。其 AI 定义(`AiScript`)改用 `Engagement=ApproachAttack`(参照 `E001CaoZhiAi` 的 `PerceptionStateMachine.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)`、`HasEligible(bool,bool)`、`Select(bool,bool,AttackSelectionMode)`、`ICombatant.HasEligibleAttack()/UseBestAttack()`、`IEnemyLocomotion.Pursue(Vector2)`、`EnemyBase.AttackSelector`、`Config.Engagement/ApproachState/AttackState` —— 各任务引用一致。 +- **顺序依赖**:Task 4(引用 `EnemyBase.AttackSelector`)需与 Task 6 连续实现、统一编译门(已在 Task 4 Step 2/5 注明)。