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() {