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
@@ -52,10 +52,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
|
|||||||
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
|
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
|
||||||
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
|
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
|
||||||
→ `Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`).
|
→ `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`,
|
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
|
||||||
`scene.UseDevConsole()` last in OnLoad). → `Core` (Myra renders with its own
|
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
|
||||||
SpriteBatch internally).
|
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
|
- **`Assets.Generator`** — Roslyn source generator for typed asset handles; standalone
|
||||||
netstandard2.0 analyzer.
|
netstandard2.0 analyzer.
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
|
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
|
||||||
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
|
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **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<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
|
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `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) |
|
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
|
||||||
|
|
||||||
Планируемые области (по мере развития): `Physics2D`, `Particles` — добавляются как
|
Планируемые области (по мере развития): `Physics2D`, `Particles` — добавляются как
|
||||||
@@ -73,8 +73,8 @@ MrGameEng.Audio ─┐
|
|||||||
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
|
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
|
||||||
│ └──► Friflo.Engine.ECS
|
│ └──► Friflo.Engine.ECS
|
||||||
MrGameEng.Content ─┤
|
MrGameEng.Content ─┤
|
||||||
MrGameEng.Simulation ─┴──► MrGameEng.Graphics (Content — за Texture2DRegion,
|
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
|
||||||
MrGameEng.UI ──► MrGameEng.Core Simulation — за Transform2D/RectF)
|
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
|
||||||
```
|
```
|
||||||
|
|
||||||
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от MonoGame
|
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от MonoGame
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Inspector;
|
||||||
|
|
||||||
|
/// <summary>Editing capability of an inspected field, picked by the UI to choose a widget.</summary>
|
||||||
|
public enum FieldKind
|
||||||
|
{
|
||||||
|
/// <summary>Read-only value shown as text (vectors, colors, references, strings).</summary>
|
||||||
|
Text,
|
||||||
|
|
||||||
|
/// <summary>Editable number (integer or floating point).</summary>
|
||||||
|
Number,
|
||||||
|
|
||||||
|
/// <summary>Editable boolean.</summary>
|
||||||
|
Bool,
|
||||||
|
|
||||||
|
/// <summary>Editable enum; <see cref="FieldRow.EnumOptions"/> lists the choices.</summary>
|
||||||
|
Enum,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One inspected field of a component: its name, formatted value and how it can be edited.</summary>
|
||||||
|
public sealed record FieldRow(
|
||||||
|
string Name,
|
||||||
|
string Value,
|
||||||
|
bool Editable,
|
||||||
|
FieldKind Kind,
|
||||||
|
string[]? EnumOptions = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reflects over boxed component structs for the ECS inspector: turns their public fields into
|
||||||
|
/// displayable <see cref="FieldRow"/>s and writes simple scalar fields back. Pure and GPU-free —
|
||||||
|
/// the inspector's testable core. Only top-level scalar fields (numbers, <c>bool</c>, enums) are
|
||||||
|
/// editable; vectors, colors, strings and references are shown read-only.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentReflector
|
||||||
|
{
|
||||||
|
private const BindingFlags PublicInstance = BindingFlags.Public | BindingFlags.Instance;
|
||||||
|
|
||||||
|
/// <summary>Reads the public fields of <paramref name="component"/> into display rows.</summary>
|
||||||
|
public static IReadOnlyList<FieldRow> Read(object component)
|
||||||
|
{
|
||||||
|
var type = component.GetType();
|
||||||
|
var rows = new List<FieldRow>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <paramref name="raw"/> and writes it into the scalar field <paramref name="fieldName"/>
|
||||||
|
/// of the boxed component. Returns false when the field is missing, not editable, or unparseable.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
using MrGameEng.Graphics;
|
||||||
|
|
||||||
|
namespace MrGameEng.Inspector;
|
||||||
|
|
||||||
|
/// <summary>Tabs of the ECS inspector.</summary>
|
||||||
|
public enum InspectorTab
|
||||||
|
{
|
||||||
|
/// <summary>Entity tree: archetypes → entities → components and fields.</summary>
|
||||||
|
Entities,
|
||||||
|
|
||||||
|
/// <summary>Renderer frame timings and counters.</summary>
|
||||||
|
Performance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An archetype group in the entity list: a component signature and how many entities share it.</summary>
|
||||||
|
public sealed record ArchetypeRow(int Index, string Signature, int Count);
|
||||||
|
|
||||||
|
/// <summary>One component of the selected entity: its type and reflected fields.</summary>
|
||||||
|
public sealed record ComponentView(string TypeName, Type Type, IReadOnlyList<FieldRow> Fields);
|
||||||
|
|
||||||
|
/// <summary>A snapshot of renderer/world metrics for the performance tab.</summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="Revision"/> changes.
|
||||||
|
/// </summary>
|
||||||
|
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<Archetype> _archetypes = [];
|
||||||
|
private readonly List<ArchetypeRow> _rows = [];
|
||||||
|
private bool _dirty = true;
|
||||||
|
private int _entityCount;
|
||||||
|
private float _refreshTimer;
|
||||||
|
|
||||||
|
/// <summary>Creates an inspector over <paramref name="store"/>; the renderer and clock enable picking and metrics.</summary>
|
||||||
|
public EcsInspector(EntityStore store, Renderer2D? renderer = null, GameClock? clock = null)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_renderer = renderer;
|
||||||
|
_clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True while the inspector panel is open.</summary>
|
||||||
|
public bool IsOpen { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Increments on every change the UI should rebuild for (open/select/edit/refresh).</summary>
|
||||||
|
public int Revision { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The active tab.</summary>
|
||||||
|
public InspectorTab ActiveTab { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>True while the next world click selects the entity under the cursor.</summary>
|
||||||
|
public bool PickArmed { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Runtime id of the selected entity, or -1 when none is selected.</summary>
|
||||||
|
public int SelectedEntityId { get; private set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>Index of the selected archetype group, or -1 when none is selected.</summary>
|
||||||
|
public int SelectedArchetype { get; private set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>Case-insensitive filter applied to archetype signatures.</summary>
|
||||||
|
public string Search { get; private set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Opens or closes the panel.</summary>
|
||||||
|
public void Toggle()
|
||||||
|
{
|
||||||
|
IsOpen = !IsOpen;
|
||||||
|
_dirty = true;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Switches the active tab.</summary>
|
||||||
|
public void SetTab(InspectorTab tab)
|
||||||
|
{
|
||||||
|
ActiveTab = tab;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Arms (or disarms) world-pick mode.</summary>
|
||||||
|
public void ArmPick(bool armed = true)
|
||||||
|
{
|
||||||
|
PickArmed = armed;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the archetype filter and refreshes the list.</summary>
|
||||||
|
public void SetSearch(string search)
|
||||||
|
{
|
||||||
|
Search = search ?? "";
|
||||||
|
_dirty = true;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Selects an archetype group by index; clears the entity selection.</summary>
|
||||||
|
public void SelectArchetype(int index)
|
||||||
|
{
|
||||||
|
SelectedArchetype = index;
|
||||||
|
SelectedEntityId = -1;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Selects an entity by runtime id.</summary>
|
||||||
|
public void SelectEntity(int entityId)
|
||||||
|
{
|
||||||
|
SelectedEntityId = entityId;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances internal refresh timing while open; periodically marks the entity list dirty so
|
||||||
|
/// live additions/removals show up. <paramref name="deltaSeconds"/> is unscaled real time.
|
||||||
|
/// </summary>
|
||||||
|
public void Tick(float deltaSeconds)
|
||||||
|
{
|
||||||
|
if (!IsOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshTimer += deltaSeconds;
|
||||||
|
if (_refreshTimer >= 0.5f)
|
||||||
|
{
|
||||||
|
_refreshTimer = 0f;
|
||||||
|
_dirty = true;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Archetype groups (filtered by <see cref="Search"/>), rebuilt on demand.</summary>
|
||||||
|
public IReadOnlyList<ArchetypeRow> Archetypes()
|
||||||
|
{
|
||||||
|
RebuildIfNeeded();
|
||||||
|
return _rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runtime ids of entities in the given archetype group (capped at a display limit).</summary>
|
||||||
|
public IReadOnlyList<int> EntitiesOf(int archetypeIndex)
|
||||||
|
{
|
||||||
|
RebuildIfNeeded();
|
||||||
|
if (archetypeIndex < 0 || archetypeIndex >= _archetypes.Count)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = _archetypes[archetypeIndex];
|
||||||
|
var ids = new List<int>();
|
||||||
|
foreach (var entity in _store.Entities)
|
||||||
|
{
|
||||||
|
if (entity.Archetype == target)
|
||||||
|
{
|
||||||
|
ids.Add(entity.Id);
|
||||||
|
if (ids.Count >= EntityListLimit)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Components (with reflected fields) of the entity, or an empty list when it is gone.</summary>
|
||||||
|
public IReadOnlyList<ComponentView> Inspect(int entityId)
|
||||||
|
{
|
||||||
|
if (!TryFindEntity(entityId, out var entity))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var views = new List<ComponentView>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <paramref name="raw"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects the topmost entity whose sprite covers <paramref name="screenPoint"/> (physical
|
||||||
|
/// pixels). Needs a renderer; returns the selected id or -1. Disarms pick mode on a hit.
|
||||||
|
/// </summary>
|
||||||
|
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<Transform2D, Sprite>().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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// World-space bounding circle of the selected entity (needs <c>Transform2D</c>+<c>Sprite</c>).
|
||||||
|
/// Used by the UI to draw the selection highlight; false when there is nothing to highlight.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetSelectedBounds(out Vector2 center, out float radius)
|
||||||
|
{
|
||||||
|
center = default;
|
||||||
|
radius = 0f;
|
||||||
|
if (!TryFindEntity(SelectedEntityId, out var entity))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entity.HasComponent<Transform2D>() || !entity.HasComponent<Sprite>())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transform = entity.GetComponent<Transform2D>();
|
||||||
|
var sprite = entity.GetComponent<Sprite>();
|
||||||
|
if (sprite.Region is not { } region)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current renderer/world metrics (zeroed when no renderer is attached).</summary>
|
||||||
|
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<Archetype, int>();
|
||||||
|
var order = new List<Archetype>();
|
||||||
|
_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<string>();
|
||||||
|
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>(T) not found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="InputCapture.Captured"/> while any overlay is open. Runs last so its
|
||||||
|
/// capture value (OR-ed with the console) is the final word for the frame.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InspectorSystem : BaseSystem
|
||||||
|
{
|
||||||
|
private readonly EcsInspector _inspector;
|
||||||
|
private readonly EcsInspectorUi _ui;
|
||||||
|
private readonly InputCapture _capture;
|
||||||
|
private readonly Func<bool> _otherOverlayOpen;
|
||||||
|
private KeyboardState _previousKeyboard;
|
||||||
|
private ButtonState _previousLeft;
|
||||||
|
|
||||||
|
internal InspectorSystem(
|
||||||
|
EcsInspector inspector,
|
||||||
|
EcsInspectorUi ui,
|
||||||
|
InputCapture capture,
|
||||||
|
Func<bool> otherOverlayOpen
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_inspector = inspector;
|
||||||
|
_ui = ui;
|
||||||
|
_capture = capture;
|
||||||
|
_otherOverlayOpen = otherOverlayOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw-phase system rendering the inspector overlay.</summary>
|
||||||
|
public sealed class InspectorRenderSystem : BaseSystem
|
||||||
|
{
|
||||||
|
private readonly EcsInspectorUi _ui;
|
||||||
|
|
||||||
|
internal InspectorRenderSystem(EcsInspectorUi ui) => _ui = ui;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnUpdateGroup() => _ui.Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wires the ECS inspector into a <see cref="Scene"/>.</summary>
|
||||||
|
public static class SceneInspectorExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <paramref name="renderer"/> for entity picking,
|
||||||
|
/// the selection highlight and the performance tab. Call from <c>OnLoad</c> <b>before</b>
|
||||||
|
/// <c>UseDevConsole</c> so the console still draws on top. Toggle with <b>F1</b>.
|
||||||
|
/// </summary>
|
||||||
|
public static EcsInspector UseInspector(this Scene scene, Renderer2D renderer)
|
||||||
|
{
|
||||||
|
var services = scene.Context.Services;
|
||||||
|
MyraEnvironment.Game = services.Get<Microsoft.Xna.Framework.Game>();
|
||||||
|
|
||||||
|
var inspector = new EcsInspector(scene.Store, renderer, scene.Context.Clock);
|
||||||
|
var ui = new EcsInspectorUi(inspector, renderer);
|
||||||
|
|
||||||
|
var capture = services.GetOrDefault<InputCapture>();
|
||||||
|
if (capture is null)
|
||||||
|
{
|
||||||
|
capture = new InputCapture();
|
||||||
|
services.Add(capture);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OtherOverlayOpen() =>
|
||||||
|
services.GetOrDefault<MrGameEng.DevConsole.DevConsole>()?.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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<FieldRow> rows, string name)
|
||||||
|
{
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row.Name == name)
|
||||||
|
{
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Xunit.Sdk.XunitException($"field {name} not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Probe>().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<Probe>().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<T>(IReadOnlyList<T> items, Func<T, bool> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user