diff --git a/Docs_Dev/superpowers/plans/2026-07-28-remove-pathfinding-library.md b/Docs_Dev/superpowers/plans/2026-07-28-remove-pathfinding-library.md
new file mode 100644
index 00000000..26e99231
--- /dev/null
+++ b/Docs_Dev/superpowers/plans/2026-07-28-remove-pathfinding-library.md
@@ -0,0 +1,849 @@
+# 移除寻路库 / 敌人导航框架重构 实现计划
+
+> **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` 纯逻辑 struct(TDD)
+
+**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
+{
+ ///
+ /// StallDetector:窗口内净位移不足即判停滞。
+ /// 该逻辑原本在 3 处各写一遍(巡逻 Pace / 导航层 / 冲锋),此处集中钉住行为。
+ ///
+ 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
+{
+ ///
+ /// 推进停滞检测:滑动窗口内的**净水平位移**不足阈值即判为停滞。
+ /// 用于"命令了移动却推不动"的兜底(撞上未纳入检测层的碰撞体、贴墙微抖等)。
+ ///
+ /// 用净位移而非逐帧差值,避免"贴墙推进-被弹回"的微抖每帧重置计数。
+ /// 调用方须以字段持有本结构(struct 的方法会就地修改字段);
+ /// 开始移动 / 更换目标后调用 重开窗口。
+ ///
+ public struct StallDetector
+ {
+ private readonly float _windowSeconds;
+ private readonly float _minProgress;
+ private float _timer;
+ private float _startX;
+ private bool _initialized;
+
+ /// 判定窗口时长(秒)。
+ /// 窗口内所需的最小净位移(米)。
+ public StallDetector(float windowSeconds, float minProgress)
+ {
+ _windowSeconds = windowSeconds;
+ _minProgress = minProgress;
+ _timer = 0f;
+ _startX = 0f;
+ _initialized = false;
+ }
+
+ /// 以当前位置重开窗口。
+ public void Reset(float currentX)
+ {
+ _startX = currentX;
+ _timer = 0f;
+ _initialized = true;
+ }
+
+ ///
+ /// 推进一帧。返回 true 表示本窗口内推进不足(停滞);返回 true 后窗口自动以当前位置重开。
+ ///
+ 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
+{
+ ///
+ /// 敌人导航抽象:把"去某个世界点"的请求翻译为具体移动方式(地面沿地形走 / 飞行直取),
+ /// 并报告到达与受阻。
+ ///
+ /// 不含任何路径图概念——可达性由移动层的墙/悬崖夹紧天然决定:
+ /// 走得过去就是可达,被夹停的地方就是边界。
+ ///
+ /// 实现:GroundNavigator(地面)、FlyingNavigator(飞行)、
+ /// (无导航/测试)。
+ ///
+ public interface IEnemyNavigator
+ {
+ ///
+ /// 请求朝世界坐标移动。目标被记录后由实现逐帧驱动,**重复传入同一目标是幂等的**
+ /// (每帧调用或只在目标变化时调用皆可)。传入新目标会清除 / 。
+ ///
+ void MoveTowards(Vector2 target);
+
+ /// 停止移动并清除当前目标。
+ void Stop();
+
+ /// 设置移动速度(巡逻走速 / 追击跑速)。传 0 表示使用移动层配置的默认步速。
+ void SetSpeed(float speed);
+
+ /// 当前是否正朝目标推进(有目标、未到达、未受阻)。
+ bool IsMoving { get; }
+
+ /// 是否已到达目标(进入停止距离内)。
+ bool HasArrived { get; }
+
+ /// 是否被地形挡住而无法继续靠近目标(墙 / 悬崖夹停 / 推进停滞)。
+ bool IsBlocked { get; }
+
+ /// 取一个可供游走的目标点。取不到时返回 false。
+ bool TryPickWanderPoint(out Vector2 point);
+ }
+
+ /// 无导航单位 / 测试用空实现:不移动,不报告到达或受阻。
+ 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
+{
+ /// GroundNavigator 的方向/到达判定纯函数(无场景依赖)。
+ 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
+{
+ ///
+ /// 地面单位导航:朝目标 X 直接行走,由 的墙/悬崖夹紧决定可达性。
+ ///
+ /// 不做任何地形扫描:走得过去就是可达,被夹停的地方就是边界。
+ /// 因此不存在"脱离导航图"这类状态——敌人无论被击退/冲锋到哪里,都能正常继续行走。
+ ///
+ /// 只驱动水平方向;垂直交给刚体重力。移动经 下发,
+ /// 速度为 0 时退化为移动层配置的步速。
+ ///
+ [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();
+ _movement = GetComponent();
+ _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();
+ }
+ }
+
+ // ── 纯函数(便于无场景单测)──────────────────────────────────────
+ /// 身体中心 X 是否已进入目标 X 的停止距离内。
+ public static bool Arrived(float currentX, float targetX, float stopDistance)
+ => Mathf.Abs(targetX - currentX) <= stopDistance;
+
+ ///
+ /// 游走目标 X:自当前位置朝 方向,
+ /// 在 [minRange, maxRange] 区间内按插值系数 (0..1) 取点。
+ ///
+ 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() ?? new NullPathAgent();`
+ → `_nav = GetComponent() ?? new NullEnemyNavigator();`
+- 更新类头注释中提到 `IPathAgent` 的措辞。
+- `MoveTo(Vector2)` 内部对 `_nav.RequestMoveTo` 的调用改为 `_nav.MoveTowards`;
+ `StopMovement()` 内对 `_nav.StopNavigation()` 的调用改为 `_nav.Stop()`(按实际代码定位)。
+
+- [ ] **Step 2: `EnemyLocomotion` — Wander 改用新接口**
+
+`TickWander()` 整体替换为:
+```csharp
+ ///
+ /// 就近随机游走:朝随机取的目标点走,**到达或被地形夹停**即视为本次游走结束,
+ /// 停顿 [_wanderPauseMin, _wanderPauseMax] 秒(期间播 Idle)后再挑下一个点。
+ /// 走到平台边缘停下转身是预期表现——可达性由移动层夹紧决定,不做地形扫描。
+ ///
+ 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
+ /// 取当前路点坐标并出发。路点若摆在崖外/墙后,敌人会走到边缘被夹停 → IsBlocked → 结算推进下一个。
+ 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}");
+```
+- **删除**:第 128–133 行(`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()`
+- 打印确认 `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/`(沿用现有验证文档风格)并提交该文档。
+
+---
+
+# 阶段 P3–P5:清理
+
+## 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()`;
+ 删除 `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 1–10 分组。**无遗漏**。
+- **占位符扫描**:无 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,其后不再新增。