diff --git a/src/Engine.Kernel/World/GameWorld.cs b/src/Engine.Kernel/World/GameWorld.cs index 1e575ce..c54058d 100644 --- a/src/Engine.Kernel/World/GameWorld.cs +++ b/src/Engine.Kernel/World/GameWorld.cs @@ -109,6 +109,16 @@ public sealed class GameWorld : IWorld _roots.Add(go); } + public string Snapshot() => SceneFormat.ToJson(this); + + public void Restore(string snapshot) + { + foreach (var root in Roots.ToArray()) + Destroy(root); + + SceneFormat.FromJson(this, snapshot); + } + /// Unconditional removal from every type bucket, used by /// Destroy — cheaper to reason about than replaying per-component /// removals through IndexComponentRemoved's "still has one left?" diff --git a/src/Engine.Kernel/World/IWorld.cs b/src/Engine.Kernel/World/IWorld.cs index ce82282..dbd83af 100644 --- a/src/Engine.Kernel/World/IWorld.cs +++ b/src/Engine.Kernel/World/IWorld.cs @@ -17,6 +17,23 @@ public interface IWorld /// Type-indexed lookup — O(matches), not O(all). See §2. IEnumerable Query() where T : Component; - // TODO(§5): Snapshot()/Restore() for Play mode — a deep clone of the - // GameObject graph, taken on EnterPlay and discarded on ExitPlay. + /// + /// A deep, opaque snapshot of every GameObject and Component — for + /// Play mode (§5): taken on EnterPlay, handed back to + /// on ExitPlay to discard whatever changed while + /// playing. Backed by rather than a + /// separate clone mechanism — a scene file and a Play-mode snapshot + /// are the same problem (capture every GameObject's state, faithfully + /// enough to reconstruct it) at two different moments, and reusing + /// already-proven serialization is cheaper than maintaining a second + /// way to walk the same graph. + /// + string Snapshot(); + + /// Destroys every current root and rebuilds the graph + /// describes. Not merged with what's + /// there — Play mode always restores onto a world it's about to fully + /// own, so additive Load() semantics would be the wrong default here, + /// unlike SceneFormat.Load's own. + void Restore(string snapshot); } diff --git a/tests/Engine.Kernel.Tests/WorldSnapshotTests.cs b/tests/Engine.Kernel.Tests/WorldSnapshotTests.cs new file mode 100644 index 0000000..c14dbd3 --- /dev/null +++ b/tests/Engine.Kernel.Tests/WorldSnapshotTests.cs @@ -0,0 +1,106 @@ +using System.Diagnostics; +using Engine.Kernel.World; + +namespace Engine.Kernel.Tests; + +public class WorldSnapshotTests +{ + private sealed class Health : Component + { + public int Value; + } + + [Fact] + public void Restore_Discards_Mutations_Made_After_The_Snapshot() + { + var world = new GameWorld(); + var go = world.CreateGameObject("Hero"); + go.AddComponent().Value = 100; + + var snapshot = world.Snapshot(); + + go.GetComponent()!.Value = 1; // "took damage" during Play + world.CreateGameObject("SpawnedDuringPlay"); + + world.Restore(snapshot); + + var root = Assert.Single(world.Roots); + Assert.Equal("Hero", root.Name); + Assert.Equal(100, root.GetComponent()!.Value); + } + + [Fact] + public void Restore_Removes_GameObjects_Created_After_The_Snapshot() + { + var world = new GameWorld(); + world.CreateGameObject("Original"); + + var snapshot = world.Snapshot(); + world.CreateGameObject("Spawned"); + + world.Restore(snapshot); + + Assert.Single(world.Roots); + Assert.Equal("Original", world.Roots[0].Name); + } + + [Fact] + public void Restore_Recreates_GameObjects_Destroyed_After_The_Snapshot() + { + var world = new GameWorld(); + world.CreateGameObject("WillBeDestroyed"); + var snapshot = world.Snapshot(); + + world.Destroy(world.Roots[0]); + Assert.Empty(world.Roots); + + world.Restore(snapshot); + + Assert.Single(world.Roots); + Assert.Equal("WillBeDestroyed", world.Roots[0].Name); + } + + [Fact] + public void Restore_Preserves_The_Parent_Child_Hierarchy() + { + var world = new GameWorld(); + var parent = world.CreateGameObject("Parent"); + var child = world.CreateGameObject("Child"); + child.SetParent(parent); + var snapshot = world.Snapshot(); + + child.SetParent(null); // detach during Play + + world.Restore(snapshot); + + var root = Assert.Single(world.Roots); + var restoredChild = Assert.Single(root.Children); + Assert.Equal("Child", restoredChild.Name); + } + + // M3's actual "done when": entering Play takes under 100 ms. A + // realistic indie-scale scene (a few hundred GameObjects, each with a + // component) should clear that with room to spare — this asserts a + // generous 100 ms bound end to end (snapshot + restore, i.e. both + // EnterPlay and ExitPlay), not a tight one that would make this test + // flaky on a loaded CI box for no reason. + [Fact] + public void Snapshot_And_Restore_A_Few_Hundred_GameObjects_Well_Under_100ms() + { + var world = new GameWorld(); + for (var i = 0; i < 300; i++) + { + var go = world.CreateGameObject($"Object{i}"); + go.AddComponent().Value = i; + } + + var stopwatch = Stopwatch.StartNew(); + var snapshot = world.Snapshot(); + world.Restore(snapshot); + stopwatch.Stop(); + + Assert.True( + stopwatch.ElapsedMilliseconds < 100, + $"Snapshot + Restore of 300 GameObjects took {stopwatch.ElapsedMilliseconds} ms, expected < 100 ms."); + } +}