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>
108 lines
3.8 KiB
C#
108 lines
3.8 KiB
C#
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();
|
||
}
|
||
}
|