using System.Diagnostics; namespace MrGameEng.Core; /// /// A game-loop host without a window, GPU or any platform dependency: drives the active /// scene's update phase on a fixed timestep. Suits dedicated servers, batch simulation and /// tests. Scenes still own draw systems — they are simply never run, and platform services /// (graphics device, window) are absent from , so only /// platform-free modules can be used. The fixed step makes simulation time independent of /// wall-clock jitter: N ticks always advance the world by exactly N × /// seconds. /// public sealed class HeadlessHost : IDisposable { /// Engine context shared with scenes and systems. public EngineContext Context { get; } = new(); /// Fixed simulation step in seconds: 1 / . public float FixedDeltaTime { get; } /// Ticks completed since the host was created. public long TickCount => Context.Clock.FrameCount; // Отстав сильнее этого, Run ресинкается с настоящим временем вместо лавины тиков. private const double MaxLagSeconds = 1.0; private readonly HeadlessHostOptions _options; /// Creates a host that starts with . The scene /// loads on the first tick, mirroring the windowed host's deferred switch. public HeadlessHost(HeadlessHostOptions options, Scene initialScene) { if (options.TicksPerSecond <= 0f) { throw new ArgumentOutOfRangeException( nameof(options), "TicksPerSecond must be positive." ); } _options = options; FixedDeltaTime = 1f / options.TicksPerSecond; Context.Scenes.Switch(initialScene); } /// Advances the world by exactly one fixed tick. public void Tick() { Context.Clock.Advance(FixedDeltaTime); Context.Scenes.Update(Context.Clock); } /// Advances the world by ticks as fast as possible. public void RunTicks(long count) { for (long i = 0; i < count; i++) { Tick(); } } /// /// Runs until is cancelled. With /// ticks are paced to the wall clock — the /// loop sleeps when ahead and, having fallen more than a second behind, resyncs instead /// of bursting a catch-up avalanche. Pacing relies on /// and is accurate to a few milliseconds, not exact. /// public void Run(CancellationToken cancellationToken = default) { if (!_options.Realtime) { while (!cancellationToken.IsCancellationRequested) { Tick(); } return; } var wallClock = Stopwatch.StartNew(); var nextTickAt = 0.0; while (!cancellationToken.IsCancellationRequested) { Tick(); nextTickAt += FixedDeltaTime; var ahead = nextTickAt - wallClock.Elapsed.TotalSeconds; if (ahead > 0) { Thread.Sleep(TimeSpan.FromSeconds(ahead)); } else if (-ahead > MaxLagSeconds) { nextTickAt = wallClock.Elapsed.TotalSeconds; } } } /// Unloads the active scene and disposes context-owned services. public void Dispose() { Context.Scenes.Switch(null); Context.Scenes.ApplyPending(); Context.DisposeOwnedResources(); } }