Files
mrgameeng/src/MrGameEng.Core/HeadlessHost.cs
T
Leonid PershinandClaude Fable 5 2ac074004a
CI / build-test (push) Successful in 1m24s
Split the platform out of Core: new Host library + HeadlessHost
Core now depends only on Friflo.Engine.ECS — no MonoGame, no platform.
The windowed MonoGame host moves to the new MrGameEng.Host library:
GameHost, GameHostOptions, visual scene transitions (OverlayTransition,
Transitions.Fade/Wipe, TransitionRenderer) and the Input feature
(namespace MrGameEng.Input is unchanged). Transition timing stays in
Core (SceneManager exposes ActiveTransition/TransitionCoverage/
TransitionPhase; the host draws the overlay). EngineContext loses its
GraphicsDevice property: hosts publish the device as a service and
graphics code reads it via context.GetGraphicsDevice() in Graphics.

Core gains HeadlessHost: a fixed-timestep loop without a window or GPU
(Tick/RunTicks, Run with wall-clock pacing and lag resync) for dedicated
servers, batch simulation and tests. Graphics and Audio now carry their
own MonoGame.Framework.DesktopGL reference instead of inheriting it
from Core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:04:34 +03:00

108 lines
3.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
namespace MrGameEng.Core;
/// <summary>
/// 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 <see cref="EngineContext.Services"/>, 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 ×
/// <see cref="FixedDeltaTime"/> seconds.
/// </summary>
public sealed class HeadlessHost : IDisposable
{
/// <summary>Engine context shared with scenes and systems.</summary>
public EngineContext Context { get; } = new();
/// <summary>Fixed simulation step in seconds: 1 / <see cref="HeadlessHostOptions.TicksPerSecond"/>.</summary>
public float FixedDeltaTime { get; }
/// <summary>Ticks completed since the host was created.</summary>
public long TickCount => Context.Clock.FrameCount;
// Отстав сильнее этого, Run ресинкается с настоящим временем вместо лавины тиков.
private const double MaxLagSeconds = 1.0;
private readonly HeadlessHostOptions _options;
/// <summary>Creates a host that starts with <paramref name="initialScene"/>. The scene
/// loads on the first tick, mirroring the windowed host's deferred switch.</summary>
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);
}
/// <summary>Advances the world by exactly one fixed tick.</summary>
public void Tick()
{
Context.Clock.Advance(FixedDeltaTime);
Context.Scenes.Update(Context.Clock);
}
/// <summary>Advances the world by <paramref name="count"/> ticks as fast as possible.</summary>
public void RunTicks(long count)
{
for (long i = 0; i < count; i++)
{
Tick();
}
}
/// <summary>
/// Runs until <paramref name="cancellationToken"/> is cancelled. With
/// <see cref="HeadlessHostOptions.Realtime"/> 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 <see cref="Thread.Sleep(TimeSpan)"/>
/// and is accurate to a few milliseconds, not exact.
/// </summary>
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;
}
}
}
/// <summary>Unloads the active scene and disposes context-owned services.</summary>
public void Dispose()
{
Context.Scenes.Switch(null);
Context.Scenes.ApplyPending();
Context.DisposeOwnedResources();
}
}