diff --git a/tank-frontend/src/PlayerInfo.tsx b/tank-frontend/src/PlayerInfo.tsx index 0832adc..188c6dd 100644 --- a/tank-frontend/src/PlayerInfo.tsx +++ b/tank-frontend/src/PlayerInfo.tsx @@ -1,7 +1,8 @@ -import {useQuery} from '@tanstack/react-query' -import {makeApiUrl, Player} from './serverCalls'; -import {Guid} from "./Guid.ts"; -import Column from "./components/Column.tsx"; +import {makeApiUrl, Scores} from './serverCalls'; +import {Guid} from './Guid.ts'; +import Column from './components/Column.tsx'; +import useWebSocket, {ReadyState} from 'react-use-websocket'; +import {useEffect, useState} from 'react'; function ScoreRow({name, value}: { name: string; @@ -13,33 +14,66 @@ function ScoreRow({name, value}: { ; } -export default function PlayerInfo({playerId}: { playerId: Guid }) { - const query = useQuery({ - queryKey: ['player'], - refetchInterval: 1000, - queryFn: async () => { - const url = makeApiUrl('/player'); - url.searchParams.set('id', playerId); +type Controls = { + readonly forward: boolean; + readonly backward: boolean; + readonly turnLeft: boolean; + readonly turnRight: boolean; + readonly shoot: boolean; +} - const response = await fetch(url, {method: 'GET'}); - if (!response.ok) - throw new Error(`response failed with code ${response.status} (${response.status})${await response.text()}`) - return await response.json() as Player; - } +type PlayerInfoMessage = { + readonly name: string; + readonly scores: Scores; + readonly controls: Controls; +} + +function controlsString(controls: Controls) { + let str = ""; + if (controls.forward) + str += "▲"; + if (controls.backward) + str += "▼"; + if (controls.turnLeft) + str += "◄"; + if (controls.turnRight) + str += "►"; + if (controls.shoot) + str += "•"; + return str; +} + +export default function PlayerInfo({playerId}: { playerId: Guid }) { + const [shouldSendMessage, setShouldSendMessage] = useState(true); + + const url = makeApiUrl('/player'); + url.searchParams.set('id', playerId); + + const {lastJsonMessage, readyState, sendMessage} = useWebSocket(url.toString(), { + onMessage: () => setShouldSendMessage(true) }); - return + useEffect(() => { + if (!shouldSendMessage || readyState !== ReadyState.OPEN) + return; + setShouldSendMessage(false); + sendMessage(''); + }, [readyState, shouldSendMessage]); + + if (!lastJsonMessage) + return <>; + + return

- {query.isPending && 'loading...'} - {query.isSuccess && `Playing as ${query.data.name}`} + Playing as {lastJsonMessage.name}

- {query.isError &&

{query.error.message}

} - {query.isSuccess && +
- - - + + + + -
} +
; } diff --git a/tank-frontend/src/serverCalls.tsx b/tank-frontend/src/serverCalls.tsx index 9a03ded..7281471 100644 --- a/tank-frontend/src/serverCalls.tsx +++ b/tank-frontend/src/serverCalls.tsx @@ -12,14 +12,16 @@ export type ServerResponse = { successResult?: T; } +export type Scores = { + readonly kills: number; + readonly deaths: number; + readonly wallsDestroyed: number; +}; + export type Player = { readonly name: string; readonly id: Guid; - readonly scores: { - readonly kills: number; - readonly deaths: number; - readonly wallsDestroyed: number; - }; + readonly scores: Scores; }; export type NameId = { diff --git a/tanks-backend/TanksServer/Endpoints.cs b/tanks-backend/TanksServer/Endpoints.cs index a002cdd..957d830 100644 --- a/tanks-backend/TanksServer/Endpoints.cs +++ b/tanks-backend/TanksServer/Endpoints.cs @@ -33,11 +33,18 @@ internal static class Endpoints : Results.Unauthorized(); }); - app.MapGet("/player", ([FromQuery] Guid id) => - playerService.TryGet(id, out var foundPlayer) - ? Results.Ok((object?)foundPlayer) - : Results.NotFound() - ); + app.MapGet("/player", async (HttpContext context, [FromQuery] Guid id) => + { + if (!playerService.TryGet(id, out var foundPlayer)) + return Results.NotFound(); + + if (!context.WebSockets.IsWebSocketRequest) + return Results.Ok((object?)foundPlayer); + + using var ws = await context.WebSockets.AcceptWebSocketAsync(); + await playerService.HandleClientAsync(ws, foundPlayer); + return Results.Empty; + }); app.MapGet("/scores", () => playerService.GetAll()); diff --git a/tanks-backend/TanksServer/Interactivity/AppSerializerContext.cs b/tanks-backend/TanksServer/Interactivity/AppSerializerContext.cs index da1cbbd..e5acef1 100644 --- a/tanks-backend/TanksServer/Interactivity/AppSerializerContext.cs +++ b/tanks-backend/TanksServer/Interactivity/AppSerializerContext.cs @@ -7,4 +7,5 @@ namespace TanksServer.Interactivity; [JsonSerializable(typeof(Guid))] [JsonSerializable(typeof(NameId))] [JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(PlayerInfo))] internal sealed partial class AppSerializerContext : JsonSerializerContext; diff --git a/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs b/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs index 8a1ec11..78efb6d 100644 --- a/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs +++ b/tanks-backend/TanksServer/Interactivity/ByteChannelWebSocket.cs @@ -7,9 +7,12 @@ internal sealed class ByteChannelWebSocket(WebSocket socket, ILogger logger, int { private readonly byte[] _buffer = new byte[messageSize]; - public ValueTask SendAsync(ReadOnlyMemory data, bool endOfMessage = true) => + public ValueTask SendBinaryAsync(ReadOnlyMemory data, bool endOfMessage = true) => socket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage, CancellationToken.None); + public ValueTask SendTextAsync(ReadOnlyMemory data, bool endOfMessage = true) => + socket.SendAsync(data, WebSocketMessageType.Text, endOfMessage, CancellationToken.None); + public async IAsyncEnumerable> ReadAllAsync() { while (socket.State is WebSocketState.Open or WebSocketState.CloseSent) diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs index 588ef57..c84d3f5 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs @@ -26,7 +26,7 @@ internal sealed class ClientScreenServerConnection( if (!await _wantedFrames.WaitAsync(TimeSpan.Zero)) { - logger.LogTrace("client does not want a frame yet"); + Logger.LogTrace("client does not want a frame yet"); return; } @@ -35,17 +35,17 @@ internal sealed class ClientScreenServerConnection( if (_playerScreenData != null) RefreshPlayerSpecificData(gamePixelGrid); - logger.LogTrace("sending"); + Logger.LogTrace("sending"); try { - logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length); - await Socket.SendAsync(pixels.Data, _playerScreenData == null); + Logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length); + await Socket.SendBinaryAsync(pixels.Data, _playerScreenData == null); if (_playerScreenData != null) - await Socket.SendAsync(_playerScreenData.GetPacket()); + await Socket.SendBinaryAsync(_playerScreenData.GetPacket()); } catch (WebSocketException ex) { - logger.LogWarning(ex, "send failed"); + Logger.LogWarning(ex, "send failed"); } } @@ -61,5 +61,9 @@ internal sealed class ClientScreenServerConnection( } } - protected override void HandleMessage(Memory _) => _wantedFrames.Release(); + protected override ValueTask HandleMessageAsync(Memory _) + { + _wantedFrames.Release(); + return ValueTask.CompletedTask; + } } diff --git a/tanks-backend/TanksServer/Interactivity/ControlsServer.cs b/tanks-backend/TanksServer/Interactivity/ControlsServer.cs index 78c88bd..429eedf 100644 --- a/tanks-backend/TanksServer/Interactivity/ControlsServer.cs +++ b/tanks-backend/TanksServer/Interactivity/ControlsServer.cs @@ -7,13 +7,11 @@ internal sealed class ControlsServer( ILoggerFactory loggerFactory ) : WebsocketServer(logger) { - public async Task HandleClientAsync(WebSocket ws, Player player) + public Task HandleClientAsync(WebSocket ws, Player player) { logger.LogDebug("control client connected {}", player.Id); var clientLogger = loggerFactory.CreateLogger(); var sock = new ControlsServerConnection(ws, clientLogger, player); - await AddConnection(sock); - await sock.ReceiveAsync(); - await RemoveConnection(sock); + return HandleClientAsync(sock); } } diff --git a/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs index 8a178b1..08d21a1 100644 --- a/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs @@ -23,12 +23,12 @@ internal sealed class ControlsServerConnection( Shoot = 0x05 } - protected override void HandleMessage(Memory buffer) + protected override ValueTask HandleMessageAsync(Memory buffer) { var type = (MessageType)buffer.Span[0]; var control = (InputType)buffer.Span[1]; - logger.LogTrace("player input {} {} {}", player.Id, type, control); + Logger.LogTrace("player input {} {} {}", player.Id, type, control); var isEnable = type switch { @@ -59,5 +59,7 @@ internal sealed class ControlsServerConnection( default: throw new ArgumentException("invalid control type"); } + + return ValueTask.CompletedTask; } } diff --git a/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs new file mode 100644 index 0000000..e6acd67 --- /dev/null +++ b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs @@ -0,0 +1,58 @@ +using System.Net.WebSockets; +using System.Text.Json; + +namespace TanksServer.Interactivity; + +internal sealed class PlayerInfoConnection( + Player player, + ILogger logger, + WebSocket rawSocket +) : WebsocketServerConnection(logger, new ByteChannelWebSocket(rawSocket, logger, 0)), IDisposable +{ + private readonly SemaphoreSlim _wantedFrames = new(1); + private readonly AppSerializerContext _context = new(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + private byte[] _lastMessage = []; + + protected override ValueTask HandleMessageAsync(Memory buffer) + { + var response = GetMessageToSend(); + if (response == null) + { + Logger.LogTrace("cannot respond directly, increasing wanted frames"); + _wantedFrames.Release(); + return ValueTask.CompletedTask; + } + + Logger.LogTrace("responding directly"); + return Socket.SendTextAsync(response); + } + + public async Task OnGameTickAsync() + { + if (!await _wantedFrames.WaitAsync(TimeSpan.Zero)) + return; + + var response = GetMessageToSend(); + if (response == null) + { + _wantedFrames.Release(); + return; + } + + Logger.LogTrace("responding indirectly"); + await Socket.SendTextAsync(response); + } + + private byte[]? GetMessageToSend() + { + var info = new PlayerInfo(player.Name, player.Scores, player.Controls); + var response = JsonSerializer.SerializeToUtf8Bytes(info, _context.PlayerInfo); + + if (response.SequenceEqual(_lastMessage)) + return null; + + return _lastMessage = response; + } + + public void Dispose() => _wantedFrames.Dispose(); +} diff --git a/tanks-backend/TanksServer/Interactivity/PlayerServer.cs b/tanks-backend/TanksServer/Interactivity/PlayerServer.cs index e1d7c80..03546a3 100644 --- a/tanks-backend/TanksServer/Interactivity/PlayerServer.cs +++ b/tanks-backend/TanksServer/Interactivity/PlayerServer.cs @@ -1,30 +1,32 @@ using System.Diagnostics.CodeAnalysis; +using System.Net.WebSockets; using TanksServer.GameLogic; namespace TanksServer.Interactivity; internal sealed class PlayerServer( ILogger logger, + ILogger connectionLogger, TankSpawnQueue tankSpawnQueue -) +) : WebsocketServer(logger), ITickStep { private readonly ConcurrentDictionary _players = new(); public Player? GetOrAdd(string name, Guid id) { - Player AddAndSpawn() - { - var player = new Player(name, id); - tankSpawnQueue.EnqueueForImmediateSpawn(player); - return player; - } - - var player = _players.GetOrAdd(name, _ => AddAndSpawn()); - if (player.Id != id) + var existingOrAddedPlayer = _players.GetOrAdd(name, _ => AddAndSpawn()); + if (existingOrAddedPlayer.Id != id) return null; - logger.LogInformation("player {} (re)joined", player.Id); - return player; + logger.LogInformation("player {} (re)joined", existingOrAddedPlayer.Id); + return existingOrAddedPlayer; + + Player AddAndSpawn() + { + var newPlayer = new Player(name, id); + tankSpawnQueue.EnqueueForImmediateSpawn(newPlayer); + return newPlayer; + } } public bool TryGet(Guid? playerId, [MaybeNullWhen(false)] out Player foundPlayer) @@ -42,4 +44,10 @@ internal sealed class PlayerServer( } public IEnumerable GetAll() => _players.Values; + + public Task HandleClientAsync(WebSocket webSocket, Player player) + => HandleClientAsync(new PlayerInfoConnection(player, connectionLogger, webSocket)); + + public Task TickAsync(TimeSpan delta) + => ParallelForEachConnectionAsync(connection => connection.OnGameTickAsync()); } diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs index e86f0b0..d5678a2 100644 --- a/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs @@ -35,7 +35,7 @@ internal abstract class WebsocketServer( } } - protected Task AddConnection(T connection) => Locked(() => + private Task AddConnectionAsync(T connection) => Locked(() => { if (_closing) { @@ -47,7 +47,7 @@ internal abstract class WebsocketServer( return Task.CompletedTask; }, CancellationToken.None); - protected Task RemoveConnection(T connection) => Locked(() => + private Task RemoveConnectionAsync(T connection) => Locked(() => { _connections.Remove(connection); return Task.CompletedTask; @@ -55,9 +55,9 @@ internal abstract class WebsocketServer( protected async Task HandleClientAsync(T connection) { - await AddConnection(connection); + await AddConnectionAsync(connection); await connection.ReceiveAsync(); - await RemoveConnection(connection); + await RemoveConnectionAsync(connection); } private async Task Locked(Func action, CancellationToken cancellationToken) diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs index 891b1ba..629c44b 100644 --- a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs @@ -2,22 +2,24 @@ namespace TanksServer.Interactivity; internal abstract class WebsocketServerConnection( ILogger logger, - ByteChannelWebSocket socket) + ByteChannelWebSocket socket +) { protected readonly ByteChannelWebSocket Socket = socket; + protected readonly ILogger Logger = logger; public Task CloseAsync() { - logger.LogDebug("closing connection"); + Logger.LogDebug("closing connection"); return Socket.CloseAsync(); } public async Task ReceiveAsync() { await foreach (var buffer in Socket.ReadAllAsync()) - HandleMessage(buffer); - logger.LogTrace("done receiving"); + await HandleMessageAsync(buffer); + Logger.LogTrace("done receiving"); } - protected abstract void HandleMessage(Memory buffer); + protected abstract ValueTask HandleMessageAsync(Memory buffer); } diff --git a/tanks-backend/TanksServer/Models/PlayerInfo.cs b/tanks-backend/TanksServer/Models/PlayerInfo.cs new file mode 100644 index 0000000..a5cd3f5 --- /dev/null +++ b/tanks-backend/TanksServer/Models/PlayerInfo.cs @@ -0,0 +1,3 @@ +namespace TanksServer.Models; + +internal sealed record class PlayerInfo(string Name, Scores Scores, PlayerControls Controls); diff --git a/tanks-backend/TanksServer/Program.cs b/tanks-backend/TanksServer/Program.cs index 42782bf..49b3b44 100644 --- a/tanks-backend/TanksServer/Program.cs +++ b/tanks-backend/TanksServer/Program.cs @@ -77,6 +77,7 @@ public static class Program builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton();