M3, part one: World.Snapshot()/Restore() — Play mode's actual mechanic
The TODO left on IWorld since the very first kernel scaffold commit, closed: a deep, opaque snapshot of every GameObject and Component, for Play/Stop to build on. Not a new clone mechanism — backed by SceneFormat. A scene file and a Play-mode snapshot are the same problem (capture every GameObject faithfully enough to reconstruct it) at two different moments; reusing already-proven serialization beats maintaining a second way to walk the same graph. Restore() is NOT additive the way SceneFormat.Load() is by design — it destroys every current root first. Play mode always restores onto a world it's about to fully own; additive semantics would be the wrong default here even though they're the right one for loading a scene into existing content. Verified against M3's actual "done when" (entering Play takes under 100 ms), not just round-trip correctness: 300 GameObjects, each with a component, snapshot + restore end to end comes in well under the 100 ms bound — asserted directly with a Stopwatch, not eyeballed. Also covers what Play mode depends on specifically: mutations, GameObjects created or destroyed, and hierarchy changes made after the snapshot are all discarded on Restore. 73 tests total now (49 in Engine.Kernel.Tests, 8 in Engine.Assets.Tests, 8 in Engine.ConformanceHarness... — wait, that's 65; the two build-time contract projects add no test counts. Actual total per the run: 49+8+8 = 65.), all green on a clean build. Next for M3: the editor itself — hierarchy, a reflection-based inspector, Play/Stop wired to this, gizmos. All still ahead; this commit is only the kernel mechanic underneath Play/Stop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>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?"
|
||||
|
||||
@@ -17,6 +17,23 @@ public interface IWorld
|
||||
/// <summary>Type-indexed lookup — O(matches), not O(all). See §2.</summary>
|
||||
IEnumerable<GameObject> Query<T>() where T : Component;
|
||||
|
||||
// TODO(§5): Snapshot()/Restore() for Play mode — a deep clone of the
|
||||
// GameObject graph, taken on EnterPlay and discarded on ExitPlay.
|
||||
/// <summary>
|
||||
/// A deep, opaque snapshot of every GameObject and Component — for
|
||||
/// Play mode (§5): taken on EnterPlay, handed back to
|
||||
/// <see cref="Restore"/> on ExitPlay to discard whatever changed while
|
||||
/// playing. Backed by <see cref="SceneFormat"/> 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.
|
||||
/// </summary>
|
||||
string Snapshot();
|
||||
|
||||
/// <summary>Destroys every current root and rebuilds the graph
|
||||
/// <paramref name="snapshot"/> 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.</summary>
|
||||
void Restore(string snapshot);
|
||||
}
|
||||
|
||||
@@ -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<Health>().Value = 100;
|
||||
|
||||
var snapshot = world.Snapshot();
|
||||
|
||||
go.GetComponent<Health>()!.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<Health>()!.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<Health>().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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user