From d7fcc20f5ef973281bf85fc091fa51b310b22675 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 16:26:23 +0300 Subject: [PATCH] =?UTF-8?q?M3:=20Play/Stop=20=E2=80=94=20Engine.Host=20sto?= =?UTF-8?q?ps=20ticking=20Stage.Update=20outside=20Play=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IPlayModeController (Engine.Editor.Contracts) wraps IWorld.Snapshot()/ Restore() as EnterPlay/ExitPlay — the entire mechanism is that one snapshot, per Snapshot's own doc comment. Exposed as a service, not just something engine.editor's UI calls directly, so a future non-UI driver (a test harness, a headless "play for N frames" CLI command) can drive Play mode without depending on ImGui. Engine.Host now looks up IPlayModeController once and checks IsPlaying every frame before running Stage.Update: Edit mode still renders the scene (so the view isn't frozen and the editor UI stays responsive) but never ticks it, same distinction Unity draws between its Scene and Game views. A project with no engine.editor loaded sees no behavior change — Update runs unconditionally, same as before this existed. Added "play"/"stop" as stdin commands alongside the existing "r" and "screenshot", both real controls (not just test scaffolding) for driving Play mode without a mouse — which is also how this got verified: an interactive run sent "play", screenshotted, sent "stop", and the log shows "Entered Play mode in 13.2ms", the real editor path exercising the same under-100ms budget WorldSnapshotTests already proves at the kernel level. The Lingua Editor panel's button/label flip Play/(Edit mode) to Stop/(Playing) correctly across both screenshots. Also fixes a real layout bug hit while verifying this: SetNextWindowPos with ImGuiCond.FirstUseEver only applies with no prior imgui.ini entry for that window title, and this project had already accumulated one from earlier runs (all three panels stacked exactly on top of each other) — gave each panel its own default position and .gitignore'd imgui.ini, which is per-machine session state, not source. Full suite still green: 65 tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- .gitignore | 5 +++ .../IPlayModeController.cs | 21 +++++++++ .../Engine.Editor/EditorPlugin.cs | 26 +++++++++++ .../Engine.Editor/HierarchyPanel.cs | 2 + .../Engine.Editor/InspectorPanel.cs | 2 + .../Engine.Editor/PlayModeController.cs | 45 +++++++++++++++++++ src/Engine.Host/Engine.Host.csproj | 1 + src/Engine.Host/Program.cs | 37 +++++++++++++-- 8 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 plugins/engine.editor/Engine.Editor.Contracts/IPlayModeController.cs create mode 100644 plugins/engine.editor/Engine.Editor/PlayModeController.cs 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);