refactor(editor): 脚手架改用 GroundNavigator,移除寻路库引用与导航面放置工具

This commit is contained in:
2026-07-28 11:45:24 +08:00
parent 219fe46719
commit 4455062ee9
10 changed files with 14 additions and 695 deletions
@@ -24,7 +24,6 @@
"BaseGames.Feedback",
"BaseGames.Dialogue",
"BaseGames.Progression",
"PathBerserker2d",
"Unity.Cinemachine",
"Kybernetik.Animancer",
"BaseGames.Animation",
@@ -1,86 +0,0 @@
using System.Reflection;
using UnityEditor;
using UnityEngine;
using PathBerserker2d;
namespace BaseGames.Editor
{
/// <summary>
/// 快捷键:BaseGames → Tools → Bake All NavSurfacesCtrl+Shift+B
/// 烘焙当前场景中所有 PathBerserker2d NavSurface 的导航网格。
/// 等效于在每个 NavSurface Inspector 中逐一点击 "Bake"。
/// </summary>
public static class NavSurfaceBakeShortcut
{
// NavSurface.StartBakeJob() 和 NavSurface.BakeJob 均为 internal,通过反射访问。
private static readonly MethodInfo s_startBakeJobMethod =
typeof(NavSurface).GetMethod("StartBakeJob", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly PropertyInfo s_bakeJobProp =
typeof(NavSurface).GetProperty("BakeJob", BindingFlags.NonPublic | BindingFlags.Instance);
[MenuItem("BaseGames/Scene/Bake All NavSurfaces %#b", priority = 100)]
public static void BakeAll()
{
var surfaces = Object.FindObjectsByType<NavSurface>(FindObjectsSortMode.None);
if (surfaces.Length == 0)
{
Debug.Log("[NavSurfaceBake] 当前场景没有找到 NavSurface 组件。");
return;
}
int count = 0;
foreach (var surface in surfaces)
{
if (surface == null) continue;
s_startBakeJobMethod?.Invoke(surface, null);
EditorApplication.update -= MakeWatcher(surface);
EditorApplication.update += MakeWatcher(surface);
count++;
}
Debug.Log($"[NavSurfaceBake] 开始烘焙 {count} 个 NavSurface……");
}
[MenuItem("BaseGames/Scene/Bake All NavSurfaces %#b", validate = true)]
private static bool BakeAllValidate()
{
// 仅在非 Play Mode 时可用(NavSurface.Bake 仅支持编辑器模式)
return !Application.isPlaying;
}
// ── 每个 NavSurface 独立监听烘焙完成 ──────────────────────────────
private static EditorApplication.CallbackFunction MakeWatcher(NavSurface surface)
{
EditorApplication.CallbackFunction watcher = null;
watcher = () =>
{
if (surface == null)
{
EditorApplication.update -= watcher;
return;
}
var bakeJob = s_bakeJobProp?.GetValue(surface);
if (bakeJob == null)
{
EditorApplication.update -= watcher;
return;
}
bool isFinished = (bool)bakeJob.GetType()
.GetProperty("IsFinished")!.GetValue(bakeJob);
if (isFinished)
{
EditorApplication.update -= watcher;
EditorUtility.SetDirty(surface);
float totalTime = (float)bakeJob.GetType()
.GetProperty("TotalBakeTime")!.GetValue(bakeJob);
Debug.Log($"[NavSurfaceBake] ✓ {surface.name} 烘焙完成({totalTime} ms");
}
};
return watcher;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 291f3b33fb176b8469ebaaa8afa317ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -21,7 +21,6 @@ using BaseGames.Player;
using BaseGames.Player.States;
using BaseGames.Skills;
using BaseGames.World;
using PathBerserker2d;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
@@ -303,10 +302,7 @@ namespace BaseGames.Editor
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
NavAgent navAgent = GetOrAddComponent<NavAgent>(go);
AssignBool(navAgent, "allowCloseEnoughPath", true); // 追击近似目标(玩家常不在导航段上)寻路到最近可达点
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
var (hurtBox, bodyContact, contactHitBox) =
@@ -405,7 +401,7 @@ namespace BaseGames.Editor
SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report);
report.Add("填写 _bossId。");
report.Add("★ 挂载 BossSkillExecutor 并指定 BossSkillSO 列表(技能执行层)NavAgent 需手工添加。");
report.Add("★ 挂载 BossSkillExecutor 并指定 BossSkillSO 列表(技能执行层)。");
report.Add("★ AI(BrainGraph):先写一个 [AiDefinition(\"<id>\")] 的 AiScript 子类," +
"再挂 EnemyAiBrain 组件并把 _definitionId 设为该 id。");
report.Add("多阶段 Boss 可在此 GameObject 上继续 AddComponent 阶段切换控制器。");
@@ -448,10 +444,7 @@ namespace BaseGames.Editor
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
NavAgent navAgent = GetOrAddComponent<NavAgent>(go);
AssignBool(navAgent, "allowCloseEnoughPath", true); // 追击近似目标(玩家常不在导航段上)寻路到最近可达点
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
var (hurtBox, bodyContact, contactHitBox) =
@@ -545,9 +538,7 @@ namespace BaseGames.Editor
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
// HurtBox + ContactDamageZone(身体接触伤害常驻开启;HurtBox 悬挂期外关闭见下)
@@ -647,10 +638,7 @@ namespace BaseGames.Editor
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
NavAgent navAgent = GetOrAddComponent<NavAgent>(go);
AssignBool(navAgent, "allowCloseEnoughPath", true); // 追击近似目标(玩家常不在导航段上)寻路到最近可达点
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
var (hurtBox, bodyContact, contactHitBox) =
@@ -739,9 +727,7 @@ namespace BaseGames.Editor
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyFeedback feedback = GetOrAddComponent<EnemyFeedback>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
var (hurtBox, bodyContact, contactHitBox) =
@@ -870,9 +856,7 @@ namespace BaseGames.Editor
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
var (hurtBox, bodyContact, contactHitBox) =
@@ -982,10 +966,7 @@ namespace BaseGames.Editor
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
NavAgent navAgent = GetOrAddComponent<NavAgent>(go);
AssignBool(navAgent, "allowCloseEnoughPath", true); // 追击近似目标(玩家常不在导航段上)寻路到最近可达点
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
var (hurtBox, bodyContact, contactHitBox) =
@@ -1086,9 +1067,7 @@ namespace BaseGames.Editor
EnemyStats bossStats = GetOrAddComponent<EnemyStats>(go);
EnemyFeedback feedback = GetOrAddComponent<EnemyFeedback>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
BossSkillExecutor skillExec = GetOrAddComponent<BossSkillExecutor>(go);
ChaoFengFloatController floatCtrl = GetOrAddComponent<ChaoFengFloatController>(go);
ChaoFengKnockdownCounter knockdown = GetOrAddComponent<ChaoFengKnockdownCounter>(go);
@@ -2082,22 +2061,6 @@ namespace BaseGames.Editor
MarkDirtyAndLog("Tilemap Ground", gridGo, report);
}
[MenuItem("BaseGames/Scene/Place/Nav Surface", priority = 170)]
public static void PlaceNavSurface()
{
var report = new List<string>();
GameObject go = new GameObject("NavSurface");
Undo.RegisterCreatedObjectUndo(go, "Place Nav Surface");
go.transform.position = GetDropPosition();
GetOrAddComponent<NavSurface>(go);
report.Add("NavSurface 已添加。在 Inspector 中点击 Bake 生成导航网格。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Nav Surface", go, report);
}
[MenuItem("BaseGames/Scene/Place/Obstacle (Static)", priority = 190)]
public static void PlaceObstacle()
{
@@ -2185,14 +2148,14 @@ namespace BaseGames.Editor
col = box;
break;
}
AlignColliderBottomToPivot(col); // 底部对齐 y=0:原点=脚底,供 NavAgent 检测原点落在导航线上
AlignColliderBottomToPivot(col); // 底部对齐 y=0:原点=脚底
return col;
}
/// <summary>
/// 把碰撞体底部对齐到 transform 原点(y=0)——原点即脚底。
/// PathBerserker2d NavAgent 用 transform 原点(0,0)判定是否在导航段上,故所有创建物的碰撞体底部须在 y=0
/// 否则站到地面时原点悬在导航线上方,导致 HasValidPosition=False、寻路失效
/// 所有创建物的碰撞体底部对齐 y=0(原点=脚底),使地面探测与站位判定以脚底为基准
/// 否则站到地面时原点悬在地面上方,导致地面检测/落点判定出现偏差
/// </summary>
private static void AlignColliderBottomToPivot(Collider2D col)
{
@@ -15,7 +15,6 @@ using BaseGames.UI.Splash;
using BaseGames.World;
using BaseGames.World.Map;
using BaseGames.World.Streaming;
using PathBerserker2d;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
@@ -764,10 +763,6 @@ namespace BaseGames.Editor
groundRb.bodyType = RigidbodyType2D.Static;
GetOrAddComponent<CompositeCollider2D>(groundTileGo);
// NavSurface for PathBerserker2d
GameObject navGo = GetOrCreateChild(envGroup, "NavSurface").gameObject;
GetOrAddComponent<NavSurface>(navGo);
// ── [Transitions] ──────────────────────────────────────────────
GetOrCreateChild(root.transform, "[Transitions]");
@@ -813,7 +808,7 @@ namespace BaseGames.Editor
else
report.Add($"MapRoomDataSO 已存在({roomSoPath}),请确认其 RoomId / Exits 与场景保持同步。");
report.Add("绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算正确的 BoxCollider。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形。");
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
MarkDirtyAndLog("Game Room 脚手架", root, report);
-127
View File
@@ -1,127 +0,0 @@
using System;
using UnityEngine;
namespace BaseGames.Enemies
{
// ── 连接段类型(映射 PB2d LinkTypeName 字符串)───────────────────────
public enum NavLinkType
{
None,
Segment, // 普通地面/平台段
Jump,
Fall,
Corner,
Climb,
Elevator,
Teleport,
Custom
}
/// <summary>
/// 导航代理抽象接口(架构 07_EnemyModule §5)。
/// EnemyBase 和 BD Task 只依赖此接口,不依赖具体导航库。
/// 实现:EnemyNavAgentPathBerserker2d);测试用 NullPathAgent。
/// </summary>
public interface IPathAgent
{
// ── 核心移动 ────────────────────────────────────────────────────
/// <summary>请求移动到世界坐标 target。</summary>
void RequestMoveTo(Vector2 target);
/// <summary>立即停止导航(清除路径)。</summary>
void StopNavigation();
/// <summary>是否已到达目标(距离 ≤ stoppingDistance)。</summary>
bool IsAtDestination();
/// <summary>运行时覆盖移动速度。</summary>
void SetSpeed(float speed);
/// <summary>当前帧是否在移动(速度 > 0.01 且有有效路径)。</summary>
bool IsMoving { get; }
/// <summary>是否接近平台边缘(脚下或前方无地面时为 true)。</summary>
bool IsNearEdge();
// ── 连接段感知(NavLink 平台跳跃/爬梯/传送)─────────────────────
/// <summary>当前是否正在穿越连接段(跳跃/下落/爬梯等)。</summary>
bool IsOnLink { get; }
/// <summary>当前正在穿越的连接段类型;不在连接段时为 None。</summary>
NavLinkType CurrentLinkType { get; }
/// <summary>当前连接段的起始世界坐标;不在连接段时为 Vector2.zero。</summary>
Vector2 CurrentLinkStart { get; }
/// <summary>当前连接段的目标世界坐标;不在连接段时为 Vector2.zero。</summary>
Vector2 CurrentLinkEnd { get; }
/// <summary>检查目标是否可以从当前位置导航到达(同步轻量查询)。</summary>
bool CanReach(Vector2 target);
/// <summary>移动到随机可到达点(巡逻 fallback)。</summary>
bool WalkToRandom();
/// <summary>
/// 段内随机游走:随机点限制在 agent 当前所站的这一条导航段上,不跨越任何 NavLink(跳/落/拐角)。
/// 供 Wander 巡逻使用——地面单位只在当前平台段内来回,避免被崖边夹停或卡在过不去的 link 处。
/// 未映射到导航段(不在图上)时返回 false。
/// </summary>
bool WalkToRandomOnSegment();
/// <summary>
/// 目标点吸附:把任意世界 <paramref name="target"/> 映射到最近可行走段、并按敌人身体宽度从段两端内缩,
/// 得到"身体真正站得住"的点 <paramref name="standable"/>(避免落在角色宽度站不住的崖沿被移动层夹停)。
/// 身体宽度只属于导航执行层——意图层(AI/Locomotion)递原始目标即可,无需自行计算宽度。
/// target 离导航面太远(超出搜索半径)时返回 falsestandable 回落为原始 target。
/// </summary>
bool ResolveStandablePoint(Vector2 target, out Vector2 standable);
/// <summary>
/// 上次寻路移动是否因目标不可达/被挡而受阻(走到最近可达点或原地停下,而非到达原目标)。
/// 供意图层(Locomotion)判断本次移动应否视为结算/推进。对新目标 RequestMoveTo 后复位。
/// </summary>
bool LastMoveObstructed { get; }
/// <summary>取上次寻路失败时算出的最近可达世界点(尽力贴近目标的点)。无则返回 false。</summary>
bool TryGetClosestReachable(out Vector2 worldPos);
// ── 连接段事件 ──────────────────────────────────────────────────
/// <summary>开始穿越连接段时触发(传入连接段类型)。</summary>
event Action<NavLinkType> OnLinkStarted;
/// <summary>连接段穿越完成时触发。</summary>
event Action<NavLinkType> OnLinkCompleted;
/// <summary>路径寻路失败事件(目标不可达时触发)。</summary>
event Action OnNavPathFailed;
/// <summary>到达目标事件(替代轮询 IsAtDestination)。</summary>
event Action OnGoalReached;
}
// ── 无导航 / 测试用空实现 ─────────────────────────────────────────────
public sealed class NullPathAgent : IPathAgent
{
public void RequestMoveTo(Vector2 _) { }
public void StopNavigation() { }
public bool IsAtDestination() => true;
public void SetSpeed(float _) { }
public bool IsMoving => false;
public bool IsNearEdge() => false;
public bool IsOnLink => false;
public NavLinkType CurrentLinkType => NavLinkType.None;
public Vector2 CurrentLinkStart => Vector2.zero;
public Vector2 CurrentLinkEnd => Vector2.zero;
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 bool LastMoveObstructed => false;
public bool TryGetClosestReachable(out Vector2 worldPos) { worldPos = Vector2.zero; return false; }
public event Action<NavLinkType> OnLinkStarted { add { } remove { } }
public event Action<NavLinkType> OnLinkCompleted{ add { } remove { } }
public event Action OnNavPathFailed { add { } remove { } }
public event Action OnGoalReached { add { } remove { } }
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 99f648f6975de20469192afe7fcc4e0a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -9,8 +9,7 @@
"rootNamespace": "BaseGames.Enemies.Navigation",
"references": [
"BaseGames.Enemies",
"BaseGames.AI",
"PathBerserker2d"
"BaseGames.AI"
],
"autoReferenced": true,
"overrideReferences": false,
@@ -1,391 +0,0 @@
using UnityEngine;
using PathBerserker2d;
using BaseGames.Enemies;
using System;
using System.Collections.Generic;
namespace BaseGames.Enemies.Navigation
{
/// <summary>
/// PathBerserker2d 导航代理包装器(架构 07_EnemyModule §5 能力驱动版)。
///
/// 设计原则:
/// <list type="bullet">
/// <item>连接段穿越(跳跃/攀爬/传送等)委托给 <see cref="INavLinkHandler"/> 能力组件执行</item>
/// <item>能力自行驱动动画 + 物理,本组件只做"调度 → 等回调 → 通知 PB2d 继续"</item>
/// <item>无对应能力时自动回退到 TransformBasedMovement(兜底行为)</item>
/// <item>Awake 自动发现 GameObject 上的全部 INavLinkHandler,并禁用 TBM 对应 FeatureFlag</item>
/// </list>
/// </summary>
[RequireComponent(typeof(NavAgent))]
[RequireComponent(typeof(TransformBasedMovement))]
public class EnemyNavAgent : MonoBehaviour, IPathAgent
{
// ── 序列化 ──────────────────────────────────────────────────────
[Tooltip("边缘检测前向距离(m")]
[SerializeField] private float _edgeCheckFwdOffset = 0.3f;
[Tooltip("边缘检测向下射线长度(m")]
[SerializeField] private float _edgeCheckDownLen = 0.6f;
[Tooltip("边缘检测 LayerMask(留空 = 所有层)")]
[SerializeField] private LayerMask _groundMask = ~0;
[Header("重寻路防抖(PathBerserker2d 不可每帧 UpdatePath,否则路径永远算不完)")]
[Tooltip("目标位移阈值(m):目标移动超过此值才重算路径")]
[SerializeField] private float _repathTargetThreshold = 0.5f;
[Tooltip("两次重算最小间隔(s):留出跨帧路径计算完成的时间")]
[SerializeField] private float _repathMinInterval = 0.25f;
[Header("目标点吸附")]
[Tooltip("把巡逻/移动目标吸附到最近可行走段时,允许目标离最近段的最大真实距离(m)。语义同 PB2d 的 " +
"closestToSegmentMaxDistance——想多大就多大;只要目标在此距离内一定吸附得到。仅当目标离所有" +
"烘焙段都超过此值(真正脱离导航面)才判失败。")]
[SerializeField] private float _maxSnapDistance = 10f;
[Header("受阻检测(寻路移动无进展即判被挡)")]
[Tooltip("卡死检测窗口时长(s):段上移动时,此时长内朝子目标推进小于阈值即判受阻")]
[SerializeField] private float _stuckWindowSeconds = 0.5f;
[Tooltip("卡死推进阈值(m):窗口内朝子目标的距离缩小不足此值即判卡死")]
[SerializeField] private float _stuckMinProgress = 0.05f;
// 重寻路防抖状态
private Vector2 _lastPathGoal;
private bool _hasPathGoal;
private float _lastPathTime;
// ── IPathAgent 公开状态 ────────────────────────────────────────
public bool IsMoving => _navAgent != null && _navAgent.IsFollowingAPath;
public bool IsOnLink => _navAgent != null && _navAgent.IsOnLink;
public NavLinkType CurrentLinkType => _currentLinkType;
public Vector2 CurrentLinkStart => _currentLinkStart;
public Vector2 CurrentLinkEnd => _currentLinkEnd;
/// <summary>上次寻路移动是否因不可达/被挡而受阻(走到最近点或原地停,而非到达原目标)。新目标 RequestMoveTo 后复位。</summary>
public bool LastMoveObstructed => _lastMoveObstructed;
/// <summary>取上次寻路失败时算出的最近可达世界点。无则返回 false。</summary>
public bool TryGetClosestReachable(out Vector2 worldPos)
{
if (_navAgent != null) return _navAgent.TryGetClosestReachablePosition(out worldPos);
worldPos = Vector2.zero;
return false;
}
// ── IPathAgent 事件 ────────────────────────────────────────────
public event Action<NavLinkType> OnLinkStarted;
public event Action<NavLinkType> OnLinkCompleted;
public event Action OnNavPathFailed;
public event Action OnGoalReached;
// ── 私有 ────────────────────────────────────────────────────────
private NavAgent _navAgent;
private TransformBasedMovement _movement;
private EnemyMovement _enemyMovement;
// 能力 handler 注册表(NavLinkType → INavLinkHandler
private readonly Dictionary<NavLinkType, INavLinkHandler> _handlers
= new Dictionary<NavLinkType, INavLinkHandler>();
private INavLinkHandler _activeHandler;
// 受阻状态(供意图层结算)
private bool _lastMoveObstructed;
private bool _redirectedToClosest; // 本轮已改道到最近点,避免每帧重复下发
private bool _obstructionSignal; // 受阻信号(寻路失败/卡死),延后到 FixedUpdate 统一处理
// 卡死检测窗口
private StallDetector _segStall;
// 连接段状态缓存
private NavLinkType _currentLinkType = NavLinkType.None;
private bool _wasNavOnSegment;
private Vector2 _currentLinkStart;
private Vector2 _currentLinkEnd;
// ── 初始化 ─────────────────────────────────────────────────────
private void Awake()
{
_navAgent = GetComponent<NavAgent>();
_movement = GetComponent<TransformBasedMovement>();
_enemyMovement = GetComponent<EnemyMovement>();
// 物理原生移动:禁用 TBM 段移动——不让它直接写 transform.position(会与 Dynamic 刚体物理冲突)。
// 段上的水平移动改由 EnemyMovement 用 velocity 执行、刚体重力保持贴地(见 FixedUpdate)。
if (_movement != null)
_movement.enabledFeatures &= ~TransformBasedMovement.FeatureFlags.SegmentMovement;
// 自动发现 INavLinkHandler 组件并注册(包含子对象)
foreach (var handler in GetComponentsInChildren<INavLinkHandler>(true))
RegisterLinkHandler(handler);
_navAgent.OnStartLinkTraversal += HandleLinkStart;
_navAgent.OnSegmentTraversal += HandleSegmentTraversal;
_navAgent.OnFailedToFindPath += HandlePathFailed;
_navAgent.OnReachedGoal += HandleGoalReached;
_segStall = new StallDetector(_stuckWindowSeconds, _stuckMinProgress);
}
private void OnDestroy()
{
if (_navAgent == null) return;
_navAgent.OnStartLinkTraversal -= HandleLinkStart;
_navAgent.OnSegmentTraversal -= HandleSegmentTraversal;
_navAgent.OnFailedToFindPath -= HandlePathFailed;
_navAgent.OnReachedGoal -= HandleGoalReached;
}
// ── Handler 注册 ───────────────────────────────────────────────
/// <summary>
/// 注册能力为某连接段类型的处理器。
/// 注册后 TransformBasedMovement 中对应 FeatureFlag 将被禁用,由能力全权负责该类型穿越。
/// </summary>
public void RegisterLinkHandler(INavLinkHandler handler)
{
if (handler == null) return;
foreach (var type in handler.HandledLinkTypes)
{
_handlers[type] = handler;
// 禁用 TBM 对该类型的处理,完全交给能力
var flag = TypeToFeatureFlag(type);
if (flag != 0 && _movement != null)
_movement.enabledFeatures &= ~flag;
}
}
/// <summary>移除某处理器(例如能力被永久禁用时)。TBM FeatureFlag 自动恢复。</summary>
public void UnregisterLinkHandler(INavLinkHandler handler)
{
if (handler == null) return;
foreach (var type in handler.HandledLinkTypes)
{
if (_handlers.TryGetValue(type, out var registered) && ReferenceEquals(registered, handler))
{
_handlers.Remove(type);
// 恢复 TBM 对该类型的处理
var flag = TypeToFeatureFlag(type);
if (flag != 0 && _movement != null)
_movement.enabledFeatures |= flag;
}
}
}
// ── IPathAgent ─────────────────────────────────────────────────
public void RequestMoveTo(Vector2 target)
{
if (_navAgent == null) return;
// PathBerserker2d 明确警告不可每帧 UpdatePath(路径计算跨帧,每帧重启则永不完成、agent 永不移动)。
// 防抖:目标未明显移动 且 距上次重算不足间隔 → 跳过,让当前路径算完并跟随。
if (_hasPathGoal
&& Vector2.Distance(target, _lastPathGoal) < _repathTargetThreshold
&& Time.time - _lastPathTime < _repathMinInterval)
return;
_lastPathGoal = target;
_hasPathGoal = true;
_lastPathTime = Time.time;
_lastMoveObstructed = false; // 新目标 → 复位受阻态
_redirectedToClosest = false;
ResetStuckWindow();
_navAgent.UpdatePath(target);
}
public void StopNavigation()
{
_activeHandler?.AbortLinkTraversal();
_activeHandler = null;
_hasPathGoal = false; // 复位防抖,下次请求立即重新寻路
_navAgent?.Stop();
}
public bool IsAtDestination() => _navAgent == null || _navAgent.IsIdle;
public void SetSpeed(float speed)
{
if (_movement != null) _movement.movementSpeed = speed;
}
public bool CanReach(Vector2 target) => _navAgent?.CanReach(target) ?? false;
public bool WalkToRandom() => _navAgent?.SetRandomDestination() ?? false;
// 段内随机游走:按身体前缘安全余量内缩段两端,避免随机点落在角色宽度站不住的崖沿(WouldFallAhead 夹停)。
public bool WalkToRandomOnSegment()
=> _navAgent?.SetRandomDestinationOnCurrentSegment(
_enemyMovement != null ? _enemyMovement.EdgeSafeMargin : 0f) ?? false;
// 目标点吸附:把任意世界目标映射到最近可行走段、并按身体宽度(EdgeSafeMargin)从段两端内缩,
// 得到"身体真正站得住"的点。身体宽度是本执行层独占的知识——意图层(AI/Locomotion)只递原始目标。
// 用 PB2d 映射任意点到导航面的机制(_maxSnapDistance 内一定命中最近段);返回 false 仅表示目标真正脱离导航面。
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, _maxSnapDistance, margin, out standable);
}
public bool IsNearEdge()
{
var origin = (Vector2)transform.position;
var facing = transform.localScale.x >= 0f ? Vector2.right : Vector2.left;
return !Physics2D.Raycast(origin + facing * _edgeCheckFwdOffset,
Vector2.down, _edgeCheckDownLen, _groundMask);
}
// ── 底层 NavAgent 直接访问 ──────────────────────────────────────
/// <summary>原始 PB2d NavAgent,供需要 PB2d 高级功能的能力直接使用。</summary>
public NavAgent RawNavAgent => _navAgent;
// ── 连接段调度核心 ─────────────────────────────────────────────
private void HandleLinkStart(NavAgent agent)
{
var seg = agent.CurrentPathSegment;
_currentLinkType = ParseLinkType(seg?.link?.LinkTypeName);
_currentLinkStart = seg?.LinkStart ?? Vector2.zero;
_currentLinkEnd = seg?.LinkEnd ?? Vector2.zero;
OnLinkStarted?.Invoke(_currentLinkType);
if (_handlers.TryGetValue(_currentLinkType, out var handler))
{
if (handler.CanHandleLink(_currentLinkType, _currentLinkStart, _currentLinkEnd))
{
_activeHandler = handler;
handler.BeginLinkTraversal(_currentLinkType, _currentLinkStart, _currentLinkEnd,
onComplete: () =>
{
_activeHandler = null;
_navAgent?.CompleteLinkTraversal();
});
return;
}
// CanHandleLink 返回 false 且 TBM 已禁用 → NavAgent 会卡住,警告设计者
Debug.LogWarning($"[EnemyNavAgent] '{handler.GetType().Name}' reported CanHandleLink=false " +
$"for {_currentLinkType} (TBM disabled). NavAgent may stall. " +
$"Check link distance/height vs. ability parameters.", this);
}
// 无 handler → TBM 兜底(其 FeatureFlag 仍开启)
}
private void HandleSegmentTraversal(NavAgent agent)
{
if (_currentLinkType != NavLinkType.None && !agent.IsOnLink)
{
var finished = _currentLinkType;
_currentLinkType = NavLinkType.None;
_currentLinkStart = Vector2.zero;
_currentLinkEnd = Vector2.zero;
OnLinkCompleted?.Invoke(finished);
}
}
private void HandlePathFailed(NavAgent _)
{
_obstructionSignal = true; // 延后到 FixedUpdate 统一处理,避免在 NavAgent 失败回调内重入 UpdatePath
OnNavPathFailed?.Invoke();
}
private void HandleGoalReached(NavAgent _) => OnGoalReached?.Invoke();
// ── 物理原生移动桥 ─────────────────────────────────────────────
/// <summary>
/// 跟随路径段时:向 <see cref="EnemyMovement"/> 写入朝当前子目标的水平方向+速度(由 velocity+重力执行),
/// 并在到达子目标时推进导航到下一段(TBM 段移动已在 Awake 禁用,改由本组件推进)。
/// 不直接写 transform.position → 与 Dynamic 刚体物理不冲突,保留击退/击飞/落地等物理反馈。
/// </summary>
private void FixedUpdate()
{
if (_enemyMovement == null || _navAgent == null) return;
bool onSegment = _navAgent.IsMovingOnSegment;
_enemyMovement.NavDriving = onSegment; // 状态标记(供调试/上层判断"是否导航中"
TickStuckDetection(onSegment);
if (onSegment)
{
// 物理原生:朝子目标水平移动,由 EnemyMovement 施加 velocity、刚体重力保持贴地
float dx = _navAgent.PathSubGoal.x - transform.position.x;
_enemyMovement.PendingInput.MoveDir = Mathf.Abs(dx) > 0.05f ? Mathf.Sign(dx) : 0f;
_enemyMovement.PendingInput.MoveSpeed = _movement != null ? _movement.movementSpeed : 0f;
// 段推进:到达当前子目标 → 推进到下一段
if (_navAgent.HasReachedCurrentSubGoal(0.25f))
_navAgent.CompleteSegmentTraversal();
}
else if (_wasNavOnSegment && !_navAgent.IsFollowingAPath)
{
// 导航刚结束(到达目标 / 路径失败)→ 停下水平移动
_enemyMovement.PendingInput.WantStop = true;
}
// 受阻信号统一处理(寻路失败/卡死):此处不在 NavAgent 回调内,UpdatePath 安全
if (_obstructionSignal)
{
_obstructionSignal = false;
ProcessObstruction();
}
_wasNavOnSegment = onSegment;
}
// 卡死检测:段上移动时,窗口内净水平位移不足 → 判受阻(覆盖"障碍未进图"的物理空推)。
// 原实现比较"到子目标的距离缩小量",现统一为净水平位移:不依赖子目标,且绕行时不会误判为卡死。
private void TickStuckDetection(bool onSegment)
{
if (!onSegment) { _segStall.Reset(transform.position.x); return; }
if (_segStall.Tick(transform.position.x, Time.fixedDeltaTime))
_obstructionSignal = true; // 窗口内几乎没推进 → 卡死
}
private void ResetStuckWindow() => _segStall.Reset(transform.position.x);
// 统一受阻处理:尽力改道到最近可达点(走到障碍前),拿不到更近点则停下,交由意图层结算。
private void ProcessObstruction()
{
_lastMoveObstructed = true;
if (!_redirectedToClosest
&& _navAgent.TryGetClosestReachablePosition(out var closest)
&& Vector2.Distance(transform.position, closest) > _repathTargetThreshold)
{
_redirectedToClosest = true;
ResetStuckWindow();
// 同步防抖记账:改道等价于把当前寻路目标换成 closest,避免 _lastPathGoal/_lastPathTime
// 停留在原(受阻)目标,导致下一次对新目标的 RequestMoveTo 被防抖误吞。
_lastPathGoal = closest;
_lastPathTime = Time.time;
_navAgent.UpdatePath(closest); // 走向最近可达点
return;
}
// 无更近点 / 已在最近点附近(含"障碍未进图"无 closest 数据)→ 停下
_navAgent.Stop();
_enemyMovement.PendingInput.WantStop = true;
}
// ── 工具 ───────────────────────────────────────────────────────
private static NavLinkType ParseLinkType(string name) => name switch
{
"corner" => NavLinkType.Corner,
"jump" => NavLinkType.Jump,
"fall" => NavLinkType.Fall,
"teleport" => NavLinkType.Teleport,
"climb" => NavLinkType.Climb,
"elevator" => NavLinkType.Elevator,
null or "" => NavLinkType.Segment,
_ => NavLinkType.Custom
};
/// <summary>NavLinkType → TransformBasedMovement.FeatureFlags 映射。</summary>
private static TransformBasedMovement.FeatureFlags TypeToFeatureFlag(NavLinkType type) => type switch
{
NavLinkType.Jump => TransformBasedMovement.FeatureFlags.JumpLinks,
NavLinkType.Fall => TransformBasedMovement.FeatureFlags.FallLinks,
NavLinkType.Corner => TransformBasedMovement.FeatureFlags.CornerLinks,
NavLinkType.Climb => TransformBasedMovement.FeatureFlags.ClimbLinks,
NavLinkType.Elevator => TransformBasedMovement.FeatureFlags.ElevatorLinks,
NavLinkType.Teleport => TransformBasedMovement.FeatureFlags.TeleportLinks,
_ => (TransformBasedMovement.FeatureFlags)0
};
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 44871319d7318de40b9ac21757b69c78
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: