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.Player.States",
"BaseGames.Enemies", "BaseGames.Enemies",
"BaseGames.Enemies.Navigation", "BaseGames.Enemies.Navigation",
"BaseGames.AI",
"BaseGames.Camera", "BaseGames.Camera",
"BaseGames.World", "BaseGames.World",
"BaseGames.UI", "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:
@@ -40,6 +40,7 @@ namespace BaseGames.Enemies
private bool _warnedNoWaypoints; private bool _warnedNoWaypoints;
// 当前路点吸附后的"站得住"目标(宽度由 Nav 层解析,本层不感知身体尺寸) // 当前路点吸附后的"站得住"目标(宽度由 Nav 层解析,本层不感知身体尺寸)
private Vector2 _wpResolvedGoal; private Vector2 _wpResolvedGoal;
private bool _wpResolveOk; // 上次路点是否成功吸附到导航段(调试用)
// Wander 到点停顿状态 // Wander 到点停顿状态
private bool _wanderPausing; private bool _wanderPausing;
private float _wanderPauseTimer; private float _wanderPauseTimer;
@@ -214,7 +215,8 @@ namespace BaseGames.Enemies
private void SetWaypointGoal() private void SetWaypointGoal()
{ {
Vector2 raw = _waypoints[_wpIndex].position; Vector2 raw = _waypoints[_wpIndex].position;
if (_enemy.Nav == null || !_enemy.Nav.ResolveStandablePoint(raw, out _wpResolvedGoal)) _wpResolveOk = _enemy.Nav != null && _enemy.Nav.ResolveStandablePoint(raw, out _wpResolvedGoal);
if (!_wpResolveOk)
{ {
_wpResolvedGoal = raw; _wpResolvedGoal = raw;
Debug.LogWarning($"[EnemyLocomotion] 路点 '{_waypoints[_wpIndex].name}'(index {_wpIndex}) 无法吸附到任何可行走导航段" + Debug.LogWarning($"[EnemyLocomotion] 路点 '{_waypoints[_wpIndex].name}'(index {_wpIndex}) 无法吸附到任何可行走导航段" +
@@ -236,5 +238,43 @@ namespace BaseGames.Enemies
_wpIndex = (_wpIndex + 1) % _waypoints.Length; _wpIndex = (_wpIndex + 1) % _waypoints.Length;
} }
} }
#if UNITY_EDITOR
// ── 调试只读(供自定义 Inspector / Gizmos,仅编辑器)────────────────────────
public EnemyBase DebugEnemy => _enemy;
public PatrolStrategy DebugStrategy => _patrolStrategy;
public Transform[] DebugWaypoints => _waypoints;
public int DebugWaypointIndex => _wpIndex;
public Vector2 DebugResolvedGoal => _wpResolvedGoal;
public bool DebugResolveOk => _wpResolveOk;
public float DebugArriveRadius => _waypointArriveRadius;
public bool DebugWanderPausing => _wanderPausing;
private void OnDrawGizmosSelected()
{
if (_patrolStrategy != PatrolStrategy.Waypoints || _waypoints == null) return;
// 所有路点 + 连线(编辑期即可见,用于摆点)
Gizmos.color = new Color(0.3f, 0.8f, 1f);
for (int i = 0; i < _waypoints.Length; i++)
{
if (_waypoints[i] == null) continue;
Gizmos.DrawWireSphere(_waypoints[i].position, 0.15f);
var next = _waypoints[(i + 1) % _waypoints.Length];
if (next != null) Gizmos.DrawLine(_waypoints[i].position, next.position);
}
// 运行期:当前吸附目标 + 到达半径 + 连线(绿=吸附成功 红=失败回落原始点)
if (Application.isPlaying && _wpIndex >= 0)
{
Vector3 from = _enemy != null ? _enemy.transform.position : transform.position;
Gizmos.color = _wpResolveOk ? Color.green : Color.red;
Gizmos.DrawWireSphere(_wpResolvedGoal, 0.22f);
Gizmos.DrawLine(from, _wpResolvedGoal);
Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.7f);
Gizmos.DrawWireSphere(_wpResolvedGoal, _waypointArriveRadius);
}
}
#endif
} }
} }