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);