docs(enemy): 敌人寻路受阻处理设计(Waypoints+MoveTo,含卡死检测与最近可达点)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# 敌人寻路受阻处理设计(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 到达判定改为「到达或受阻结算」,删除无限重发 |
|
||||
Reference in New Issue
Block a user