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