refactor(enemy): 三处卡死检测收敛为 StallDetector;修复 Pace 持续受阻时无法掉头的死锁

This commit is contained in:
2026-07-28 11:04:18 +08:00
parent 5cfacf199a
commit c929b5efb7
3 changed files with 25 additions and 54 deletions
@@ -31,9 +31,8 @@ namespace BaseGames.Enemies.Abilities
{
private RushAbilitySO _cfg;
// 卡死检测:窗口时长与窗口内最小净位移(撞上未纳入墙层的碰撞体时兜底结束冲锋)
private const float StuckWindowSeconds = 0.15f;
private const float StuckMinProgress = 0.05f;
// 推进停滞检测(撞上未纳入墙层的碰撞体时兜底结束冲锋)
private StallDetector _stall = new StallDetector(0.15f, 0.05f);
// 落位收敛的最长额外推进时长(秒):用于脱离"中心悬在崖外"的非法收尾姿态
private const float SettleMaxSeconds = 0.5f;
@@ -101,8 +100,7 @@ namespace BaseGames.Enemies.Abilities
{
var body = _enemy.Body;
float elapsed = 0f;
float winX = _enemy.transform.position.x;
float winT = 0f;
_stall.Reset(_enemy.transform.position.x);
while (true)
{
@@ -120,16 +118,8 @@ namespace BaseGames.Enemies.Abilities
float dt = Time.deltaTime;
elapsed += dt;
winT += dt;
// 兜底:撞上未纳入墙层的碰撞体(玩家/边界墙)导致原地不动 → 结束本次冲锋。
// 用窗口净位移判定,避免"贴住微抖"每帧重置。
if (winT >= StuckWindowSeconds)
{
if (Mathf.Abs(_enemy.transform.position.x - winX) < StuckMinProgress) break;
winX = _enemy.transform.position.x;
winT = 0f;
}
// 兜底:撞上未纳入墙层的碰撞体(玩家/边界墙)导致原地不动 → 结束本次冲锋
if (_stall.Tick(_enemy.transform.position.x, dt)) break;
}
}
@@ -33,8 +33,8 @@ namespace BaseGames.Enemies
private bool _gaitInit;
private float _paceDir = 1f;
private bool _paceBlockedPrev;
private float _paceWindowStartX;
private int _paceWindowFrames;
// Pace 推进停滞检测(原为 12 帧计数,@60fps ≈ 0.2s,改用统一原语并保持观感)
private StallDetector _paceStall = new StallDetector(0.2f, 0.05f);
private int _wpIndex = -1;
private int _wpDir = 1;
private bool _warnedNoWaypoints;
@@ -179,22 +179,17 @@ namespace BaseGames.Enemies
// 用与移动层夹紧同一判定(WouldBlockAhead,即时按请求方向+碰撞体前缘),
// 保证"夹停在地形边缘"与"掉头"一致,不会夹停却不翻向。
bool blocked = mv != null && mv.WouldBlockAhead(_paceDir);
// 兜底卡死检测(窗口净位移):命令前进但一段时间净位移过小(撞上未挂检测层的墙/
// 卡角,含贴墙微抖)也视为受阻,使巡逻对场景配置鲁棒。用净位移而非逐帧差,
// 避免"贴墙推进-被弹回"的微抖每帧重置计数。
float px = _enemy.transform.position.x;
if (++_paceWindowFrames >= 12)
{
if (Mathf.Abs(px - _paceWindowStartX) < 0.05f) blocked = true;
_paceWindowStartX = px;
_paceWindowFrames = 0;
}
// 边沿触发:仅"障碍首次出现"翻一次;翻后按新方向即时判定,天然消抖,
// 且两侧都堵(窄台)时翻一次即停住、不来回抖。
if (blocked && !_paceBlockedPrev)
// 兜底卡死检测:命令前进但窗口内净位移过小(撞上未挂检测层的墙/卡角,含贴墙微抖)也视为受阻。
if (_paceStall.Tick(_enemy.transform.position.x, Time.deltaTime)) blocked = true;
// 翻向:首次受阻翻一次(边沿触发,天然消抖);或"持续受阻但反方向畅通"也翻——
// 后者修复"顶着堵侧却因边沿触发失效而永远转不回来"的死锁。
// 两侧都堵(窄台)时只有边沿那一次翻向,之后保持,不来回抖。
bool oppositeFree = mv != null && !mv.WouldBlockAhead(-_paceDir);
if (blocked && (!_paceBlockedPrev || oppositeFree))
{
_paceDir = -_paceDir;
_paceWindowStartX = px; _paceWindowFrames = 0; // 翻向后重开窗口
_paceStall.Reset(_enemy.transform.position.x); // 翻向后重开窗口
}
_paceBlockedPrev = blocked;
_enemy.MoveInDirection(_paceDir);
@@ -91,9 +91,7 @@ namespace BaseGames.Enemies.Navigation
private bool _redirectedToClosest; // 本轮已改道到最近点,避免每帧重复下发
private bool _obstructionSignal; // 受阻信号(寻路失败/卡死),延后到 FixedUpdate 统一处理
// 卡死检测窗口
private float _stuckWinTimer;
private float _stuckWinStartDist;
private bool _stuckWinInit;
private StallDetector _segStall;
// 连接段状态缓存
private NavLinkType _currentLinkType = NavLinkType.None;
@@ -121,6 +119,8 @@ namespace BaseGames.Enemies.Navigation
_navAgent.OnSegmentTraversal += HandleSegmentTraversal;
_navAgent.OnFailedToFindPath += HandlePathFailed;
_navAgent.OnReachedGoal += HandleGoalReached;
_segStall = new StallDetector(_stuckWindowSeconds, _stuckMinProgress);
}
private void OnDestroy()
@@ -327,30 +327,16 @@ namespace BaseGames.Enemies.Navigation
_wasNavOnSegment = onSegment;
}
// 卡死检测:段上移动时,滑动窗口内朝子目标推进不足阈值 → 判受阻(覆盖"障碍未进图"的物理空推)
// 卡死检测:段上移动时,窗口内净水平位移不足 → 判受阻覆盖"障碍未进图"的物理空推
// 原实现比较"到子目标的距离缩小量",现统一为净水平位移:不依赖子目标,且绕行时不会误判为卡死。
private void TickStuckDetection(bool onSegment)
{
if (!onSegment) { _stuckWinInit = false; return; }
float dist = Mathf.Abs(_navAgent.PathSubGoal.x - transform.position.x);
if (!_stuckWinInit)
{
_stuckWinInit = true;
_stuckWinTimer = 0f;
_stuckWinStartDist = dist;
return;
}
_stuckWinTimer += Time.fixedDeltaTime;
if (_stuckWinTimer >= _stuckWindowSeconds)
{
if (_stuckWinStartDist - dist < _stuckMinProgress)
_obstructionSignal = true; // 窗口内几乎没靠近子目标 → 卡死
_stuckWinTimer = 0f;
_stuckWinStartDist = dist;
}
if (!onSegment) { _segStall.Reset(transform.position.x); return; }
if (_segStall.Tick(transform.position.x, Time.fixedDeltaTime))
_obstructionSignal = true; // 窗口内几乎没推进 → 卡死
}
private void ResetStuckWindow() => _stuckWinInit = false;
private void ResetStuckWindow() => _segStall.Reset(transform.position.x);
// 统一受阻处理:尽力改道到最近可达点(走到障碍前),拿不到更近点则停下,交由意图层结算。
private void ProcessObstruction()