feat(enemy): 目标点吸附统一处理导航宽度,Waypoint 不再卡崖边

将"角色导航宽度"收口到导航执行层单一入口,意图层(Locomotion/AI)只递原始目标、不再感知身体尺寸:

- NavAgent 新增 TryGetStandablePointNear:把世界点映射到最近可行走段并按 endMargin 内缩两端(与 SetRandomDestinationOnCurrentSegment 同源)
- EnemyNavAgent 新增 ResolveStandablePoint,以 EnemyMovement.EdgeSafeMargin(半宽+落崖余量)为唯一宽度真源;加 _goalMapSearchRadius
- IPathAgent 暴露 ResolveStandablePoint;FlyingDirectNavigator 原样返回(飞行无崖沿约束),NullPathAgent 空实现
- EnemyLocomotion Waypoint 分支改用吸附后的可达点做移动与到达判定,修复路点摆在空中/崖外时永不到达而卡死;吸附失败显式报错暴露误配,不静默兜底

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 15:52:18 +08:00
co-authored by Claude Opus 4.8
parent 7f7d237915
commit 132cd24908
5 changed files with 79 additions and 2 deletions
@@ -38,6 +38,8 @@ namespace BaseGames.Enemies
private int _wpIndex = -1;
private int _wpDir = 1;
private bool _warnedNoWaypoints;
// 当前路点吸附后的"站得住"目标(宽度由 Nav 层解析,本层不感知身体尺寸)
private Vector2 _wpResolvedGoal;
// Wander 到点停顿状态
private bool _wanderPausing;
private float _wanderPauseTimer;
@@ -193,16 +195,34 @@ namespace BaseGames.Enemies
}
// 仅首次或"已到达当前路点"时推进并寻路——不依赖 IsMoving
// PathBerserker2d 寻路跨帧异步,IsMoving 在寻路计算期间为 false,会误触发每帧推进)。
// 到达判定对"吸附后的可达点"做,而非原始路点:原始路点可能摆在空中/崖外,
// 与之的距离永远进不了到达半径 → 卡死;吸附点是身体站得住的可达点。
if (_wpIndex < 0 ||
Vector2.Distance(_enemy.transform.position, _waypoints[_wpIndex].position) <= _waypointArriveRadius)
Vector2.Distance(_enemy.transform.position, _wpResolvedGoal) <= _waypointArriveRadius)
{
AdvanceWaypoint();
_enemy.MoveTo(_waypoints[_wpIndex].position);
SetWaypointGoal();
}
break;
}
}
/// <summary>
/// 解析当前路点为"身体站得住的可达点"并出发。宽度处理下沉给 Nav 层(<see cref="IPathAgent.ResolveStandablePoint"/>),
/// 本层只递原始路点坐标、不感知身体尺寸。吸附失败=路点摆得离导航面太远,显式报错暴露根因,不静默兜底。
/// </summary>
private void SetWaypointGoal()
{
Vector2 raw = _waypoints[_wpIndex].position;
if (_enemy.Nav == null || !_enemy.Nav.ResolveStandablePoint(raw, out _wpResolvedGoal))
{
_wpResolvedGoal = raw;
Debug.LogWarning($"[EnemyLocomotion] 路点 '{_waypoints[_wpIndex].name}'(index {_wpIndex}) 无法吸附到任何可行走导航段" +
"(超出搜索半径)。请检查该路点是否摆放在贴近地面/平台的可行走处。", this);
}
_enemy.MoveTo(_wpResolvedGoal);
}
private void AdvanceWaypoint()
{
if (_pingPong)
@@ -35,6 +35,10 @@ namespace BaseGames.Enemies.Navigation
[Tooltip("两次重算最小间隔(s):留出跨帧路径计算完成的时间")]
[SerializeField] private float _repathMinInterval = 0.25f;
[Header("目标点吸附")]
[Tooltip("把巡逻/移动目标映射到最近可行走段时允许的最大偏离(m);超出视为无效目标(路点摆得离导航面太远)")]
[SerializeField] private float _goalMapSearchRadius = 2f;
// 重寻路防抖状态
private Vector2 _lastPathGoal;
private bool _hasPathGoal;
@@ -176,6 +180,16 @@ namespace BaseGames.Enemies.Navigation
=> _navAgent?.SetRandomDestinationOnCurrentSegment(
_enemyMovement != null ? _enemyMovement.EdgeSafeMargin : 0f) ?? false;
// 目标点吸附:把任意世界目标映射到最近可行走段、并按身体宽度(EdgeSafeMargin)从段两端内缩,
// 得到"身体真正站得住"的点。身体宽度是本执行层独占的知识——意图层(AI/Locomotion)只递原始目标。
// 映射不到任何段(目标离导航面太远)时返回 falsestandable 回落为原始 target。
public bool ResolveStandablePoint(Vector2 target, out Vector2 standable)
{
if (_navAgent == null) { standable = target; return false; }
float margin = _enemyMovement != null ? _enemyMovement.EdgeSafeMargin : 0f;
return _navAgent.TryGetStandablePointNear(target, _goalMapSearchRadius, margin, out standable);
}
public bool IsNearEdge()
{
var origin = (Vector2)transform.position;
@@ -123,6 +123,13 @@ namespace BaseGames.Enemies.Navigation
// 天然满足"就近游走"语义,直接复用。
public bool WalkToRandomOnSegment() => WalkToRandom();
// 飞行单位直飞、无地面/崖沿/身体宽度约束——任意目标本就"站得住",原样返回即可。
public bool ResolveStandablePoint(Vector2 target, out Vector2 standable)
{
standable = target;
return true;
}
// ── 内部移动逻辑 ───────────────────────────────────────────────
private void UpdateChase()
{