live player info in client

This commit is contained in:
Vinzenz Schroeter 2024-04-22 19:03:07 +02:00
parent fb675e59ff
commit a50a9770c9
14 changed files with 193 additions and 70 deletions

View file

@ -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}: {
</tr>;
}
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<PlayerInfoMessage>(url.toString(), {
onMessage: () => setShouldSendMessage(true)
});
return <Column className='PlayerInfo'>
useEffect(() => {
if (!shouldSendMessage || readyState !== ReadyState.OPEN)
return;
setShouldSendMessage(false);
sendMessage('');
}, [readyState, shouldSendMessage]);
if (!lastJsonMessage)
return <></>;
return <Column className="PlayerInfo">
<h3>
{query.isPending && 'loading...'}
{query.isSuccess && `Playing as ${query.data.name}`}
Playing as {lastJsonMessage.name}
</h3>
{query.isError && <p>{query.error.message}</p>}
{query.isSuccess && <table>
<table>
<tbody>
<ScoreRow name='kills' value={query.data?.scores?.kills}/>
<ScoreRow name='deaths' value={query.data?.scores?.deaths}/>
<ScoreRow name='walls' value={query.data?.scores?.wallsDestroyed}/>
<ScoreRow name="controls" value={controlsString(lastJsonMessage.controls)}/>
<ScoreRow name="kills" value={lastJsonMessage.scores.kills}/>
<ScoreRow name="deaths" value={lastJsonMessage.scores.deaths}/>
<ScoreRow name="walls" value={lastJsonMessage.scores.wallsDestroyed}/>
</tbody>
</table>}
</table>
</Column>;
}

View file

@ -12,14 +12,16 @@ export type ServerResponse<T> = {
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 = {

View file

@ -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());

View file

@ -7,4 +7,5 @@ namespace TanksServer.Interactivity;
[JsonSerializable(typeof(Guid))]
[JsonSerializable(typeof(NameId))]
[JsonSerializable(typeof(IEnumerable<string>))]
[JsonSerializable(typeof(PlayerInfo))]
internal sealed partial class AppSerializerContext : JsonSerializerContext;

View file

@ -7,9 +7,12 @@ internal sealed class ByteChannelWebSocket(WebSocket socket, ILogger logger, int
{
private readonly byte[] _buffer = new byte[messageSize];
public ValueTask SendAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) =>
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) =>
socket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage, CancellationToken.None);
public ValueTask SendTextAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) =>
socket.SendAsync(data, WebSocketMessageType.Text, endOfMessage, CancellationToken.None);
public async IAsyncEnumerable<Memory<byte>> ReadAllAsync()
{
while (socket.State is WebSocketState.Open or WebSocketState.CloseSent)

View file

@ -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<byte> _) => _wantedFrames.Release();
protected override ValueTask HandleMessageAsync(Memory<byte> _)
{
_wantedFrames.Release();
return ValueTask.CompletedTask;
}
}

View file

@ -7,13 +7,11 @@ internal sealed class ControlsServer(
ILoggerFactory loggerFactory
) : WebsocketServer<ControlsServerConnection>(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<ControlsServerConnection>();
var sock = new ControlsServerConnection(ws, clientLogger, player);
await AddConnection(sock);
await sock.ReceiveAsync();
await RemoveConnection(sock);
return HandleClientAsync(sock);
}
}

View file

@ -23,12 +23,12 @@ internal sealed class ControlsServerConnection(
Shoot = 0x05
}
protected override void HandleMessage(Memory<byte> buffer)
protected override ValueTask HandleMessageAsync(Memory<byte> 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;
}
}

View file

@ -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<byte> 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();
}

View file

@ -1,30 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.WebSockets;
using TanksServer.GameLogic;
namespace TanksServer.Interactivity;
internal sealed class PlayerServer(
ILogger<PlayerServer> logger,
ILogger<PlayerInfoConnection> connectionLogger,
TankSpawnQueue tankSpawnQueue
)
) : WebsocketServer<PlayerInfoConnection>(logger), ITickStep
{
private readonly ConcurrentDictionary<string, Player> _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<Player> 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());
}

View file

@ -35,7 +35,7 @@ internal abstract class WebsocketServer<T>(
}
}
protected Task AddConnection(T connection) => Locked(() =>
private Task AddConnectionAsync(T connection) => Locked(() =>
{
if (_closing)
{
@ -47,7 +47,7 @@ internal abstract class WebsocketServer<T>(
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<T>(
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<Task> action, CancellationToken cancellationToken)

View file

@ -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<byte> buffer);
protected abstract ValueTask HandleMessageAsync(Memory<byte> buffer);
}

View file

@ -0,0 +1,3 @@
namespace TanksServer.Models;
internal sealed record class PlayerInfo(string Name, Scores Scores, PlayerControls Controls);

View file

@ -77,6 +77,7 @@ public static class Program
builder.Services.AddSingleton<ITickStep>(sp => sp.GetRequiredService<TankSpawnQueue>());
builder.Services.AddSingleton<ITickStep, SpawnPowerUp>();
builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>();
builder.Services.AddSingleton<ITickStep, PlayerServer>(sp => sp.GetRequiredService<PlayerServer>());
builder.Services.AddSingleton<IDrawStep, DrawMapStep>();
builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>();