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

878 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 敌人寻路逼近 + 攻击选择器 实现计划
> **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
{
/// <summary>能力调度分类:攻击选择器只从 <see cref="Attack"/> 类里选招。</summary>
public enum AbilityCategory
{
None, // 未分类(默认,不参与选招)
Attack, // 攻击招式(选择器候选)
Movement, // 位移/机动
Utility // 辅助/增益
}
}
```
- [ ] **Step 2: 建 AttackSelectionMode 枚举**
`AttackSelectionMode.cs`
```csharp
namespace BaseGames.Enemies.Abilities
{
/// <summary>多攻击选招策略。</summary>
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
{
/// <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`,把类声明改为实现接口:
```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;
/// <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: 提交**
```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<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`
```csharp
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: 提交**
```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
/// <summary>攻击选择器中是否有"够得着且未冷却"的招(供 Approach→Attack 转换)。</summary>
bool HasEligibleAttack();
/// <summary>按敌人配置的选招模式选一个攻击并触发;返回是否成功触发。</summary>
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
/// <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` 类内追加:
```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
/// <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 合并)**
```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
/// <summary>交战风格:单一冲锋能力,或"寻路逼近 + 选招攻击"两态子机。</summary>
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<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)`
```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<IAttackCandidate>)``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 注明)。