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

108 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 敌人寻路受阻处理设计(Waypoints 巡逻 + MoveTo 一次性目标)
> 日期:2026-07-23
> 状态:设计已批准,待写实现计划
> 范围:**仅**走 PathBerserker2d 寻路的移动——Waypoints 巡逻、一次性 `MoveTo(point)`。
> 不在本次:Approach 追击(当前未接线且真实追击 `ContactChaseAbility` 走速度冲刺、不经寻路)。
## 1. 问题
在巡逻 waypoint 之间放一个障碍物时,敌人会「对着障碍物一直空推」,不会智能寻路到最近可到达的点。根因是受阻有**两种成因**,当前都没被正确处理:
- **成因 A:障碍已进 NavGraph**(已烘焙/未来软障碍)。寻路失败(`NoPathFromStartToGoal`),`NavAgent.OnFailedToFindPath``EnemyNavAgent.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.closestReachablePosition``Pathfinder.cs:79``UpdateClosestPositionToGoal:325`),但该字段在 PB2D 程序集内部,外部拿不到。
- 新增字段 `private NavSegmentPositionPointer _lastClosestReachable = NavSegmentPositionPointer.Invalid;`
- 在**两处** `OnFailedToFindPath` 触发前赋值该字段:
- `HandlePathRequest()` 的 Failed 分支(`currentPathRequest` 失败,`:947-969`
- `Repath()` 的 Failed 分支(`repathPathRequest` 失败,`:922-931`
- 仅当 `FailReason == NoPathFromStartToGoal``closestReachablePosition` 有效时赋值,否则置 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`)。无进展即判「卡死」。
- **寻路失败(覆盖成因 A**`OnNavPathFailed` 已存在,接入统一「受阻」判定。
- **统一受阻处理**:受阻 = 卡死 ∪ 寻路失败。触发后:
- `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 到达判定改为:**到达 `_wpEffectiveGoal`** 或 **`Nav.LastMoveObstructed``Nav` 空闲)**。
- 后者即:执行层已把敌人带到最近可达点并停住 → 视为「本路点结算」。
- 结算 → 停顿(复用 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 到达判定改为「到达或受阻结算」,删除无限重发 |