feat(enemy): EnemyLocomotion 运行时巡逻调试(Inspector 面板 + Scene Gizmos)

排查"敌人原地不动"用:PlayMode 下自定义 Inspector 实时显示 模式/策略/是否移动/是否在导航图上/
当前路点与吸附目标/到目标距离vs到达半径/目标是否可达,并按优先级给出定位诊断(模式非Patrol|不在
NavGraph|吸附失败|不可达|已寻路未动)。Scene 视图 Gizmos 画出全部路点连线 + 当前吸附目标(绿/红)+到达半径。

EnemyLocomotion 加 #if UNITY_EDITOR 只读调试访问器 + OnDrawGizmosSelected;记录 _wpResolveOk。
Editor 程序集补 BaseGames.AI 引用(PatrolStrategy/LocomotionMode 所在)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:57:02 +08:00
co-authored by Claude Opus 4.8
parent 66d100f353
commit 00ee48f750
4 changed files with 221 additions and 1 deletions
@@ -16,6 +16,7 @@
"BaseGames.Player.States",
"BaseGames.Enemies",
"BaseGames.Enemies.Navigation",
"BaseGames.AI",
"BaseGames.Camera",
"BaseGames.World",
"BaseGames.UI",
@@ -0,0 +1,168 @@
using UnityEditor;
using UnityEngine;
using BaseGames.AI;
using BaseGames.Enemies;
using BaseGames.Enemies.Navigation;
namespace BaseGames.Editor
{
/// <summary>
/// <see cref="EnemyLocomotion"/> 自定义 Inspector。
/// PlayMode 下实时显示移动/巡逻状态,并对"原地不动"给出定位诊断:
/// 模式、策略、当前路点与吸附目标、到达距离、是否在导航图上、目标是否可达。
/// </summary>
[CustomEditor(typeof(EnemyLocomotion))]
public class EnemyLocomotionEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
if (!Application.isPlaying)
{
EditorGUILayout.Space(4f);
EditorGUILayout.HelpBox("进入 Play 模式后此处显示实时巡逻调试信息。", MessageType.None);
return;
}
EditorGUILayout.Space(6f);
DrawRuntimeSection();
Repaint(); // 每帧刷新
}
private void DrawRuntimeSection()
{
var loco = (EnemyLocomotion)target;
var enemy = loco.DebugEnemy;
EditorGUILayout.LabelField("运行时调试", EditorStyles.boldLabel);
// ── 模式 / 策略 ─────────────────────────────────────────────
var mode = loco.CurrentMode;
bool patrolActive = mode == LocomotionMode.Patrol;
Row("当前模式 (Mode)", mode.ToString(),
patrolActive ? Green : (mode == LocomotionMode.Idle ? Gray : White));
Row("巡逻策略 (Strategy)", loco.DebugStrategy.ToString(), White);
Row("正在移动 (IsMoving)", loco.IsMoving ? "是" : "否", loco.IsMoving ? Green : Gray);
if (enemy == null)
{
EditorGUILayout.HelpBox("EnemyBase 未解析(_enemy=null)。", MessageType.Warning);
return;
}
// ── 导航代理状态 ────────────────────────────────────────────
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;
Row("在导航图上 (Mapped)", agentMapped ? "是" : "否 —— 敌人不在 NavGraph 上",
agentMapped ? Green : Red);
Row("Nav 正在寻路/移动", navMoving ? "是" : "否", navMoving ? Green : Gray);
// 身体宽度 / 吸附距离(上下文)
var mv = enemy.Movement;
if (mv != null) Row("EdgeSafeMargin(身体半宽+偏移)", mv.EdgeSafeMargin.ToString("F2"), White);
if (nav is EnemyNavAgent ena2)
{
var so = new SerializedObject(ena2);
var msd = so.FindProperty("_maxSnapDistance");
if (msd != null) Row("_maxSnapDistance", msd.floatValue.ToString("F1"), White);
}
// ── 路点巡逻细节 ────────────────────────────────────────────
if (loco.DebugStrategy == PatrolStrategy.Waypoints)
{
EditorGUILayout.Space(4f);
EditorGUILayout.LabelField("Waypoints", EditorStyles.miniBoldLabel);
var wps = loco.DebugWaypoints;
int idx = loco.DebugWaypointIndex;
int cnt = wps != null ? wps.Length : 0;
Row("当前路点索引", cnt > 0 ? $"{idx} / {cnt - 1}" : "无路点", cnt > 0 ? White : Red);
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);
float dist = Vector2.Distance(enemy.transform.position, goal);
bool arrived = dist <= loco.DebugArriveRadius;
Row("到目标距离 / 到达半径", $"{dist:F2} / {loco.DebugArriveRadius:F2}{(arrived ? " ()" : "")}",
arrived ? Green : White);
bool canReach = nav != null && nav.CanReach(goal);
Row("目标可达 (CanReach)", canReach ? "是" : "否 —— 起点/终点不连通", canReach ? Green : Red);
DrawDiagnosis(patrolActive, loco.DebugStrategy, agentMapped, loco.DebugResolveOk, canReach, navMoving, arrived);
}
}
if (GUILayout.Button("在 Hierarchy 中定位敌人", EditorStyles.miniButton))
Selection.activeGameObject = enemy.gameObject;
}
// ── "原地不动" 定位诊断 ─────────────────────────────────────────
private static void DrawDiagnosis(bool patrolActive, PatrolStrategy strat, bool mapped,
bool resolveOk, bool canReach, bool navMoving, bool arrived)
{
EditorGUILayout.Space(4f);
string msg; MessageType type;
if (!patrolActive)
{
msg = "模式不是 Patrol —— AI 尚未进入巡逻态(如伪装/待机),TickPatrol 不会执行,故不动。" +
"需触发 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 夹停)、刚体约束、是否被其他状态覆盖输入。";
type = MessageType.Warning;
}
else
{
msg = "巡逻链路正常。";
type = MessageType.Info;
}
EditorGUILayout.HelpBox(msg, type);
}
// ── 绘制辅助 ────────────────────────────────────────────────────
private static readonly Color Green = new Color(0.4f, 0.9f, 0.4f);
private static readonly Color Red = new Color(1f, 0.5f, 0.4f);
private static readonly Color Gray = new Color(0.7f, 0.7f, 0.7f);
private static readonly Color White = Color.white;
private static void Row(string label, string value, Color valueColor)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(label, GUILayout.Width(200f));
var prev = GUI.color; GUI.color = valueColor;
EditorGUILayout.LabelField(value, EditorStyles.boldLabel);
GUI.color = prev;
EditorGUILayout.EndHorizontal();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3c75f9efe0c364f47b03472f71be96b9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: