diff --git a/Docs_Dev/superpowers/plans/2026-07-23-enemy-pathfind-obstruction-handling.md b/Docs_Dev/superpowers/plans/2026-07-23-enemy-pathfind-obstruction-handling.md new file mode 100644 index 00000000..9cafce79 --- /dev/null +++ b/Docs_Dev/superpowers/plans/2026-07-23-enemy-pathfind-obstruction-handling.md @@ -0,0 +1,509 @@ +# 敌人寻路受阻处理 实现计划(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#、PathBerserker2d(vendored 源码可改)、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`)之后新增一行: + +```csharp + private bool stopRequested; + // [项目新增] 上次寻路失败(NoPathFromStartToGoal)时算出的最近可达点,供执行层"寻路到最近可到达点"读取。 + private NavSegmentPositionPointer _lastClosestReachable = NavSegmentPositionPointer.Invalid; +``` + +- [ ] **Step 2: 加公共读取方法** + +在 `CanReach(Vector2 goal)` 方法结束(`return pr.Status == PathRequest.RequestState.Finished; }`,约 `:838`)之后新增: + +```csharp + /// + /// After the most recent failed path request (NoPathFromStartToGoal), returns the closest + /// reachable world position found towards the goal. Returns false if none is available. + /// + 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`): + +```csharp + OnFailedToFindPath?.Invoke(this); + currentPathRequest.Reset(); +``` +改为: +```csharp + RaiseFailedToFindPath(currentPathRequest); + currentPathRequest.Reset(); +``` + +第二处——`Failed` 分支 `allowCloseEnoughPath` 内(约 `:967`): + +```csharp + OnFailedToFindPath?.Invoke(this); + } + break; + case PathRequest.RequestState.Finished: +``` +改为: +```csharp + RaiseFailedToFindPath(currentPathRequest); + } + break; + case PathRequest.RequestState.Finished: +``` + +- [ ] **Step 4: 替换 `Repath` 里的失败广播** + +`Repath()` 的 `Failed` 分支(约 `:927`): + +```csharp + Stop(); + OnFailedToFindPath?.Invoke(this); + } + + repathPathRequest.Reset(); +``` +改为: +```csharp + Stop(); + RaiseFailedToFindPath(repathPathRequest); + } + + repathPathRequest.Reset(); +``` + +- [ ] **Step 5: 编译验证** + +调用 `unity_get_compilation_errors`。 +预期:无错误。(`NavSegmentPositionPointer.Position`/`.IsInvalid()`/`.Invalid` 已在本文件使用过,均可访问。) + +- [ ] **Step 6: 提交** + +```bash +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 方法/属性**(`LastMoveObstructed`、`TryGetClosestReachable`)。Task 4 才把它们加进 `IPathAgent` 接口——届时本类已满足接口,无需再改。 + +- [ ] **Step 1: 加序列化调参字段** + +在 `_maxSnapDistance` 字段块(约 `:42`)之后、`// 重寻路防抖状态` 之前新增: + +```csharp + [Header("受阻检测(寻路移动无进展即判被挡)")] + [Tooltip("卡死检测窗口时长(s):段上移动时,此时长内朝子目标推进小于阈值即判受阻")] + [SerializeField] private float _stuckWindowSeconds = 0.5f; + [Tooltip("卡死推进阈值(m):窗口内朝子目标的距离缩小不足此值即判卡死")] + [SerializeField] private float _stuckMinProgress = 0.05f; +``` + +- [ ] **Step 2: 加受阻/卡死运行时状态字段** + +在 `// 连接段状态缓存` 区(`_currentLinkType` 等,约 `:73`)之前或之后新增: + +```csharp + // 受阻状态(供意图层结算) + 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`)之后新增: + +```csharp + /// 上次寻路移动是否因不可达/被挡而受阻(走到最近点或原地停,而非到达原目标)。新目标 RequestMoveTo 后复位。 + public bool LastMoveObstructed => _lastMoveObstructed; + + /// 取上次寻路失败时算出的最近可达世界点。无则返回 false。 + 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 行复位): + +```csharp + 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`)改为: + +```csharp + private void HandlePathFailed(NavAgent _) + { + _obstructionSignal = true; // 延后到 FixedUpdate 统一处理,避免在 NavAgent 失败回调内重入 UpdatePath + OnNavPathFailed?.Invoke(); + } +``` + +- [ ] **Step 6: `FixedUpdate` 接入卡死检测 + 受阻处理** + +将 `FixedUpdate`(约 `:260-285`)整体替换为: + +```csharp + 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: 提交** + +```bash +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`)之后新增: + +```csharp + // 飞行单位直飞、始终可达,无"受阻/最近可达点"概念。 + public bool LastMoveObstructed => false; + public bool TryGetClosestReachable(out Vector2 worldPos) { worldPos = Vector2.zero; return false; } +``` + +- [ ] **Step 2: 编译验证** + +调用 `unity_get_compilation_errors`。预期:无错误。 + +- [ ] **Step 3: 提交** + +```bash +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` + +> 此时 `EnemyNavAgent`(Task 2)与 `FlyingDirectNavigator`(Task 3)已具备这两个成员,加进接口后即满足。 + +- [ ] **Step 1: 接口新增成员** + +在接口内 `bool ResolveStandablePoint(Vector2 target, out Vector2 standable);`(约 `:78`)之后新增: + +```csharp + /// + /// 上次寻路移动是否因目标不可达/被挡而受阻(走到最近可达点或原地停下,而非到达原目标)。 + /// 供意图层(Locomotion)判断本次移动应否视为结算/推进。对新目标 RequestMoveTo 后复位。 + /// + bool LastMoveObstructed { get; } + + /// 取上次寻路失败时算出的最近可达世界点(尽力贴近目标的点)。无则返回 false。 + bool TryGetClosestReachable(out Vector2 worldPos); +``` + +- [ ] **Step 2: `NullPathAgent` 实现** + +在 `NullPathAgent` 内 `public bool ResolveStandablePoint(...) { standable = target; return false; }`(约 `:110`)之后新增: + +```csharp + 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: 提交** + +```bash +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`)之后新增: + +```csharp + // Waypoints 受阻结算停顿状态 + private bool _wpPausing; + private float _wpPauseTimer; +``` + +- [ ] **Step 2: 切模式时复位路点停顿** + +`SetMode` 内 `_wanderPausing = false;`(约 `:63`)之后新增一行: + +```csharp + _wanderPausing = false; // 切换模式清 Wander 停顿态 + _wpPausing = false; // 同时清 Waypoints 受阻停顿态 +``` + +- [ ] **Step 3: 替换 Waypoints 分支** + +将 `case PatrolStrategy.Waypoints:` 整段(约 `:187-214`,从 `case PatrolStrategy.Waypoints:` 到该 case 的 `break;`)替换为: + +```csharp + 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: 提交** + +```bash +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)最后——每步均可编译。