diff --git a/tanks-backend/TanksServer/Endpoints.cs b/tanks-backend/TanksServer/Endpoints.cs index 1267084..6815fc0 100644 --- a/tanks-backend/TanksServer/Endpoints.cs +++ b/tanks-backend/TanksServer/Endpoints.cs @@ -48,14 +48,18 @@ internal sealed class Endpoints( return TypedResults.Empty; } - private async Task> ConnectScreenAsync(HttpContext context, + private async Task> ConnectScreenAsync(HttpContext context, [FromQuery] string? playerName) { if (!context.WebSockets.IsWebSocketRequest) return TypedResults.BadRequest(); + Player? player = null; + if (!string.IsNullOrWhiteSpace(playerName) && !playerService.TryGet(playerName, out player)) + return TypedResults.NotFound(); + using var ws = await context.WebSockets.AcceptWebSocketAsync(); - await clientScreenServer.HandleClientAsync(ws, playerName); + await clientScreenServer.HandleClientAsync(ws, player); return TypedResults.Empty; } diff --git a/tanks-backend/TanksServer/GameLogic/GameTickWorker.cs b/tanks-backend/TanksServer/GameLogic/GameTickWorker.cs index a529889..e684b80 100644 --- a/tanks-backend/TanksServer/GameLogic/GameTickWorker.cs +++ b/tanks-backend/TanksServer/GameLogic/GameTickWorker.cs @@ -7,37 +7,19 @@ internal sealed class GameTickWorker( IEnumerable steps, IHostApplicationLifetime lifetime, ILogger logger -) : IHostedService, IDisposable +) : IHostedLifecycleService, IDisposable { private readonly CancellationTokenSource _cancellation = new(); + private readonly TaskCompletionSource _shutdownCompletion = new(); private readonly List _steps = steps.ToList(); - private Task? _run; - public void Dispose() + public async Task StartedAsync(CancellationToken cancellationToken) { - _cancellation.Dispose(); - _run?.Dispose(); - } + await Task.Yield(); - public Task StartAsync(CancellationToken cancellationToken) - { - _run = RunAsync(); - return Task.CompletedTask; - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - await _cancellation.CancelAsync(); - if (_run != null) await _run; - } - - private async Task RunAsync() - { // the first tick is really short (< 0.01ms) if this line is directly above the while var sw = Stopwatch.StartNew(); - - // do not block in StartAsync - await Task.Delay(1).ConfigureAwait(false); + await Task.Delay(1, CancellationToken.None).ConfigureAwait(false); try { @@ -55,5 +37,19 @@ internal sealed class GameTickWorker( logger.LogError(ex, "game tick service crashed"); lifetime.StopApplication(); } + + _shutdownCompletion.SetResult(); } + + public Task StoppingAsync(CancellationToken cancellationToken) => _cancellation.CancelAsync(); + + public Task StopAsync(CancellationToken cancellationToken) => _shutdownCompletion.Task; + + public void Dispose() => _cancellation.Dispose(); + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs b/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs index b2a7f36..eba8ebf 100644 --- a/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs +++ b/tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs @@ -8,26 +8,38 @@ internal sealed class GeneratePixelsTickStep( IEnumerable consumers ) : ITickStep { - private readonly GamePixelGrid _gamePixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); + private GamePixelGrid _lastGamePixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); + private PixelGrid _lastObserverPixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); + private GamePixelGrid _gamePixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); + private PixelGrid _observerPixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); + private readonly List _drawSteps = drawSteps.ToList(); private readonly List _consumers = consumers.ToList(); public async ValueTask TickAsync(TimeSpan _) { - PixelGrid observerPixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); + Draw(_gamePixelGrid, _observerPixelGrid); + if (_observerPixelGrid.Data.Span.SequenceEqual(_lastObserverPixelGrid.Data.Span)) + return; - _gamePixelGrid.Clear(); + await Task.WhenAll(_consumers.Select(c => c.OnFrameDoneAsync(_gamePixelGrid, _observerPixelGrid))); + + (_lastGamePixelGrid, _gamePixelGrid) = (_gamePixelGrid, _lastGamePixelGrid); + (_lastObserverPixelGrid, _observerPixelGrid) = (_observerPixelGrid, _lastObserverPixelGrid); + } + + private void Draw(GamePixelGrid gamePixelGrid, PixelGrid observerPixelGrid) + { + gamePixelGrid.Clear(); foreach (var step in _drawSteps) - step.Draw(_gamePixelGrid); + step.Draw(gamePixelGrid); + observerPixelGrid.Clear(); for (var y = 0; y < MapService.PixelsPerColumn; y++) for (var x = 0; x < MapService.PixelsPerRow; x++) { - if (_gamePixelGrid[x, y].EntityType.HasValue) + if (gamePixelGrid[x, y].EntityType.HasValue) observerPixelGrid[(ushort)x, (ushort)y] = true; } - - foreach (var consumer in _consumers) - await consumer.OnFrameDoneAsync(_gamePixelGrid, observerPixelGrid); } } diff --git a/tanks-backend/TanksServer/Graphics/IFrameConsumer.cs b/tanks-backend/TanksServer/Graphics/IFrameConsumer.cs index cc6521f..5a83a86 100644 --- a/tanks-backend/TanksServer/Graphics/IFrameConsumer.cs +++ b/tanks-backend/TanksServer/Graphics/IFrameConsumer.cs @@ -4,5 +4,5 @@ namespace TanksServer.Graphics; internal interface IFrameConsumer { - ValueTask OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels); + Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels); } diff --git a/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs b/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs index 0e61d4d..700e4e1 100644 --- a/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs +++ b/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs @@ -16,20 +16,20 @@ internal sealed class ByteChannelWebSocket(WebSocket socket, ILogger logger, int catch (WebSocketException e) { logger.LogError(e, "could not send binary message"); - await CloseAsync(); + await CloseWithErrorAsync(e.Message); } } - public async ValueTask SendTextAsync(ReadOnlyMemory data, bool endOfMessage = true) + public async ValueTask SendTextAsync(ReadOnlyMemory utf8Data, bool endOfMessage = true) { try { - await socket.SendAsync(data, WebSocketMessageType.Text, endOfMessage, CancellationToken.None); + await socket.SendAsync(utf8Data, WebSocketMessageType.Text, endOfMessage, CancellationToken.None); } catch (WebSocketException e) { logger.LogError(e, "could not send text message"); - await CloseAsync(); + await CloseWithErrorAsync(e.Message); } } diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs index 8e3b498..1f9f1cf 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServer.cs @@ -9,13 +9,13 @@ internal sealed class ClientScreenServer( ILoggerFactory loggerFactory ) : WebsocketServer(logger), IFrameConsumer { - public Task HandleClientAsync(WebSocket socket, string? player) - => base.HandleClientAsync(new( + public Task HandleClientAsync(WebSocket socket, Player? player) + => base.HandleClientAsync(new ClientScreenServerConnection( socket, loggerFactory.CreateLogger(), player )); - public ValueTask OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) - => ParallelForEachConnectionAsync(c => c.OnGameTickAsync(observerPixels, gamePixelGrid).AsTask()); + public async Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) + => await ParallelForEachConnectionAsync(c => c.OnGameTickAsync(observerPixels, gamePixelGrid).AsTask()); } diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs index b963690..55985d2 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Diagnostics; using System.Net.WebSockets; using DisplayCommands; using TanksServer.Graphics; @@ -7,67 +9,87 @@ namespace TanksServer.Interactivity; internal sealed class ClientScreenServerConnection( WebSocket webSocket, ILogger logger, - string? playerName = null + Player? player ) : WebsocketServerConnection(logger, new ByteChannelWebSocket(webSocket, logger, 0)) { - private bool _wantsFrameOnTick = true; + private sealed record class Package( + IMemoryOwner PixelsOwner, + Memory Pixels, + IMemoryOwner? PlayerDataOwner, + Memory? PlayerData + ); - private PixelGrid? _lastSentPixels; - private PixelGrid? _nextPixels; - private readonly PlayerScreenData? _nextPlayerData = playerName != null ? new PlayerScreenData(logger) : null; + private readonly MemoryPool _memoryPool = MemoryPool.Shared; + private int _wantsFrameOnTick = 1; + private Package? _next; - protected override async ValueTask HandleMessageLockedAsync(Memory _) + private readonly PlayerScreenData? _playerDataBuilder = player == null + ? null + : new PlayerScreenData(logger, player); + + protected override ValueTask HandleMessageLockedAsync(Memory buffer) => throw new UnreachableException(); + + protected override ValueTask HandleMessageAsync(Memory _) { - if (_nextPixels == null) - { - _wantsFrameOnTick = true; - return; - } + if (_wantsFrameOnTick != 0) + return ValueTask.CompletedTask; - await SendNowAsync(); + var package = Interlocked.Exchange(ref _next, null); + if (package != null) + return SendAndDisposeAsync(package); + + // the delay between one exchange and this set could be enough for another frame to complete + // this would mean the client simply drops a frame, so this should be fine + _wantsFrameOnTick = 1; + return ValueTask.CompletedTask; } - public ValueTask OnGameTickAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid) => LockedAsync(async () => + public async ValueTask OnGameTickAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid) { - if (pixels == _lastSentPixels) - return; + await Task.Yield(); - if (_nextPlayerData != null) + var nextPixelsOwner = _memoryPool.Rent(pixels.Data.Length); + var nextPixels = nextPixelsOwner.Memory[..pixels.Data.Length]; + pixels.Data.CopyTo(nextPixels); + + IMemoryOwner? nextPlayerDataOwner = null; + Memory? nextPlayerData = null; + if (_playerDataBuilder != null) { - _nextPlayerData.Clear(); - foreach (var gamePixel in gamePixelGrid) - { - if (!gamePixel.EntityType.HasValue) - continue; - _nextPlayerData.Add(gamePixel.EntityType.Value, gamePixel.BelongsTo?.Name == playerName); - } + var data = _playerDataBuilder.Build(gamePixelGrid); + nextPlayerDataOwner = _memoryPool.Rent(data.Length); + nextPlayerData = nextPlayerDataOwner.Memory[..data.Length]; + data.CopyTo(nextPlayerData.Value); } - _nextPixels = pixels; - if (_wantsFrameOnTick) - _ = await SendNowAsync(); - }); + var next = new Package(nextPixelsOwner, nextPixels, nextPlayerDataOwner, nextPlayerData); + if (Interlocked.Exchange(ref _wantsFrameOnTick, 0) != 0) + { + await SendAndDisposeAsync(next); + return; + } - private async ValueTask SendNowAsync() + var oldNext = Interlocked.Exchange(ref _next, next); + oldNext?.PixelsOwner.Dispose(); + oldNext?.PlayerDataOwner?.Dispose(); + } + + private async ValueTask SendAndDisposeAsync(Package package) { - var pixels = _nextPixels - ?? throw new InvalidOperationException("next pixels not set"); - try { - await Socket.SendBinaryAsync(pixels.Data, _nextPlayerData == null); - if (_nextPlayerData != null) - await Socket.SendBinaryAsync(_nextPlayerData.GetPacket()); - - _lastSentPixels = _nextPixels; - _nextPixels = null; - _wantsFrameOnTick = false; - return true; + await Socket.SendBinaryAsync(package.Pixels, package.PlayerData == null); + if (package.PlayerData != null) + await Socket.SendBinaryAsync(package.PlayerData.Value); } catch (WebSocketException ex) { Logger.LogWarning(ex, "send failed"); - return false; + } + finally + { + package.PixelsOwner.Dispose(); + package.PlayerDataOwner?.Dispose(); } } } diff --git a/tanks-backend/TanksServer/Interactivity/PlayerScreenData.cs b/tanks-backend/TanksServer/Interactivity/PlayerScreenData.cs index f79e7a8..6c99806 100644 --- a/tanks-backend/TanksServer/Interactivity/PlayerScreenData.cs +++ b/tanks-backend/TanksServer/Interactivity/PlayerScreenData.cs @@ -4,25 +4,30 @@ using TanksServer.Graphics; namespace TanksServer.Interactivity; -internal sealed class PlayerScreenData(ILogger logger) +internal sealed class PlayerScreenData(ILogger logger, Player player) { private readonly Memory _data = new byte[MapService.PixelsPerRow * MapService.PixelsPerColumn / 2]; private int _count; - public void Clear() + public ReadOnlyMemory Build(GamePixelGrid gamePixelGrid) { - _count = 0; - _data.Span.Clear(); - } + Clear(); + foreach (var gamePixel in gamePixelGrid) + { + if (!gamePixel.EntityType.HasValue) + continue; + Add(gamePixel.EntityType.Value, gamePixel.BelongsTo == player); + } - public ReadOnlyMemory GetPacket() - { var index = _count / 2 + (_count % 2 == 0 ? 0 : 1); - logger.LogTrace("packet length: {} (count={})", index, _count); + + if (logger.IsEnabled(LogLevel.Trace)) + logger.LogTrace("packet length: {} (count={})", index, _count); + return _data[..index]; } - public void Add(GamePixelEntityType entityKind, bool isCurrentPlayer) + private void Add(GamePixelEntityType entityKind, bool isCurrentPlayer) { var result = (byte)(isCurrentPlayer ? 0x1 : 0x0); var kind = (byte)entityKind; @@ -36,4 +41,10 @@ internal sealed class PlayerScreenData(ILogger logger) _data.Span[index] = result; _count++; } + + private void Clear() + { + _count = 0; + _data.Span.Clear(); + } } diff --git a/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs b/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs index b73f2ff..7a9d5a5 100644 --- a/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs +++ b/tanks-backend/TanksServer/Interactivity/SendToServicePointDisplay.cs @@ -48,11 +48,13 @@ internal sealed class SendToServicePointDisplay : IFrameConsumer }; } - public async ValueTask OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) + public async Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) { if (DateTime.Now < _nextFrameAfter) return; + _nextFrameAfter = DateTime.Now + _minFrameTime; + await Task.Yield(); RefreshScores(); diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs index c8feccc..3610981 100644 --- a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs @@ -18,10 +18,13 @@ internal abstract class WebsocketServerConnection( public async Task ReceiveAsync() { await foreach (var buffer in Socket.ReadAllAsync()) - await LockedAsync(() => HandleMessageLockedAsync(buffer)); + await HandleMessageAsync(buffer); Logger.LogTrace("done receiving"); } + protected virtual ValueTask HandleMessageAsync(Memory buffer) + => LockedAsync(() => HandleMessageLockedAsync(buffer)); + protected abstract ValueTask HandleMessageLockedAsync(Memory buffer); protected async ValueTask LockedAsync(Func action)