From e7f1633877ab3502a5414ba72356802799d0399f Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 27 Jul 2026 16:31:32 +0800 Subject: [PATCH] =?UTF-8?q?docs(enemy):=20=E5=AF=BC=E8=88=AA=E8=84=B1?= =?UTF-8?q?=E5=9B=BE=E7=BB=9F=E4=B8=80=E8=84=B1=E5=9B=B0=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?(=E5=90=AB=20Wander/Waypoints=20=E9=9D=99=E9=BB=98=E5=90=9E?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8E=20Pace=20=E8=BE=B9=E6=B2=BF?= =?UTF-8?q?=E7=BF=BB=E5=90=91=E4=B8=A4=E4=B8=AA=E6=A0=B9=E5=9B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-07-27-nav-offgraph-recovery-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 Docs_Dev/superpowers/specs/2026-07-27-nav-offgraph-recovery-design.md diff --git a/Docs_Dev/superpowers/specs/2026-07-27-nav-offgraph-recovery-design.md b/Docs_Dev/superpowers/specs/2026-07-27-nav-offgraph-recovery-design.md new file mode 100644 index 00000000..7eec55fb --- /dev/null +++ b/Docs_Dev/superpowers/specs/2026-07-27-nav-offgraph-recovery-design.md @@ -0,0 +1,170 @@ +# 导航脱图统一脱困(Off-Graph Recovery)设计 + +> 日期: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("脱困时的水平移动速度(m/s)。脱图期间无法寻路,只能速度驱动走回地面。")] +[SerializeField] [Min(0f)] private float _offGraphRecoverySpeed = 2f; +[Tooltip("脱困持续超过此时长仍未回到导航图,输出一次告警(0 = 不告警)。")] +[SerializeField] [Min(0f)] private float _offGraphWarnAfter = 2f; + +public bool IsOnNavGraph => _navAgent != null && _navAgent.HasValidPosition; +``` + +`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 = _offGraphRecoverySpeed;`,**return**(跳过段推进/受阻处理)。 + - `dir == 0` **且两侧脚下都无地** → 异常几何 → 按 `_offGraphWarnAfter` 限频告警,暴露根因,不臆造恢复。 + - `dir == 0` **但两侧都有地**(中心下方恰为窄缝等罕见几何)→ 不告警、不干预,交由物理与后续帧自然解决。 + +### 3.2 `IPathAgent` 增加脱图查询 + +```csharp +/// 当前是否映射在导航图上。false = 脱图(无法寻路,正在自动脱困)。 +bool IsOnNavGraph { get; } +``` +- `EnemyNavAgent` → `_navAgent.HasValidPosition` +- `FlyingDirectNavigator` → 恒 `true`(飞行不依赖烘焙图,不受本机制约束) +- `NullPathAgent` → 恒 `true`(无导航单位不受本机制约束,避免触发无谓脱困) + +### 3.3 `EnemyMovement` 增加"哪边有地"查询 + +身体几何取自唯一权威 `EnemyBase.Body`(见身体权威 spec),探测参数归移动层。 + +```csharp +/// +/// 返回"回到地面"的水平方向:+1 右 / -1 左 / 0 两侧脚下都无地(或都有地,无需脱困)。 +/// 在身体左右前缘脚下各探一次地,用于脱图脱困时判断该往哪边走。 +/// +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 步) | +| 两侧脚下都无地 | 限频告警(含坐标),不臆造恢复——暴露关卡/物理异常 | +| 两侧脚下都有地 | 不告警、不干预(罕见几何,交由物理与后续帧自然解决) | +| 恢复速度配成 0 | 敌人不动 → 由 `_offGraphWarnAfter` 告警暴露(不做隐式兜底) | +| 飞行敌人 / 无导航敌人 | `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`(两组纯函数单测)