Files
zeling_v2/Docs_Dev/superpowers/specs/2026-07-27-nav-offgraph-recovery-design.md
T

197 lines
12 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.
# 【已作废】导航脱图统一脱困(Off-Graph Recovery)设计
> ⚠ **本设计已于 2026-07-28 作废,未实施。**
> 取代方案:`2026-07-28-enemy-navigation-without-pathfinding-design.md`(移除第三方寻路库)。
> 原因:本设计要解决的"agent 脱离烘焙段 → 寻路 API 全面静默失效"问题,
> 在移除导航图后**根本不存在**,无需脱困机制。
> 其中唯一仍然有效的部分(Pace 边沿翻向缺陷修复)已并入取代方案并**已实施**。
> 保留本文件仅作决策留痕,请勿据此实现。
> 日期:2026-07-27  状态:设计(待实现)
> 关联:`2026-07-27-enemy-body-authority-design.md`(身体几何权威)、
> `2026-07-23-enemy-pathfind-obstruction-handling-design.md`(寻路受阻处理)。
## 1. 背景:已坐实的两个独立根因
真机测试发现:敌人中心点脱离 PathBerserker2d 烘焙段(例如冲锋/击退后停在崖沿、中心悬在崖外)时,
`EnemyLocomotion` 的三种巡逻策略全部失效。代码级追查确认这是**两个互相独立**的根因。
### RC-1:导航 API 静默返回 false,且返回值被丢弃(影响 Wander / Waypoints
`NavAgent.UpdateMappedPosition()` 每帧把 agent 位置映射到烘焙段。中心离开段 → `currentMappedPosition`
失效 → `HasValidPosition == false` → 所有寻路入口**直接返回 false,连寻路请求都不发出**:
| API | 未映射时的行为 | 位置 |
|---|---|---|
| `SetRandomDestinationOnCurrentSegment()`Wander 用) | `return false` | `NavAgent.cs:617` |
| `PathTo()``MoveTo`/`UpdatePath` 最终调用) | `return false` | `NavAgent.cs:577` |
`EnemyLocomotion` 把这两个 false 全部丢弃,于是失败被完全吞掉(违反 CLAUDE.md §6):
| 策略 | 实测(4 秒) | 机制 |
|---|---|---|
| Wander | 位移 **0**,动画卡在 **Idle** | `WalkToRandomOnSegment()` 返回值未接 → 下一帧仍"没在移动" → 又进停顿分支 → 步态被切成 Idle |
| Waypoints | 位移 **0**,动画 Move(原地踏步) | `PathTo` 未发请求 → `OnFailedToFindPath` 不触发 → `LastMoveObstructed` 恒 false → 走"未受阻却没在移动"的重发分支,无限静默重试 |
> 关键旁证:Waypoints 下 `DebugResolveOk == true`——**目标点吸附是成功的**,
> 不可达的是 **agent 自己**`TryGetStandablePointNear` 映射的是目标点,不依赖 agent 是否在图上)。
### RC-2:Pace 的边沿触发翻向无法自救(与导航无关)
实测内部状态:`_paceDir = -1`(顶着崖外)、`_paceBlockedPrev = true`、当前方向受阻 = true、
**反方向受阻 = false(回平台的路畅通)**
翻向条件 `if (blocked && !_paceBlockedPrev)` 是**边沿触发**`blocked` 持续为真时永不再翻,
敌人被永久锁死在朝向障碍的方向。原注释意图是"窄台两侧都堵时翻一次即停、不来回抖",
但实现无法区分"两侧都堵"与"我正顶着堵的一侧、另一侧其实通畅"。
**该缺陷不依赖悬空**:任何让敌人连续受阻的情形(含卡死窗口误判)都可能触发。
## 2. 目标 / 非目标
**目标**
- 建立**统一的脱困机制**:任何依赖 PB2d 寻路的功能,脱图后都能自动恢复,无需各消费端自行处理。
- 让"脱图"这一状态**可查询**,使消费端能正确表达(尤其动画步态不能再错播 Idle)。
- 修复 RC-2,使 Pace 在"顶着堵侧、另一侧通畅"时能掉头。
- 不再静默丢弃导航 API 的失败返回值。
**非目标**
- 不改 PathBerserker2d 本身。
- 不处理"敌人被摆在完全无地形处"(关卡数据错误)——该情形显式告警暴露,不做臆造恢复。
- 不改飞行单位(`FlyingDirectNavigator` 不依赖烘焙图)。
## 3. 设计
### 3.1 统一脱困放在 `EnemyNavAgent`PB2d 唯一集成点)
**理由**`EnemyNavAgent` 是项目里唯一封装 PB2d `NavAgent` 的组件,所有寻路调用
`RequestMoveTo` / `WalkToRandomOnSegment` / `ResolveStandablePoint` / `CanReach`)都经它。
把脱困放在这里,则**所有现有与将来的寻路消费者自动获得脱困能力**,消费端无需各写一套。
它已经在 `FixedUpdate` 里驱动段内移动与受阻处理,新增脱困与既有职责同构。
**机制**`FixedUpdate` 开头判定——**着地** 且 **未映射到烘焙图** → 进入脱困,朝"有地的一侧"
以恢复速度水平移动(速度驱动,不经寻路),并跳过本帧的段推进/受阻处理;一旦重新映射即自动退出。
```csharp
// EnemyNavAgent 新增
[Header("脱图脱困(中心离开烘焙段时自动走回可行走地面)")]
[Tooltip("脱困持续超过此时长仍未回到导航图,输出一次告警(0 = 不告警)。")]
[SerializeField] [Min(0f)] private float _offGraphWarnAfter = 2f;
public bool IsOnNavGraph => _navAgent != null && _navAgent.HasValidPosition;
```
**脱困移动使用敌人自身的移动,不引入独立速度参数。** 巡逻层的方向驱动移动路径为:
```
EnemyLocomotion(Pace) _enemy.MoveInDirection(dir)
→ PendingInput.MoveDir = dir(不写 MoveSpeed
→ EnemyMovement.ConsumeInputMoveSpeed == 0 → MoveHorizontal(dir)
→ vel.x = dir * _config.WalkSpeed ← 敌人自己 EnemyStatsSO 配的步行速度
```
脱困沿用同一条路径:写 `PendingInput.MoveDir = dir`**显式置 `MoveSpeed = 0f`**`WantStop = false`
(等价于 `EnemyBase.MoveInDirection(dir)`),从而走 `MoveHorizontal` → 敌人自身 `WalkSpeed`
> ⚠ `MoveSpeed` 是**持久字段**(不逐帧清零),而 `EnemyNavAgent` 的段内移动会把它写成导航速度。
> 故脱困必须**显式清零** `MoveSpeed`,否则会沿用上一次的导航速度而非敌人自身步速。
附带好处:`MoveHorizontal` 自带 `WouldBlockAhead` 夹紧(墙 + 悬崖),
脱困走回平台时天然不会从另一侧冲出去。
`FixedUpdate` 流程(在现有逻辑之前),**判定顺序即为排除顺序**:
1. **`_navAgent.IsOnLink` → 立即跳过脱困**。⚠ 关键:PB2d 在穿越连接段时会主动把映射置为无效
`NavAgent.UpdateMappedPosition()` 开头 `if (IsOnLink) { currentMappedPosition = Invalid; return; }`),
若不排除,**每次跳跃/下落 NavLink 都会误触发脱困**,把正常的跨沟行为打断。
2. `mapped = _navAgent.HasValidPosition`;映射正常 → 清脱困计时,走原有逻辑。
3. 未映射且 `!_enemyMovement.IsGrounded` → 处于空中(下落中),不干预(落地后自会重判)。
4. 未映射且着地 → `dir = _enemyMovement.GroundSideDir()`
- `dir != 0` → 写入 `PendingInput.MoveDir = dir; MoveSpeed = 0f; WantStop = false;`(=敌人自身步速),
**return**(跳过段推进/受阻处理)。
- `dir == 0` **且两侧脚下都无地** → 异常几何 → 按 `_offGraphWarnAfter` 限频告警,暴露根因,不臆造恢复。
- `dir == 0` **但两侧都有地**(中心下方恰为窄缝等罕见几何)→ 不告警、不干预,交由物理与后续帧自然解决。
### 3.2 `IPathAgent` 增加脱图查询
```csharp
/// <summary>当前是否映射在导航图上。false = 脱图(无法寻路,正在自动脱困)。</summary>
bool IsOnNavGraph { get; }
```
- `EnemyNavAgent``_navAgent.HasValidPosition`
- `FlyingDirectNavigator` → 恒 `true`(飞行不依赖烘焙图,不受本机制约束)
- `NullPathAgent` → 恒 `true`(无导航单位不受本机制约束,避免触发无谓脱困)
### 3.3 `EnemyMovement` 增加"哪边有地"查询
身体几何取自唯一权威 `EnemyBase.Body`(见身体权威 spec),探测参数归移动层。
```csharp
/// <summary>
/// 返回"回到地面"的水平方向:+1 右 / -1 左 / 0 两侧脚下都无地(或都有地,无需脱困)。
/// 在身体左右前缘脚下各探一次地,用于脱图脱困时判断该往哪边走。
/// </summary>
public int GroundSideDir()
```
实现:在 `Body.Bounds``min.x` / `max.x``min.y` 处各向下 `_groundCheckDist``_groundMask`
右侧有地而左侧无 → `+1`;左侧有地而右侧无 → `-1`;两侧相同(都有/都无)→ `0`
### 3.4 `EnemyLocomotion` 配合改动(表达正确 + 不再吞失败)
| 位置 | 现状 | 改后 |
|---|---|---|
| `Update()` 步态选择 | Wander 停顿期强制 Idle | 脱图期间**不允许**切 Idle(脱困在移动,必须播 Move) |
| `TickWander()` | 未映射时把"挑点失败"当成"已到达 → 停顿" | 脱图期间直接 return(脱困由 Nav 层驱动),不进停顿记账 |
| `TickWander()` | `WalkToRandomOnSegment()` 返回值丢弃 | **在图上却挑点失败**=真异常 → 限频告警(不再静默) |
| `TickPatrol()` Waypoints | 脱图时每帧重发 `MoveTo`(静默失败) | 脱图期间跳过重发(脱困优先),回图后自然继续 |
| `TickPatrol()` Pace | `blocked && !_paceBlockedPrev` | `blocked && (!_paceBlockedPrev || 反方向可走)` —— 见 §3.5 |
### 3.5 RC-2 修复:Pace 翻向
```csharp
bool oppositeFree = mv != null && !mv.WouldBlockAhead(-_paceDir);
if (blocked && (!_paceBlockedPrev || oppositeFree)) { _paceDir = -_paceDir; ... }
```
- 顶着堵侧、另一侧通畅 → 掉头(修复 RC-2);掉头后新方向可走 → `blocked` 转 false → 稳定,不抖。
- 两侧都堵(窄台)→ 只有边沿那一次翻向,之后保持 → 保留原意图。
## 4. 边界与错误处理
| 情形 | 行为 |
|---|---|
| 脱图但处于空中(下落中) | 不干预,落地后重判(避免干扰自由落体与 NavLink 跳/落) |
| 正在穿越 NavLink`IsOnLink`) | PB2d 本身即置映射无效,**必须最先排除**,否则每次跳/落都被脱困打断(见 §3.1 第 1 步) |
| 两侧脚下都无地 | 限频告警(含坐标),不臆造恢复——暴露关卡/物理异常 |
| 两侧脚下都有地 | 不告警、不干预(罕见几何,交由物理与后续帧自然解决) |
| `EnemyStatsSO.WalkSpeed` 配成 0 | 敌人无法脱困(也本就无法巡逻)→ 由 `_offGraphWarnAfter` 告警暴露,不做隐式兜底 |
| 脱困期间需要先转身 | `MoveHorizontal``if (_isTurning) return;` 会让转身动画期间暂不位移,转身结束后自然继续——符合预期,无需特殊处理 |
| 飞行敌人 / 无导航敌人 | `IsOnNavGraph` 恒 true,本机制不介入 |
| 脱困期间玩家进入追逐区 | AI 照常切追击态;追击同样经 Nav,脱困先把敌人带回图上再寻路 |
## 5. 测试
**EditMode 单测**(纯逻辑,沿用 `WeightedPick`/`EnemyBody` 的无场景依赖范式):
- `GroundSideDir` 的判定表:右有左无 → +1;左有右无 → -1;两侧都有 → 0;两侧都无 → 0。
(抽为 static 纯函数 `GroundSide(bool leftHasGround, bool rightHasGround)` 以便无场景单测。)
- Pace 翻向判定:抽为 static 纯函数
`ShouldFlipPace(bool blocked, bool blockedPrev, bool oppositeFree)`,覆盖四类组合
(首次受阻翻向 / 持续受阻且反向通畅→翻 / 持续受阻且两侧都堵→不翻 / 未受阻→不翻)。
**回归**:现有 EditMode 全套(当前 191 条)必须全绿。
**PlayMode 复现验证**(已建立可编程复现:把敌人置于平台左缘外 center=-22.30
`HasValidPosition` 即为 false):
- 三种策略分别验证:脱困启动 → 走回平台 → `HasValidPosition` 恢复 true → 巡逻正常继续;
- Wander 脱困期间动画为 **Move**(不再是 Idle);
- Pace:从"顶着崖外"状态能掉头走回(RC-2);
- 对照:正常平台上的巡逻不受影响(脱困不介入)。
## 6. 涉及文件
- 改:`Assets/_Game/Scripts/Enemies/IPathAgent.cs`+`IsOnNavGraph`,含 `NullPathAgent` 实现)
- 改:`Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs`(脱困主体 + `IsOnNavGraph`
- 改:`Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs``IsOnNavGraph => true`
- 改:`Assets/_Game/Scripts/Enemies/EnemyMovement.cs`+`GroundSideDir` 与其 static 纯函数)
- 改:`Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs`(步态/Wander/Waypoints/Pace 四处)
- 新:`Assets/Tests/EditMode/Enemies/NavRecoveryLogicTests.cs`(两组纯函数单测)