diff --git a/docs/kernel-contract.md b/docs/kernel-contract.md index 3bc14e8..a86a8a6 100644 --- a/docs/kernel-contract.md +++ b/docs/kernel-contract.md @@ -53,8 +53,8 @@ nothing. | 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. | | 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. | | 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. | -| 05 | **Event Bus** | Decoupled notifications: `GameObject` created, asset reloaded, plugin unloaded. | -| 06 | **Time & Log** | Frame clock, fixed-step accumulator, logging interface. Kept minimal. | +| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; nothing publishes `GameObject` created/destroyed or asset-reloaded facts yet — the first because `GameWorld` doesn't touch the bus at all (a publish on every structural change would tax the hot path for listeners that usually don't exist), the second because there's no asset system yet. | +| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`) and logging, both on `IPluginContext`. No fixed-step accumulator yet — deferred to M4, alongside the physics system it would actually drive. | `GameObject.Transform` is the one field embedded directly rather than modeled as a `Component` subclass — it's a plain struct holding local @@ -161,8 +161,9 @@ public interface IPluginContext IWorld World { get; } // data IServiceRegistry Services { get; } // Provide / Require ISchedule Schedule { get; } // systems and ordering - IEventBus Events { get; } + IEventBus Events { get; } // Publish / Subscribe ILogger Log { get; } + ITime Time { get; } // DeltaTime, ElapsedTime, FrameCount } ``` @@ -429,10 +430,44 @@ cost of changing course is still zero. --- -Open questions to resolve before M0: whether `Time` and `Log` belong in the -kernel or as plugins; whether the Event Bus is needed at launch or whether -event-components in `World` cover its role; whether the set of frame stages -is fixed or plugin-extensible; and whether a data-oriented fast path (for -bulk operations like particles) is worth introducing later without -abandoning GameObject/Component for everything else. Assembly names in the -examples are placeholders. +**The kernel's open questions are resolved.** What M0 shipped without +deciding, in order: + +- **`Time` and `Log`: both in the kernel**, both on `IPluginContext`. Every + plugin needs logging and a frame clock; there's no realistic case for a + project wanting to swap either out per-project the way it would swap + physics or rendering. `Log` was already built this way by the time the + question got asked explicitly — `Time` (`DeltaTime`, `ElapsedTime`, + `FrameCount`) shipped alongside closing the question, not before it. + **Explicitly still deferred:** the fixed-step accumulator the original + kernel scope named alongside the frame clock. Building it now, with no + physics system to test it against, would be untested speculative + machinery — exactly what this project has avoided everywhere else. It + arrives with M4, alongside the `Stage.FixedUpdate` it would drive. +- **Event Bus: real, not event-components in `World`.** A disposable + event-as-`GameObject` fits a pure ECS's cheap-entity model better than + ours, where `GameObject` carries persistent identity and hierarchy. A + conventional `Publish`/`Subscribe` bus is the better fit for this object + model specifically. `PluginHost` publishing `PluginLoaded` / + `PluginUnloaded` is the concrete proof it's real infrastructure, not an + API nobody calls — and `Subscribe`/`RemoveAllFrom` follow the exact + leak-safety shape `Schedule` already established (ownership tracked by + the subscribing delegate's declaring assembly), proven the same way: + `sandbox.echo` subscribes for real, and the 200-cycle leak test in + `AlcUnloadTests` now exercises that cleanup path, not just `Schedule`'s. +- **Frame stages: fixed, kernel-defined — not plugin-extensible.** A stage + is part of the shared language every plugin and the host loop rely on; + letting plugins register arbitrary custom stages would mean the host's + frame loop can no longer just call a known, closed set of `RunStage`s. + The set stays `{Update, Render}` until `FixedUpdate` arrives with M4 — no + stage gets added without something real to run in it. +- **Data-oriented fast path: still open, on purpose, with a trigger + condition instead of a deadline.** Not "undecided" the way the other + three were — deliberately not worth deciding before there's a concrete + system to decide it against. Revisit when a specific system (particles is + the standing example) needs tens of thousands of `GameObject`s updated + per frame *and* profiling — not intuition — shows `GameObject`/`Component` + overhead is the actual bottleneck. Until then, an early decision here + would be optimizing against a guess. + +Assembly names in the examples are placeholders. diff --git a/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs b/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs index 5d140e9..0f6abb0 100644 --- a/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs +++ b/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs @@ -18,6 +18,12 @@ public sealed class EchoPlugin : IPlugin ctx.Schedule.Add(Stage.Update, Tick) .Writes(); + // A real cross-ALC subscription, not just a system — this is what + // makes the 200-cycle leak test in AlcUnloadTests actually prove + // EventBus.RemoveAllFrom works, the same way registering Tick + // above proves it for Schedule.RemoveAllFrom. + ctx.Events.Subscribe(OnPluginLoaded); + // There's no scene format yet (that's M2) — nothing else will ever // put a GameObject in front of `engine run --headless`, so this // deliberately-a-test-fixture plugin seeds its own. A real plugin @@ -30,6 +36,7 @@ public sealed class EchoPlugin : IPlugin public void Shutdown(IPluginContext ctx) { ctx.Schedule.RemoveAllFrom("sandbox.echo"); + ctx.Events.RemoveAllFrom("sandbox.echo"); } static void Tick(IWorld world) @@ -37,4 +44,7 @@ public sealed class EchoPlugin : IPlugin foreach (var go in world.Query()) go.GetComponent()!.Count++; } + + static void OnPluginLoaded(PluginLoaded evt) => + Console.WriteLine($"[sandbox.echo] observed load of '{evt.PluginId}'"); } diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index 9a6222b..3c785f0 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -116,7 +116,9 @@ if (projectPath is null || pluginsPath is null) var world = new GameWorld(); var schedule = new Schedule(); var services = new ServiceRegistry(); -var host = new PluginHost(world, services, schedule, new NullEventBus()); +var events = new EventBus(); +var time = new Time(); +var host = new PluginHost(world, services, schedule, events, time); IReadOnlyList loaded; try @@ -164,12 +166,17 @@ if (windowed) }); var frameCount = 0; + var lastTime = window!.Native.Time; - while (!window!.IsClosing) + while (!window.IsClosing) { frameCount++; window.Native.DoEvents(); + var currentTime = window.Native.Time; + time.Tick((float)(currentTime - lastTime)); + lastTime = currentTime; + if (window.IsClosing) break; @@ -244,8 +251,17 @@ if (windowed) } else { + // No wall clock to measure — headless runs as fast as the CPU allows, + // not once per real 1/60s. A fixed nominal delta keeps ITime.DeltaTime + // meaningful for systems that use it, and keeps headless runs + // deterministic, which --windowed's real wall-clock delta can't be. + const float headlessDeltaTime = 1f / 60f; + for (var frame = 0; frame < frames; frame++) + { + time.Tick(headlessDeltaTime); schedule.RunStage(Stage.Update, world); + } Console.WriteLine($"Ran {frames} update frame(s)."); } diff --git a/src/Engine.Kernel/Diagnostics/ITime.cs b/src/Engine.Kernel/Diagnostics/ITime.cs new file mode 100644 index 0000000..58a2e4c --- /dev/null +++ b/src/Engine.Kernel/Diagnostics/ITime.cs @@ -0,0 +1,23 @@ +namespace Engine.Kernel.Diagnostics; + +/// +/// Frame clock. See docs/kernel-contract.md §2. +/// +/// No fixed-step accumulator yet — that was in the original kernel scope +/// but nothing consumes it: building it now, with no physics system to +/// test it against, would be exactly the kind of untested speculative +/// machinery this project has avoided everywhere else. It arrives with +/// M4, alongside the Stage.FixedUpdate it would drive — a "FixedUpdate" +/// stage without a real fixed-timestep accumulator behind it would be +/// actively misleading, not just incomplete. +/// +public interface ITime +{ + /// Seconds since the previous frame's Update stage. + float DeltaTime { get; } + + /// Seconds since the engine started. + double ElapsedTime { get; } + + int FrameCount { get; } +} diff --git a/src/Engine.Kernel/Diagnostics/Time.cs b/src/Engine.Kernel/Diagnostics/Time.cs new file mode 100644 index 0000000..5dac6f4 --- /dev/null +++ b/src/Engine.Kernel/Diagnostics/Time.cs @@ -0,0 +1,21 @@ +namespace Engine.Kernel.Diagnostics; + +/// +/// Read-only to plugins (ITime); advanced by whoever owns the frame loop — +/// Engine.Host today, headless or windowed — via , which +/// isn't on the interface. Same split as Schedule/ISchedule: the host gets +/// the extra, plugin-facing code doesn't. +/// +public sealed class Time : ITime +{ + public float DeltaTime { get; private set; } + public double ElapsedTime { get; private set; } + public int FrameCount { get; private set; } + + public void Tick(float deltaTime) + { + DeltaTime = deltaTime; + ElapsedTime += deltaTime; + FrameCount++; + } +} diff --git a/src/Engine.Kernel/Events/EventBus.cs b/src/Engine.Kernel/Events/EventBus.cs new file mode 100644 index 0000000..964dd61 --- /dev/null +++ b/src/Engine.Kernel/Events/EventBus.cs @@ -0,0 +1,52 @@ +using System.Reflection; + +namespace Engine.Kernel.Events; + +/// +/// Ownership tracked the same way Schedule tracks systems: by the +/// subscribing delegate's declaring assembly, not a string tag per +/// subscription. See and +/// Engine.Kernel.Scheduling.Schedule. +/// +public sealed class EventBus : IEventBus +{ + private readonly Dictionary> _handlers = []; + private readonly Dictionary _pluginAssemblies = []; + + /// Called by PluginHost right after loading a plugin's + /// implementation assembly, before Configure() runs — mirrors + /// Schedule.RegisterPlugin exactly. + internal void RegisterPlugin(string pluginId, Assembly implementationAssembly) + => _pluginAssemblies[pluginId] = implementationAssembly; + + public void Publish(TEvent evt) where TEvent : notnull + { + if (!_handlers.TryGetValue(typeof(TEvent), out var handlers) || handlers.Count == 0) + return; + + // Snapshot: a handler subscribing or unsubscribing during dispatch + // must not corrupt the in-progress iteration. + foreach (var handler in handlers.ToArray()) + ((Action)handler)(evt); + } + + public void Subscribe(Action handler) where TEvent : notnull + { + if (!_handlers.TryGetValue(typeof(TEvent), out var handlers)) + { + handlers = []; + _handlers[typeof(TEvent)] = handlers; + } + + handlers.Add(handler); + } + + public void RemoveAllFrom(string pluginId) + { + if (!_pluginAssemblies.Remove(pluginId, out var assembly)) + return; + + foreach (var handlers in _handlers.Values) + handlers.RemoveAll(h => h.Method.DeclaringType?.Assembly == assembly); + } +} diff --git a/src/Engine.Kernel/Events/IEventBus.cs b/src/Engine.Kernel/Events/IEventBus.cs index a37acab..246e0aa 100644 --- a/src/Engine.Kernel/Events/IEventBus.cs +++ b/src/Engine.Kernel/Events/IEventBus.cs @@ -1,11 +1,25 @@ namespace Engine.Kernel.Events; /// -/// Open question (see the footer of docs/kernel-contract.md): whether this -/// is needed at launch at all, or whether event-components in World cover -/// its role. Left as a bare marker so IPluginContext compiles — no API -/// decided yet. +/// Decoupled notifications: a publisher fires a fact with no fixed +/// consumer at design time. See the "two channels" — well, three, counting +/// this — table in docs/kernel-contract.md §2. +/// +/// Deliberately not "events as World entities": disposable event +/// GameObjects would need something to create and clean them up every +/// frame, which fits a pure-ECS model better than GameObject/Component's +/// persistent-identity one. A real pub/sub bus is the better fit here. /// public interface IEventBus { + void Publish(TEvent evt) where TEvent : notnull; + + /// No matching Unsubscribe — same shape as ISchedule, which + /// only offers bulk removal by plugin id, not per-system removal. + /// Call RemoveAllFrom your own plugin id in Shutdown(), or a forgotten + /// subscription pins your ALC exactly the way a forgotten system + /// does — see §4. + void Subscribe(Action handler) where TEvent : notnull; + + void RemoveAllFrom(string pluginId); } diff --git a/src/Engine.Kernel/Events/NullEventBus.cs b/src/Engine.Kernel/Events/NullEventBus.cs deleted file mode 100644 index c835d79..0000000 --- a/src/Engine.Kernel/Events/NullEventBus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Engine.Kernel.Events; - -/// -/// IEventBus has no members yet — see the open question in -/// docs/kernel-contract.md's footer. Nothing to implement until it does. -/// -public sealed class NullEventBus : IEventBus; diff --git a/src/Engine.Kernel/Plugins/IPluginContext.cs b/src/Engine.Kernel/Plugins/IPluginContext.cs index a2a7730..11bd2e8 100644 --- a/src/Engine.Kernel/Plugins/IPluginContext.cs +++ b/src/Engine.Kernel/Plugins/IPluginContext.cs @@ -13,4 +13,5 @@ public interface IPluginContext ISchedule Schedule { get; } // systems and ordering IEventBus Events { get; } ILogger Log { get; } + ITime Time { get; } } diff --git a/src/Engine.Kernel/Plugins/PluginContext.cs b/src/Engine.Kernel/Plugins/PluginContext.cs index 38d8688..6ddf5e5 100644 --- a/src/Engine.Kernel/Plugins/PluginContext.cs +++ b/src/Engine.Kernel/Plugins/PluginContext.cs @@ -14,11 +14,13 @@ internal sealed class PluginContext( IWorld world, IServiceRegistry services, ISchedule schedule, - IEventBus events) : IPluginContext + IEventBus events, + ITime time) : IPluginContext { public IWorld World { get; } = world; public IServiceRegistry Services { get; } = services; public ISchedule Schedule { get; } = schedule; public IEventBus Events { get; } = events; public ILogger Log { get; } = new ConsoleLogger(pluginId); + public ITime Time { get; } = time; } diff --git a/src/Engine.Kernel/Plugins/PluginEvents.cs b/src/Engine.Kernel/Plugins/PluginEvents.cs new file mode 100644 index 0000000..5fe1e3c --- /dev/null +++ b/src/Engine.Kernel/Plugins/PluginEvents.cs @@ -0,0 +1,9 @@ +namespace Engine.Kernel.Plugins; + +/// +/// The concrete proof that EventBus is real infrastructure, not an +/// unused API — PluginHost publishes both. See docs/kernel-contract.md §2. +/// +public readonly record struct PluginLoaded(string PluginId); + +public readonly record struct PluginUnloaded(string PluginId); diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs index bcf5dd0..eb5f4a4 100644 --- a/src/Engine.Kernel/Plugins/PluginHost.cs +++ b/src/Engine.Kernel/Plugins/PluginHost.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Runtime.Loader; using System.Text.Json; +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Scheduling; using Engine.Kernel.Services; @@ -22,7 +23,12 @@ namespace Engine.Kernel.Plugins; /// will fail wherever its own code first touches them, not with a /// host-level error. /// -public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule schedule, IEventBus events) +public sealed class PluginHost( + IWorld world, + IServiceRegistry services, + Schedule schedule, + EventBus events, + ITime time) { private static readonly JsonSerializerOptions ManifestOptions = new() { @@ -64,11 +70,13 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule var instance = (IPlugin)Activator.CreateInstance(pluginType)!; schedule.RegisterPlugin(manifest.Id, implAssembly); - var ctx = new PluginContext(manifest.Id, world, services, schedule, events); + events.RegisterPlugin(manifest.Id, implAssembly); + var ctx = new PluginContext(manifest.Id, world, services, schedule, events, time); instance.Configure(ctx); _loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx); + events.Publish(new PluginLoaded(manifest.Id)); return manifest.Id; } @@ -120,6 +128,7 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule var weakAlc = new WeakReference(loaded.Alc); loaded.Alc.Unload(); + events.Publish(new PluginUnloaded(pluginId)); return weakAlc; } diff --git a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs index fab7870..dd8a1e0 100644 --- a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs +++ b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs @@ -1,4 +1,5 @@ using System.Runtime.Loader; +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; @@ -22,7 +23,7 @@ public class AlcUnloadTests Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo"); private static PluginHost NewHost() => - new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus()); + new(new GameWorld(), new ServiceRegistry(), new Schedule(), new EventBus(), new Time()); [Fact] public void Plugin_Survives_200_Load_Unload_Cycles() diff --git a/tests/Engine.ConformanceHarness/PluginSystemTests.cs b/tests/Engine.ConformanceHarness/PluginSystemTests.cs index e2d9e06..da78f35 100644 --- a/tests/Engine.ConformanceHarness/PluginSystemTests.cs +++ b/tests/Engine.ConformanceHarness/PluginSystemTests.cs @@ -1,3 +1,4 @@ +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; @@ -28,7 +29,7 @@ public class PluginSystemTests { var world = new GameWorld(); var schedule = new Schedule(); - var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus()); + var host = new PluginHost(world, new ServiceRegistry(), schedule, new EventBus(), new Time()); var ping = world.CreateGameObject("Pinger").AddComponent(); var id = host.Load(PluginDirectory); @@ -46,7 +47,7 @@ public class PluginSystemTests { var world = new GameWorld(); var schedule = new Schedule(); - var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus()); + var host = new PluginHost(world, new ServiceRegistry(), schedule, new EventBus(), new Time()); var ping = world.CreateGameObject("Pinger").AddComponent(); var id = host.Load(PluginDirectory); @@ -56,4 +57,34 @@ public class PluginSystemTests Assert.Equal(0, ping.Count); } + + [Fact] + public void Loading_A_Plugin_Publishes_PluginLoaded_With_Its_Id() + { + var world = new GameWorld(); + var events = new EventBus(); + var host = new PluginHost(world, new ServiceRegistry(), new Schedule(), events, new Time()); + string? observedId = null; + events.Subscribe(e => observedId = e.PluginId); + + var id = host.Load(PluginDirectory); + + Assert.Equal("sandbox.echo", observedId); + host.Unload(id); + } + + [Fact] + public void Unloading_A_Plugin_Publishes_PluginUnloaded_With_Its_Id() + { + var world = new GameWorld(); + var events = new EventBus(); + var host = new PluginHost(world, new ServiceRegistry(), new Schedule(), events, new Time()); + var id = host.Load(PluginDirectory); + string? observedId = null; + events.Subscribe(e => observedId = e.PluginId); + + host.Unload(id); + + Assert.Equal("sandbox.echo", observedId); + } } diff --git a/tests/Engine.Kernel.Tests/EventBusTests.cs b/tests/Engine.Kernel.Tests/EventBusTests.cs new file mode 100644 index 0000000..32e33c5 --- /dev/null +++ b/tests/Engine.Kernel.Tests/EventBusTests.cs @@ -0,0 +1,55 @@ +using Engine.Kernel.Events; + +namespace Engine.Kernel.Tests; + +public class EventBusTests +{ + private readonly record struct Ping(int Value); + + [Fact] + public void Publish_Invokes_Every_Subscriber_For_That_Event_Type() + { + var bus = new EventBus(); + var received = new List(); + + bus.Subscribe(p => received.Add(p.Value)); + bus.Subscribe(p => received.Add(p.Value * 10)); + + bus.Publish(new Ping(3)); + + Assert.Equal([3, 30], received); + } + + [Fact] + public void Publish_With_No_Subscribers_Does_Not_Throw() + { + var bus = new EventBus(); + + var exception = Record.Exception(() => bus.Publish(new Ping(1))); + + Assert.Null(exception); + } + + [Fact] + public void Publish_Does_Not_Invoke_Handlers_Subscribed_To_A_Different_Event_Type() + { + var bus = new EventBus(); + var otherReceived = false; + + bus.Subscribe(_ => otherReceived = true); + + bus.Publish(new Ping(1)); + + Assert.False(otherReceived); + } + + [Fact] + public void RemoveAllFrom_An_Id_With_No_Registered_Plugin_Is_A_No_Op() + { + var bus = new EventBus(); + + var exception = Record.Exception(() => bus.RemoveAllFrom("nothing-registered")); + + Assert.Null(exception); + } +} diff --git a/tests/Engine.Kernel.Tests/PluginHostTests.cs b/tests/Engine.Kernel.Tests/PluginHostTests.cs index 2611ac9..274cfcf 100644 --- a/tests/Engine.Kernel.Tests/PluginHostTests.cs +++ b/tests/Engine.Kernel.Tests/PluginHostTests.cs @@ -1,3 +1,4 @@ +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; @@ -14,7 +15,7 @@ namespace Engine.Kernel.Tests; public class PluginHostTests { private static PluginHost NewHost() => - new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus()); + new(new GameWorld(), new ServiceRegistry(), new Schedule(), new EventBus(), new Time()); [Fact] public void Load_Throws_When_The_Directory_Has_No_Manifest() diff --git a/tests/Engine.Kernel.Tests/TimeTests.cs b/tests/Engine.Kernel.Tests/TimeTests.cs new file mode 100644 index 0000000..5f696da --- /dev/null +++ b/tests/Engine.Kernel.Tests/TimeTests.cs @@ -0,0 +1,41 @@ +using Engine.Kernel.Diagnostics; + +namespace Engine.Kernel.Tests; + +public class TimeTests +{ + [Fact] + public void Starts_At_Zero() + { + var time = new Time(); + + Assert.Equal(0, time.DeltaTime); + Assert.Equal(0, time.ElapsedTime); + Assert.Equal(0, time.FrameCount); + } + + [Fact] + public void Tick_Sets_DeltaTime_And_Advances_ElapsedTime_And_FrameCount() + { + var time = new Time(); + + time.Tick(0.5f); + + Assert.Equal(0.5f, time.DeltaTime); + Assert.Equal(0.5, time.ElapsedTime, 3); + Assert.Equal(1, time.FrameCount); + } + + [Fact] + public void ElapsedTime_And_FrameCount_Accumulate_Across_Ticks() + { + var time = new Time(); + + time.Tick(0.5f); + time.Tick(0.25f); + + Assert.Equal(0.25f, time.DeltaTime); + Assert.Equal(0.75, time.ElapsedTime, 3); + Assert.Equal(2, time.FrameCount); + } +}