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,20 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Work item handed from a connection thread to the loop thread. The simulation is
|
||||
/// single-threaded, so every mutation arrives as one of these.
|
||||
/// </summary>
|
||||
internal abstract record GameCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
|
||||
/// with the replication id so the handler can send a Welcome frame.
|
||||
/// </summary>
|
||||
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
|
||||
|
||||
internal sealed record Leave(uint PlayerId) : GameCommand;
|
||||
|
||||
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Multi-producer, single-consumer inbox drained at the start of every tick.</summary>
|
||||
internal sealed class GameCommandQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<GameCommand> _commands = new();
|
||||
|
||||
public void Enqueue(GameCommand command) => _commands.Enqueue(command);
|
||||
|
||||
public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Game-loop counters surfaced in the Aspire dashboard.</summary>
|
||||
internal sealed class GameMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "HSchool.Server.Game";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _ticks;
|
||||
private readonly Histogram<double> _tickDuration;
|
||||
private readonly UpDownCounter<long> _connectedPlayers;
|
||||
private readonly Counter<long> _snapshotBytes;
|
||||
|
||||
public GameMetrics(IMeterFactory meterFactory)
|
||||
{
|
||||
_meter = meterFactory.Create(MeterName);
|
||||
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
|
||||
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
|
||||
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
|
||||
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
|
||||
}
|
||||
|
||||
public void RecordTick(double durationMs)
|
||||
{
|
||||
_ticks.Add(1);
|
||||
_tickDuration.Record(durationMs);
|
||||
}
|
||||
|
||||
public void PlayerJoined() => _connectedPlayers.Add(1);
|
||||
|
||||
public void PlayerLeft() => _connectedPlayers.Add(-1);
|
||||
|
||||
public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user