Split the platform out of Core: new Host library + HeadlessHost
CI / build-test (push) Successful in 1m24s
CI / build-test (push) Successful in 1m24s
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
79d406a9f4
commit
2ac074004a
@@ -0,0 +1,107 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user