From 09dbdfad79b9d6a651ae6bdf6e9f2010f9a28d8b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 12:45:50 +0300 Subject: [PATCH] Add MrGameEng.Inspector: in-engine ECS debugger overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 9 +- docs/architecture.md | 6 +- src/MrGameEng.UI/ComponentReflector.cs | 201 ++++++++ src/MrGameEng.UI/EcsInspector.cs | 455 ++++++++++++++++++ src/MrGameEng.UI/EcsInspectorUi.cs | 276 +++++++++++ src/MrGameEng.UI/InspectorSystems.cs | 110 +++++ src/MrGameEng.UI/MrGameEng.UI.csproj | 1 + .../ComponentReflectorTests.cs | 89 ++++ tests/MrGameEng.UI.Tests/EcsInspectorTests.cs | 144 ++++++ 9 files changed, 1285 insertions(+), 6 deletions(-) create mode 100644 src/MrGameEng.UI/ComponentReflector.cs create mode 100644 src/MrGameEng.UI/EcsInspector.cs create mode 100644 src/MrGameEng.UI/EcsInspectorUi.cs create mode 100644 src/MrGameEng.UI/InspectorSystems.cs create mode 100644 tests/MrGameEng.UI.Tests/ComponentReflectorTests.cs create mode 100644 tests/MrGameEng.UI.Tests/EcsInspectorTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 2f73baf..2c24f76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,13 @@ Engine libraries (each feature is a namespaced subfolder of its host): **Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems). → `Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`). -- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`) plus +- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`), **DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`, - `scene.UseDevConsole()` last in OnLoad). → `Core` (Myra renders with its own - SpriteBatch internally). + `scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a + Chrome-DevTools-style ECS debugger overlay — entity tree by archetype, component/field + view with simple-field editing, world-pick with selection highlight, renderer perf tab; + `scene.UseInspector(renderer)`, toggle F1). → `Core`, `Graphics` (the inspector picks + entities and reads renderer timings; Myra renders with its own SpriteBatch internally). - **`Assets.Generator`** — Roslyn source generator for typed asset handles; standalone netstandard2.0 analyzer. diff --git a/docs/architecture.md b/docs/architecture.md index 1f0328c..2559069 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ | `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) | | `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента | | `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast | -| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение | +| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение. **Inspector** (`MrGameEng.Inspector`): ECS-дебагер в духе Chrome DevTools — дерево сущностей по архетипам, компоненты/поля с правкой простых полей, выбор кликом по миру с подсветкой, вкладка перфа рендера (`scene.UseInspector(renderer)`, F1) | | `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) | Планируемые области (по мере развития): `Physics2D`, `Particles` — добавляются как @@ -73,8 +73,8 @@ MrGameEng.Audio ─┐ MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL │ └──► Friflo.Engine.ECS MrGameEng.Content ─┤ -MrGameEng.Simulation ─┴──► MrGameEng.Graphics (Content — за Texture2DRegion, -MrGameEng.UI ──► MrGameEng.Core Simulation — за Transform2D/RectF) +MrGameEng.Simulation ─┤ (Content — за Texture2DRegion, +MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер) ``` Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от MonoGame diff --git a/src/MrGameEng.UI/ComponentReflector.cs b/src/MrGameEng.UI/ComponentReflector.cs new file mode 100644 index 0000000..84e55b5 --- /dev/null +++ b/src/MrGameEng.UI/ComponentReflector.cs @@ -0,0 +1,201 @@ +using System.Globalization; +using System.Reflection; +using Microsoft.Xna.Framework; + +namespace MrGameEng.Inspector; + +/// Editing capability of an inspected field, picked by the UI to choose a widget. +public enum FieldKind +{ + /// Read-only value shown as text (vectors, colors, references, strings). + Text, + + /// Editable number (integer or floating point). + Number, + + /// Editable boolean. + Bool, + + /// Editable enum; lists the choices. + Enum, +} + +/// One inspected field of a component: its name, formatted value and how it can be edited. +public sealed record FieldRow( + string Name, + string Value, + bool Editable, + FieldKind Kind, + string[]? EnumOptions = null +); + +/// +/// Reflects over boxed component structs for the ECS inspector: turns their public fields into +/// displayable s and writes simple scalar fields back. Pure and GPU-free — +/// the inspector's testable core. Only top-level scalar fields (numbers, bool, enums) are +/// editable; vectors, colors, strings and references are shown read-only. +/// +public static class ComponentReflector +{ + private const BindingFlags PublicInstance = BindingFlags.Public | BindingFlags.Instance; + + /// Reads the public fields of into display rows. + public static IReadOnlyList Read(object component) + { + var type = component.GetType(); + var rows = new List(); + foreach (var field in type.GetFields(PublicInstance)) + { + var value = field.GetValue(component); + rows.Add(BuildRow(field.Name, field.FieldType, value)); + } + + // Read-only display of public gettable properties (rare on components, but harmless). + foreach (var prop in type.GetProperties(PublicInstance)) + { + if (prop.GetIndexParameters().Length > 0 || prop.GetMethod is null) + { + continue; + } + + var value = prop.GetValue(component); + rows.Add( + new FieldRow(prop.Name, Format(prop.PropertyType, value), false, FieldKind.Text) + ); + } + + return rows; + } + + /// + /// Parses and writes it into the scalar field + /// of the boxed component. Returns false when the field is missing, not editable, or unparseable. + /// + public static bool TrySet(object component, string fieldName, string raw) + { + var field = component.GetType().GetField(fieldName, PublicInstance); + if (field is null || !IsScalar(field.FieldType)) + { + return false; + } + + if (!TryParse(field.FieldType, raw, out var parsed)) + { + return false; + } + + field.SetValue(component, parsed); + return true; + } + + private static FieldRow BuildRow(string name, Type type, object? value) + { + if (type.IsEnum) + { + return new FieldRow( + name, + value?.ToString() ?? "", + true, + FieldKind.Enum, + Enum.GetNames(type) + ); + } + + if (type == typeof(bool)) + { + return new FieldRow(name, Format(type, value), true, FieldKind.Bool); + } + + if (IsNumeric(type)) + { + return new FieldRow(name, Format(type, value), true, FieldKind.Number); + } + + return new FieldRow(name, Format(type, value), false, FieldKind.Text); + } + + private static string Format(Type type, object? value) + { + switch (value) + { + case null: + return "null"; + case Vector2 v: + return $"({v.X:0.###}, {v.Y:0.###})"; + case Color c: + return $"{c.R}, {c.G}, {c.B}, {c.A}"; + case bool b: + return b ? "true" : "false"; + case string s: + return s; + } + + if (type.IsEnum || IsNumeric(type)) + { + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + } + + // Reference/struct without a custom ToString prints its full type name — show the short name. + var text = value.ToString(); + return string.IsNullOrEmpty(text) || text == type.FullName ? type.Name : text; + } + + private static bool IsScalar(Type type) => + type.IsEnum || type == typeof(bool) || IsNumeric(type); + + private static bool IsNumeric(Type type) => + Type.GetTypeCode(type) switch + { + TypeCode.Byte + or TypeCode.SByte + or TypeCode.Int16 + or TypeCode.UInt16 + or TypeCode.Int32 + or TypeCode.UInt32 + or TypeCode.Int64 + or TypeCode.UInt64 + or TypeCode.Single + or TypeCode.Double + or TypeCode.Decimal => true, + _ => false, + }; + + private static bool TryParse(Type type, string raw, out object? value) + { + raw = raw.Trim(); + if (type.IsEnum) + { + if (Enum.TryParse(type, raw, ignoreCase: true, out var parsed)) + { + value = parsed; + return true; + } + + value = null; + return false; + } + + if (type == typeof(bool)) + { + if (bool.TryParse(raw, out var b)) + { + value = b; + return true; + } + + value = null; + return false; + } + + try + { + value = Convert.ChangeType(raw, type, CultureInfo.InvariantCulture); + return true; + } + catch (Exception e) when (e is FormatException or OverflowException or InvalidCastException) + { + value = null; + return false; + } + } +} diff --git a/src/MrGameEng.UI/EcsInspector.cs b/src/MrGameEng.UI/EcsInspector.cs new file mode 100644 index 0000000..68931d0 --- /dev/null +++ b/src/MrGameEng.UI/EcsInspector.cs @@ -0,0 +1,455 @@ +using System.Reflection; +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace MrGameEng.Inspector; + +/// Tabs of the ECS inspector. +public enum InspectorTab +{ + /// Entity tree: archetypes → entities → components and fields. + Entities, + + /// Renderer frame timings and counters. + Performance, +} + +/// An archetype group in the entity list: a component signature and how many entities share it. +public sealed record ArchetypeRow(int Index, string Signature, int Count); + +/// One component of the selected entity: its type and reflected fields. +public sealed record ComponentView(string TypeName, Type Type, IReadOnlyList Fields); + +/// A snapshot of renderer/world metrics for the performance tab. +public readonly record struct PerfSnapshot( + float SubmitMs, + float SortMs, + float BuildMs, + float UploadMs, + float DrawMs, + int DrawCalls, + int SubmittedSprites, + int CulledSprites, + int Fps, + int Entities, + int Archetypes +); + +/// +/// Headless core of the in-engine ECS inspector (the DevTools-style debugger): enumerates the +/// store's entities grouped by archetype, reflects a selected entity's components and fields, +/// writes simple field edits back, and (when a renderer is supplied) picks entities under the +/// cursor and reports frame metrics. Holds no Myra state, so its logic is unit-testable without a +/// GPU. UI lazily rebuilds when changes. +/// +public sealed class EcsInspector +{ + private const int EntityListLimit = 200; + + private readonly EntityStore _store; + private readonly Renderer2D? _renderer; + private readonly GameClock? _clock; + private static readonly MethodInfo AddComponentMethod = ResolveAddComponent(); + + private readonly List _archetypes = []; + private readonly List _rows = []; + private bool _dirty = true; + private int _entityCount; + private float _refreshTimer; + + /// Creates an inspector over ; the renderer and clock enable picking and metrics. + public EcsInspector(EntityStore store, Renderer2D? renderer = null, GameClock? clock = null) + { + _store = store; + _renderer = renderer; + _clock = clock; + } + + /// True while the inspector panel is open. + public bool IsOpen { get; private set; } + + /// Increments on every change the UI should rebuild for (open/select/edit/refresh). + public int Revision { get; private set; } + + /// The active tab. + public InspectorTab ActiveTab { get; private set; } + + /// True while the next world click selects the entity under the cursor. + public bool PickArmed { get; private set; } + + /// Runtime id of the selected entity, or -1 when none is selected. + public int SelectedEntityId { get; private set; } = -1; + + /// Index of the selected archetype group, or -1 when none is selected. + public int SelectedArchetype { get; private set; } = -1; + + /// Case-insensitive filter applied to archetype signatures. + public string Search { get; private set; } = ""; + + /// Opens or closes the panel. + public void Toggle() + { + IsOpen = !IsOpen; + _dirty = true; + Bump(); + } + + /// Switches the active tab. + public void SetTab(InspectorTab tab) + { + ActiveTab = tab; + Bump(); + } + + /// Arms (or disarms) world-pick mode. + public void ArmPick(bool armed = true) + { + PickArmed = armed; + Bump(); + } + + /// Sets the archetype filter and refreshes the list. + public void SetSearch(string search) + { + Search = search ?? ""; + _dirty = true; + Bump(); + } + + /// Selects an archetype group by index; clears the entity selection. + public void SelectArchetype(int index) + { + SelectedArchetype = index; + SelectedEntityId = -1; + Bump(); + } + + /// Selects an entity by runtime id. + public void SelectEntity(int entityId) + { + SelectedEntityId = entityId; + Bump(); + } + + /// + /// Advances internal refresh timing while open; periodically marks the entity list dirty so + /// live additions/removals show up. is unscaled real time. + /// + public void Tick(float deltaSeconds) + { + if (!IsOpen) + { + return; + } + + _refreshTimer += deltaSeconds; + if (_refreshTimer >= 0.5f) + { + _refreshTimer = 0f; + _dirty = true; + Bump(); + } + } + + /// Archetype groups (filtered by ), rebuilt on demand. + public IReadOnlyList Archetypes() + { + RebuildIfNeeded(); + return _rows; + } + + /// Runtime ids of entities in the given archetype group (capped at a display limit). + public IReadOnlyList EntitiesOf(int archetypeIndex) + { + RebuildIfNeeded(); + if (archetypeIndex < 0 || archetypeIndex >= _archetypes.Count) + { + return []; + } + + var target = _archetypes[archetypeIndex]; + var ids = new List(); + foreach (var entity in _store.Entities) + { + if (entity.Archetype == target) + { + ids.Add(entity.Id); + if (ids.Count >= EntityListLimit) + { + break; + } + } + } + + return ids; + } + + /// Components (with reflected fields) of the entity, or an empty list when it is gone. + public IReadOnlyList Inspect(int entityId) + { + if (!TryFindEntity(entityId, out var entity)) + { + return []; + } + + var views = new List(); + foreach (var component in entity.Components) + { + var value = BoxedValue(component); // boxes the struct — inspector only, not a hot path + var type = value.GetType(); + views.Add(new ComponentView(type.Name, type, ComponentReflector.Read(value))); + } + + return views; + } + + /// + /// Parses into the scalar field of the component and writes it back to + /// the entity. Returns false when the entity/component/field is gone or the value is invalid. + /// + public bool SetField(int entityId, Type componentType, string fieldName, string raw) + { + if (!TryFindEntity(entityId, out var entity)) + { + return false; + } + + foreach (var component in entity.Components) + { + var boxed = BoxedValue(component); + if (boxed.GetType() != componentType) + { + continue; + } + + if (!ComponentReflector.TrySet(boxed, fieldName, raw)) + { + return false; + } + + // Adding an existing component type replaces it with the edited value. + AddComponentMethod.MakeGenericMethod(componentType).Invoke(entity, [boxed]); + Bump(); + return true; + } + + return false; + } + + /// + /// Selects the topmost entity whose sprite covers (physical + /// pixels). Needs a renderer; returns the selected id or -1. Disarms pick mode on a hit. + /// + public int Pick(Vector2 screenPoint) + { + if (_renderer is null) + { + return -1; + } + + var world = _renderer.ScreenToWorld(screenPoint); + var best = -1; + var bestDistance = float.MaxValue; + foreach (var (transforms, sprites, entities) in _store.Query().Chunks) + { + var t = transforms.Span; + var s = sprites.Span; + for (var i = 0; i < t.Length; i++) + { + if (s[i].Region is not { } region) + { + continue; + } + + var (center, radius) = CullingMath.SpriteBoundingCircle( + in t[i], + region, + s[i].Origin + ); + var distance = Vector2.DistanceSquared(center, world); + if (distance <= radius * radius && distance < bestDistance) + { + bestDistance = distance; + best = entities.EntityAt(i).Id; + } + } + } + + if (best >= 0) + { + SelectedEntityId = best; + PickArmed = false; + Bump(); + } + + return best; + } + + /// + /// World-space bounding circle of the selected entity (needs Transform2D+Sprite). + /// Used by the UI to draw the selection highlight; false when there is nothing to highlight. + /// + public bool TryGetSelectedBounds(out Vector2 center, out float radius) + { + center = default; + radius = 0f; + if (!TryFindEntity(SelectedEntityId, out var entity)) + { + return false; + } + + if (!entity.HasComponent() || !entity.HasComponent()) + { + return false; + } + + var transform = entity.GetComponent(); + var sprite = entity.GetComponent(); + if (sprite.Region is not { } region) + { + return false; + } + + (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin); + return true; + } + + /// Current renderer/world metrics (zeroed when no renderer is attached). + public PerfSnapshot Performance() + { + RebuildIfNeeded(); + var fps = _clock is { UnscaledDeltaTime: > 0f } + ? (int)MathF.Round(1f / _clock.UnscaledDeltaTime) + : 0; + var r = _renderer; + return new PerfSnapshot( + r?.SubmitMs ?? 0f, + r?.SortMs ?? 0f, + r?.BuildMs ?? 0f, + r?.UploadMs ?? 0f, + r?.DrawMs ?? 0f, + r?.DrawCalls ?? 0, + r?.SubmittedSprites ?? 0, + r?.CulledSprites ?? 0, + fps, + _entityCount, + _rows.Count + ); + } + + // The boxed component value. EntityComponent.Value is the documented (if deprecated) path to a + // generic, type-erased component; the inspector intentionally boxes for reflection. +#pragma warning disable CS0618 + private static object BoxedValue(EntityComponent component) => component.Value; +#pragma warning restore CS0618 + + private void Bump() => Revision++; + + private void RebuildIfNeeded() + { + if (!_dirty) + { + return; + } + + _dirty = false; + _archetypes.Clear(); + _rows.Clear(); + var counts = new Dictionary(); + var order = new List(); + _entityCount = 0; + foreach (var entity in _store.Entities) + { + _entityCount++; + var archetype = entity.Archetype; + if (archetype is null) + { + continue; + } + + if (counts.TryGetValue(archetype, out var count)) + { + counts[archetype] = count + 1; + } + else + { + counts[archetype] = 1; + order.Add(archetype); + } + } + + foreach (var archetype in order) + { + var signature = SignatureOf(archetype); + if ( + Search.Length > 0 + && !signature.Contains(Search, StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } + + _rows.Add(new ArchetypeRow(_archetypes.Count, signature, counts[archetype])); + _archetypes.Add(archetype); + } + } + + // Component names of an archetype, derived from a representative entity's components. + private string SignatureOf(Archetype archetype) + { + foreach (var entity in _store.Entities) + { + if (entity.Archetype != archetype) + { + continue; + } + + var names = new List(); + foreach (var component in entity.Components) + { + names.Add(BoxedValue(component).GetType().Name); + } + + return names.Count > 0 ? string.Join(", ", names) : "(no components)"; + } + + return "(empty)"; + } + + private bool TryFindEntity(int entityId, out Entity entity) + { + foreach (var candidate in _store.Entities) + { + if (candidate.Id == entityId) + { + entity = candidate; + return true; + } + } + + entity = default; + return false; + } + + private static MethodInfo ResolveAddComponent() + { + foreach ( + var method in typeof(Entity).GetMethods(BindingFlags.Public | BindingFlags.Instance) + ) + { + if ( + method.Name == "AddComponent" + && method.IsGenericMethodDefinition + && method.GetGenericArguments().Length == 1 + && method.GetParameters().Length == 1 + ) + { + return method; + } + } + + throw new InvalidOperationException("Friflo Entity.AddComponent(T) not found."); + } +} diff --git a/src/MrGameEng.UI/EcsInspectorUi.cs b/src/MrGameEng.UI/EcsInspectorUi.cs new file mode 100644 index 0000000..0b39769 --- /dev/null +++ b/src/MrGameEng.UI/EcsInspectorUi.cs @@ -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; + +/// +/// 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 +/// changes. +/// +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(); + } + + /// True when the physical screen point is over the panel (so a pick click is ignored). + 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; + } +} diff --git a/src/MrGameEng.UI/InspectorSystems.cs b/src/MrGameEng.UI/InspectorSystems.cs new file mode 100644 index 0000000..5f5f418 --- /dev/null +++ b/src/MrGameEng.UI/InspectorSystems.cs @@ -0,0 +1,110 @@ +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Core; +using MrGameEng.Graphics; +using Myra; +using Myra.Graphics2D.UI; + +namespace MrGameEng.Inspector; + +/// +/// Update-phase system: toggles the inspector with F1 (polls the keyboard directly, like the +/// console) and, while pick mode is armed, selects the entity under a left click that lands outside +/// the panel. Holds while any overlay is open. Runs last so its +/// capture value (OR-ed with the console) is the final word for the frame. +/// +public sealed class InspectorSystem : BaseSystem +{ + private readonly EcsInspector _inspector; + private readonly EcsInspectorUi _ui; + private readonly InputCapture _capture; + private readonly Func _otherOverlayOpen; + private KeyboardState _previousKeyboard; + private ButtonState _previousLeft; + + internal InspectorSystem( + EcsInspector inspector, + EcsInspectorUi ui, + InputCapture capture, + Func otherOverlayOpen + ) + { + _inspector = inspector; + _ui = ui; + _capture = capture; + _otherOverlayOpen = otherOverlayOpen; + } + + /// + protected override void OnUpdateGroup() + { + var keyboard = Keyboard.GetState(); + if (keyboard.IsKeyDown(Keys.F1) && _previousKeyboard.IsKeyUp(Keys.F1)) + { + _inspector.Toggle(); + } + + _previousKeyboard = keyboard; + + var mouse = Mouse.GetState(); + if ( + _inspector is { IsOpen: true, PickArmed: true } + && mouse.LeftButton == ButtonState.Pressed + && _previousLeft == ButtonState.Released + && !_ui.IsOverPanel(new Point(mouse.X, mouse.Y)) + ) + { + _inspector.Pick(new Vector2(mouse.X, mouse.Y)); + } + + _previousLeft = mouse.LeftButton; + _inspector.Tick(Tick.deltaTime); + _capture.Captured = _inspector.IsOpen || _otherOverlayOpen(); + } +} + +/// Draw-phase system rendering the inspector overlay. +public sealed class InspectorRenderSystem : BaseSystem +{ + private readonly EcsInspectorUi _ui; + + internal InspectorRenderSystem(EcsInspectorUi ui) => _ui = ui; + + /// + protected override void OnUpdateGroup() => _ui.Render(); +} + +/// Wires the ECS inspector into a . +public static class SceneInspectorExtensions +{ + /// + /// Adds the ECS inspector overlay to the scene (a fresh instance bound to this scene's store) + /// and registers its systems. Pass the scene's for entity picking, + /// the selection highlight and the performance tab. Call from OnLoad before + /// UseDevConsole so the console still draws on top. Toggle with F1. + /// + public static EcsInspector UseInspector(this Scene scene, Renderer2D renderer) + { + var services = scene.Context.Services; + MyraEnvironment.Game = services.Get(); + + var inspector = new EcsInspector(scene.Store, renderer, scene.Context.Clock); + var ui = new EcsInspectorUi(inspector, renderer); + + var capture = services.GetOrDefault(); + if (capture is null) + { + capture = new InputCapture(); + services.Add(capture); + } + + bool OtherOverlayOpen() => + services.GetOrDefault()?.IsOpen ?? false; + + scene.UpdateSystems.Add(new InspectorSystem(inspector, ui, capture, OtherOverlayOpen)); + scene.DrawSystems.Add(new InspectorRenderSystem(ui)); + Log.Info("ECS inspector ready — press F1 to toggle"); + return inspector; + } +} diff --git a/src/MrGameEng.UI/MrGameEng.UI.csproj b/src/MrGameEng.UI/MrGameEng.UI.csproj index 0ea8a0a..7a6f2ef 100644 --- a/src/MrGameEng.UI/MrGameEng.UI.csproj +++ b/src/MrGameEng.UI/MrGameEng.UI.csproj @@ -9,5 +9,6 @@ + diff --git a/tests/MrGameEng.UI.Tests/ComponentReflectorTests.cs b/tests/MrGameEng.UI.Tests/ComponentReflectorTests.cs new file mode 100644 index 0000000..56dc088 --- /dev/null +++ b/tests/MrGameEng.UI.Tests/ComponentReflectorTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Xna.Framework; +using MrGameEng.Inspector; +using Xunit; + +namespace MrGameEng.Inspector.Tests; + +public class ComponentReflectorTests +{ + private enum Mode + { + Idle, + Run, + } + + private struct Probe + { + public int Count; + public float Speed; + public bool Active; + public Mode State; + public Vector2 Offset; + } + + [Fact] + public void Read_ClassifiesFieldKinds() + { + var rows = ComponentReflector.Read( + new Probe + { + Count = 3, + Speed = 1.5f, + Active = true, + State = Mode.Run, + Offset = new Vector2(2f, 4f), + } + ); + + Assert.Equal(FieldKind.Number, Find(rows, "Count").Kind); + Assert.Equal(FieldKind.Bool, Find(rows, "Active").Kind); + Assert.Equal(FieldKind.Enum, Find(rows, "State").Kind); + Assert.Equal(FieldKind.Text, Find(rows, "Offset").Kind); + Assert.False(Find(rows, "Offset").Editable); + Assert.Equal("Run", Find(rows, "State").Value); + Assert.Equal("(2, 4)", Find(rows, "Offset").Value); + } + + [Fact] + public void TrySet_UpdatesScalarFields() + { + object boxed = new Probe(); + + Assert.True(ComponentReflector.TrySet(boxed, "Count", "42")); + Assert.True(ComponentReflector.TrySet(boxed, "Active", "true")); + Assert.True(ComponentReflector.TrySet(boxed, "State", "Run")); + + var probe = (Probe)boxed; + Assert.Equal(42, probe.Count); + Assert.True(probe.Active); + Assert.Equal(Mode.Run, probe.State); + } + + [Fact] + public void TrySet_RejectsUnparseableValue() + { + object boxed = new Probe(); + Assert.False(ComponentReflector.TrySet(boxed, "Count", "not-a-number")); + } + + [Fact] + public void TrySet_RejectsNonScalarOrMissingField() + { + object boxed = new Probe(); + Assert.False(ComponentReflector.TrySet(boxed, "Offset", "1,2")); + Assert.False(ComponentReflector.TrySet(boxed, "Missing", "1")); + } + + private static FieldRow Find(IReadOnlyList rows, string name) + { + foreach (var row in rows) + { + if (row.Name == name) + { + return row; + } + } + + throw new Xunit.Sdk.XunitException($"field {name} not found"); + } +} diff --git a/tests/MrGameEng.UI.Tests/EcsInspectorTests.cs b/tests/MrGameEng.UI.Tests/EcsInspectorTests.cs new file mode 100644 index 0000000..5365d72 --- /dev/null +++ b/tests/MrGameEng.UI.Tests/EcsInspectorTests.cs @@ -0,0 +1,144 @@ +using Friflo.Engine.ECS; +using MrGameEng.Inspector; +using Xunit; + +namespace MrGameEng.Inspector.Tests; + +public class EcsInspectorTests +{ + private enum Mode + { + Idle, + Run, + } + + private struct Probe : IComponent + { + public int Count; + public bool Active; + public Mode State; + } + + private struct Marker : IComponent + { + public int Value; + } + + [Fact] + public void Archetypes_GroupsEntitiesByComponentSet() + { + var store = new EntityStore(); + store.CreateEntity(new Probe { Count = 1 }); + store.CreateEntity(new Probe { Count = 2 }); + store.CreateEntity(new Marker { Value = 9 }); + var inspector = new EcsInspector(store); + + var rows = inspector.Archetypes(); + + var probeRow = Single(rows, r => r.Signature.Contains("Probe")); + Assert.Equal(2, probeRow.Count); + Assert.Contains(rows, r => r.Signature.Contains("Marker") && r.Count == 1); + } + + [Fact] + public void EntitiesOf_ReturnsIdsOfArchetype() + { + var store = new EntityStore(); + var a = store.CreateEntity(new Probe { Count = 1 }); + var b = store.CreateEntity(new Probe { Count = 2 }); + var inspector = new EcsInspector(store); + + var probeRow = Single(inspector.Archetypes(), r => r.Signature.Contains("Probe")); + var ids = inspector.EntitiesOf(probeRow.Index); + + Assert.Contains(a.Id, ids); + Assert.Contains(b.Id, ids); + Assert.Equal(2, ids.Count); + } + + [Fact] + public void Inspect_ReturnsComponentFields() + { + var store = new EntityStore(); + var entity = store.CreateEntity( + new Probe + { + Count = 5, + Active = true, + State = Mode.Run, + } + ); + var inspector = new EcsInspector(store); + + var view = Single(inspector.Inspect(entity.Id), c => c.TypeName == "Probe"); + + Assert.Equal("5", Field(view, "Count").Value); + Assert.Equal("true", Field(view, "Active").Value); + Assert.Equal("Run", Field(view, "State").Value); + } + + [Fact] + public void SetField_WritesValueBackToEntity() + { + var store = new EntityStore(); + var entity = store.CreateEntity(new Probe { Count = 5 }); + var inspector = new EcsInspector(store); + + var ok = inspector.SetField(entity.Id, typeof(Probe), "Count", "42"); + + Assert.True(ok); + Assert.Equal(42, entity.GetComponent().Count); + } + + [Fact] + public void SetField_RejectsInvalidValue() + { + var store = new EntityStore(); + var entity = store.CreateEntity(new Probe { Count = 5 }); + var inspector = new EcsInspector(store); + + Assert.False(inspector.SetField(entity.Id, typeof(Probe), "Count", "bad")); + Assert.Equal(5, entity.GetComponent().Count); + } + + [Fact] + public void Search_FiltersArchetypesBySignature() + { + var store = new EntityStore(); + store.CreateEntity(new Probe()); + store.CreateEntity(new Marker()); + var inspector = new EcsInspector(store); + + inspector.SetSearch("Marker"); + + var rows = inspector.Archetypes(); + Assert.Single(rows); + Assert.Contains("Marker", rows[0].Signature); + } + + private static T Single(IReadOnlyList items, Func predicate) + { + foreach (var item in items) + { + if (predicate(item)) + { + return item; + } + } + + throw new Xunit.Sdk.XunitException("no matching item"); + } + + private static FieldRow Field(ComponentView view, string name) + { + foreach (var row in view.Fields) + { + if (row.Name == name) + { + return row; + } + } + + throw new Xunit.Sdk.XunitException($"field {name} not found"); + } +}