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

8.5 KiB
Raw Blame History

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

日期:2026-07-23 状态:设计已批准,待写实现计划 范围:走 PathBerserker2d 寻路的移动——Waypoints 巡逻、一次性 MoveTo(point)。 不在本次:Approach 追击(当前未接线且真实追击 ContactChaseAbility 走速度冲刺、不经寻路)。

1. 问题

在巡逻 waypoint 之间放一个障碍物时,敌人会「对着障碍物一直空推」,不会智能寻路到最近可到达的点。根因是受阻有两种成因,当前都没被正确处理:

  • 成因 A:障碍已进 NavGraph(已烘焙/未来软障碍)。寻路失败(NoPathFromStartToGoal),NavAgent.OnFailedToFindPathEnemyNavAgent.OnNavPathFailed 触发,但全项目无人订阅(已 grep 确认)。EnemyLocomotion 的 Waypoints 分支在 nav 空闲且未到达时无限重发同一不可达点EnemyLocomotion.cs:207-213)→ 死循环。
  • 成因 B:障碍未进 NavGraph(当前裸摆的 Collider)。寻路成功返回一条穿墙直线,Nav.IsMoving=true,敌人被物理碰撞体夹停、永远到不了目标、既不「到达」也不「失败」→ 纯物理空推。

说明:成因 A 的「让障碍进图」是另一项工作(Tier-1 软障碍 / 重烘焙),本设计只负责「受阻后敌人如何正确反应」。为使本次独立可测(对当前裸障碍即成因 B 立即见效),必须同时覆盖 A 与 B

2. 目标行为

当一次寻路移动无法到达目标时,敌人应寻路到最近的可到达点,而非对着障碍空推:

  • Waypoints 巡逻:走到最近可达点 → 短暂停顿 → 推进到下一个 waypoint 继续巡逻。(拿不到更近点则直接视为「到达受阻点」→ 停顿 → 推进。)
  • MoveTo 一次性目标:尽力走到最近可达点并停住(best-effort reach),不空推。

3. 架构与职责划分

三层,职责清晰、可独立理解与测试:

3.1 PB2D NavAgent.cs(vendored 源码)— 暴露既有的「最近可达点」

寻路失败时 Pathfinder 已把最近可达点写进 PathRequest.closestReachablePositionPathfinder.cs:79UpdateClosestPositionToGoal:325),但该字段在 PB2D 程序集内部,外部拿不到。

  • 新增字段 private NavSegmentPositionPointer _lastClosestReachable = NavSegmentPositionPointer.Invalid;
  • 两处 OnFailedToFindPath 触发前赋值该字段:
    • HandlePathRequest() 的 Failed 分支(currentPathRequest 失败,:947-969
    • Repath() 的 Failed 分支(repathPathRequest 失败,:922-931
    • 仅当 FailReason == NoPathFromStartToGoalclosestReachablePosition 有效时赋值,否则置 Invalid。
  • 新增 public bool TryGetClosestReachablePosition(out Vector2 worldPos):读 _lastClosestReachable,无效返回 false。
  • 不改动任何现有逻辑,纯暴露既有数据。约 12 行。

3.2 EnemyNavAgent.cs(寻路执行层)— 通用「受阻检测 + best-effort 改道」

这是可复用的核心,将来「基于寻路的追击」直接复用。

  • 卡死检测(覆盖成因 B:当 Nav.IsMovingOnSegment 时,用滑动窗口测「到 PathSubGoal 的距离是否在 ~0.5s 内几乎未减小」(复用 Pace 已验证的净位移窗口思路,EnemyLocomotion.cs:169-175)。无进展即判「卡死」。
  • 寻路失败(覆盖成因 AOnNavPathFailed 已存在,接入统一「受阻」判定。
  • 统一受阻处理:受阻 = 卡死 ∪ 寻路失败。触发后:
    • TryGetClosestReachable(out p) 拿到有效、且明显比当前位置更靠近目标的点、且本轮尚未改道 → 自动 UpdatePath(p) 一次(走向最近点),置 LastMoveObstructed=true
    • 拿不到更近点(已在最近点附近 / 无最近点 / 成因 B 无 closest 数据)→ Stop(),置 LastMoveObstructed=true
  • 对外暴露
    • bool LastMoveObstructed { get; }(下次对新目标 RequestMoveTo 时复位)。
    • IPathAgent 新增 bool TryGetClosestReachable(out Vector2 worldPos)(转发 RawNavAgent.TryGetClosestReachablePosition)。
  • 效果:所有走寻路的 MoveTo 天然变为「尽力到最近点、绝不空推」;一次性 MoveTo 无需额外策略即正确。

3.3 EnemyLocomotion.cs(意图层)— 仅 Waypoints 推进策略

  • Waypoints 到达判定改为:到达 _wpEffectiveGoalNav.LastMoveObstructedNav 空闲)
    • 后者即:执行层已把敌人带到最近可达点并停住 → 视为「本路点结算」。
  • 结算 → 停顿(复用 Wander 的 _wanderPauseMin/Max)→ AdvanceWaypoint() 推进下一个点。删除对同一不可达点的无限重发:207-213)。
  • 保留「刚出生尚未映射到导航图」的瞬态重发(防抖),但只在 Nav 空闲、未到达、且未受阻时。

3.4 IPathAgent.cs 及其实现

  • 接口新增 bool TryGetClosestReachable(out Vector2 worldPos)bool LastMoveObstructed { get; }
  • FlyingDirectNavigator 与空实现:TryGetClosestReachable 返回 false、LastMoveObstructed 返回 false(飞行敌走直线,无此概念)。

4. 数据流

EnemyLocomotion.TickPatrol (Waypoints)
  └─ SetWaypointGoal(): ResolveStandablePoint → _wpEffectiveGoal → MoveTo
        └─ EnemyNavAgent.RequestMoveTo(target)  [复位 LastMoveObstructed]
              └─ NavAgent.UpdatePath(target) → 异步 A*
                    ├─ 成功: 跟随路径(FixedUpdate 朝 PathSubGoal 走)
                    │     └─ 卡死检测(窗口无进展) ─┐
                    └─ 失败(NoPath): OnFailedToFindPath ─┤
                                                          ▼
                                          EnemyNavAgent 统一受阻处理
                                          ├─ 有更近点 → UpdatePath(closest), LastMoveObstructed=true
                                          └─ 无更近点 → Stop(), LastMoveObstructed=true
  └─ 到达 _wpEffectiveGoal 或 (LastMoveObstructed && Nav 空闲)
        └─ 停顿 → AdvanceWaypoint()

5. 边界与错误处理

  • 最近点 ≈ 当前位置(障碍就在脚下):执行层 Stop() → Nav 立即空闲 → Waypoints 结算推进。不空推。
  • 无导航图 / 映射失败TryGetClosestReachable 返回 false → Waypoints 结算推进;MoveTo 停。
  • 不误判瞬态Pending 期间只累计卡死窗口、OnNavPathFailed 不误触发;卡死窗口仅在 IsMovingOnSegment 时累计。
  • 防抖动:改道后 LastMoveObstructed=true 抑制本轮重复改道,等 Nav 结算;对新目标RequestMoveTo 才复位。
  • 根因合规(CLAUDE.md §6:卡死检测是「AI 对无法推进的正确反应」,是真实行为而非兜底掩盖;TryGetClosestReachablePosition 是暴露既有数据而非改逻辑。让障碍进图仍是另一项独立工作。

6. 扩展点(本次不实现,但预留)

将来「基于寻路的追击」接线 IEnemyLocomotion.Approach(Transform) → 每帧 MoveTo(玩家位置) 时,因受阻处理在 EnemyNavAgent 执行层,追击自动获得 best-effort 贴近最近可达点、绝不空推的行为,无需改动本次代码。

7. 验证方式(Play 模式,经 Unity MCP

  • 成因 B(当前裸障碍):两 waypoint 间摆 Collider → 敌人走到障碍前、停顿、推进下一个点(不再空推)。
  • 成因 A(待软障碍/烘焙障碍就绪后复测):寻路失败 → 走最近点分支同样结算推进。
  • MoveTo 一次性:目标被挡 → 敌人停在最近可达点、不空推。
  • 回归:无障碍时 Waypoints/Wander/Pace 巡逻与既有行为一致。

8. 受影响文件清单

文件 改动
Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs _lastClosestReachable 字段 + 两处赋值 + TryGetClosestReachablePosition
Assets/_Game/Scripts/Enemies/IPathAgent.cs 接口加 TryGetClosestReachable / LastMoveObstructed;空实现返回 false
Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs 卡死检测 + 统一受阻处理 + best-effort 改道 + 暴露状态
Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs 实现新接口成员(返回 false
Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs Waypoints 到达判定改为「到达或受阻结算」,删除无限重发