diff --git a/.gitignore b/.gitignore
index bbe2403..983129f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,8 @@ Thumbs.db
## Build output
out/
artifacts/
+
+## ImGui's own persisted window layout (position/size/open-state) — a
+## per-machine editing session preference, not project source, same
+## category as an IDE's own workspace state.
+imgui.ini
diff --git a/plugins/engine.editor/Engine.Editor.Contracts/IPlayModeController.cs b/plugins/engine.editor/Engine.Editor.Contracts/IPlayModeController.cs
new file mode 100644
index 0000000..1ee0355
--- /dev/null
+++ b/plugins/engine.editor/Engine.Editor.Contracts/IPlayModeController.cs
@@ -0,0 +1,21 @@
+namespace Engine.Editor.Contracts;
+
+///
+/// Play/Stop, backed by IWorld.Snapshot()/Restore() — see those doc
+/// comments for why a scene-format snapshot, not a separate clone
+/// mechanism, is what Play mode actually is. Engine.Host reads IsPlaying
+/// each frame to decide whether to run Stage.Update at all: Edit mode
+/// renders the scene but never ticks it, same as Unity's Scene view versus
+/// Game view distinction. Exposed as a service (not something only
+/// engine.editor's own UI calls) so a future non-UI driver — a test
+/// harness, a CLI "play for N frames and dump" command — can drive Play
+/// mode without depending on ImGui at all.
+///
+public interface IPlayModeController
+{
+ bool IsPlaying { get; }
+
+ void EnterPlay();
+
+ void ExitPlay();
+}
diff --git a/plugins/engine.editor/Engine.Editor/EditorPlugin.cs b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs
index 278a8b1..ea0fddf 100644
--- a/plugins/engine.editor/Engine.Editor/EditorPlugin.cs
+++ b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs
@@ -1,3 +1,4 @@
+using Engine.Editor.Contracts;
using Engine.Input.Contracts;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Plugins;
@@ -31,6 +32,7 @@ public sealed class EditorPlugin : IPlugin
private GL? _gl;
private ImGuiController? _controller;
private ITime? _time;
+ private PlayModeController? _playMode;
public void Configure(IPluginContext ctx)
{
@@ -42,6 +44,9 @@ public sealed class EditorPlugin : IPlugin
_gl = window.Native.CreateOpenGL();
_controller = new ImGuiController(_gl, window.Native, input.Native);
+ _playMode = new PlayModeController(ctx.World, ctx.Log);
+ ctx.Services.Provide(_playMode);
+
ctx.Schedule.Add(Stage.Render, DrawUi);
ctx.Log.Info("editor UI ready (ImGui)");
}
@@ -49,11 +54,13 @@ public sealed class EditorPlugin : IPlugin
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("engine.editor");
+ ctx.Services.Revoke();
_controller?.Dispose();
_gl?.Dispose();
_controller = null;
_gl = null;
_time = null;
+ _playMode = null;
}
private void DrawUi(IWorld world)
@@ -68,9 +75,28 @@ public sealed class EditorPlugin : IPlugin
if (_state.Selected is null && world.Roots.Count > 0)
_state.Selected = world.Roots[0];
+ // FirstUseEver, not every frame: a real editor session lets the
+ // user drag panels wherever they want, and re-forcing a position
+ // every frame would fight that the moment they did. This only
+ // picks a sane, non-overlapping default before ImGui has ever
+ // seen these windows (or after Reset Layout, once that exists).
+ ImGui.SetNextWindowPos(new(10, 10), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new(220, 90), ImGuiCond.FirstUseEver);
ImGui.Begin("Lingua Editor");
ImGui.Text($"FPS: {1f / MathF.Max(_time.DeltaTime, 0.0001f):F0}");
ImGui.Text($"Frame: {_time.FrameCount}");
+ ImGui.Separator();
+
+ if (ImGui.Button(_playMode!.IsPlaying ? "Stop" : "Play"))
+ {
+ if (_playMode.IsPlaying)
+ _playMode.ExitPlay();
+ else
+ _playMode.EnterPlay();
+ }
+
+ ImGui.SameLine();
+ ImGui.Text(_playMode.IsPlaying ? "(Playing)" : "(Edit mode)");
ImGui.End();
HierarchyPanel.Draw(world, _state);
diff --git a/plugins/engine.editor/Engine.Editor/HierarchyPanel.cs b/plugins/engine.editor/Engine.Editor/HierarchyPanel.cs
index 8609cee..f051a3a 100644
--- a/plugins/engine.editor/Engine.Editor/HierarchyPanel.cs
+++ b/plugins/engine.editor/Engine.Editor/HierarchyPanel.cs
@@ -13,6 +13,8 @@ internal static class HierarchyPanel
{
public static void Draw(IWorld world, EditorState state)
{
+ ImGui.SetNextWindowPos(new(10, 110), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new(220, 300), ImGuiCond.FirstUseEver);
ImGui.Begin("Hierarchy");
foreach (var root in world.Roots)
diff --git a/plugins/engine.editor/Engine.Editor/InspectorPanel.cs b/plugins/engine.editor/Engine.Editor/InspectorPanel.cs
index b48dd72..759869f 100644
--- a/plugins/engine.editor/Engine.Editor/InspectorPanel.cs
+++ b/plugins/engine.editor/Engine.Editor/InspectorPanel.cs
@@ -21,6 +21,8 @@ internal static class InspectorPanel
{
public static void Draw(EditorState state)
{
+ ImGui.SetNextWindowPos(new(240, 10), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new(300, 400), ImGuiCond.FirstUseEver);
ImGui.Begin("Inspector");
var go = state.Selected;
diff --git a/plugins/engine.editor/Engine.Editor/PlayModeController.cs b/plugins/engine.editor/Engine.Editor/PlayModeController.cs
new file mode 100644
index 0000000..4578082
--- /dev/null
+++ b/plugins/engine.editor/Engine.Editor/PlayModeController.cs
@@ -0,0 +1,45 @@
+using System.Diagnostics;
+using Engine.Editor.Contracts;
+using Engine.Kernel.Diagnostics;
+using Engine.Kernel.World;
+
+namespace Engine.Editor;
+
+///
+/// A snapshot taken on EnterPlay is the only state this needs — see
+/// IWorld.Snapshot's doc comment for why that's Play mode's entire
+/// mechanism, not a simplification of some richer one. Logs EnterPlay's
+/// wall-clock cost: M3's "done when" criterion is entering Play in under
+/// 100ms, already proven at the kernel level by WorldSnapshotTests'
+/// 300-GameObject timing assertion — this is the same measurement taken
+/// through the real editor path instead of a unit test, so a regression
+/// specific to this plugin (not the kernel primitive) would show up here
+/// even if the kernel test stays green.
+///
+internal sealed class PlayModeController(IWorld world, ILogger log) : IPlayModeController
+{
+ private string? _snapshot;
+
+ public bool IsPlaying => _snapshot is not null;
+
+ public void EnterPlay()
+ {
+ if (IsPlaying)
+ return;
+
+ var stopwatch = Stopwatch.StartNew();
+ _snapshot = world.Snapshot();
+ stopwatch.Stop();
+ log.Info($"Entered Play mode in {stopwatch.Elapsed.TotalMilliseconds:F1}ms");
+ }
+
+ public void ExitPlay()
+ {
+ if (!IsPlaying)
+ return;
+
+ world.Restore(_snapshot!);
+ _snapshot = null;
+ log.Info("Exited Play mode");
+ }
+}
diff --git a/src/Engine.Host/Engine.Host.csproj b/src/Engine.Host/Engine.Host.csproj
index 92197ed..90ecb19 100644
--- a/src/Engine.Host/Engine.Host.csproj
+++ b/src/Engine.Host/Engine.Host.csproj
@@ -4,6 +4,7 @@
+
diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs
index edbe551..7210cc2 100644
--- a/src/Engine.Host/Program.cs
+++ b/src/Engine.Host/Program.cs
@@ -28,6 +28,7 @@
// referencing an ALC; nothing has ever failed to unload in
// testing, so there's nothing to build this against yet.
+using Engine.Editor.Contracts;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
@@ -163,11 +164,15 @@ if (windowed)
return 1;
}
+ var playMode = services.TryRequire(out var pmc) ? pmc : null;
+
Console.WriteLine(
"""
Window open — close it to exit. Commands (type + Enter):
r reload that plugin live
screenshot save the current frame to a PNG (needs a plugin providing IScreenCapture)
+ play enter Play mode (needs a plugin providing IPlayModeController)
+ stop exit Play mode, restoring the pre-Play snapshot
""");
// Line-based, not Console.ReadKey: KeyAvailable needs a real terminal
@@ -181,8 +186,12 @@ if (windowed)
while ((line = Console.ReadLine()) is not null)
{
var parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
- if (parts is [var command, var argument])
- commandQueue.Enqueue((command, argument));
+ // >= 1, not == 2: "play"/"stop" (added alongside
+ // IPlayModeController) take no argument, unlike "r " and
+ // "screenshot " — an empty argument is harmless for
+ // those two since they never got parsed with one anyway.
+ if (parts.Length >= 1)
+ commandQueue.Enqueue((parts[0], parts.Length > 1 ? parts[1] : ""));
}
});
@@ -236,13 +245,35 @@ if (windowed)
pendingScreenshots.Add(cmd.Argument);
break;
+ case "play":
+ if (playMode is null)
+ Console.Error.WriteLine("No loaded plugin provides IPlayModeController (e.g. engine.editor).");
+ else
+ playMode.EnterPlay();
+ break;
+
+ case "stop":
+ if (playMode is null)
+ Console.Error.WriteLine("No loaded plugin provides IPlayModeController (e.g. engine.editor).");
+ else
+ playMode.ExitPlay();
+ break;
+
default:
Console.Error.WriteLine($"Unknown command: '{cmd.Command}'");
break;
}
}
- schedule.RunStage(Stage.Update, world);
+ // No IPlayModeController loaded (no engine.editor) means there's no
+ // Edit/Play distinction to make — Update always runs, same as
+ // before this plugin existed. With one loaded, Update only runs
+ // while actually Playing: Edit mode still renders the scene every
+ // frame (so the editor UI stays responsive and the view isn't
+ // frozen mid-edit), it just never ticks it.
+ if (playMode is null || playMode.IsPlaying)
+ schedule.RunStage(Stage.Update, world);
+
schedule.RunStage(Stage.Render, world);
schedule.RunStage(Stage.Present, world);