diff --git a/Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs b/Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs
index fb5a725e..1a259062 100644
--- a/Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs
+++ b/Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs
@@ -620,6 +620,33 @@ namespace PathBerserker2d
return PathTo(goal);
}
+ // [项目新增] 目标点吸附:把任意世界点映射到最近的、agent 容身得下的可行走段上,再按 endMargin
+ // 从段两端内缩,得到"身体真正站得住"的世界点(避免落在角色宽度站不住的崖沿被移动层夹停)。
+ // 与 SetRandomDestinationOnCurrentSegment 同源:endMargin 传 agent 半宽 + 落崖检测余量。
+ // 段短于 2*endMargin → 退化到中点。searchRadius 内映射不到任何段(目标离导航面太远)时返回 false。
+ ///
+ /// Maps to the nearest walkable segment the agent fits on,
+ /// then insets by from BOTH segment ends so the returned point is
+ /// one a bodied agent can actually stand on (not clamped at a ledge). A segment shorter than
+ /// 2*endMargin degenerates to its midpoint. Returns false (and echoes worldTarget) if nothing
+ /// maps within .
+ ///
+ public bool TryGetStandablePointNear(Vector2 worldTarget, float searchRadius, float endMargin, out Vector2 standable)
+ {
+ if (!PBWorld.TryMapPoint(worldTarget, searchRadius, this, out var ptr))
+ {
+ standable = worldTarget;
+ return false;
+ }
+
+ var cluster = ptr.cluster;
+ float len = cluster.Length;
+ float m = Mathf.Clamp(endMargin, 0f, len * 0.5f); // 段太短 → 退化到中点
+ float t = Mathf.Clamp(ptr.t, m, len - m);
+ standable = cluster.owner.LocalToWorld.MultiplyPoint3x4(cluster.GetPositionAlongSegment(t));
+ return true;
+ }
+
///
/// If you implement link traversal yourself, call this to complete a link traversal.
diff --git a/Assets/_Game/Scripts/Enemies/IPathAgent.cs b/Assets/_Game/Scripts/Enemies/IPathAgent.cs
index ce6a3513..c4c804d6 100644
--- a/Assets/_Game/Scripts/Enemies/IPathAgent.cs
+++ b/Assets/_Game/Scripts/Enemies/IPathAgent.cs
@@ -69,6 +69,14 @@ namespace BaseGames.Enemies
///
bool WalkToRandomOnSegment();
+ ///
+ /// 目标点吸附:把任意世界 映射到最近可行走段、并按敌人身体宽度从段两端内缩,
+ /// 得到"身体真正站得住"的点 (避免落在角色宽度站不住的崖沿被移动层夹停)。
+ /// 身体宽度只属于导航执行层——意图层(AI/Locomotion)递原始目标即可,无需自行计算宽度。
+ /// target 离导航面太远(超出搜索半径)时返回 false,standable 回落为原始 target。
+ ///
+ bool ResolveStandablePoint(Vector2 target, out Vector2 standable);
+
// ── 连接段事件 ──────────────────────────────────────────────────
/// 开始穿越连接段时触发(传入连接段类型)。
event Action OnLinkStarted;
@@ -99,6 +107,7 @@ namespace BaseGames.Enemies
public bool CanReach(Vector2 _) => false;
public bool WalkToRandom() => false;
public bool WalkToRandomOnSegment() => false;
+ public bool ResolveStandablePoint(Vector2 target, out Vector2 standable) { standable = target; return false; }
public event Action OnLinkStarted { add { } remove { } }
public event Action OnLinkCompleted{ add { } remove { } }
public event Action OnNavPathFailed { add { } remove { } }
diff --git a/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs b/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
index 3e8444f8..55417163 100644
--- a/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
+++ b/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
@@ -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;
}
}
+ ///
+ /// 解析当前路点为"身体站得住的可达点"并出发。宽度处理下沉给 Nav 层(),
+ /// 本层只递原始路点坐标、不感知身体尺寸。吸附失败=路点摆得离导航面太远,显式报错暴露根因,不静默兜底。
+ ///
+ 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)
diff --git a/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs b/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs
index ba067a0d..6f9c947c 100644
--- a/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs
+++ b/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs
@@ -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)只递原始目标。
+ // 映射不到任何段(目标离导航面太远)时返回 false,standable 回落为原始 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;
diff --git a/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs
index 13a0e86b..7d214b6a 100644
--- a/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs
+++ b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs
@@ -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()
{