live player info in client
This commit is contained in:
parent
fb675e59ff
commit
a50a9770c9
|
@ -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>;
|
||||
}
|
||||
|
||||
type Controls = {
|
||||
readonly forward: boolean;
|
||||
readonly backward: boolean;
|
||||
readonly turnLeft: boolean;
|
||||
readonly turnRight: boolean;
|
||||
readonly shoot: boolean;
|
||||
}
|
||||
|
||||
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 query = useQuery({
|
||||
queryKey: ['player'],
|
||||
refetchInterval: 1000,
|
||||
queryFn: async () => {
|
||||
const [shouldSendMessage, setShouldSendMessage] = useState(true);
|
||||
|
||||
const url = makeApiUrl('/player');
|
||||
url.searchParams.set('id', playerId);
|
||||
|
||||
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;
|
||||
}
|
||||
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>;
|
||||
}
|
||||
|
|
|
@ -12,14 +12,16 @@ export type ServerResponse<T> = {
|
|||
successResult?: T;
|
||||
}
|
||||
|
||||
export type Player = {
|
||||
readonly name: string;
|
||||
readonly id: Guid;
|
||||
readonly scores: {
|
||||
export type Scores = {
|
||||
readonly kills: number;
|
||||
readonly deaths: number;
|
||||
readonly wallsDestroyed: number;
|
||||
};
|
||||
|
||||
export type Player = {
|
||||
readonly name: string;
|
||||
readonly id: Guid;
|
||||
readonly scores: Scores;
|
||||
};
|
||||
|
||||
export type NameId = {
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
3
tanks-backend/TanksServer/Models/PlayerInfo.cs
Normal file
3
tanks-backend/TanksServer/Models/PlayerInfo.cs
Normal file
|
@ -0,0 +1,3 @@
|
|||
namespace TanksServer.Models;
|
||||
|
||||
internal sealed record class PlayerInfo(string Name, Scores Scores, PlayerControls Controls);
|
|
@ -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>();
|
||||
|
|
Loading…
Reference in a new issue