refactor(enemy): 消费者切换到 IEnemyNavigator(Wander/Waypoints 去导航图化,调试面板简化)

This commit is contained in:
2026-07-28 11:25:21 +08:00
parent 49627d357d
commit de3c9deff4
4 changed files with 45 additions and 158 deletions
@@ -3,34 +3,24 @@ using UnityEditor;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies;
using BaseGames.Enemies.Navigation;
namespace BaseGames.Editor
{
/// <summary>
/// <see cref="EnemyLocomotion"/> 自定义 Inspector。
/// PlayMode 下实时显示移动/巡逻状态,并对"原地不动"给出定位诊断:
/// 模式、策略、当前路点与吸附目标、到达距离、是否在导航图上、目标是否可达
/// 模式、策略、当前路点与目标、到达距离。
///
/// 性能:刷新走 EditorApplication.update 定频(~5Hz),与游戏帧率解耦;昂贵查询
/// CanReach 会同步阻塞寻路、_maxSnapDistance 读取)限频 ~2Hz 并缓存——避免每帧
/// 触发一次阻塞式寻路把帧数拖垮。
/// 性能:刷新走 EditorApplication.update 定频(~5Hz),与游戏帧率解耦
/// </summary>
[CustomEditor(typeof(EnemyLocomotion))]
public class EnemyLocomotionEditor : UnityEditor.Editor
{
private const double RepaintInterval = 0.2; // 面板刷新间隔(s),与游戏帧率解耦
private const double HeavyInterval = 0.5; // 昂贵查询(CanReach 等)重算间隔(s)
private double _nextRepaintTime;
private double _nextHeavyTime;
// 昂贵查询的缓存(仅每 HeavyInterval 刷新一次)
private bool _cachedCanReach;
private bool _cachedCanReachValid;
private float _cachedMaxSnap = -1f;
// 退出 Play 期间:Application.isPlaying 仍为 true 但 PBWorld 已销毁,此时调 CanReach 会 NRE。
// 退出 Play 期间:Application.isPlaying 仍为 true 但场景已销毁,此时读取运行时状态可能 NRE。
// 收到 ExitingPlayMode 即停止一切运行时查询。
private bool _exitingPlay;
// 本帧是否绘制运行时区块——在 Layout 事件锁定、Repaint 复用,保证两趟控件数一致(否则 IMGUI 报错)。
@@ -123,20 +113,11 @@ namespace BaseGames.Editor
var nav = enemy != null ? enemy.Nav : null;
if (nav != null)
{
sb.AppendLine($"Nav 类型: {nav.GetType().Name} Nav.IsMoving: {nav.IsMoving} IsOnLink: {nav.IsOnLink} LinkType: {nav.CurrentLinkType}");
if (nav is EnemyNavAgent ena && ena.RawNavAgent != null)
{
var raw = ena.RawNavAgent;
sb.AppendLine($"在导航图上(HasValidPosition): {raw.HasValidPosition} IsFollowingAPath: {raw.IsFollowingAPath}");
sb.AppendLine($"PB2d Agent Position: {raw.Position}");
}
}
sb.AppendLine($"Nav 类型: {nav.GetType().Name} IsMoving: {nav.IsMoving} HasArrived: {nav.HasArrived} IsBlocked: {nav.IsBlocked}");
else sb.AppendLine("Nav: null");
var mv = enemy != null ? enemy.Movement : null;
if (mv != null) sb.AppendLine($"EdgeSafeMargin(身体半宽+偏移): {mv.EdgeSafeMargin:F3}");
sb.AppendLine($"_maxSnapDistance: {ReadMaxSnap(nav)}");
if (enemy != null) sb.AppendLine($"EnemyPos: {enemy.transform.position}");
// ── Waypoints ──
@@ -148,27 +129,9 @@ namespace BaseGames.Editor
int idx = loco.DebugWaypointIndex;
sb.AppendLine($"当前索引: {idx} / {(cnt > 0 ? cnt - 1 : 0)} ArriveRadius: {loco.DebugArriveRadius:F2}");
Vector2 goal = loco.DebugResolvedGoal;
sb.AppendLine($"当前吸附目标(Resolved): {goal} ResolveOk: {loco.DebugResolveOk}");
sb.AppendLine($"当前目标点: {goal}");
if (enemy != null)
sb.AppendLine($"到目标距离: {Vector2.Distance(enemy.transform.position, goal):F2}" +
(nav != null ? $" CanReach: {nav.CanReach(goal)}" : ""));
if (wps != null)
{
sb.AppendLine("路点逐个吸附检测(点击时实时计算):");
for (int i = 0; i < wps.Length; i++)
{
if (wps[i] == null) { sb.AppendLine($" [{i}] <NULL 引用>"); continue; }
Vector2 rawp = wps[i].position;
string snapStr = "(需 Play 且有 Nav)";
if (nav != null)
{
bool ok = nav.ResolveStandablePoint(rawp, out var snapped);
snapStr = $"snap ok={ok} {snapped} |rawY-snapY|={Mathf.Abs(rawp.y - snapped.y):F2}";
}
sb.AppendLine($" [{i}] {wps[i].name} raw={rawp} -> {snapStr}");
}
}
sb.AppendLine($"到目标距离: {Vector2.Distance(enemy.transform.position, goal):F2}");
}
// ── 诊断 ──
@@ -178,30 +141,15 @@ namespace BaseGames.Editor
return sb.ToString();
}
private static string ReadMaxSnap(IPathAgent nav)
{
if (nav is EnemyNavAgent ena)
{
using var so = new SerializedObject(ena);
var msd = so.FindProperty("_maxSnapDistance");
if (msd != null) return msd.floatValue.ToString("F1");
}
return "(n/a)";
}
// 纯文本诊断(与面板 HelpBox 同一判定逻辑,供报告文末结论)
private static string Diagnose(EnemyLocomotion loco, EnemyBase enemy, IPathAgent nav)
private static string Diagnose(EnemyLocomotion loco, EnemyBase enemy, IEnemyNavigator nav)
{
if (!Application.isPlaying) return "编辑期:仅静态配置,进入 Play 后可判定运行时问题。";
if (loco.CurrentMode != LocomotionMode.Patrol)
return $"→ 不动主因:LocomotionMode={loco.CurrentMode}(非 Patrol)。AI 尚未进入巡逻态,TickPatrol 不执行。" +
(enemy?.Brain != null ? $" 当前 Brain 状态='{enemy.Brain.CurrentStateName}'——检查该状态是否/何时切到巡逻。" : "");
bool mapped = !(nav is EnemyNavAgent e && e.RawNavAgent != null) || ((EnemyNavAgent)nav).RawNavAgent.HasValidPosition;
if (!mapped) return "→ 敌人不在 NavGraph 上(HasValidPosition=false)RequestMoveTo 被忽略。检查贴地/是否在已 bake 的 NavSurface。";
if (loco.DebugStrategy == PatrolStrategy.Waypoints && !loco.DebugResolveOk)
return "→ 当前路点吸附失败:超出 _maxSnapDistance。调大或把路点摆近平台。";
if (nav != null && !nav.CanReach(loco.DebugResolvedGoal))
return "→ 目标不可达:与吸附目标不连通,或隔着不可穿越 NavLink。";
if (nav != null && !nav.IsMoving && !nav.HasArrived && !nav.IsBlocked)
return "→ 已请求移动但没有推进 —— 检查 EnemyMovement 速度/受阻/刚体约束、是否被其他状态覆盖输入。";
return "→ 巡逻链路各项正常(若仍不动,检查 EnemyMovement 速度/受阻/刚体约束)。";
}
@@ -210,11 +158,6 @@ namespace BaseGames.Editor
var loco = (EnemyLocomotion)target;
var enemy = loco.DebugEnemy;
// 是否到了重算昂贵查询的时刻(限频)
double now = EditorApplication.timeSinceStartup;
bool doHeavy = now >= _nextHeavyTime;
if (doHeavy) _nextHeavyTime = now + HeavyInterval;
EditorGUILayout.LabelField("运行时调试", EditorStyles.boldLabel);
// ── 模式 / 策略 ─────────────────────────────────────────────
@@ -233,25 +176,13 @@ namespace BaseGames.Editor
// ── 导航代理状态 ────────────────────────────────────────────
var nav = enemy.Nav;
bool agentMapped = true; // 非 PB2d 代理默认视为有效
bool navMoving = nav != null && nav.IsMoving;
if (nav is EnemyNavAgent ena && ena.RawNavAgent != null)
agentMapped = ena.RawNavAgent.HasValidPosition;
bool navMoving = nav != null && nav.IsMoving;
Row("在导航图上 (Mapped)", agentMapped ? "是" : "否 —— 敌人不在 NavGraph 上",
agentMapped ? Green : Red);
Row("Nav 类型", nav != null ? nav.GetType().Name : "null", White);
Row("Nav 正在寻路/移动", navMoving ? "是" : "否", navMoving ? Green : Gray);
// 身体宽度 / 吸附距离(_maxSnapDistance 运行期不变 → 只读一次缓存)
var mv = enemy.Movement;
if (mv != null) Row("EdgeSafeMargin(身体半宽+偏移)", mv.EdgeSafeMargin.ToString("F2"), White);
if (_cachedMaxSnap < 0f && nav is EnemyNavAgent ena2)
{
using var so = new SerializedObject(ena2);
var msd = so.FindProperty("_maxSnapDistance");
_cachedMaxSnap = msd != null ? msd.floatValue : 0f;
}
if (_cachedMaxSnap >= 0f) Row("_maxSnapDistance", _cachedMaxSnap.ToString("F1"), White);
// ── 路点巡逻细节 ────────────────────────────────────────────
if (loco.DebugStrategy == PatrolStrategy.Waypoints)
@@ -266,32 +197,15 @@ namespace BaseGames.Editor
if (wps != null && idx >= 0 && idx < cnt && wps[idx] != null)
{
Vector2 raw = wps[idx].position;
Vector2 goal = loco.DebugResolvedGoal;
Row("原始路点", $"{wps[idx].name} {raw}", White);
Row("吸附目标 (Resolved)", loco.DebugResolveOk ? goal.ToString() : $"{goal} (吸附失败,回落原始点)",
loco.DebugResolveOk ? Green : Red);
Row("当前目标点", goal.ToString(), White);
float dist = Vector2.Distance(enemy.transform.position, goal);
bool arrived = dist <= loco.DebugArriveRadius;
Row("到目标距离 / 到达半径", $"{dist:F2} / {loco.DebugArriveRadius:F2}{(arrived ? " ()" : "")}",
arrived ? Green : White);
// CanReach 会同步阻塞寻路 → 仅限频重算并缓存。
// 额外守卫 !_exitingPlay && isPlaying:退出 Play 那一刻 PBWorld 已销毁,此时调会 NRE。
if (doHeavy && nav != null && !_exitingPlay && Application.isPlaying)
{
_cachedCanReach = nav.CanReach(goal);
_cachedCanReachValid = true;
}
if (_cachedCanReachValid)
Row("目标可达 (CanReach)", _cachedCanReach ? "是" : "否 —— 起点/终点不连通",
_cachedCanReach ? Green : Red);
else
Row("目标可达 (CanReach)", "计算中…", Gray);
DrawDiagnosis(patrolActive, agentMapped, loco.DebugResolveOk,
_cachedCanReachValid && _cachedCanReach, navMoving, arrived);
DrawDiagnosis(patrolActive, navMoving, arrived);
}
}
@@ -300,8 +214,7 @@ namespace BaseGames.Editor
}
// ── "原地不动" 定位诊断 ─────────────────────────────────────────
private static void DrawDiagnosis(bool patrolActive, bool mapped,
bool resolveOk, bool canReach, bool navMoving, bool arrived)
private static void DrawDiagnosis(bool patrolActive, bool navMoving, bool arrived)
{
EditorGUILayout.Space(4f);
string msg; MessageType type;
@@ -312,25 +225,9 @@ namespace BaseGames.Editor
"需触发 AI 切到巡逻状态。";
type = MessageType.Warning;
}
else if (!mapped)
{
msg = "敌人不在导航图上(HasValidPosition=false)—— RequestMoveTo/UpdatePath 被直接忽略。" +
"检查:碰撞体底部是否贴地(原点=脚底)、是否站在已 bake 的 NavSurface 上、PointMappingDistance(默认0.2m)。";
type = MessageType.Error;
}
else if (!resolveOk)
{
msg = "路点吸附失败 —— 目标离所有烘焙段都超过 _maxSnapDistance。调大 _maxSnapDistance 或把路点摆近平台。";
type = MessageType.Error;
}
else if (!canReach)
{
msg = "目标不可达 —— 起点与吸附目标不在同一连通区,或中间隔着不可穿越的 NavLink(跳/落/爬)。";
type = MessageType.Error;
}
else if (!navMoving && !arrived)
{
msg = "已寻路成功但没有移动 —— 检查 EnemyMovement 速度/受阻(WouldBlockAhead 夹停)、刚体约束、是否被其他状态覆盖输入。";
msg = "已请求移动但没有推进 —— 检查 EnemyMovement 速度/受阻(WouldBlockAhead 夹停)、刚体约束、是否被其他状态覆盖输入。";
type = MessageType.Warning;
}
else