Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-27-enemy-body-authority.md
T

527 lines
23 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.
# 角色身体碰撞体唯一权威源(IEnemyBody)实现计划
> **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:** 把散落在 `EnemyMovement` 里 4 处重复的"身体碰撞体解析"与各处身体尺寸取用,收敛为 `EnemyBase` 持有、经 `IEnemyBody` 接口暴露的唯一权威源。
**Architecture:** 新增纯 C# 类 `EnemyBody`(实现 `IEnemyBody`,懒解析碰撞体、实时读 bounds、方向判定抽为 static 纯函数便于单测);`EnemyBase` 持有它并按 `_bodyCollider → 根 Collider2D` 顺序解析,缺失时显式报错;`EnemyMovement` 的 5 处探测与 `EdgeSafeMargin` 全部改走该接口,删除自己的 `_groundCheckCollider` 字段与 `GroundColliderOrSelf()`
**Tech Stack:** Unity 2022.3、C#、NUnit EditMode 测试(Unity Test Runner,经 MCP `unity_testing_run_tests` 运行)。
**设计文档:** `Docs_Dev/superpowers/specs/2026-07-27-enemy-body-authority-design.md`
**通用验证方式(每个 Task 都要做):**
- **编译门**:改 C# 后先 MCP `unity_execute_menu_item("Assets/Refresh")`,等约 8 秒,再 `unity_get_compilation_errors`,预期 0 错误。
- **测试门**MCP `unity_testing_run_tests({mode:"EditMode"})``unity_testing_get_job(jobId)`,**基线 177 条全绿**(本计划新增测试后基线上升,见各 Task)。
- **提交**:中文 + 类型前缀(`feat(enemy):` / `refactor(enemy):` / `test(enemy):`)。
- **重要**:本计划触及所有敌人共用的移动探测路径,**每个 Task 结束都必须跑全量 EditMode**,不能只跑新增测试。
**关键既有事实(已核实,实现时可直接依赖):**
- `EnemyBase` 已有 `protected virtual void OnValidate()``EnemyBase.cs:758``#if UNITY_EDITOR` 内),
开头是 `if (SuppressValidationWarnings) return;` —— **不要新增第二个 `OnValidate`**
- `EnemyBase.Awake``EnemyBase.cs:531-552` 一带做组件收集与 `Debug.Assert` 校验。
- `EnemyMovement._groundCheckCollider``EnemyMovement.cs:46`)在 `ENM_CaoZhi`(场景)与
`ENM_ChaoFeng`(预制体)上**实测均为 null** → 删除它不改变现有行为。
- `EnemyMovement` 目前**不持有** `EnemyBase` 引用,需要新增懒获取。
---
## 文件结构
**新建:**
- `Assets/_Game/Scripts/Enemies/EnemyBody.cs``IEnemyBody` 接口 + `EnemyBody` 实现(唯一职责:身体几何查询)。
- `Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs` — 方向纯函数单测。
**修改:**
- `Assets/_Game/Scripts/Enemies/EnemyBase.cs``_bodyCollider` 字段、`Body` 属性、`Awake` 校验、扩展既有 `OnValidate`
- `Assets/_Game/Scripts/Enemies/EnemyMovement.cs` — 5 处探测改走 `Body``EdgeSafeMargin` 改组合、删字段与私有方法。
---
## Task 1: `IEnemyBody` 接口与 `EnemyBody` 实现(含纯函数单测,TDD)
**Files:**
- Test: `Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs`
- Create: `Assets/_Game/Scripts/Enemies/EnemyBody.cs`
- [ ] **Step 1: 先写失败测试**
创建 `Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs`
```csharp
using NUnit.Framework;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.Enemies
{
/// <summary>
/// EnemyBody 方向相关纯函数测试(无场景依赖)。
/// 这三个函数的左右符号最易写反,故对两个方向与边界相等情形都做断言。
/// </summary>
public class EnemyBodyTests
{
// 身体占据 [2, 4],中心 3,半宽 1
const float MinX = 2f, MaxX = 4f;
[Test]
public void FrontEdge_Right_IsMaxX()
=> Assert.AreEqual(MaxX, EnemyBody.FrontEdge(MinX, MaxX, 1f));
[Test]
public void FrontEdge_Left_IsMinX()
=> Assert.AreEqual(MinX, EnemyBody.FrontEdge(MinX, MaxX, -1f));
[Test]
public void FrontEdge_ZeroDir_TreatedAsRight()
=> Assert.AreEqual(MaxX, EnemyBody.FrontEdge(MinX, MaxX, 0f));
[Test]
public void RearEdge_Right_IsMinX()
=> Assert.AreEqual(MinX, EnemyBody.RearEdge(MinX, MaxX, 1f));
[Test]
public void RearEdge_Left_IsMaxX()
=> Assert.AreEqual(MaxX, EnemyBody.RearEdge(MinX, MaxX, -1f));
[Test]
public void RearEdge_ZeroDir_TreatedAsRight()
=> Assert.AreEqual(MinX, EnemyBody.RearEdge(MinX, MaxX, 0f));
// ── BodyPassed:向右 ─────────────────────────────────────────────
[Test]
public void BodyPassed_Right_NotReached_False()
=> Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 5f, 1f)); // 目标在身体右侧外
[Test]
public void BodyPassed_Right_OverlappingTarget_False()
=> Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 3f, 1f)); // 目标仍在身体内,未整体越过
[Test]
public void BodyPassed_Right_ExactlyAtRearEdge_True()
=> Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 2f, 1f)); // 后缘恰好等于目标 = 已越过
[Test]
public void BodyPassed_Right_FullyPassed_True()
=> Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 1f, 1f));
// ── BodyPassed:向左 ─────────────────────────────────────────────
[Test]
public void BodyPassed_Left_NotReached_False()
=> Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 1f, -1f)); // 目标在身体左侧外
[Test]
public void BodyPassed_Left_OverlappingTarget_False()
=> Assert.IsFalse(EnemyBody.BodyPassed(MinX, MaxX, 3f, -1f));
[Test]
public void BodyPassed_Left_ExactlyAtRearEdge_True()
=> Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 4f, -1f)); // 后缘(max)恰好等于目标
[Test]
public void BodyPassed_Left_FullyPassed_True()
=> Assert.IsTrue(EnemyBody.BodyPassed(MinX, MaxX, 5f, -1f));
}
}
```
- [ ] **Step 2: 运行确认失败**
MCP`unity_execute_menu_item("Assets/Refresh")` → 等 8 秒 → `unity_get_compilation_errors`
预期:**编译失败**,报 `EnemyBody` 不存在(`CS0103`/`CS0246`)。
- [ ] **Step 3: 实现 `EnemyBody.cs`**
创建 `Assets/_Game/Scripts/Enemies/EnemyBody.cs`
```csharp
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 角色身体碰撞体的唯一权威查询口。
/// 几何实时取自碰撞体 bounds(随位置/动画变化,不缓存数值),仅缓存碰撞体引用。
/// 唯一数据源见 <see cref="EnemyBase.Body"/>——其它组件不得自行解析身体碰撞体。
/// </summary>
public interface IEnemyBody
{
Collider2D Collider { get; }
/// <summary>世界空间包围盒;实时查询。</summary>
Bounds Bounds { get; }
float Width { get; }
float Height { get; }
float HalfWidth { get; }
float HalfHeight { get; }
Vector2 Center { get; }
/// <summary>脚底中点 (center.x, min.y)。</summary>
Vector2 FootPoint { get; }
/// <summary>朝 <paramref name="dir"/> 的前缘 Xdir≥0 取 max.x,否则 min.x)。</summary>
float FrontEdgeX(float dir);
/// <summary>朝 <paramref name="dir"/> 的后缘 X(前缘的相反侧)。</summary>
float RearEdgeX(float dir);
/// <summary>
/// 身体是否已**整体**越过世界 X 坐标:
/// dir≥0 时后缘(min.x) ≥ worldXdir&lt;0 时后缘(max.x) ≤ worldX。
/// </summary>
bool HasPassed(float worldX, float dir);
}
/// <summary>
/// <see cref="IEnemyBody"/> 的实现(纯 C# 类,非 MonoBehaviour)。
/// 懒解析碰撞体 → 编辑器模式(Gizmo / OnValidateAwake 未执行)同样可用。
/// </summary>
public sealed class EnemyBody : IEnemyBody
{
private readonly GameObject _owner;
private readonly Collider2D _explicitCollider;
private Collider2D _resolved;
/// <param name="owner">宿主 GameObject(未显式指定碰撞体时从其上取 Collider2D)。</param>
/// <param name="explicitCollider">显式指定的身体碰撞体;可为 null。</param>
public EnemyBody(GameObject owner, Collider2D explicitCollider)
{
_owner = owner;
_explicitCollider = explicitCollider;
}
public Collider2D Collider
{
get
{
if (_explicitCollider != null) return _explicitCollider;
if (_resolved == null && _owner != null) _resolved = _owner.GetComponent<Collider2D>();
return _resolved;
}
}
/// <summary>
/// 碰撞体缺失时返回以宿主位置为中心的零尺寸包围盒。
/// 该错误已由 <see cref="EnemyBase"/> 在 Awake/OnValidate 显式报出,此处不重复每帧刷屏。
/// </summary>
public Bounds Bounds
{
get
{
var col = Collider;
if (col != null) return col.bounds;
return new Bounds(_owner != null ? _owner.transform.position : Vector3.zero, Vector3.zero);
}
}
public float Width => Bounds.size.x;
public float Height => Bounds.size.y;
public float HalfWidth => Bounds.extents.x;
public float HalfHeight => Bounds.extents.y;
public Vector2 Center => Bounds.center;
public Vector2 FootPoint { get { var b = Bounds; return new Vector2(b.center.x, b.min.y); } }
public float FrontEdgeX(float dir) { var b = Bounds; return FrontEdge(b.min.x, b.max.x, dir); }
public float RearEdgeX (float dir) { var b = Bounds; return RearEdge (b.min.x, b.max.x, dir); }
public bool HasPassed(float worldX, float dir)
{
var b = Bounds;
return BodyPassed(b.min.x, b.max.x, worldX, dir);
}
// ── 方向纯函数(static,便于无场景依赖单测;符号最易写反,集中于此)──────
public static float FrontEdge(float minX, float maxX, float dir) => dir >= 0f ? maxX : minX;
public static float RearEdge (float minX, float maxX, float dir) => dir >= 0f ? minX : maxX;
public static bool BodyPassed(float minX, float maxX, float worldX, float dir)
=> dir >= 0f ? minX >= worldX : maxX <= worldX;
}
}
```
- [ ] **Step 4: 编译门 + 运行测试确认通过**
MCP`Assets/Refresh` → 等 8 秒 → `unity_get_compilation_errors`(预期 0 错误)
`unity_testing_run_tests({mode:"EditMode"})``unity_testing_get_job(jobId)`
预期:**191 条全绿**(原 177 + 新增 14)。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/EnemyBody.cs Assets/Tests/EditMode/Enemies/EnemyBodyTests.cs
git commit -m "feat(enemy): 新增 IEnemyBody 身体几何权威接口与 EnemyBody 实现(+14 纯函数单测)"
```
---
## Task 2: `EnemyBase` 持有权威源
**Files:**
- Modify: `Assets/_Game/Scripts/Enemies/EnemyBase.cs`
- [ ] **Step 1: 加序列化字段与 `Body` 属性**
`EnemyBase.cs` 的"子组件(Prefab Inspector 绑定)"Header 区块内、`_hurtBox` 字段之后(约 `EnemyBase.cs:44`)追加:
```csharp
[Header("身体碰撞体(唯一权威源)")]
[Tooltip("用于身体几何(宽高/边缘/探测起点)的碰撞体。留空 = 使用本物体上的 Collider2D。" +
"移动探测、导航内缩、冲刺终点等全部以此为准,不要在各处自行取碰撞体。")]
[SerializeField] private Collider2D _bodyCollider;
```
`_colliders` 字段声明附近(约 `EnemyBase.cs:70`)追加缓存字段:
```csharp
// 身体几何权威(懒创建;OnValidate 改引用后置空重建)
private EnemyBody _body;
```
在公开属性区(`Abilities` / `AttackSelector` 属性附近)追加:
```csharp
/// <summary>身体碰撞体几何的唯一权威查询口。移动/导航/能力一律经此获取身体尺寸。</summary>
public IEnemyBody Body => _body ??= new EnemyBody(gameObject, _bodyCollider);
```
- [ ] **Step 2: `Awake` 加运行期校验**
`EnemyBase.Awake``_colliders = GetComponentsInChildren<Collider2D>(true);`(约 `EnemyBase.cs:544`)之后追加:
```csharp
// 身体几何权威校验:解析不到碰撞体 → 显式报错(根因暴露,不静默兜底)
if (Body.Collider == null)
Debug.LogError($"[EnemyBase] {name} 找不到身体碰撞体:请在 Inspector 指定 _bodyCollider" +
"或确保本物体上挂有 Collider2D。移动探测/导航内缩将不可用。", this);
```
- [ ] **Step 3: 扩展既有 `OnValidate`(不要新增第二个)**
`EnemyBase.cs:758` 已有 `protected virtual void OnValidate()`,把它改为:
```csharp
protected virtual void OnValidate()
{
_body = null; // 身体碰撞体引用可能在 Inspector 被改,缓存无条件失效(与警告抑制无关)
if (SuppressValidationWarnings) return;
if (_statsSO == null)
Debug.LogWarning($"[EnemyBase] {gameObject.name} 缺少 EnemyStatsSO 配置(运行时会 NullRef)。", this);
if (_stats == null)
Debug.LogWarning($"[EnemyBase] {gameObject.name} 未绑定 EnemyStats 组件引用。", this);
if (_animancer == null)
Debug.LogWarning($"[EnemyBase] {gameObject.name} 未绑定 AnimancerComponent 引用。", this);
if (_bodyCollider == null && GetComponent<Collider2D>() == null)
Debug.LogWarning($"[EnemyBase] {gameObject.name} 找不到身体碰撞体(未指定 _bodyCollider 且本物体无 Collider2D)。", this);
}
```
- [ ] **Step 4: 编译门 + 全量测试**
MCP`Assets/Refresh` → 等 8 秒 → `unity_get_compilation_errors`(预期 0 错误)
`unity_testing_run_tests({mode:"EditMode"})`。预期:**191 条全绿**。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/EnemyBase.cs
git commit -m "feat(enemy): EnemyBase 持有身体几何权威(_bodyCollider + Body),Awake/OnValidate 双重校验"
```
---
## Task 3: `EnemyMovement` 全部改走权威接口
**Files:**
- Modify: `Assets/_Game/Scripts/Enemies/EnemyMovement.cs`
> 本 Task 是行为等价重构:改完后巡逻/夹停/掉头行为必须与改前完全一致。
- [ ] **Step 1: 加 `EnemyBase` 懒获取与 `Body` 便捷属性**
`EnemyMovement` 类的私有字段区追加(放在 `_groundCheckCollider` 附近即可):
```csharp
// 身体几何权威(EnemyBase.Body)。懒获取以支持编辑器模式(Awake 未执行)下的 Gizmo/探测预览。
private EnemyBase _enemy;
private IEnemyBody Body
{
get
{
if (_enemy == null) _enemy = GetComponent<EnemyBase>();
return _enemy != null ? _enemy.Body : null;
}
}
```
- [ ] **Step 2: 三个射线起点改用 `Body`**
`EnemyMovement.cs` 中这三个方法整体替换(原实现各自解析碰撞体,约在 185–229 行):
```csharp
private Vector2 GetGroundRayOrigin(int index)
{
var body = Body;
if (body == null || body.Collider == null) return (Vector2)transform.position;
Bounds b = body.Bounds;
float x = _groundCheckCount <= 1
? b.center.x
: Mathf.Lerp(b.min.x, b.max.x, (float)index / (_groundCheckCount - 1));
return new Vector2(x, b.min.y);
}
private bool IsGroundedCheck()
{
for (int i = 0; i < _groundCheckCount; i++)
{
if (Physics2D.Raycast(GetGroundRayOrigin(i), Vector2.down, _groundCheckDist, _groundMask))
return true;
}
return false;
}
// 墙体射线起点:身体朝向侧边缘、中心高度
private Vector2 GetWallRayOrigin()
{
var body = Body;
if (body == null || body.Collider == null) return (Vector2)transform.position;
return new Vector2(body.FrontEdgeX(_facingDir), body.Bounds.center.y);
}
// 悬崖射线起点:身体前下角再向前偏移 _ledgeCheckFwdOffset
private Vector2 GetLedgeRayOrigin()
{
var body = Body;
if (body == null || body.Collider == null) return (Vector2)transform.position;
float sign = _facingDir >= 0 ? 1f : -1f;
return new Vector2(body.FrontEdgeX(_facingDir) + sign * _ledgeCheckFwdOffset, body.Bounds.min.y);
}
```
- [ ] **Step 3: 删除 `GroundColliderOrSelf()` 与 `_groundCheckCollider` 字段**
删除私有方法(原 `EnemyMovement.cs:231-232`):
```csharp
private Collider2D GroundColliderOrSelf()
=> _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
```
删除序列化字段及其 `[Tooltip]`(原 `EnemyMovement.cs:45-46`):
```csharp
[Tooltip("用于确定射线起点宽度和底边的 Collider2D;留空则 Awake 时自动查找")]
[SerializeField] private Collider2D _groundCheckCollider;
```
- [ ] **Step 4: 墙/悬崖判定与 `EdgeSafeMargin` 改用 `Body`**
替换这三个成员(原 `EnemyMovement.cs:243-278`):
```csharp
/// <summary>朝 <paramref name="dir"/> 前缘水平方向是否有墙。</summary>
public bool WouldHitWallAhead(float dir)
{
var body = Body;
if (body == null || body.Collider == null || _wallCheckDist <= 0f) return false;
float sign = Mathf.Sign(dir);
return Physics2D.Raycast(new Vector2(body.FrontEdgeX(dir), body.Bounds.center.y),
new Vector2(sign, 0f), _wallCheckDist, _wallMask);
}
/// <summary>朝 <paramref name="dir"/> 前缘外是否为悬崖(脚边地面缺失,会让碰撞体越出地形边缘)。</summary>
public bool WouldFallAhead(float dir)
{
var body = Body;
if (body == null || body.Collider == null || _ledgeCheckDownDist <= 0f) return false;
float sign = Mathf.Sign(dir);
return !Physics2D.Raycast(
new Vector2(body.FrontEdgeX(dir) + sign * _ledgeCheckFwdOffset, body.Bounds.min.y),
Vector2.down, _ledgeCheckDownDist, _groundMask);
}
/// <summary>
/// 身体中心到"能站住的平台边缘最近点"的水平安全余量 = 身体半宽 + 悬崖探测前向偏移。
/// 半宽取自唯一权威 <see cref="EnemyBase.Body"/>;探测偏移是本移动层自己的射线调参。
/// WouldFallAhead 在"身体前缘 + _ledgeCheckFwdOffset"处探地,故身体中心必须离平台边缘至少此距离,
/// 否则前缘探出崖沿被夹停。供 nav 段内随机游走/目标点吸附把点从段两端内缩此值。
/// </summary>
public float EdgeSafeMargin
{
get
{
var body = Body;
float halfWidth = body != null ? body.HalfWidth : 0f;
return halfWidth + Mathf.Max(0f, _ledgeCheckFwdOffset);
}
}
```
- [ ] **Step 5: 确认没有遗漏的旧引用**
Grep 确认 `_groundCheckCollider``GroundColliderOrSelf` 在整个项目已无引用:
```bash
grep -rn "_groundCheckCollider\|GroundColliderOrSelf" Assets/_Game/Scripts
```
预期:**无输出**。若编辑器脚本(如 `EnemyLocomotionEditor`)引用了它们,一并改为 `Body`
- [ ] **Step 6: 编译门 + 全量测试**
MCP`Assets/Refresh` → 等 8 秒 → `unity_get_compilation_errors`(预期 0 错误)
`unity_testing_run_tests({mode:"EditMode"})`。预期:**191 条全绿**。
- [ ] **Step 7: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/EnemyMovement.cs
git commit -m "refactor(enemy): 移动层探测与 EdgeSafeMargin 改走 Body 权威,删除重复的碰撞体解析"
```
---
## Task 4: PlayMode 行为等价性冒烟验证
**Files:** 无(场景内验证)
> 因两个现存敌人的 `_groundCheckCollider` 实测均为 null,本次重构后行为应**完全一致**。
> 本 Task 用于确认没有引入回归。
- [ ] **Step 1: 确认编辑器无新报错**
MCP `unity_console_log({type:"error", count:30})`:确认没有新增的
`[EnemyBase] ... 找不到身体碰撞体` 报错(`ENM_CaoZhi` 有根 BoxCollider2D,不该触发)。
- [ ] **Step 2: 进 Play 验证巡逻与夹停**
MCP `unity_play_mode({action:"play"})`,等约 5 秒后用 `unity_execute_code` 读取状态:
```csharp
var e = GameObject.Find("ENM_CaoZhi");
var mv = e.GetComponent<BaseGames.Enemies.EnemyMovement>();
var eb = e.GetComponent<BaseGames.Enemies.EnemyBase>();
return "grounded=" + mv.IsGrounded
+ "; halfWidth=" + eb.Body.HalfWidth.ToString("F3")
+ "; edgeSafeMargin=" + mv.EdgeSafeMargin.ToString("F3")
+ "; bodyCollider=" + (eb.Body.Collider != null ? eb.Body.Collider.GetType().Name : "NULL")
+ "; bounds=" + eb.Body.Bounds.ToString();
```
预期:`bodyCollider=BoxCollider2D`(非 NULL)、`halfWidth>0``edgeSafeMargin = halfWidth + 0.1`
`grounded=True`(敌人站在平台上)。
- [ ] **Step 3: 验证敌人仍会被崖边夹停、不会掉下平台**
保持 Play,观察 `ENM_CaoZhi` 巡逻若干秒后再读一次:
```csharp
var e = GameObject.Find("ENM_CaoZhi");
var mv = e.GetComponent<BaseGames.Enemies.EnemyMovement>();
return "pos=" + e.transform.position.ToString("F2")
+ "; grounded=" + mv.IsGrounded
+ "; wallAhead=" + mv.IsWallAhead
+ "; ledgeAhead=" + mv.IsLedgeAhead;
```
预期:`grounded=True`(未掉出平台);敌人位置在平台范围内。
- [ ] **Step 4: 退出 Play**
MCP `unity_play_mode({action:"stop"})`。确认 Console 无新增异常。
- [ ] **Step 5: 记录结果**
若全部通过,在提交信息或 `Docs_Dev/Verification/` 记一行验证结论。无代码改动则无需提交。
---
## 自检记录(写计划时)
- **Spec 覆盖**spec §3.1 权威源→Task 2;§3.2 接口 + §3.3 实现→Task 1;§3.4 消费端改造表(`EnemyMovement`
5 处 + `EdgeSafeMargin` + 删字段/方法)→Task 3;§3.5 分层原则→Task 3 Step 4 的 `EdgeSafeMargin` 注释体现;
§4 边界与错误处理→Task 2 Step 2/3Awake LogError + OnValidate 警告 + 抑制机制)、Task 1 Step 3(碰撞体缺失退化值);
§5 测试→Task 1(单测)、Task 3 Step 6(回归)、Task 4PlayMode);§6 迁移安全→Task 4 验证。
**`EnemyNavAgent` 按 spec §3.4 明确不改**,故无对应 Task。
- **占位符扫描**:无 TBD/TODO;每个改动步骤都给了完整代码。
- **类型一致性**`IEnemyBody` 成员(`Collider`/`Bounds`/`HalfWidth`/`FrontEdgeX`/`RearEdgeX`/`HasPassed`
与 static 纯函数(`FrontEdge`/`RearEdge`/`BodyPassed`)在 Task 1 定义,Task 2`Body.Collider`)、
Task 3`Body.Bounds`/`Body.FrontEdgeX`/`Body.HalfWidth`)与测试中的用法一致。
- **测试基线**:当前 177 → Task 1 后 191(新增 14 条);Task 2/3 不新增测试,仍应为 191。