parent
23d3df8973
commit
8a579a7d29
10 changed files with 1285 additions and 83 deletions
@ -0,0 +1,308 @@ |
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Reflection; |
||||
using UnityEditor; |
||||
using UnityEngine; |
||||
|
||||
namespace UltraCombos |
||||
{ |
||||
[CustomEditor(typeof(DebugMonitorHUD))] |
||||
public class DebugMonitorHUDEditor : Editor |
||||
{ |
||||
// ── Supported member types ──────────────────────────────────────────────── |
||||
|
||||
private static readonly HashSet<Type> supportedTypes = new() |
||||
{ |
||||
typeof(bool), typeof(int), typeof(float), |
||||
typeof(string), typeof(Vector2), typeof(Vector3), typeof(Color) |
||||
}; |
||||
|
||||
private static bool IsSupported(Type t) => supportedTypes.Contains(t) || t.IsEnum; |
||||
|
||||
// ── Cached SerializedProperties ─────────────────────────────────────────── |
||||
|
||||
private SerializedProperty displayTextProp; |
||||
private SerializedProperty refreshRateProp; |
||||
|
||||
private void OnEnable() |
||||
{ |
||||
displayTextProp = serializedObject.FindProperty("displayText"); |
||||
refreshRateProp = serializedObject.FindProperty("refreshRate"); |
||||
} |
||||
|
||||
// ── Inspector ───────────────────────────────────────────────────────────── |
||||
|
||||
public override void OnInspectorGUI() |
||||
{ |
||||
var hud = (DebugMonitorHUD)target; |
||||
serializedObject.Update(); |
||||
|
||||
EditorGUILayout.LabelField("Display", EditorStyles.boldLabel); |
||||
EditorGUILayout.PropertyField(displayTextProp, new GUIContent("Display Text", |
||||
"The TextMeshProUGUI component that will show the monitored values.")); |
||||
|
||||
EditorGUILayout.Space(4); |
||||
EditorGUILayout.LabelField("General", EditorStyles.boldLabel); |
||||
EditorGUILayout.PropertyField(refreshRateProp, new GUIContent("Refresh Rate")); |
||||
|
||||
serializedObject.ApplyModifiedProperties(); |
||||
|
||||
EditorGUILayout.Space(10); |
||||
EditorGUILayout.LabelField("Entries", EditorStyles.boldLabel); |
||||
|
||||
for (var i = 0; i < hud.entries.Count; i++) |
||||
{ |
||||
var removed = DrawEntry(hud, hud.entries[i], i); |
||||
if (removed) |
||||
{ |
||||
Undo.RecordObject(hud, "Remove Entry"); |
||||
hud.entries.RemoveAt(i); |
||||
EditorUtility.SetDirty(hud); |
||||
i--; |
||||
} |
||||
EditorGUILayout.Space(2); |
||||
} |
||||
|
||||
EditorGUILayout.Space(4); |
||||
|
||||
if (GUILayout.Button("+ Add Entry", GUILayout.Height(26))) |
||||
{ |
||||
Undo.RecordObject(hud, "Add Entry"); |
||||
hud.entries.Add(new DebugMonitorHUD.Entry()); |
||||
EditorUtility.SetDirty(hud); |
||||
} |
||||
} |
||||
|
||||
// ── Draw one Entry — returns true when the user clicked Remove ───────────── |
||||
|
||||
private static bool DrawEntry(DebugMonitorHUD hud, DebugMonitorHUD.Entry entry, int index) |
||||
{ |
||||
var removed = false; |
||||
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) |
||||
{ |
||||
// Header row |
||||
using (new EditorGUILayout.HorizontalScope()) |
||||
{ |
||||
var title = string.IsNullOrEmpty(entry.groupLabel) |
||||
? $"Entry {index}" |
||||
: $"Entry {index} — {entry.groupLabel}"; |
||||
EditorGUILayout.LabelField(title, EditorStyles.boldLabel); |
||||
GUILayout.FlexibleSpace(); |
||||
if (GUILayout.Button("✕", GUILayout.Width(22), GUILayout.Height(18))) |
||||
{ |
||||
removed = true; |
||||
} |
||||
} |
||||
|
||||
// Group label |
||||
EditorGUI.BeginChangeCheck(); |
||||
var newLabel = EditorGUILayout.TextField("Group Label", entry.groupLabel); |
||||
if (EditorGUI.EndChangeCheck()) |
||||
{ |
||||
Undo.RecordObject(hud, "Edit Group Label"); |
||||
entry.groupLabel = newLabel; |
||||
EditorUtility.SetDirty(hud); |
||||
} |
||||
|
||||
// Target object |
||||
EditorGUI.BeginChangeCheck(); |
||||
var newTarget = EditorGUILayout.ObjectField( |
||||
new GUIContent("Target", |
||||
"Drag any Component or ScriptableObject here.\n" + |
||||
"Dropping a GameObject picks its first non-Transform Component."), |
||||
entry.target, |
||||
typeof(UnityEngine.Object), |
||||
allowSceneObjects: true); |
||||
|
||||
if (EditorGUI.EndChangeCheck()) |
||||
{ |
||||
Undo.RecordObject(hud, "Set Target"); |
||||
|
||||
if (newTarget is GameObject go) |
||||
{ |
||||
var comps = go.GetComponents<Component>(); |
||||
newTarget = comps.FirstOrDefault(c => c is not Transform) ?? |
||||
comps.FirstOrDefault() ?? |
||||
(UnityEngine.Object)go; |
||||
} |
||||
|
||||
entry.target = newTarget; |
||||
entry.fields.Clear(); |
||||
EditorUtility.SetDirty(hud); |
||||
} |
||||
|
||||
// Member selection |
||||
if (entry.target != null) |
||||
{ |
||||
DrawMemberSelection(hud, entry); |
||||
} |
||||
} |
||||
|
||||
return removed; |
||||
} |
||||
|
||||
// ── Member checkboxes ───────────────────────────────────────────────────── |
||||
|
||||
private static void DrawMemberSelection(DebugMonitorHUD hud, DebugMonitorHUD.Entry entry) |
||||
{ |
||||
var type = entry.target.GetType(); |
||||
var members = CollectMembers(type); |
||||
|
||||
if (members.Count == 0) |
||||
{ |
||||
EditorGUILayout.HelpBox( |
||||
"No displayable fields or properties found on this object.\n" + |
||||
"Supported types: bool · int · float · string · Vector2 · Vector3 · Color · Enum\n" + |
||||
"Fields must be public or [SerializeField]. Properties must be public with a getter.", |
||||
MessageType.Info); |
||||
return; |
||||
} |
||||
|
||||
EditorGUILayout.Space(4); |
||||
|
||||
// Select All / None |
||||
using (new EditorGUILayout.HorizontalScope()) |
||||
{ |
||||
EditorGUILayout.LabelField("Members to Display", EditorStyles.boldLabel); |
||||
GUILayout.FlexibleSpace(); |
||||
|
||||
if (GUILayout.Button("All", GUILayout.Width(36))) |
||||
{ |
||||
Undo.RecordObject(hud, "Select All Members"); |
||||
entry.fields = members.Select(m => m.Name).ToList(); |
||||
EditorUtility.SetDirty(hud); |
||||
} |
||||
if (GUILayout.Button("None", GUILayout.Width(38))) |
||||
{ |
||||
Undo.RecordObject(hud, "Deselect All Members"); |
||||
entry.fields.Clear(); |
||||
EditorUtility.SetDirty(hud); |
||||
} |
||||
} |
||||
|
||||
// Per-member toggles |
||||
EditorGUI.indentLevel++; |
||||
|
||||
foreach (var mi in members) |
||||
{ |
||||
var isSelected = entry.fields.Contains(mi.Name); |
||||
var niceName = ObjectNames.NicifyVariableName(mi.Name); |
||||
var memberType = GetMemberType(mi); |
||||
var typeName = FriendlyType(memberType); |
||||
var hasRange = mi.GetCustomAttribute<RangeAttribute>() != null; |
||||
var isProperty = mi is PropertyInfo; |
||||
var suffix = (isProperty ? ", prop" : string.Empty) + |
||||
(hasRange ? ", Range" : string.Empty); |
||||
|
||||
var label = new GUIContent( |
||||
$"{niceName} ({typeName}{suffix})", |
||||
tooltip: $"{(isProperty ? "Property" : "Field")}: {mi.Name}\nType: {memberType.FullName}"); |
||||
|
||||
EditorGUI.BeginChangeCheck(); |
||||
var next = EditorGUILayout.ToggleLeft(label, isSelected); |
||||
|
||||
if (EditorGUI.EndChangeCheck()) |
||||
{ |
||||
Undo.RecordObject(hud, next ? "Add Member" : "Remove Member"); |
||||
if (next) |
||||
{ |
||||
entry.fields.Add(mi.Name); |
||||
} |
||||
else |
||||
{ |
||||
entry.fields.Remove(mi.Name); |
||||
} |
||||
EditorUtility.SetDirty(hud); |
||||
} |
||||
} |
||||
|
||||
EditorGUI.indentLevel--; |
||||
} |
||||
|
||||
// ── Reflection helpers ──────────────────────────────────────────────────── |
||||
|
||||
private static List<MemberInfo> CollectMembers(Type type) |
||||
{ |
||||
const BindingFlags fieldFlags = |
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; |
||||
const BindingFlags propFlags = |
||||
BindingFlags.Public | BindingFlags.Instance; |
||||
|
||||
var result = new List<MemberInfo>(); |
||||
|
||||
for (var t = type; t != null && !IsUnityBuiltIn(t); t = t.BaseType) |
||||
{ |
||||
foreach (var fi in t.GetFields(fieldFlags | BindingFlags.DeclaredOnly)) |
||||
{ |
||||
// Skip compiler-generated backing fields (e.g. <Prop>k__BackingField). |
||||
if (fi.Name[0] == '<') |
||||
{ |
||||
continue; |
||||
} |
||||
var exposed = fi.IsPublic || fi.GetCustomAttribute<SerializeField>() != null; |
||||
if (!exposed) |
||||
{ |
||||
continue; |
||||
} |
||||
if (fi.GetCustomAttribute<HideInInspector>() != null) |
||||
{ |
||||
continue; |
||||
} |
||||
if (!IsSupported(fi.FieldType)) |
||||
{ |
||||
continue; |
||||
} |
||||
if (!result.Any(r => r.Name == fi.Name)) |
||||
{ |
||||
result.Add(fi); |
||||
} |
||||
} |
||||
|
||||
foreach (var pi in t.GetProperties(propFlags | BindingFlags.DeclaredOnly)) |
||||
{ |
||||
// Must have a getter and no index parameters. |
||||
if (!pi.CanRead || pi.GetIndexParameters().Length > 0) |
||||
{ |
||||
continue; |
||||
} |
||||
if (pi.GetCustomAttribute<HideInInspector>() != null) |
||||
{ |
||||
continue; |
||||
} |
||||
if (!IsSupported(pi.PropertyType)) |
||||
{ |
||||
continue; |
||||
} |
||||
if (!result.Any(r => r.Name == pi.Name)) |
||||
{ |
||||
result.Add(pi); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
private static Type GetMemberType(MemberInfo mi) => |
||||
mi is FieldInfo fi ? fi.FieldType : ((PropertyInfo)mi).PropertyType; |
||||
|
||||
private static bool IsUnityBuiltIn(Type t) => |
||||
t.Namespace != null && |
||||
t.Namespace.StartsWith("UnityEngine", StringComparison.Ordinal); |
||||
|
||||
private static string FriendlyType(Type t) |
||||
{ |
||||
if (t == typeof(float)) { return "float"; } |
||||
if (t == typeof(int)) { return "int"; } |
||||
if (t == typeof(bool)) { return "bool"; } |
||||
if (t == typeof(string)) { return "string"; } |
||||
if (t == typeof(Vector2)) { return "Vector2"; } |
||||
if (t == typeof(Vector3)) { return "Vector3"; } |
||||
if (t == typeof(Color)) { return "Color"; } |
||||
if (t.IsEnum) { return t.Name; } |
||||
return t.Name; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,2 @@ |
||||
fileFormatVersion: 2 |
||||
guid: b9de48d136769a442bc787083085d69d |
||||
@ -0,0 +1,285 @@ |
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Globalization; |
||||
using System.Reflection; |
||||
using System.Text; |
||||
using TMPro; |
||||
using UnityEngine; |
||||
|
||||
namespace UltraCombos |
||||
{ |
||||
/// <summary> |
||||
/// Read-only runtime monitor that renders selected component fields and properties |
||||
/// into a single TextMeshProUGUI component using TMP rich-text colour tags. |
||||
/// Drag the target TextMeshProUGUI into the Display Text field in the Inspector. |
||||
/// </summary> |
||||
[ExecuteAlways] |
||||
public class DebugMonitorHUD : MonoBehaviour |
||||
{ |
||||
// ── Data ────────────────────────────────────────────────────────────────── |
||||
|
||||
[Serializable] |
||||
public class Entry |
||||
{ |
||||
public UnityEngine.Object target; |
||||
public string groupLabel = string.Empty; |
||||
|
||||
/// <summary>Field/property names written by the custom Editor.</summary> |
||||
public List<string> fields = new(); |
||||
} |
||||
|
||||
[Header("Display")] |
||||
[SerializeField] private TextMeshProUGUI displayText; |
||||
|
||||
[Header("General")] |
||||
[Tooltip("How many times per second the text is refreshed. 0 = every frame.")] |
||||
[Range(0f, 60f)] |
||||
public float refreshRate = 20f; |
||||
|
||||
[Header("Entries")] |
||||
public List<Entry> entries = new(); |
||||
|
||||
// ── Runtime state ───────────────────────────────────────────────────────── |
||||
|
||||
private float nextRefreshTime; |
||||
private readonly StringBuilder stringBuilder = new(); |
||||
|
||||
// ── TMP colour tokens ───────────────────────────────────────────────────── |
||||
|
||||
private const string colHeader = "#FFC940"; |
||||
private const string colLabel = "#AAAAAA"; |
||||
private const string colFloat = "#7EC8E3"; |
||||
private const string colInt = "#7EC8E3"; |
||||
private const string colBoolTrue = "#7EE37E"; |
||||
private const string colBoolFalse = "#E37E7E"; |
||||
private const string colString = "#E3D47E"; |
||||
private const string colEnum = "#C8A0E3"; |
||||
private const string colVec = "#E3C87E"; |
||||
private const string colColor = "#E3E3E3"; |
||||
private const string colReadonly = "#666666"; |
||||
|
||||
// ── Unity callbacks ─────────────────────────────────────────────────────── |
||||
|
||||
private void Update() |
||||
{ |
||||
if (displayText == null) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
if (refreshRate > 0f && Time.unscaledTime < nextRefreshTime) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
nextRefreshTime = Time.unscaledTime + (refreshRate > 0f ? 1f / refreshRate : 0f); |
||||
RebuildText(); |
||||
} |
||||
|
||||
// ── Text building ───────────────────────────────────────────────────────── |
||||
|
||||
private void RebuildText() |
||||
{ |
||||
stringBuilder.Clear(); |
||||
|
||||
var first = true; |
||||
foreach (var entry in entries) |
||||
{ |
||||
if (entry == null || entry.target == null || entry.fields.Count == 0) |
||||
{ |
||||
continue; |
||||
} |
||||
|
||||
if (!first) |
||||
{ |
||||
stringBuilder.AppendLine(); |
||||
} |
||||
first = false; |
||||
|
||||
AppendEntry(entry); |
||||
} |
||||
|
||||
displayText.SetText(stringBuilder); |
||||
} |
||||
|
||||
private void AppendEntry(Entry entry) |
||||
{ |
||||
if (!string.IsNullOrEmpty(entry.groupLabel)) |
||||
{ |
||||
stringBuilder |
||||
.Append("<b><color=").Append(colHeader).Append('>') |
||||
.Append(entry.groupLabel) |
||||
.AppendLine("</color></b>"); |
||||
} |
||||
|
||||
var type = entry.target.GetType(); |
||||
const BindingFlags fieldFlags = |
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; |
||||
const BindingFlags propFlags = |
||||
BindingFlags.Public | BindingFlags.Instance; |
||||
|
||||
foreach (var name in entry.fields) |
||||
{ |
||||
// Resolve by name — check fields first, then properties. |
||||
var mi = (MemberInfo)type.GetField(name, fieldFlags) ?? |
||||
type.GetProperty(name, propFlags); |
||||
if (mi == null) |
||||
{ |
||||
continue; |
||||
} |
||||
|
||||
try |
||||
{ |
||||
AppendMember(entry.target, mi); |
||||
} |
||||
catch |
||||
{ |
||||
// Skip members that throw during reflection. |
||||
} |
||||
} |
||||
} |
||||
|
||||
private void AppendMember(object obj, MemberInfo mi) |
||||
{ |
||||
var label = NicifyName(mi.Name); |
||||
var val = GetMemberValue(mi, obj); |
||||
var ftype = GetMemberType(mi); |
||||
|
||||
stringBuilder |
||||
.Append("<color=").Append(colLabel).Append('>') |
||||
.Append(label) |
||||
.Append("</color> "); |
||||
|
||||
AppendValue(val, ftype); |
||||
stringBuilder.AppendLine(); |
||||
} |
||||
|
||||
private void AppendValue(object val, Type ftype) |
||||
{ |
||||
if (ftype == typeof(bool)) |
||||
{ |
||||
var b = (bool)val; |
||||
stringBuilder |
||||
.Append("<color=").Append(b ? colBoolTrue : colBoolFalse).Append('>') |
||||
.Append(b ? "true" : "false") |
||||
.Append("</color>"); |
||||
return; |
||||
} |
||||
|
||||
if (ftype == typeof(float)) |
||||
{ |
||||
stringBuilder |
||||
.Append("<color=").Append(colFloat).Append('>') |
||||
.Append(((float)val).ToString("G5", CultureInfo.InvariantCulture)) |
||||
.Append("</color>"); |
||||
return; |
||||
} |
||||
|
||||
if (ftype == typeof(int)) |
||||
{ |
||||
stringBuilder |
||||
.Append("<color=").Append(colInt).Append('>') |
||||
.Append(((int)val).ToString(CultureInfo.InvariantCulture)) |
||||
.Append("</color>"); |
||||
return; |
||||
} |
||||
|
||||
if (ftype == typeof(string)) |
||||
{ |
||||
stringBuilder |
||||
.Append("<color=").Append(colString).Append(">\"") |
||||
.Append(val as string ?? string.Empty) |
||||
.Append("\"</color>"); |
||||
return; |
||||
} |
||||
|
||||
if (ftype == typeof(Vector2)) |
||||
{ |
||||
var v = (Vector2)val; |
||||
stringBuilder |
||||
.Append("<color=").Append(colVec).Append(">(") |
||||
.Append(v.x.ToString("G4", CultureInfo.InvariantCulture)).Append(", ") |
||||
.Append(v.y.ToString("G4", CultureInfo.InvariantCulture)) |
||||
.Append(")</color>"); |
||||
return; |
||||
} |
||||
|
||||
if (ftype == typeof(Vector3)) |
||||
{ |
||||
var v = (Vector3)val; |
||||
stringBuilder |
||||
.Append("<color=").Append(colVec).Append(">(") |
||||
.Append(v.x.ToString("G4", CultureInfo.InvariantCulture)).Append(", ") |
||||
.Append(v.y.ToString("G4", CultureInfo.InvariantCulture)).Append(", ") |
||||
.Append(v.z.ToString("G4", CultureInfo.InvariantCulture)) |
||||
.Append(")</color>"); |
||||
return; |
||||
} |
||||
|
||||
if (ftype == typeof(Color)) |
||||
{ |
||||
var c = (Color)val; |
||||
var hex = ColorUtility.ToHtmlStringRGBA(c); |
||||
// Coloured swatch character followed by the hex value. |
||||
stringBuilder |
||||
.Append("<color=#").Append(hex).Append(">■</color> ") |
||||
.Append("<color=").Append(colColor).Append(">#").Append(hex).Append("</color>"); |
||||
return; |
||||
} |
||||
|
||||
if (ftype.IsEnum) |
||||
{ |
||||
stringBuilder |
||||
.Append("<color=").Append(colEnum).Append('>') |
||||
.Append(val?.ToString() ?? "?") |
||||
.Append("</color>"); |
||||
return; |
||||
} |
||||
|
||||
// Fallback: display the ToString() value as read-only italic text. |
||||
stringBuilder |
||||
.Append("<color=").Append(colReadonly).Append("><i>") |
||||
.Append(val?.ToString() ?? "(null)") |
||||
.Append("</i></color>"); |
||||
} |
||||
|
||||
// ── Reflection helpers ──────────────────────────────────────────────────── |
||||
|
||||
private static Type GetMemberType(MemberInfo mi) => |
||||
mi is FieldInfo fi ? fi.FieldType : ((PropertyInfo)mi).PropertyType; |
||||
|
||||
private static object GetMemberValue(MemberInfo mi, object obj) => |
||||
mi is FieldInfo fi ? fi.GetValue(obj) : ((PropertyInfo)mi).GetValue(obj); |
||||
|
||||
/// <summary>Converts a camelCase or _prefixed field name to a readable label.</summary> |
||||
private static string NicifyName(string s) |
||||
{ |
||||
var start = 0; |
||||
while (start < s.Length && s[start] == '_') |
||||
{ |
||||
start++; |
||||
} |
||||
if (s.Length > start + 1 && s[start] == 'm' && s[start + 1] == '_') |
||||
{ |
||||
start += 2; |
||||
} |
||||
|
||||
var sb = new StringBuilder(); |
||||
for (var i = start; i < s.Length; i++) |
||||
{ |
||||
var c = s[i]; |
||||
if (i == start) |
||||
{ |
||||
sb.Append(char.ToUpper(c)); |
||||
continue; |
||||
} |
||||
if (char.IsUpper(c) && !char.IsUpper(s[i - 1])) |
||||
{ |
||||
sb.Append(' '); |
||||
} |
||||
sb.Append(c); |
||||
} |
||||
return sb.ToString(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,2 @@ |
||||
fileFormatVersion: 2 |
||||
guid: 705242ff475ca7b4bbc7341c5c7218ac |
||||
Loading…
Reference in new issue