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

36 KiB
Raw Blame History

移除寻路库 / 敌人导航框架重构 实现计划

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.csEditor/Enemies/Navigation/NavSurfaceBakeShortcut.csEditor/Scene/SceneObjectPlacerTool.csEditor/Scene/SceneScaffoldTools.cs。其余文件仅在注释提及。
  • NavLinkType / INavLinkHandler项目自有抽象,不含第三方类型。
  • Boss 代码完全不引用导航(无 .Nav/IPathAgent/MoveTo/Locomotion),其预制体上的导航组件是冗余件。
  • 敌人预制体除 ENM_ChaoFeng 外均已删除;场景敌人实例只有 TestRoomAENM_CaoZhi
  • 同名陷阱UI/MainMenu/DataDrivenMainMenuController.csNavUI 导航栈不得改动

文件结构

新建:

  • 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.csRushAbility.csEnemyBase.csEnemyLocomotionEditor.csFlyingDirectNavigator.cs(→FlyingNavigator.cs)、2 个 asmdef、3 个编辑器脚本

删除: IPathAgent.csNavigation/EnemyNavAgent.csEditor/Enemies/Navigation/NavSurfaceBakeShortcut.csAssets/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

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: 运行确认失败

MCPAssets/Refresh → 等 10 秒 → unity_get_compilation_errors。 预期:编译失败,报 StallDetector 不存在。

  • Step 3: 实现

创建 Assets/_Game/Scripts/Enemies/StallDetector.cs

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: 提交
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 两个字段替换为:

        // Pace 推进停滞检测(原为 12 帧计数,@60fps ≈ 0.2s,改用统一原语并保持观感)
        private StallDetector _paceStall = new StallDetector(0.2f, 0.05f);

TickPatrol()case PatrolStrategy.Pace: 整段替换为:

                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,改为字段:

        // 推进停滞检测(撞上未纳入墙层的碰撞体时兜底结束冲锋)
        private StallDetector _stall = new StallDetector(0.15f, 0.05f);

RushRoutine 中删除 winX / winT 两个局部变量及其窗口判定块,改为:

            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,改为:

        private StallDetector _segStall;

Awake 末尾初始化:

            _segStall = new StallDetector(_stuckWindowSeconds, _stuckMinProgress);

TickStuckDetection(bool onSegment) 整体替换为:

        // 卡死检测:段上移动时,窗口内净水平位移不足 → 判受阻(覆盖"障碍未进图"的物理空推)。
        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_CaoZhiEnemyAiBrain,用反射把 _patrolStrategy 设为 Pace 把敌人放到平台左缘外侧悬空处(center x = -22.30, y = 2.01),Locomotion.SetMode(Patrol)。 预期:敌人能掉头走回平台(修复前会永久顶着崖外不动)。验证后 unity_play_mode({action:"stop"})

  • Step 6: 提交
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:新增 IEnemyNavigatorGroundNavigator(不动现有接线)

Task 3: 新接口 IEnemyNavigator + NullEnemyNavigator

Files:

  • Create: Assets/_Game/Scripts/Enemies/IEnemyNavigator.cs

  • Step 1: 创建接口文件

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: 提交
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: 先写失败测试

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

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: 提交
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: FlyingDirectNavigatorFlyingNavigator(改接口)

Files:

  • Rename: Navigation/FlyingDirectNavigator.csNavigation/FlyingNavigator.cs(连同 .metagit mv 保 GUID

  • Modify: 重写为新接口

  • Step 1: 改名(保 GUID

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/CurrentLinkEndLastMoveObstructedTryGetClosestReachableIsNearEdgeCanReachWalkToRandomOnSegmentResolveStandablePoint、 以及 _goalFired 字段。

  • 改名RequestMoveToMoveTowards(新增幂等判断)、StopNavigationStopIsAtDestination()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: 提交

git add Assets/_Game/Scripts/Enemies/Navigation/
git commit -m "refactor(enemy): FlyingDirectNavigator 改名 FlyingNavigator 并切换到 IEnemyNavigator"

Task 6: EnemyBase / EnemyLocomotion / 编辑器面板切换到新接口

Files:

  • Modify: Enemies/EnemyBase.csEnemies/Navigation/EnemyLocomotion.csEditor/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() 整体替换为:

        /// <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() 替换为:

        /// <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 行替换为:

                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: 提交

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

  • DestroyImmediateEnemyNavAgent、PB2d NavAgentTransformBasedMovement 三个组件

  • 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 移除 NavAgentTransformBasedMovementEnemyNavAgent(已核实 Boss 代码不引用导航)。
  • Step 2: 编译门 + 测试;PlayMode 快速确认 Boss 预制体可正常实例化(无 Missing 组件报错)。
  • Step 3: 提交预制体改动。

Task 9: 删除 EnemyNavAgentIPathAgent 与编辑器中的寻路库用法

  • Step 1: 删除 Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs+.metaAssets/_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.asmdefBaseGames.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,其后不再新增。