Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-23-enemy-pathfind-obstruction-handling.md
T

21 KiB
Raw Blame History

敌人寻路受阻处理 实现计划(Waypoints 巡逻 + MoveTo 一次性目标)

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: 让走 PathBerserker2d 寻路的移动在目标被障碍挡住时「寻路到最近可到达点」而非对着障碍空推,同时覆盖「障碍已进图(寻路失败)」与「障碍未进图(物理卡死)」两种成因。

Architecture: 三层——PB2D NavAgent 暴露既有的最近可达点;EnemyNavAgent 执行层做卡死检测 + 统一受阻处理 + best-effort 改道(可复用);EnemyLocomotion 意图层只负责 Waypoints「受阻/到达即结算推进」。设计文档见 Docs_Dev/superpowers/specs/2026-07-23-enemy-pathfind-obstruction-handling-design.md

Tech Stack: Unity C#、PathBerserker2dvendored 源码可改)、Unity MCP(编译/Play 验证)。

测试策略(重要,TDD 适配): 本改动全部为 Unity 引擎耦合的运行时逻辑(NavGraph/物理/协程),项目无单元测试工程。因此不写 NUnit 单测,改用两道验证门:① 每个代码任务后用 unity_get_compilation_errors 确认零编译错误;② 全部完成后在 Play 模式经 Unity MCP 做行为验证(Task 6)。任务顺序已排成每一步都能独立编译

Unity MCP 前置: 首个 MCP 调用会自动发现实例;若多开需先 unity_select_instance。所有 MCP 调用走 unity_* 工具,勿直连 HTTP 桥。


Task 1: PB2D NavAgent 暴露最近可达点

Files:

  • Modify: Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs

  • Step 1: 加私有字段

在字段区 private bool stopRequested;(约 :366)之后新增一行:

        private bool stopRequested;
        // [项目新增] 上次寻路失败(NoPathFromStartToGoal)时算出的最近可达点,供执行层"寻路到最近可到达点"读取。
        private NavSegmentPositionPointer _lastClosestReachable = NavSegmentPositionPointer.Invalid;
  • Step 2: 加公共读取方法

CanReach(Vector2 goal) 方法结束(return pr.Status == PathRequest.RequestState.Finished; },约 :838)之后新增:

        /// <summary>
        /// After the most recent failed path request (NoPathFromStartToGoal), returns the closest
        /// reachable world position found towards the goal. Returns false if none is available.
        /// </summary>
        public bool TryGetClosestReachablePosition(out Vector2 worldPos)
        {
            if (!_lastClosestReachable.IsInvalid())
            {
                worldPos = _lastClosestReachable.Position;
                return true;
            }
            worldPos = Vector2.zero;
            return false;
        }

        // [项目新增] 记录失败请求的最近可达点后再广播寻路失败,供执行层读取。仅 NoPath 失败才有最近点。
        private void RaiseFailedToFindPath(PathRequest failedRequest)
        {
            _lastClosestReachable = (failedRequest != null
                && failedRequest.FailReason == PathRequest.RequestFailReason.NoPathFromStartToGoal)
                ? failedRequest.closestReachablePosition
                : NavSegmentPositionPointer.Invalid;
            OnFailedToFindPath?.Invoke(this);
        }
  • Step 3: 替换 HandlePathRequest 里两处失败广播

第一处——Failed 分支 !allowCloseEnoughPath 内(约 :956):

                        OnFailedToFindPath?.Invoke(this);
                        currentPathRequest.Reset();

改为:

                        RaiseFailedToFindPath(currentPathRequest);
                        currentPathRequest.Reset();

第二处——Failed 分支 allowCloseEnoughPath 内(约 :967):

                        OnFailedToFindPath?.Invoke(this);
                    }
                    break;
                case PathRequest.RequestState.Finished:

改为:

                        RaiseFailedToFindPath(currentPathRequest);
                    }
                    break;
                case PathRequest.RequestState.Finished:
  • Step 4: 替换 Repath 里的失败广播

Repath()Failed 分支(约 :927):

                        Stop();
                        OnFailedToFindPath?.Invoke(this);
                    }

                    repathPathRequest.Reset();

改为:

                        Stop();
                        RaiseFailedToFindPath(repathPathRequest);
                    }

                    repathPathRequest.Reset();
  • Step 5: 编译验证

调用 unity_get_compilation_errors。 预期:无错误。(NavSegmentPositionPointer.Position/.IsInvalid()/.Invalid 已在本文件使用过,均可访问。)

  • Step 6: 提交
git add Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs
git commit -m "feat(nav): NavAgent 暴露寻路失败时的最近可达点 TryGetClosestReachablePosition"

Task 2: EnemyNavAgent 卡死检测 + 统一受阻处理 + best-effort 改道

Files:

  • Modify: Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs

说明:本任务先把两个新成员做成具体 public 方法/属性LastMoveObstructedTryGetClosestReachable)。Task 4 才把它们加进 IPathAgent 接口——届时本类已满足接口,无需再改。

  • Step 1: 加序列化调参字段

_maxSnapDistance 字段块(约 :42)之后、// 重寻路防抖状态 之前新增:

        [Header("受阻检测(寻路移动无进展即判被挡)")]
        [Tooltip("卡死检测窗口时长(s):段上移动时,此时长内朝子目标推进小于阈值即判受阻")]
        [SerializeField] private float _stuckWindowSeconds = 0.5f;
        [Tooltip("卡死推进阈值(m):窗口内朝子目标的距离缩小不足此值即判卡死")]
        [SerializeField] private float _stuckMinProgress   = 0.05f;
  • Step 2: 加受阻/卡死运行时状态字段

// 连接段状态缓存 区(_currentLinkType 等,约 :73)之前或之后新增:

        // 受阻状态(供意图层结算)
        private bool  _lastMoveObstructed;
        private bool  _redirectedToClosest;  // 本轮已改道到最近点,避免每帧重复下发
        private bool  _obstructionSignal;    // 受阻信号(寻路失败/卡死),延后到 FixedUpdate 统一处理
        // 卡死检测窗口
        private float _stuckWinTimer;
        private float _stuckWinStartDist;
        private bool  _stuckWinInit;
  • Step 3: 加公共状态成员 + 最近点查询

public Vector2 CurrentLinkEnd => _currentLinkEnd;(约 :54)之后新增:

        /// <summary>上次寻路移动是否因不可达/被挡而受阻(走到最近点或原地停,而非到达原目标)。新目标 RequestMoveTo 后复位。</summary>
        public bool LastMoveObstructed => _lastMoveObstructed;

        /// <summary>取上次寻路失败时算出的最近可达世界点。无则返回 false。</summary>
        public bool TryGetClosestReachable(out Vector2 worldPos)
        {
            if (_navAgent != null) return _navAgent.TryGetClosestReachablePosition(out worldPos);
            worldPos = Vector2.zero;
            return false;
        }
  • Step 4: RequestMoveTo 新目标复位受阻态

RequestMoveTo(约 :145-159)改为(新增 3 行复位):

        public void RequestMoveTo(Vector2 target)
        {
            if (_navAgent == null) return;
            if (_hasPathGoal
                && Vector2.Distance(target, _lastPathGoal) < _repathTargetThreshold
                && Time.time - _lastPathTime < _repathMinInterval)
                return;

            _lastPathGoal = target;
            _hasPathGoal  = true;
            _lastPathTime = Time.time;
            _lastMoveObstructed  = false;   // 新目标 → 复位受阻态
            _redirectedToClosest = false;
            ResetStuckWindow();
            _navAgent.UpdatePath(target);
        }
  • Step 5: HandlePathFailed 仅置信号(不在回调内重入寻路)

private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke();(约 :251)改为:

        private void HandlePathFailed(NavAgent _)
        {
            _obstructionSignal = true;   // 延后到 FixedUpdate 统一处理,避免在 NavAgent 失败回调内重入 UpdatePath
            OnNavPathFailed?.Invoke();
        }
  • Step 6: FixedUpdate 接入卡死检测 + 受阻处理

FixedUpdate(约 :260-285)整体替换为:

        private void FixedUpdate()
        {
            if (_enemyMovement == null || _navAgent == null) return;

            bool onSegment = _navAgent.IsMovingOnSegment;
            _enemyMovement.NavDriving = onSegment; // 状态标记(供调试/上层判断"是否导航中"

            TickStuckDetection(onSegment);

            if (onSegment)
            {
                // 物理原生:朝子目标水平移动,由 EnemyMovement 施加 velocity、刚体重力保持贴地
                float dx = _navAgent.PathSubGoal.x - transform.position.x;
                _enemyMovement.PendingInput.MoveDir   = Mathf.Abs(dx) > 0.05f ? Mathf.Sign(dx) : 0f;
                _enemyMovement.PendingInput.MoveSpeed = _movement != null ? _movement.movementSpeed : 0f;

                // 段推进:到达当前子目标 → 推进到下一段
                if (_navAgent.HasReachedCurrentSubGoal(0.25f))
                    _navAgent.CompleteSegmentTraversal();
            }
            else if (_wasNavOnSegment && !_navAgent.IsFollowingAPath)
            {
                // 导航刚结束(到达目标 / 路径失败)→ 停下水平移动
                _enemyMovement.PendingInput.WantStop = true;
            }

            // 受阻信号统一处理(寻路失败/卡死):此处不在 NavAgent 回调内,UpdatePath 安全
            if (_obstructionSignal)
            {
                _obstructionSignal = false;
                ProcessObstruction();
            }

            _wasNavOnSegment = onSegment;
        }

        // 卡死检测:段上移动时,滑动窗口内朝子目标推进不足阈值 → 判受阻(覆盖"障碍未进图"的物理空推)。
        private void TickStuckDetection(bool onSegment)
        {
            if (!onSegment) { _stuckWinInit = false; return; }

            float dist = Mathf.Abs(_navAgent.PathSubGoal.x - transform.position.x);
            if (!_stuckWinInit)
            {
                _stuckWinInit      = true;
                _stuckWinTimer     = 0f;
                _stuckWinStartDist = dist;
                return;
            }
            _stuckWinTimer += Time.fixedDeltaTime;
            if (_stuckWinTimer >= _stuckWindowSeconds)
            {
                if (_stuckWinStartDist - dist < _stuckMinProgress)
                    _obstructionSignal = true; // 窗口内几乎没靠近子目标 → 卡死
                _stuckWinTimer     = 0f;
                _stuckWinStartDist = dist;
            }
        }

        private void ResetStuckWindow() => _stuckWinInit = false;

        // 统一受阻处理:尽力改道到最近可达点(走到障碍前),拿不到更近点则停下,交由意图层结算。
        private void ProcessObstruction()
        {
            _lastMoveObstructed = true;

            if (!_redirectedToClosest
                && _navAgent.TryGetClosestReachablePosition(out var closest)
                && Vector2.Distance(transform.position, closest) > _repathTargetThreshold)
            {
                _redirectedToClosest = true;
                ResetStuckWindow();
                _navAgent.UpdatePath(closest);   // 走向最近可达点
                return;
            }

            // 无更近点 / 已在最近点附近(含"障碍未进图"无 closest 数据)→ 停下
            _navAgent.Stop();
            _enemyMovement.PendingInput.WantStop = true;
        }
  • Step 7: 编译验证

调用 unity_get_compilation_errors。 预期:无错误。(_movement/_navAgent/PathSubGoal/IsMovingOnSegment/HasReachedCurrentSubGoal/UpdatePath/Stop 均已存在。)

  • Step 8: 提交
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs
git commit -m "feat(enemy): EnemyNavAgent 卡死检测+统一受阻处理+best-effort 改道到最近可达点"

Task 3: FlyingDirectNavigator 实现新成员(返回 false

Files:

  • Modify: Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs

  • Step 1: 加两个成员

在 IPathAgent 属性区 public Vector2 CurrentLinkEnd => Vector2.zero;(约 :67)之后新增:

        // 飞行单位直飞、始终可达,无"受阻/最近可达点"概念。
        public bool LastMoveObstructed => false;
        public bool TryGetClosestReachable(out Vector2 worldPos) { worldPos = Vector2.zero; return false; }
  • Step 2: 编译验证

调用 unity_get_compilation_errors。预期:无错误。

  • Step 3: 提交
git add Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs
git commit -m "feat(enemy): FlyingDirectNavigator 实现受阻/最近可达点成员(飞行单位恒 false)"

Task 4: IPathAgent 加接口成员 + NullPathAgent 实现

Files:

  • Modify: Assets/_Game/Scripts/Enemies/IPathAgent.cs

此时 EnemyNavAgentTask 2)与 FlyingDirectNavigator(Task 3)已具备这两个成员,加进接口后即满足。

  • Step 1: 接口新增成员

在接口内 bool ResolveStandablePoint(Vector2 target, out Vector2 standable);(约 :78)之后新增:

        /// <summary>
        /// 上次寻路移动是否因目标不可达/被挡而受阻(走到最近可达点或原地停下,而非到达原目标)。
        /// 供意图层(Locomotion)判断本次移动应否视为结算/推进。对新目标 RequestMoveTo 后复位。
        /// </summary>
        bool LastMoveObstructed { get; }

        /// <summary>取上次寻路失败时算出的最近可达世界点(尽力贴近目标的点)。无则返回 false。</summary>
        bool TryGetClosestReachable(out Vector2 worldPos);
  • Step 2: NullPathAgent 实现

NullPathAgentpublic bool ResolveStandablePoint(...) { standable = target; return false; }(约 :110)之后新增:

        public bool LastMoveObstructed       => false;
        public bool TryGetClosestReachable(out Vector2 worldPos) { worldPos = Vector2.zero; return false; }
  • Step 3: 编译验证

调用 unity_get_compilation_errors。 预期:无错误(三个实现者 EnemyNavAgent/FlyingDirectNavigator/NullPathAgent 均已具备成员)。

  • Step 4: 提交
git add Assets/_Game/Scripts/Enemies/IPathAgent.cs
git commit -m "feat(enemy): IPathAgent 增加 LastMoveObstructed/TryGetClosestReachable 契约"

Task 5: EnemyLocomotion Waypoints 受阻/到达结算,删除无限重发

Files:

  • Modify: Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs

  • Step 1: 加路点停顿状态字段

// Wander 到点停顿状态_wanderPausing/_wanderPauseTimer,约 :45-46)之后新增:

        // Waypoints 受阻结算停顿状态
        private bool  _wpPausing;
        private float _wpPauseTimer;
  • Step 2: 切模式时复位路点停顿

SetMode_wanderPausing = false;(约 :63)之后新增一行:

            _wanderPausing = false; // 切换模式清 Wander 停顿态
            _wpPausing     = false; // 同时清 Waypoints 受阻停顿态
  • Step 3: 替换 Waypoints 分支

case PatrolStrategy.Waypoints: 整段(约 :187-214,从 case PatrolStrategy.Waypoints: 到该 case 的 break;)替换为:

                case PatrolStrategy.Waypoints:
                    if (_waypoints == null || _waypoints.Length == 0)
                    {
                        if (!_warnedNoWaypoints)
                        {
                            Debug.LogWarning("[EnemyLocomotion] PatrolStrategy=Waypoints 但未配置 _waypoints,无法巡逻。", this);
                            _warnedNoWaypoints = true;
                        }
                        break;
                    }

                    var nav = _enemy.Nav;

                    // 到达判定对"吸附后的可达点"做(原始路点可能摆在空中/崖外,永远进不了到达半径)。
                    bool wpArrived = _wpIndex >= 0 &&
                        Vector2.Distance(_enemy.transform.position, _wpResolvedGoal) <= _waypointArriveRadius;

                    // 受阻结算:执行层已把敌人带到最近可达点并停下(或原地停) → 视为本路点完成。
                    bool obstructedSettled = nav != null && nav.LastMoveObstructed && !nav.IsMoving;

                    if (_wpIndex < 0 || wpArrived || obstructedSettled)
                    {
                        // 受阻结算走短暂停顿(复用 Wander 停顿参数);正常到达/首个点立即推进。
                        if (obstructedSettled && !_wpPausing)
                        {
                            _wpPausing    = true;
                            _wpPauseTimer = Random.Range(_wanderPauseMin, Mathf.Max(_wanderPauseMin, _wanderPauseMax));
                        }
                        if (_wpPausing)
                        {
                            _wpPauseTimer -= Time.deltaTime;
                            if (_wpPauseTimer > 0f) break; // 停顿中,原地待机
                            _wpPausing = false;
                        }
                        AdvanceWaypoint();
                        SetWaypointGoal();
                    }
                    else if (nav != null && !nav.IsMoving && !nav.LastMoveObstructed)
                    {
                        // 瞬态:刚出生尚未映射那一帧的 MoveTo 失败 → 重发(RequestMoveTo 自带 0.25s 防抖)。
                        // 仅"未受阻"时重发;受阻由上面 obstructedSettled 分支结算,不再无限重发不可达点。
                        _enemy.MoveTo(_wpResolvedGoal);
                    }
                    break;
  • Step 4: 编译验证

调用 unity_get_compilation_errors。 预期:无错误(nav.LastMoveObstructed 来自 Task 4 接口;Random.Range/_wanderPauseMin/Max 已存在)。

  • Step 5: 提交
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
git commit -m "feat(enemy): Waypoints 受阻/到达即结算推进,删除对不可达点的无限重发"

Task 6: Play 模式行为验证(经 Unity MCP)

Files: 无(验证任务)

  • Step 1: 打开测试场景

unity_scene_open 打开 Assets/_Game/Scenes/Testings/TestRoomA.unity。确认场景内有 NavSurface + 一个 Waypoints 巡逻敌人(EnemyLocomotion.PatrolStrategy=Waypoints,至少 2 个路点)。若无,用脚手架 SceneObjectPlacerTool 放置敌人并配路点(遵守 CLAUDE.md §2 脚手架规范)。

  • Step 2: 成因 B 验证——裸障碍

在两个路点之间放一个挡路的 Collider(高度≥敌人 Height,横跨路径)。unity_play_mode 进入 Play。观察(unity_screenshot_game 连续截图或 unity_editor_state 读位置): 预期:敌人走到障碍前 → 停顿约 _wanderPauseMin~Max 秒 → 推进到下一个路点继续巡逻。不再对着障碍空推

  • Step 3: 回归——无障碍巡逻

移除障碍,重进 Play。 预期:Waypoints 巡逻在各路点间正常往返,行为与改动前一致(正常到达不停顿、立即推进)。

  • Step 4: 记录结果

把验证结论(成因 B 通过 / 回归通过)追加到设计文档末尾或开发笔记。成因 A(障碍已进图寻路失败)待后续 Tier-1 软障碍/重烘焙就绪后复测——本次不阻塞。

  • Step 5: 退出 Play

unity_play_mode 退出 Play 模式。


自检记录(写计划时已核对)

  • Spec 覆盖:设计 §3.1→Task1;§3.2→Task2;§3.4→Task3+Task4;§3.3→Task5;§7→Task6。全覆盖。
  • 占位符:无 TBD/TODO,每个代码步含完整代码。
  • 类型一致TryGetClosestReachablePosition(NavAgent) / TryGetClosestReachable(IPathAgent 实现) / LastMoveObstructed 三处命名在各任务间一致;_obstructionSignal/ResetStuckWindow/ProcessObstruction 定义与调用一致。
  • 可编译顺序:先在实现类(Task2/3)加具体成员,再加接口(Task4),消费方(Task5)最后——每步均可编译。