Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-28-remove-pathfinding-library.md
T

850 lines
36 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:** 用约 200 行的"直接移动 + 地形夹紧"导航替换 405 行第三方寻路适配层,删除第三方寻路包(21,069 行)及对其源码的 4 处修改,同时收敛 3 处重复的卡死检测并修复 Pace 翻向缺陷。
**Architecture:** 可达性由 `EnemyMovement` 的墙/悬崖夹紧天然表达——"走过去、被夹停就是边界",不重建任何图/段结构。导航接口从 20+ 成员瘦身为 7 成员的 `IEnemyNavigator`,地面/飞行两个实现;巡逻策略与 AI 决策层契约不变。
**Tech Stack:** Unity 2022.3、C#、NUnit EditMode(经 MCP `unity_testing_run_tests` 运行)。
**设计文档:** `Docs_Dev/superpowers/specs/2026-07-28-enemy-navigation-without-pathfinding-design.md`
**通用验证(每个 Task 都要做):**
- **编译门**MCP `unity_execute_menu_item("Assets/Refresh")` → 等约 10 秒 → `unity_get_compilation_errors`,预期 0 错误。
- **测试门**MCP `unity_testing_run_tests({mode:"EditMode"})``unity_testing_get_job(jobId)`。**当前基线 191 条**,新增测试后基线上升(各 Task 注明)。
- **提交**:中文 + 类型前缀。每个 Task 独立提交。
**关键既有事实(已核实,可直接依赖):**
- 硬依赖 `using PathBerserker2d` 的仅 **4 个文件**`Navigation/EnemyNavAgent.cs``Editor/Enemies/Navigation/NavSurfaceBakeShortcut.cs``Editor/Scene/SceneObjectPlacerTool.cs``Editor/Scene/SceneScaffoldTools.cs`。其余文件仅在注释提及。
- `NavLinkType` / `INavLinkHandler` 是**项目自有抽象**,不含第三方类型。
- **Boss 代码完全不引用导航**(无 `.Nav`/`IPathAgent`/`MoveTo`/`Locomotion`),其预制体上的导航组件是冗余件。
- 敌人预制体除 `ENM_ChaoFeng` 外均已删除;场景敌人实例只有 `TestRoomA``ENM_CaoZhi`
-**同名陷阱**`UI/MainMenu/DataDrivenMainMenuController.cs``Nav`**UI 导航栈****不得改动**。
---
## 文件结构
**新建:**
- `Assets/_Game/Scripts/Enemies/StallDetector.cs` — 推进停滞检测(纯逻辑 struct)
- `Assets/_Game/Scripts/Enemies/IEnemyNavigator.cs` — 新导航接口 + `NullEnemyNavigator`
- `Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs` — 地面直接移动导航
- `Assets/Tests/EditMode/Enemies/StallDetectorTests.cs`
- `Assets/Tests/EditMode/Enemies/GroundNavigatorLogicTests.cs`
**修改:** `EnemyLocomotion.cs``RushAbility.cs``EnemyBase.cs``EnemyLocomotionEditor.cs``FlyingDirectNavigator.cs`(→`FlyingNavigator.cs`)、2 个 asmdef、3 个编辑器脚本
**删除:** `IPathAgent.cs``Navigation/EnemyNavAgent.cs``Editor/Enemies/Navigation/NavSurfaceBakeShortcut.cs``Assets/PathBerserker2d/`
---
# 阶段 P0:修 Pace 翻向缺陷 + 收敛卡死检测
> 此阶段与寻路库无关,独立成立。完成后寻路库仍在。
## Task 1: `StallDetector` 纯逻辑 structTDD
**Files:**
- Test: `Assets/Tests/EditMode/Enemies/StallDetectorTests.cs`
- Create: `Assets/_Game/Scripts/Enemies/StallDetector.cs`
- [ ] **Step 1: 先写失败测试**
创建 `Assets/Tests/EditMode/Enemies/StallDetectorTests.cs`
```csharp
using NUnit.Framework;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.Enemies
{
/// <summary>
/// StallDetector:窗口内净位移不足即判停滞。
/// 该逻辑原本在 3 处各写一遍(巡逻 Pace / 导航层 / 冲锋),此处集中钉住行为。
/// </summary>
public class StallDetectorTests
{
const float Window = 0.5f, MinProgress = 0.05f;
[Test]
public void BeforeWindowElapses_NeverStalled()
{
var d = new StallDetector(Window, MinProgress);
d.Reset(0f);
Assert.IsFalse(d.Tick(0f, 0.1f)); // 完全没动,但窗口未到
Assert.IsFalse(d.Tick(0f, 0.1f));
}
[Test]
public void NoProgressOverWindow_Stalled()
{
var d = new StallDetector(Window, MinProgress);
d.Reset(0f);
Assert.IsFalse(d.Tick(0f, 0.4f));
Assert.IsTrue (d.Tick(0f, 0.2f)); // 累计 0.6s ≥ 窗口,净位移 0
}
[Test]
public void EnoughProgressOverWindow_NotStalled()
{
var d = new StallDetector(Window, MinProgress);
d.Reset(0f);
Assert.IsFalse(d.Tick(1f, 0.6f)); // 位移 1m 远超阈值
}
[Test]
public void ProgressJustBelowThreshold_Stalled()
{
var d = new StallDetector(Window, MinProgress);
d.Reset(0f);
Assert.IsTrue(d.Tick(0.04f, 0.6f)); // 0.04 < 0.05
}
[Test]
public void ProgressCountsBothDirections()
{
var d = new StallDetector(Window, MinProgress);
d.Reset(0f);
Assert.IsFalse(d.Tick(-1f, 0.6f)); // 向左移动同样算推进
}
[Test]
public void AfterStalled_WindowRestartsFromCurrentPosition()
{
var d = new StallDetector(Window, MinProgress);
d.Reset(0f);
Assert.IsTrue (d.Tick(0f, 0.6f)); // 第一次判停滞
Assert.IsFalse(d.Tick(0f, 0.1f)); // 窗口已重开,未到期
Assert.IsTrue (d.Tick(0f, 0.6f)); // 再次到期且仍无推进
}
[Test]
public void Reset_ClearsAccumulatedTime()
{
var d = new StallDetector(Window, MinProgress);
d.Reset(0f);
d.Tick(0f, 0.4f);
d.Reset(0f); // 重开窗口
Assert.IsFalse(d.Tick(0f, 0.2f)); // 只累计了 0.2s
}
[Test]
public void WithoutReset_FirstTickInitializes()
{
var d = new StallDetector(Window, MinProgress);
Assert.IsFalse(d.Tick(5f, 1f)); // 未 Reset 时首帧只做初始化,不误判
}
}
}
```
- [ ] **Step 2: 运行确认失败**
MCP`Assets/Refresh` → 等 10 秒 → `unity_get_compilation_errors`
预期:**编译失败**,报 `StallDetector` 不存在。
- [ ] **Step 3: 实现**
创建 `Assets/_Game/Scripts/Enemies/StallDetector.cs`
```csharp
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 推进停滞检测:滑动窗口内的**净水平位移**不足阈值即判为停滞。
/// 用于"命令了移动却推不动"的兜底(撞上未纳入检测层的碰撞体、贴墙微抖等)。
///
/// <para>用净位移而非逐帧差值,避免"贴墙推进-被弹回"的微抖每帧重置计数。</para>
/// <para><b>调用方须以字段持有本结构</b>(struct 的方法会就地修改字段);
/// 开始移动 / 更换目标后调用 <see cref="Reset"/> 重开窗口。</para>
/// </summary>
public struct StallDetector
{
private readonly float _windowSeconds;
private readonly float _minProgress;
private float _timer;
private float _startX;
private bool _initialized;
/// <param name="windowSeconds">判定窗口时长(秒)。</param>
/// <param name="minProgress">窗口内所需的最小净位移(米)。</param>
public StallDetector(float windowSeconds, float minProgress)
{
_windowSeconds = windowSeconds;
_minProgress = minProgress;
_timer = 0f;
_startX = 0f;
_initialized = false;
}
/// <summary>以当前位置重开窗口。</summary>
public void Reset(float currentX)
{
_startX = currentX;
_timer = 0f;
_initialized = true;
}
/// <summary>
/// 推进一帧。返回 true 表示本窗口内推进不足(停滞);返回 true 后窗口自动以当前位置重开。
/// </summary>
public bool Tick(float currentX, float deltaTime)
{
if (!_initialized) { Reset(currentX); return false; }
_timer += deltaTime;
if (_timer < _windowSeconds) return false;
bool stalled = Mathf.Abs(currentX - _startX) < _minProgress;
_startX = currentX;
_timer = 0f;
return stalled;
}
}
}
```
- [ ] **Step 4: 编译门 + 测试**
预期 0 错误;EditMode **199 条**全绿(191 + 新增 8)。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/StallDetector.cs Assets/Tests/EditMode/Enemies/StallDetectorTests.cs
git commit -m "feat(enemy): 新增 StallDetector 推进停滞检测(+8 单测),为收敛三处重复实现做准备"
```
---
## Task 2: 三处改用 `StallDetector` + 修复 Pace 翻向缺陷
**Files:**
- Modify: `Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/RushAbility.cs`
- Modify: `Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs`
- [ ] **Step 1: `EnemyLocomotion` — Pace 改用 StallDetector 并修翻向**
字段区把 `_paceWindowStartX` / `_paceWindowFrames` 两个字段替换为:
```csharp
// Pace 推进停滞检测(原为 12 帧计数,@60fps ≈ 0.2s,改用统一原语并保持观感)
private StallDetector _paceStall = new StallDetector(0.2f, 0.05f);
```
`TickPatrol()``case PatrolStrategy.Pace:` 整段替换为:
```csharp
case PatrolStrategy.Pace: // 撞墙/悬崖翻向的来回踱步(速度驱动,不依赖 nav)
var mv = _enemy.Movement;
// 用与移动层夹紧同一判定(WouldBlockAhead,即时按请求方向+碰撞体前缘),
// 保证"夹停在地形边缘"与"掉头"一致,不会夹停却不翻向。
bool blocked = mv != null && mv.WouldBlockAhead(_paceDir);
// 兜底卡死检测:命令前进但窗口内净位移过小(撞上未挂检测层的墙/卡角,含贴墙微抖)也视为受阻。
if (_paceStall.Tick(_enemy.transform.position.x, Time.deltaTime)) blocked = true;
// 翻向:首次受阻翻一次(边沿触发,天然消抖);或"持续受阻但反方向畅通"也翻——
// 后者修复"顶着堵侧却因边沿触发失效而永远转不回来"的死锁。
// 两侧都堵(窄台)时只有边沿那一次翻向,之后保持,不来回抖。
bool oppositeFree = mv != null && !mv.WouldBlockAhead(-_paceDir);
if (blocked && (!_paceBlockedPrev || oppositeFree))
{
_paceDir = -_paceDir;
_paceStall.Reset(_enemy.transform.position.x); // 翻向后重开窗口
}
_paceBlockedPrev = blocked;
_enemy.MoveInDirection(_paceDir);
break;
```
- [ ] **Step 2: `RushAbility` — 内联停滞检测改用 StallDetector**
删除常量 `StuckWindowSeconds` / `StuckMinProgress`,改为字段:
```csharp
// 推进停滞检测(撞上未纳入墙层的碰撞体时兜底结束冲锋)
private StallDetector _stall = new StallDetector(0.15f, 0.05f);
```
`RushRoutine` 中删除 `winX` / `winT` 两个局部变量及其窗口判定块,改为:
```csharp
var body = _enemy.Body;
float elapsed = 0f;
_stall.Reset(_enemy.transform.position.x);
while (true)
{
if (body != null && body.HasPassed(lockX, dir)) break;
if (mv != null && mv.WouldHitWallAhead(dir)) break;
if (_cfg.maxDashDuration > 0f && elapsed >= _cfg.maxDashDuration) break;
_enemy.MoveInDirectionWithSpeed(dir, speed, allowLedgeCross: true);
yield return null;
float dt = Time.deltaTime;
elapsed += dt;
// 兜底:撞上未纳入墙层的碰撞体(玩家/边界墙)导致原地不动 → 结束本次冲锋
if (_stall.Tick(_enemy.transform.position.x, dt)) break;
}
```
- [ ] **Step 3: `EnemyNavAgent` — 段内推进停滞改用 StallDetector**
删除字段 `_stuckWinTimer` / `_stuckWinStartDist` / `_stuckWinInit`,改为:
```csharp
private StallDetector _segStall;
```
`Awake` 末尾初始化:
```csharp
_segStall = new StallDetector(_stuckWindowSeconds, _stuckMinProgress);
```
`TickStuckDetection(bool onSegment)` 整体替换为:
```csharp
// 卡死检测:段上移动时,窗口内净水平位移不足 → 判受阻(覆盖"障碍未进图"的物理空推)。
private void TickStuckDetection(bool onSegment)
{
if (!onSegment) { _segStall.Reset(transform.position.x); return; }
if (_segStall.Tick(transform.position.x, Time.fixedDeltaTime))
_obstructionSignal = true;
}
```
并把 `ResetStuckWindow()` 的实现改为 `_segStall.Reset(transform.position.x);`
> 注:原实现比较的是"到子目标的距离缩小量",新实现比较"净水平位移"。两者都用于识别"推不动",
> 后者与另外两处统一且不依赖子目标;行为差异仅在"绕行时距离不减但确实在移动"的场景——
> 那种情况下**新实现更准确**(不会误判为卡死)。
- [ ] **Step 4: 编译门 + 测试**
预期 0 错误;EditMode **199 条**全绿。
- [ ] **Step 5: PlayMode 验证 Pace 修复**
进 Play,禁用 `ENM_CaoZhi``EnemyAiBrain`,用反射把 `_patrolStrategy` 设为 `Pace`
把敌人放到平台左缘外侧悬空处(`center x = -22.30, y = 2.01`),`Locomotion.SetMode(Patrol)`
预期:敌人**能掉头走回平台**(修复前会永久顶着崖外不动)。验证后 `unity_play_mode({action:"stop"})`
- [ ] **Step 6: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs \
Assets/_Game/Scripts/Enemies/Abilities/RushAbility.cs \
Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs
git commit -m "refactor(enemy): 三处卡死检测收敛为 StallDetector;修复 Pace 持续受阻时无法掉头的死锁"
```
---
# 阶段 P1:新增 `IEnemyNavigator` 与 `GroundNavigator`(不动现有接线)
## Task 3: 新接口 `IEnemyNavigator` + `NullEnemyNavigator`
**Files:**
- Create: `Assets/_Game/Scripts/Enemies/IEnemyNavigator.cs`
- [ ] **Step 1: 创建接口文件**
```csharp
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 敌人导航抽象:把"去某个世界点"的请求翻译为具体移动方式(地面沿地形走 / 飞行直取),
/// 并报告到达与受阻。
///
/// <para><b>不含任何路径图概念</b>——可达性由移动层的墙/悬崖夹紧天然决定:
/// 走得过去就是可达,被夹停的地方就是边界。</para>
///
/// 实现:<c>GroundNavigator</c>(地面)、<c>FlyingNavigator</c>(飞行)、
/// <see cref="NullEnemyNavigator"/>(无导航/测试)。
/// </summary>
public interface IEnemyNavigator
{
/// <summary>
/// 请求朝世界坐标移动。目标被记录后由实现逐帧驱动,**重复传入同一目标是幂等的**
/// (每帧调用或只在目标变化时调用皆可)。传入新目标会清除 <see cref="HasArrived"/> / <see cref="IsBlocked"/>。
/// </summary>
void MoveTowards(Vector2 target);
/// <summary>停止移动并清除当前目标。</summary>
void Stop();
/// <summary>设置移动速度(巡逻走速 / 追击跑速)。传 0 表示使用移动层配置的默认步速。</summary>
void SetSpeed(float speed);
/// <summary>当前是否正朝目标推进(有目标、未到达、未受阻)。</summary>
bool IsMoving { get; }
/// <summary>是否已到达目标(进入停止距离内)。</summary>
bool HasArrived { get; }
/// <summary>是否被地形挡住而无法继续靠近目标(墙 / 悬崖夹停 / 推进停滞)。</summary>
bool IsBlocked { get; }
/// <summary>取一个可供游走的目标点。取不到时返回 false。</summary>
bool TryPickWanderPoint(out Vector2 point);
}
/// <summary>无导航单位 / 测试用空实现:不移动,不报告到达或受阻。</summary>
public sealed class NullEnemyNavigator : IEnemyNavigator
{
public void MoveTowards(Vector2 _) { }
public void Stop() { }
public void SetSpeed(float _) { }
public bool IsMoving => false;
public bool HasArrived => false;
public bool IsBlocked => false;
public bool TryPickWanderPoint(out Vector2 point) { point = Vector2.zero; return false; }
}
}
```
- [ ] **Step 2: 编译门 + 测试**
预期 0 错误;EditMode 199 条全绿(新接口尚无人使用)。
- [ ] **Step 3: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/IEnemyNavigator.cs
git commit -m "feat(enemy): 新增 IEnemyNavigator 瘦身导航接口(7 成员)与 NullEnemyNavigator"
```
---
## Task 4: `GroundNavigator` + 纯逻辑单测(TDD
**Files:**
- Test: `Assets/Tests/EditMode/Enemies/GroundNavigatorLogicTests.cs`
- Create: `Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs`
- [ ] **Step 1: 先写失败测试**
```csharp
using NUnit.Framework;
using BaseGames.Enemies.Navigation;
namespace BaseGames.Tests.EditMode.Enemies
{
/// <summary>GroundNavigator 的方向/到达判定纯函数(无场景依赖)。</summary>
public class GroundNavigatorLogicTests
{
[Test]
public void Arrived_WhenWithinStopDistance()
=> Assert.IsTrue(GroundNavigator.Arrived(1.0f, 1.1f, 0.2f));
[Test]
public void Arrived_ExactlyAtStopDistance_True()
=> Assert.IsTrue(GroundNavigator.Arrived(1.0f, 1.2f, 0.2f));
[Test]
public void NotArrived_WhenOutsideStopDistance()
=> Assert.IsFalse(GroundNavigator.Arrived(1.0f, 1.5f, 0.2f));
[Test]
public void Arrived_IgnoresDirection()
{
Assert.IsTrue(GroundNavigator.Arrived(1.0f, 0.9f, 0.2f)); // 目标在左侧
Assert.IsFalse(GroundNavigator.Arrived(1.0f, 0.5f, 0.2f));
}
[Test]
public void WanderX_PositiveSign_LandsWithinForwardRange()
{
float x = GroundNavigator.WanderX(10f, 1.5f, 5f, +1f, 0f);
Assert.AreEqual(11.5f, x, 1e-4f); // t=0 → 最近端
x = GroundNavigator.WanderX(10f, 1.5f, 5f, +1f, 1f);
Assert.AreEqual(15f, x, 1e-4f); // t=1 → 最远端
}
[Test]
public void WanderX_NegativeSign_LandsWithinBackwardRange()
{
float x = GroundNavigator.WanderX(10f, 1.5f, 5f, -1f, 0f);
Assert.AreEqual(8.5f, x, 1e-4f);
x = GroundNavigator.WanderX(10f, 1.5f, 5f, -1f, 1f);
Assert.AreEqual(5f, x, 1e-4f);
}
[Test]
public void WanderX_MidT_Interpolates()
=> Assert.AreEqual(13.25f, GroundNavigator.WanderX(10f, 1.5f, 5f, +1f, 0.5f), 1e-4f);
}
}
```
- [ ] **Step 2: 运行确认失败**`GroundNavigator` 不存在)
- [ ] **Step 3: 实现**
创建 `Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs`
```csharp
using UnityEngine;
namespace BaseGames.Enemies.Navigation
{
/// <summary>
/// 地面单位导航:朝目标 X 直接行走,由 <see cref="EnemyMovement"/> 的墙/悬崖夹紧决定可达性。
///
/// <para><b>不做任何地形扫描</b>:走得过去就是可达,被夹停的地方就是边界。
/// 因此不存在"脱离导航图"这类状态——敌人无论被击退/冲锋到哪里,都能正常继续行走。</para>
///
/// <para>只驱动水平方向;垂直交给刚体重力。移动经 <see cref="EnemyMoveInput"/> 下发,
/// 速度为 0 时退化为移动层配置的步速。</para>
/// </summary>
[RequireComponent(typeof(EnemyMovement))]
public sealed class GroundNavigator : MonoBehaviour, IEnemyNavigator
{
[Header("到达判定")]
[Tooltip("身体中心 X 与目标 X 的距离小于此值即视为到达(m)")]
[SerializeField] [Min(0.01f)] private float _stoppingDistance = 0.2f;
[Header("游走取点(Wander 用)")]
[Tooltip("单次游走的最近距离(m)")]
[SerializeField] [Min(0f)] private float _wanderMinRange = 1.5f;
[Tooltip("单次游走的最远距离(m)。走不到会被地形夹停,自然结束本次游走。")]
[SerializeField] [Min(0f)] private float _wanderMaxRange = 5f;
[Header("推进停滞检测")]
[Tooltip("窗口时长(s):此时长内净位移不足即判受阻")]
[SerializeField] [Min(0.05f)] private float _stallWindowSeconds = 0.4f;
[Tooltip("窗口内所需最小净位移(m)")]
[SerializeField] [Min(0f)] private float _stallMinProgress = 0.05f;
private EnemyBase _enemy;
private EnemyMovement _movement;
private StallDetector _stall;
private Vector2 _target;
private bool _hasTarget;
private float _speed; // 0 = 用移动层配置步速
private bool _arrived;
private bool _blocked;
public bool IsMoving => _hasTarget && !_arrived && !_blocked;
public bool HasArrived => _arrived;
public bool IsBlocked => _blocked;
private void Awake()
{
_enemy = GetComponent<EnemyBase>();
_movement = GetComponent<EnemyMovement>();
_stall = new StallDetector(_stallWindowSeconds, _stallMinProgress);
if (_enemy == null)
Debug.LogError($"[GroundNavigator] {name} 找不到 EnemyBase,无法读取身体几何。", this);
}
// ── IEnemyNavigator ────────────────────────────────────────────
public void MoveTowards(Vector2 target)
{
// 幂等:同一目标重复调用不重置状态,避免每帧调用时永远清掉到达/受阻标志
if (_hasTarget && (target - _target).sqrMagnitude < 1e-6f) return;
_target = target;
_hasTarget = true;
_arrived = false;
_blocked = false;
_stall.Reset(BodyX);
}
public void Stop()
{
_hasTarget = false;
_arrived = false;
_blocked = false;
_enemy?.StopMovement();
}
public void SetSpeed(float speed) => _speed = Mathf.Max(0f, speed);
public bool TryPickWanderPoint(out Vector2 point)
{
float sign = Random.value < 0.5f ? -1f : 1f;
float x = WanderX(BodyX, _wanderMinRange, _wanderMaxRange, sign, Random.value);
point = new Vector2(x, transform.position.y);
return true; // 地面单位总能尝试;走不到会被夹停并置 IsBlocked
}
// ── 驱动 ───────────────────────────────────────────────────────
private float BodyX => _enemy != null ? _enemy.Body.Center.x : transform.position.x;
private void FixedUpdate()
{
if (!_hasTarget || _arrived || _blocked || _movement == null) return;
float bodyX = BodyX;
if (Arrived(bodyX, _target.x, _stoppingDistance))
{
_arrived = true;
_enemy?.StopMovement();
return;
}
float dir = Mathf.Sign(_target.x - bodyX);
// 地形夹紧即为"不可达边界":着地时前方是墙或悬崖 → 本次移动到此为止
if (_movement.IsGrounded && _movement.WouldBlockAhead(dir))
{
_blocked = true;
_enemy?.StopMovement();
return;
}
_movement.PendingInput.MoveDir = dir;
_movement.PendingInput.MoveSpeed = _speed; // 0 → MoveHorizontal 用配置步速
_movement.PendingInput.WantStop = false;
// 兜底:命令了移动却推不动(撞上未纳入检测层的碰撞体等)
if (_stall.Tick(bodyX, Time.fixedDeltaTime))
{
_blocked = true;
_enemy?.StopMovement();
}
}
// ── 纯函数(便于无场景单测)──────────────────────────────────────
/// <summary>身体中心 X 是否已进入目标 X 的停止距离内。</summary>
public static bool Arrived(float currentX, float targetX, float stopDistance)
=> Mathf.Abs(targetX - currentX) <= stopDistance;
/// <summary>
/// 游走目标 X:自当前位置朝 <paramref name="sign"/> 方向,
/// 在 [minRange, maxRange] 区间内按插值系数 <paramref name="t"/>(0..1) 取点。
/// </summary>
public static float WanderX(float currentX, float minRange, float maxRange, float sign, float t)
=> currentX + sign * Mathf.Lerp(minRange, Mathf.Max(minRange, maxRange), Mathf.Clamp01(t));
}
}
```
- [ ] **Step 4: 编译门 + 测试**
预期 0 错误;EditMode **206 条**全绿(199 + 新增 7)。
- [ ] **Step 5: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs \
Assets/Tests/EditMode/Enemies/GroundNavigatorLogicTests.cs
git commit -m "feat(enemy): 新增 GroundNavigator 地面直接移动导航(+7 纯函数单测)"
```
---
# 阶段 P2:消费者切换(唯一需重点比对行为的阶段)
## Task 5: `FlyingDirectNavigator` → `FlyingNavigator`(改接口)
**Files:**
- Rename: `Navigation/FlyingDirectNavigator.cs``Navigation/FlyingNavigator.cs`(连同 `.meta``git mv` 保 GUID
- Modify: 重写为新接口
- [ ] **Step 1: 改名(保 GUID**
```bash
cd Assets/_Game/Scripts/Enemies/Navigation
git mv FlyingDirectNavigator.cs FlyingNavigator.cs
git mv FlyingDirectNavigator.cs.meta FlyingNavigator.cs.meta
```
- [ ] **Step 2: 改类名与接口**
- 类声明改为 `public sealed class FlyingNavigator : MonoBehaviour, IEnemyNavigator`
- **删除**4 个事件(`OnLinkStarted`/`OnLinkCompleted`/`OnNavPathFailed`/`OnGoalReached`)、
`IsOnLink`/`CurrentLinkType`/`CurrentLinkStart`/`CurrentLinkEnd``LastMoveObstructed`
`TryGetClosestReachable``IsNearEdge``CanReach``WalkToRandomOnSegment``ResolveStandablePoint`
以及 `_goalFired` 字段。
- **改名**`RequestMoveTo``MoveTowards`(新增幂等判断)、`StopNavigation``Stop`
`IsAtDestination()``HasArrived` 属性、`WalkToRandom()``TryPickWanderPoint(out Vector2)`
- **新增**`public bool IsBlocked => false;`(飞行直取,无地形阻挡概念)
- `UpdateChase()` 中原先触发 `OnGoalReached` 的分支,改为置 `_arrived = true`
- 状态字段改为:`Vector2 _target; bool _hasTarget; bool _arrived;`
`IsMoving => _hasTarget && !_arrived;`
- 悬停逻辑(`UpdateHover`)与朝向逻辑**保持不变**。
- [ ] **Step 3: 编译门 + 测试** → 0 错误、206 条全绿
- [ ] **Step 4: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/Navigation/
git commit -m "refactor(enemy): FlyingDirectNavigator 改名 FlyingNavigator 并切换到 IEnemyNavigator"
```
---
## Task 6: `EnemyBase` / `EnemyLocomotion` / 编辑器面板切换到新接口
**Files:**
- Modify: `Enemies/EnemyBase.cs``Enemies/Navigation/EnemyLocomotion.cs``Editor/Enemies/EnemyLocomotionEditor.cs`
- [ ] **Step 1: `EnemyBase` 换类型**
- 字段:`protected IPathAgent _nav;``protected IEnemyNavigator _nav;`
- 属性:`public IPathAgent Nav => _nav;``public IEnemyNavigator Nav => _nav;`
- `Awake``_nav = GetComponent<IPathAgent>() ?? new NullPathAgent();`
`_nav = GetComponent<IEnemyNavigator>() ?? new NullEnemyNavigator();`
- 更新类头注释中提到 `IPathAgent` 的措辞。
- `MoveTo(Vector2)` 内部对 `_nav.RequestMoveTo` 的调用改为 `_nav.MoveTowards`
`StopMovement()` 内对 `_nav.StopNavigation()` 的调用改为 `_nav.Stop()`(按实际代码定位)。
- [ ] **Step 2: `EnemyLocomotion` — Wander 改用新接口**
`TickWander()` 整体替换为:
```csharp
/// <summary>
/// 就近随机游走:朝随机取的目标点走,**到达或被地形夹停**即视为本次游走结束,
/// 停顿 [_wanderPauseMin, _wanderPauseMax] 秒(期间播 Idle)后再挑下一个点。
/// 走到平台边缘停下转身是预期表现——可达性由移动层夹紧决定,不做地形扫描。
/// </summary>
private void TickWander()
{
var nav = _enemy.Nav;
if (nav == null) return;
// 仍在推进:清停顿态,等到达/受阻
if (nav.IsMoving) { _wanderPausing = false; return; }
// 到达、受阻或尚未出发:先停顿计时,再挑下一个点
if (!_wanderPausing)
{
_wanderPausing = true;
_wanderPauseTimer = Random.Range(_wanderPauseMin, Mathf.Max(_wanderPauseMin, _wanderPauseMax));
}
_wanderPauseTimer -= Time.deltaTime;
if (_wanderPauseTimer <= 0f)
{
_wanderPausing = false;
if (nav.TryPickWanderPoint(out var point)) nav.MoveTowards(point);
}
}
```
- [ ] **Step 3: `EnemyLocomotion` — Waypoints 去掉吸附**
- 删除字段 `_wpResolvedGoal` 的"吸附"语义相关:保留 `_wpResolvedGoal` 作为**当前路点坐标**缓存,
删除 `_wpResolveOk` 字段及其 `DebugResolveOk` 只读属性。
- `SetWaypointGoal()` 替换为:
```csharp
/// <summary>取当前路点坐标并出发。路点若摆在崖外/墙后,敌人会走到边缘被夹停 → IsBlocked → 结算推进下一个。</summary>
private void SetWaypointGoal()
{
_wpResolvedGoal = _waypoints[_wpIndex].position;
_enemy.Nav?.MoveTowards(_wpResolvedGoal);
}
```
- `case PatrolStrategy.Waypoints:` 中:
- `bool obstructedSettled = nav != null && nav.LastMoveObstructed && !nav.IsMoving;`
`bool obstructedSettled = nav != null && nav.IsBlocked;`
- 末尾的 `else if (nav != null && !nav.IsMoving && !nav.LastMoveObstructed) { _enemy.MoveTo(_wpResolvedGoal); }`
→ 改为 `else if (nav != null && !nav.IsMoving && !nav.IsBlocked && !nav.HasArrived) { nav.MoveTowards(_wpResolvedGoal); }`
- [ ] **Step 4: `EnemyLocomotion` — Approach/Pursue 改用新接口**
`Pursue(Vector2)``MoveTo(Vector2)``Update()``LocomotionMode.Approach` 分支里
`_enemy.MoveTo(...)` 的调用保持不变(`EnemyBase.MoveTo` 内部已在 Step 1 改为 `MoveTowards`)。
- [ ] **Step 5: `EnemyLocomotionEditor` — 调试面板去掉导航图信息**
- 第 127 行替换为:
```csharp
sb.AppendLine($"Nav 类型: {nav.GetType().Name} IsMoving: {nav.IsMoving} HasArrived: {nav.HasArrived} IsBlocked: {nav.IsBlocked}");
```
- **删除**:第 128133 行(`EnemyNavAgent`/`RawNavAgent`/`HasValidPosition` 相关块)、
第 139 行 `_maxSnapDistance` 行及 `ReadMaxSnap` 方法、
Waypoints 段中 `CanReach` 与"路点逐个吸附检测"整块(第 152–171 行)、
以及 `Diagnose(...)` 里所有引用已删成员之处(按编译错误逐个修正)。
- Waypoints 段保留:当前索引/ArriveRadius/当前目标/到目标距离。
- [ ] **Step 6: 编译门 + 测试** → 0 错误、206 条全绿
- [ ] **Step 7: 提交**
```bash
git add Assets/_Game/Scripts/Enemies/EnemyBase.cs \
Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs \
Assets/_Game/Scripts/Editor/Enemies/EnemyLocomotionEditor.cs
git commit -m "refactor(enemy): 消费者切换到 IEnemyNavigator(Wander/Waypoints 去导航图化,调试面板简化)"
```
---
## Task 7: 场景敌人换装 `GroundNavigator` + PlayMode 全面验收
**Files:** 场景 `TestRoomA`(不提交场景改动,除非用户要求)
- [ ] **Step 1: 换装组件**
MCP `unity_execute_code`:对 `ENM_CaoZhi`
- `DestroyImmediate``EnemyNavAgent`、PB2d `NavAgent``TransformBasedMovement` 三个组件
- `AddComponent<GroundNavigator>()`
- 打印确认 `EnemyBase.Nav` 类型为 `GroundNavigator`(需重进 Play 让 Awake 重取)
- [ ] **Step 2: 三种巡逻验收**
进 Play,禁用 AI 大脑,逐一切换 `_patrolStrategy` 并观察 4 秒:
| 策略 | 预期 |
|---|---|
| Wander | 位移 > 0;走到平台边缘停下 → 停顿 → 反向再走;**动画为 Move(不再卡 Idle** |
| Pace | 正常来回;顶到崖沿能掉头 |
| Waypoints | 依次推进路点;路点够不着时受阻结算并推进下一个 |
- [ ] **Step 3: 追击与冲锋验收**
- 启用 AI 大脑,玩家放到同平台 → 敌人应能追击并进入攻击(ApproachAttack
- 玩家跳到够不着的位置 → 敌人走到边缘停下(**不卡死、不原地踏步**)
- 触发 `e001_chase` 冲锋 → 越过锁定点即停、可冲下悬崖、收尾不悬空
- [ ] **Step 4: 退出 Play,确认 Console 无新增报错**
- [ ] **Step 5: 记录结果**
把验收结论写入 `Docs_Dev/Verification/`(沿用现有验证文档风格)并提交该文档。
---
# 阶段 P3P5:清理
## Task 8: Boss 预制体移除冗余导航组件
- [ ] **Step 1:** 打开 `Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab`
移除 `NavAgent``TransformBasedMovement``EnemyNavAgent`(已核实 Boss 代码不引用导航)。
- [ ] **Step 2:** 编译门 + 测试;PlayMode 快速确认 Boss 预制体可正常实例化(无 Missing 组件报错)。
- [ ] **Step 3:** 提交预制体改动。
## Task 9: 删除 `EnemyNavAgent`、`IPathAgent` 与编辑器中的寻路库用法
- [ ] **Step 1:** 删除 `Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs`+`.meta`
`Assets/_Game/Scripts/Enemies/IPathAgent.cs`+`.meta`)。
> `NavLinkType` / `INavLinkHandler` 若仍被 `EnemyMovement`/`BlinkStrikeAbility` 使用则**保留**——
> 把 `NavLinkType` 枚举移入 `INavLinkHandler.cs`。
- [ ] **Step 2:** 删除 `Assets/_Game/Scripts/Editor/Enemies/Navigation/NavSurfaceBakeShortcut.cs`+`.meta`)。
- [ ] **Step 3:** `SceneObjectPlacerTool.cs` / `SceneScaffoldTools.cs`:删除 `using PathBerserker2d;`
与所有放置 `NavAgent`/`TransformBasedMovement`/`NavSurface` 的代码;敌人放置改为 `AddComponent<GroundNavigator>()`
删除 `PlaceNavSurface` 菜单项。
- [ ] **Step 4:** 两个 asmdef 移除对 PathBerserker 的引用:
`BaseGames.Editor.asmdef``BaseGames.Enemies.Navigation.asmdef`
- [ ] **Step 5:** 编译门 + 测试 → 0 错误、206 条全绿。
- [ ] **Step 6:** 提交。
## Task 10: 删除第三方寻路包
- [ ] **Step 1:** 确认 `grep -rn "PathBerserker2d" Assets/_Game Assets/Tests --include=*.cs` **无输出**
- [ ] **Step 2:** 场景中删除导航面物体(`TestRoomA` 的 NavSurface 所在 GameObject)。
- [ ] **Step 3:** 删除 `Assets/PathBerserker2d/` 整目录(含 `.meta`)。
- [ ] **Step 4:** 编译门 + 测试 → 0 错误、206 条全绿;PlayMode 冒烟:敌人巡逻/追击/冲锋均正常。
- [ ] **Step 5:** 提交(提交信息注明删除行数)。
---
## 自检记录(写计划时)
- **Spec 覆盖**:§5 接口→Task 3;§6.1 地面实现→Task 4;§6.2 飞行→Task 5;§6.3 Null→Task 3
§7 巡逻/追击→Task 6;§7.1 Pace 修复→Task 2;§8 StallDetector→Task 1+2
§9.1 消费者→Task 6;§9.2 删除清单→Task 8/9/10;§12 测试→Task 1/4 单测 + 各 Task 回归 + Task 7 PlayMode
§13 阶段→Task 110 分组。**无遗漏**。
- **占位符扫描**:无 TBD;新文件均给出完整代码;修改处给出替换后的完整代码或精确删除清单。
- **类型一致性**`IEnemyNavigator` 7 成员(`MoveTowards`/`Stop`/`SetSpeed`/`IsMoving`/`HasArrived`/`IsBlocked`/`TryPickWanderPoint`
在 Task 3 定义,Task 4/5/6 用法一致;`StallDetector(windowSeconds, minProgress)` + `Reset(x)` + `Tick(x, dt)`
在 Task 1 定义,Task 2/4 用法一致;`GroundNavigator.Arrived/WanderX` 静态签名与测试一致。
- **测试基线**191 → Task 1 后 199 → Task 4 后 206,其后不再新增。