Introduce a Chrome-DevTools-style ECS inspector in the UI library. The headless core (ComponentReflector + EcsInspector) enumerates the store's entities grouped by archetype, reflects a selected entity's components and fields, and writes simple scalar edits (number/bool/enum) back through a generic AddComponent — all unit-tested without a GPU. The Myra overlay (EcsInspectorUi + InspectorSystems) adds a side panel with an archetypes -> entities -> fields tree, an editable field view, a world-pick mode with a selection highlight, and a renderer performance tab; wired via scene.UseInspector(renderer), toggled with F1. The UI library now references Graphics (for picking, renderer timings and Transform2D/Sprite). Docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f791ee6c95
commit
09dbdfad79
@@ -0,0 +1,276 @@
|
||||
using System;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Graphics;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace MrGameEng.Inspector;
|
||||
|
||||
/// <summary>
|
||||
/// Myra overlay of the ECS inspector — a Chrome-DevTools-style side panel. The top bar switches the
|
||||
/// Entities/Performance tabs and arms world-pick; the Entities tab shows archetypes → entities →
|
||||
/// the selected entity's components and editable fields; the Performance tab shows renderer
|
||||
/// timings. A thin border tracks the selected entity in the world each frame. Rebuilds lazily on
|
||||
/// <see cref="EcsInspector.Revision"/> changes.
|
||||
/// </summary>
|
||||
internal sealed class EcsInspectorUi
|
||||
{
|
||||
private const int PanelWidth = 540;
|
||||
private static readonly Color Accent = new(120, 200, 255);
|
||||
|
||||
private readonly EcsInspector _inspector;
|
||||
private readonly Renderer2D _renderer;
|
||||
private readonly Desktop _desktop;
|
||||
|
||||
private readonly VerticalStackPanel _archetypeList = Stack();
|
||||
private readonly VerticalStackPanel _entityList = Stack();
|
||||
private readonly VerticalStackPanel _detailList = Stack();
|
||||
private readonly Label _stats = new();
|
||||
private readonly Label _perf = new() { Wrap = false };
|
||||
private readonly Label _pickLabel = new() { Text = "Pick" };
|
||||
private readonly Widget _entitiesSection;
|
||||
private readonly Panel _highlight;
|
||||
private readonly VerticalStackPanel _panel;
|
||||
|
||||
private int _lastRevision = -1;
|
||||
|
||||
public EcsInspectorUi(EcsInspector inspector, Renderer2D renderer)
|
||||
{
|
||||
_inspector = inspector;
|
||||
_renderer = renderer;
|
||||
|
||||
var tabEntities = MakeButton("Entities", () => _inspector.SetTab(InspectorTab.Entities));
|
||||
var tabPerf = MakeButton("Performance", () => _inspector.SetTab(InspectorTab.Performance));
|
||||
var pick = new Button { Content = _pickLabel };
|
||||
pick.Click += (_, _) => _inspector.ArmPick(!_inspector.PickArmed);
|
||||
|
||||
var search = new TextBox { HintText = "filter components", Width = 160 };
|
||||
search.TextChanged += (_, _) => _inspector.SetSearch(search.Text ?? "");
|
||||
|
||||
var bar = new HorizontalStackPanel { Spacing = 6 };
|
||||
bar.Widgets.Add(tabEntities);
|
||||
bar.Widgets.Add(tabPerf);
|
||||
bar.Widgets.Add(pick);
|
||||
bar.Widgets.Add(search);
|
||||
|
||||
_entitiesSection = BuildEntitiesSection();
|
||||
|
||||
_panel = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Padding = new Thickness(8),
|
||||
Width = PanelWidth,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
Background = new SolidBrush(new Color(8, 10, 14, 235)),
|
||||
};
|
||||
_panel.Widgets.Add(bar);
|
||||
_panel.Widgets.Add(_stats);
|
||||
_panel.Widgets.Add(new HorizontalSeparator());
|
||||
_panel.Widgets.Add(_entitiesSection);
|
||||
_panel.Widgets.Add(_perf);
|
||||
|
||||
_highlight = new Panel
|
||||
{
|
||||
Border = new SolidBrush(Accent),
|
||||
BorderThickness = new Thickness(2),
|
||||
Visible = false,
|
||||
};
|
||||
|
||||
var root = new Panel();
|
||||
root.Widgets.Add(_highlight);
|
||||
root.Widgets.Add(_panel);
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
public void Render()
|
||||
{
|
||||
if (!_inspector.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_inspector.Revision != _lastRevision)
|
||||
{
|
||||
_lastRevision = _inspector.Revision;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
UpdateHighlight();
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
/// <summary>True when the physical screen point is over the panel (so a pick click is ignored).</summary>
|
||||
public bool IsOverPanel(Point screen) => _panel.Bounds.Contains(screen);
|
||||
|
||||
private Widget BuildEntitiesSection()
|
||||
{
|
||||
var section = new VerticalStackPanel { Spacing = 4 };
|
||||
section.Widgets.Add(Header("Archetypes"));
|
||||
section.Widgets.Add(new ScrollViewer { Content = _archetypeList, Height = 150 });
|
||||
section.Widgets.Add(Header("Entities"));
|
||||
section.Widgets.Add(new ScrollViewer { Content = _entityList, Height = 150 });
|
||||
section.Widgets.Add(Header("Selected"));
|
||||
section.Widgets.Add(new ScrollViewer { Content = _detailList, Height = 320 });
|
||||
return section;
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
var entitiesTab = _inspector.ActiveTab == InspectorTab.Entities;
|
||||
_entitiesSection.Visible = entitiesTab;
|
||||
_perf.Visible = !entitiesTab;
|
||||
_pickLabel.Text = _inspector.PickArmed ? "Pick*" : "Pick";
|
||||
|
||||
var perf = _inspector.Performance();
|
||||
_stats.Text =
|
||||
$"entities {perf.Entities} | archetypes {perf.Archetypes} | {perf.Fps} FPS"
|
||||
+ (_inspector.PickArmed ? " | click an entity…" : "");
|
||||
|
||||
if (entitiesTab)
|
||||
{
|
||||
RebuildArchetypes();
|
||||
RebuildEntities();
|
||||
RebuildDetails();
|
||||
}
|
||||
else
|
||||
{
|
||||
_perf.Text =
|
||||
$"FPS {perf.Fps}\n"
|
||||
+ $"submit {perf.SubmitMs:0.00} ms\n"
|
||||
+ $"sort {perf.SortMs:0.00} ms\n"
|
||||
+ $"build {perf.BuildMs:0.00} ms\n"
|
||||
+ $"upload {perf.UploadMs:0.00} ms\n"
|
||||
+ $"draw {perf.DrawMs:0.00} ms\n"
|
||||
+ $"draw calls {perf.DrawCalls}\n"
|
||||
+ $"sprites {perf.SubmittedSprites} drawn, {perf.CulledSprites} culled\n"
|
||||
+ $"entities {perf.Entities}";
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildArchetypes()
|
||||
{
|
||||
_archetypeList.Widgets.Clear();
|
||||
foreach (var row in _inspector.Archetypes())
|
||||
{
|
||||
var index = row.Index;
|
||||
var button = MakeButton(
|
||||
$"[{row.Count}] {row.Signature}",
|
||||
() => _inspector.SelectArchetype(index)
|
||||
);
|
||||
if (index == _inspector.SelectedArchetype)
|
||||
{
|
||||
Highlight(button);
|
||||
}
|
||||
|
||||
_archetypeList.Widgets.Add(button);
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildEntities()
|
||||
{
|
||||
_entityList.Widgets.Clear();
|
||||
if (_inspector.SelectedArchetype < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in _inspector.EntitiesOf(_inspector.SelectedArchetype))
|
||||
{
|
||||
var entityId = id;
|
||||
var button = MakeButton($"entity {id}", () => _inspector.SelectEntity(entityId));
|
||||
if (entityId == _inspector.SelectedEntityId)
|
||||
{
|
||||
Highlight(button);
|
||||
}
|
||||
|
||||
_entityList.Widgets.Add(button);
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildDetails()
|
||||
{
|
||||
_detailList.Widgets.Clear();
|
||||
if (_inspector.SelectedEntityId < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var component in _inspector.Inspect(_inspector.SelectedEntityId))
|
||||
{
|
||||
_detailList.Widgets.Add(new Label { Text = component.TypeName, TextColor = Accent });
|
||||
foreach (var field in component.Fields)
|
||||
{
|
||||
_detailList.Widgets.Add(BuildFieldRow(component.Type, field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Widget BuildFieldRow(Type componentType, FieldRow field)
|
||||
{
|
||||
var row = new HorizontalStackPanel { Spacing = 6 };
|
||||
row.Widgets.Add(new Label { Text = field.Name, Width = 150 });
|
||||
|
||||
if (!field.Editable)
|
||||
{
|
||||
row.Widgets.Add(new Label { Text = field.Value });
|
||||
return row;
|
||||
}
|
||||
|
||||
var editor = new TextBox { Text = field.Value, Width = 220 };
|
||||
var entityId = _inspector.SelectedEntityId;
|
||||
editor.KeyDown += (_, args) =>
|
||||
{
|
||||
if (args.Data == Microsoft.Xna.Framework.Input.Keys.Enter)
|
||||
{
|
||||
_inspector.SetField(entityId, componentType, field.Name, editor.Text ?? "");
|
||||
}
|
||||
};
|
||||
row.Widgets.Add(editor);
|
||||
if (field.Kind == FieldKind.Enum && field.EnumOptions is { Length: > 0 })
|
||||
{
|
||||
row.Widgets.Add(new Label { Text = "(" + string.Join("/", field.EnumOptions) + ")" });
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private void UpdateHighlight()
|
||||
{
|
||||
if (!_inspector.TryGetSelectedBounds(out var center, out var radius))
|
||||
{
|
||||
_highlight.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var screenCenter = _renderer.WorldToScreen(center);
|
||||
var edge = _renderer.WorldToScreen(center + new Vector2(radius, 0f));
|
||||
var screenRadius = MathF.Max(4f, MathF.Abs(edge.X - screenCenter.X));
|
||||
_highlight.Visible = true;
|
||||
_highlight.Left = (int)(screenCenter.X - screenRadius);
|
||||
_highlight.Top = (int)(screenCenter.Y - screenRadius);
|
||||
_highlight.Width = (int)(screenRadius * 2f);
|
||||
_highlight.Height = (int)(screenRadius * 2f);
|
||||
}
|
||||
|
||||
private static void Highlight(Button button)
|
||||
{
|
||||
if (button.Content is Label label)
|
||||
{
|
||||
label.TextColor = Accent;
|
||||
}
|
||||
}
|
||||
|
||||
private static VerticalStackPanel Stack() => new() { Spacing = 2 };
|
||||
|
||||
private static Label Header(string text) =>
|
||||
new() { Text = text, TextColor = new Color(150, 160, 175) };
|
||||
|
||||
private static Button MakeButton(string text, Action onClick)
|
||||
{
|
||||
var button = new Button { Content = new Label { Text = text } };
|
||||
button.Click += (_, _) => onClick();
|
||||
return button;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user