Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
|
||||
/// drain commands, step the simulation, broadcast a full snapshot.
|
||||
/// The world is touched from this thread only.
|
||||
/// </summary>
|
||||
internal sealed class GameLoopService(
|
||||
IOptions<SimulationOptions> options,
|
||||
GameCommandQueue commands,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
ILogger<GameLoopService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
|
||||
private const int MaxCatchUpSteps = 5;
|
||||
|
||||
private readonly SimulationOptions _options = options.Value;
|
||||
private readonly List<EntitySnapshot> _snapshotBuffer = [];
|
||||
private readonly GameWorld _world = new(options.Value);
|
||||
|
||||
private uint _currentTick;
|
||||
private int _playerCount;
|
||||
|
||||
public uint CurrentTick => Volatile.Read(ref _currentTick);
|
||||
|
||||
public int PlayerCount => Volatile.Read(ref _playerCount);
|
||||
|
||||
public SimulationOptions Options => _options;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
|
||||
_options.TickRate,
|
||||
_options.WorldWidth,
|
||||
_options.WorldHeight);
|
||||
|
||||
using var timer = new PeriodicTimer(_options.TickInterval);
|
||||
var fixedDelta = _options.FixedDeltaTime;
|
||||
var lastTimestamp = Stopwatch.GetTimestamp();
|
||||
var accumulator = 0d;
|
||||
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
|
||||
lastTimestamp = now;
|
||||
|
||||
DrainCommands();
|
||||
|
||||
var steps = 0;
|
||||
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
|
||||
{
|
||||
var stepStarted = Stopwatch.GetTimestamp();
|
||||
_world.Tick();
|
||||
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
|
||||
|
||||
accumulator -= fixedDelta;
|
||||
steps++;
|
||||
}
|
||||
|
||||
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
|
||||
{
|
||||
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
|
||||
accumulator = 0d;
|
||||
}
|
||||
|
||||
if (steps > 0)
|
||||
{
|
||||
Volatile.Write(ref _currentTick, _world.CurrentTick);
|
||||
BroadcastSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_world.Dispose();
|
||||
logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainCommands()
|
||||
{
|
||||
while (commands.TryDequeue(out var command))
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case GameCommand.Join join:
|
||||
HandleJoin(join);
|
||||
break;
|
||||
|
||||
case GameCommand.Leave leave:
|
||||
_world.DespawnPlayer(leave.PlayerId);
|
||||
Volatile.Write(ref _playerCount, _world.PlayerCount);
|
||||
metrics.PlayerLeft();
|
||||
logger.LogInformation("Player {PlayerId} left; {PlayerCount} remaining.", leave.PlayerId, _world.PlayerCount);
|
||||
break;
|
||||
|
||||
case GameCommand.Input input:
|
||||
_world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleJoin(GameCommand.Join join)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entityId = _world.SpawnPlayer(join.PlayerId);
|
||||
Volatile.Write(ref _playerCount, _world.PlayerCount);
|
||||
metrics.PlayerJoined();
|
||||
join.EntityId.TrySetResult(entityId);
|
||||
logger.LogInformation(
|
||||
"Player {PlayerId} joined as entity {EntityId}; {PlayerCount} connected.",
|
||||
join.PlayerId,
|
||||
entityId,
|
||||
_world.PlayerCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
join.EntityId.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void BroadcastSnapshot()
|
||||
{
|
||||
_world.CaptureSnapshot(_snapshotBuffer);
|
||||
|
||||
// One immutable buffer is shared by every recipient, so nothing has to be copied per client.
|
||||
var frame = new byte[ProtocolCodec.SnapshotSize(_snapshotBuffer.Count)];
|
||||
var written = ProtocolCodec.WriteSnapshot(frame, _world.CurrentTick, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_snapshotBuffer));
|
||||
|
||||
var recipients = clients.Broadcast(frame.AsMemory(0, written));
|
||||
if (recipients > 0)
|
||||
{
|
||||
metrics.SnapshotSent(written, recipients);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user