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

12 KiB
Raw Blame History

导航脱图统一脱困(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 统一脱困放在 EnemyNavAgentPB2d 唯一集成点)

理由EnemyNavAgent 是项目里唯一封装 PB2d NavAgent 的组件,所有寻路调用 RequestMoveTo / WalkToRandomOnSegment / ResolveStandablePoint / CanReach)都经它。 把脱困放在这里,则所有现有与将来的寻路消费者自动获得脱困能力,消费端无需各写一套。 它已经在 FixedUpdate 里驱动段内移动与受阻处理,新增脱困与既有职责同构。

机制FixedUpdate 开头判定——着地未映射到烘焙图 → 进入脱困,朝"有地的一侧" 以恢复速度水平移动(速度驱动,不经寻路),并跳过本帧的段推进/受阻处理;一旦重新映射即自动退出。

// 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 = 0fWantStop = 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 增加脱图查询

/// <summary>当前是否映射在导航图上。false = 脱图(无法寻路,正在自动脱困)。</summary>
bool IsOnNavGraph { get; }
  • EnemyNavAgent_navAgent.HasValidPosition
  • FlyingDirectNavigator → 恒 true(飞行不依赖烘焙图,不受本机制约束)
  • NullPathAgent → 恒 true(无导航单位不受本机制约束,避免触发无谓脱困)

3.3 EnemyMovement 增加"哪边有地"查询

身体几何取自唯一权威 EnemyBase.Body(见身体权威 spec),探测参数归移动层。

/// <summary>
/// 返回"回到地面"的水平方向:+1 右 / -1 左 / 0 两侧脚下都无地(或都有地,无需脱困)。
/// 在身体左右前缘脚下各探一次地,用于脱图脱困时判断该往哪边走。
/// </summary>
public int GroundSideDir()

实现:在 Body.Boundsmin.x / max.xmin.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 RC-2 修复:Pace 翻向

bool oppositeFree = mv != null && !mv.WouldBlockAhead(-_paceDir);
if (blocked && (!_paceBlockedPrev || oppositeFree)) { _paceDir = -_paceDir; ... }
  • 顶着堵侧、另一侧通畅 → 掉头(修复 RC-2);掉头后新方向可走 → blocked 转 false → 稳定,不抖。
  • 两侧都堵(窄台)→ 只有边沿那一次翻向,之后保持 → 保留原意图。

4. 边界与错误处理

情形 行为
脱图但处于空中(下落中) 不干预,落地后重判(避免干扰自由落体与 NavLink 跳/落)
正在穿越 NavLinkIsOnLink PB2d 本身即置映射无效,必须最先排除,否则每次跳/落都被脱困打断(见 §3.1 第 1 步)
两侧脚下都无地 限频告警(含坐标),不臆造恢复——暴露关卡/物理异常
两侧脚下都有地 不告警、不干预(罕见几何,交由物理与后续帧自然解决)
EnemyStatsSO.WalkSpeed 配成 0 敌人无法脱困(也本就无法巡逻)→ 由 _offGraphWarnAfter 告警暴露,不做隐式兜底
脱困期间需要先转身 MoveHorizontalif (_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.csIsOnNavGraph => 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(两组纯函数单测)