From c4c4eb6358a4bb92178b1988f3bacbf12f85c0e8 Mon Sep 17 00:00:00 2001 From: Vinzenz Schroeter Date: Tue, 16 Apr 2024 00:07:44 +0200 Subject: [PATCH] render current player in secondary color --- README.md | 106 ++++++++++++++++++ TanksServer/Graphics/GamePixelGrid.cs | 17 +-- .../Interactivity/ClientScreenServer.cs | 9 +- .../ClientScreenServerConnection.cs | 25 +---- TanksServer/Interactivity/PlayerScreenData.cs | 39 +++++++ .../SendToServicePointDisplay.cs | 8 +- TanksServer/Program.cs | 8 +- TanksServer/TanksServer.csproj | 3 +- tank-frontend/.env | 4 - tank-frontend/src/App.tsx | 2 +- tank-frontend/src/ClientScreen.tsx | 100 ++++++++++++++--- tank-frontend/src/Controls.tsx | 2 +- tank-frontend/src/serverCalls.tsx | 4 +- 13 files changed, 255 insertions(+), 72 deletions(-) create mode 100644 README.md create mode 100644 TanksServer/Interactivity/PlayerScreenData.cs diff --git a/README.md b/README.md new file mode 100644 index 0000000..7981903 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# CCCB-Tanks + + + +## Service point display + + + +In CCCB, there is a big pixel matrix hanging on the wall. It is called "Airport Display" or "Service Point Display". + +- Resolution: 352x160=56,320 pixels +- Pixels are grouped into 44x20=880 tiles (8x8=64 pixels each) +- Smallest addressable unit: row of pixels inside of a tile (8 pixels = 1 byte) +- The brightness can only be set per tile +- Screen content can be changed using a simple UDP protocol +- Between each row of tiles, there is a gap of around 4 pixels size. This gap changes the aspect ratio of the display. + +### Binary format + +A UDP package sent to the display has a header size of 10 bytes. +Each header value has a size of two bytes (unsigned 16 bit integer). +Depending on the command, there can be a payload following the header. + +The commands are implemented in DisplayCommands. + +To change screen contents, these commands are the most relevant: +1. Clear screen + - command: `0x0002` + - (rest does not matter) +2. Send CP437 data: render specified text into rectangular region + - command: `0x0003` + - top left tile x + - top left tile y + - width in tiles + - height in tiles + - payload: (width in tiles * height in tiles) bytes + - 1 byte = 1 character + - each character is rendered into one tile (mono-spaced) + - characters are encoded using code page 437 +3. Send bitmap window: set pixel states for a rectangular region + - command: `0x0013` + - top left tile x + - top left _pixel_ y + - width in tiles + - height in _pixels_ + - payload: (width in tiles * height in pixels) bytes + - network byte order + - 1 bit = 1 pixel + +There are other commands implemented as well, e.g. for changing the brightness. + +## Tanks game + +- By default, the backend also hosts the frontend + +### Backend + + + +- Stack: .NET / C# / ASP.NET / AOT-compiled +- Both traditional JSON over HTTP APIs and real-time WebSocket communication +- runs all game logic +- sends image and text to the service point display +- sends image to clients +- currently, the game has a fixed tick and frame rate of 25/s +- One frame is ~7KB, not including the text +- maps can be loaded from png files containing black and white pixels or simple text files +- some values (like tank speed) can be configured but are fixed at run time + +### Frontend + + + +- Stack: React / Vite / TypeScript / plain CSS +- There is no server component dedicated to the frontend, everything is a single page after build +- Shows map rendered on server by setting canvas image data +- Sends user input to server +- real time communication via WebSockets, HTTP for the REST + +### Binary formats + +#### Controls WebSocket + +- Client sends 2 byte messages. + - on or off: `0x01` or `0x02` + - input: Forward=`0x01`, Backward=`0x02`, Left=`0x03`, Right=`0x04`, Shoot=`0x05` +- The server never sends any messages. + +### Observer screen WebSocket + +- same image for all clients +- server sends same format as for the service point display +- client responds with empty message to request the next frame + +### Player screen WebSocket + +- image is rendered per player +- server sends same message as the observer WebSocket, but includes an additional 4 bits per set bit in the observer payload + - first bit: belongs to current player + - second bit: (reserved) + - third and fourth bit: type of something + - 00: wall + - 01: tank + - 10: bullet + - 11: (reserved) +- client responds with empty message to request the next frame diff --git a/TanksServer/Graphics/GamePixelGrid.cs b/TanksServer/Graphics/GamePixelGrid.cs index 485b4dd..1c35f71 100644 --- a/TanksServer/Graphics/GamePixelGrid.cs +++ b/TanksServer/Graphics/GamePixelGrid.cs @@ -15,10 +15,10 @@ internal sealed class GamePixelGrid : IEnumerable Width = width; Height = height; - _pixels = new GamePixel[height, width]; - for (var row = 0; row < height; row++) - for (var column = 0; column < width; column++) - _pixels[row, column] = new GamePixel(); + _pixels = new GamePixel[width, height]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + this[x, y] = new GamePixel(); } public GamePixel this[int x, int y] @@ -26,8 +26,9 @@ internal sealed class GamePixelGrid : IEnumerable get { Debug.Assert(y * Width + x < _pixels.Length); - return _pixels[y, x]; + return _pixels[x, y]; } + set => _pixels[x, y] = value; } public void Clear() @@ -40,8 +41,8 @@ internal sealed class GamePixelGrid : IEnumerable public IEnumerator GetEnumerator() { - for (var row = 0; row < Height; row++) - for (var column = 0; column < Width; column++) - yield return _pixels[row, column]; + for (var y = 0; y < Height; y++) + for (var x = 0; x < Width; x++) + yield return this[x, y]; } } diff --git a/TanksServer/Interactivity/ClientScreenServer.cs b/TanksServer/Interactivity/ClientScreenServer.cs index bcc313b..c853e7a 100644 --- a/TanksServer/Interactivity/ClientScreenServer.cs +++ b/TanksServer/Interactivity/ClientScreenServer.cs @@ -21,7 +21,7 @@ internal sealed class ClientScreenServer( return Task.WhenAll(_connections.Keys.Select(c => c.CloseAsync())); } - public Task HandleClient(WebSocket socket) + public Task HandleClient(WebSocket socket, Guid? playerGuid) { if (_closing) { @@ -30,8 +30,11 @@ internal sealed class ClientScreenServer( } logger.LogDebug("HandleClient"); - var connection = - new ClientScreenServerConnection(socket, loggerFactory.CreateLogger(), this); + var connection = new ClientScreenServerConnection( + socket, + loggerFactory.CreateLogger(), + this, + playerGuid); var added = _connections.TryAdd(connection, 0); Debug.Assert(added); return connection.Done; diff --git a/TanksServer/Interactivity/ClientScreenServerConnection.cs b/TanksServer/Interactivity/ClientScreenServerConnection.cs index 976cfbd..1fbe72b 100644 --- a/TanksServer/Interactivity/ClientScreenServerConnection.cs +++ b/TanksServer/Interactivity/ClientScreenServerConnection.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Net.WebSockets; using DisplayCommands; -using TanksServer.GameLogic; using TanksServer.Graphics; namespace TanksServer.Interactivity; @@ -27,7 +26,7 @@ internal sealed class ClientScreenServerConnection : IDisposable _playerGuid = playerGuid; if (playerGuid.HasValue) - _playerScreenData = new PlayerScreenData(); + _playerScreenData = new PlayerScreenData(logger); _channel = new ByteChannelWebSocket(webSocket, logger, 0); Done = ReceiveAsync(); @@ -55,6 +54,7 @@ internal sealed class ClientScreenServerConnection : IDisposable _logger.LogTrace("sending"); try { + _logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length); await _channel.SendAsync(pixels.Data, _playerScreenData == null); if (_playerScreenData != null) await _channel.SendAsync(_playerScreenData.GetPacket()); @@ -92,24 +92,3 @@ internal sealed class ClientScreenServerConnection : IDisposable return _channel.CloseAsync(); } } - -internal sealed class PlayerScreenData -{ - private Memory _data = new byte[MapService.PixelsPerRow * MapService.PixelsPerColumn]; - - public int Count { get; private set; } = 0; - - public void Clear() => Count = 0; - - public ReadOnlyMemory GetPacket() => _data[..Count]; - - public void Add(GamePixelEntityType entityKind, bool isCurrentPlayer) - { - var result = (byte)(isCurrentPlayer ? 0x1b : 0x0b); - var kind = (byte)entityKind; - Debug.Assert(kind < 3); - result += (byte)(kind << 2); - _data.Span[Count] = result; - Count++; - } -} diff --git a/TanksServer/Interactivity/PlayerScreenData.cs b/TanksServer/Interactivity/PlayerScreenData.cs new file mode 100644 index 0000000..c9a2ac1 --- /dev/null +++ b/TanksServer/Interactivity/PlayerScreenData.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using TanksServer.GameLogic; +using TanksServer.Graphics; + +namespace TanksServer.Interactivity; + +internal sealed class PlayerScreenData(ILogger logger) +{ + private readonly Memory _data = new byte[MapService.PixelsPerRow * MapService.PixelsPerColumn / 2]; + private int _count = 0; + + public void Clear() + { + _count = 0; + _data.Span.Clear(); + } + + public ReadOnlyMemory GetPacket() + { + var index = _count / 2 + (_count % 2 == 0 ? 0 : 1); + logger.LogTrace("packet length: {} (count={})", index, _count); + return _data[..index]; + } + + public void Add(GamePixelEntityType entityKind, bool isCurrentPlayer) + { + var result = (byte)(isCurrentPlayer ? 0x1 : 0x0); + var kind = (byte)entityKind; + Debug.Assert(kind < 3); + result += (byte)(kind << 2); + + var index = _count / 2; + if (_count % 2 != 0) + _data.Span[index] |= (byte)(result << 4); + else + _data.Span[index] = result; + _count++; + } +} diff --git a/TanksServer/Interactivity/SendToServicePointDisplay.cs b/TanksServer/Interactivity/SendToServicePointDisplay.cs index 17391f3..af77c66 100644 --- a/TanksServer/Interactivity/SendToServicePointDisplay.cs +++ b/TanksServer/Interactivity/SendToServicePointDisplay.cs @@ -16,8 +16,6 @@ internal sealed class SendToServicePointDisplay : IFrameConsumer private readonly ILogger _logger; private readonly PlayerServer _players; private readonly Cp437Grid _scoresBuffer; - - private PixelGrid? _lastSentFrame; private DateTime _nextFailLog = DateTime.Now; public SendToServicePointDisplay( @@ -47,12 +45,8 @@ internal sealed class SendToServicePointDisplay : IFrameConsumer RefreshScores(); try { + await _displayConnection.SendBitmapLinearWindowAsync(0, 0, observerPixels); await _displayConnection.SendCp437DataAsync(MapService.TilesPerRow, 0, _scoresBuffer); - - if (_lastSentFrame == observerPixels) - return; - _lastSentFrame = observerPixels; - await _displayConnection.SendBitmapLinearWindowAsync(0, 0, _lastSentFrame); } catch (SocketException ex) { diff --git a/TanksServer/Program.cs b/TanksServer/Program.cs index 1555b29..3a972a0 100644 --- a/TanksServer/Program.cs +++ b/TanksServer/Program.cs @@ -28,7 +28,7 @@ public static class Program app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider }); app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider }); - app.MapPost("/player", (string name, Guid id) => + app.MapPost("/player", (string name, Guid? id) => { name = name.Trim().ToUpperInvariant(); if (name == string.Empty) @@ -36,7 +36,7 @@ public static class Program if (name.Length > 12) return Results.BadRequest("name too long"); - var player = playerService.GetOrAdd(name, id); + var player = playerService.GetOrAdd(name, id ?? Guid.NewGuid()); return player != null ? Results.Ok(new NameId(player.Name, player.Id)) : Results.Unauthorized(); @@ -50,13 +50,13 @@ public static class Program app.MapGet("/scores", () => playerService.GetAll()); - app.Map("/screen", async (HttpContext context) => + app.Map("/screen", async (HttpContext context, [FromQuery] Guid? player) => { if (!context.WebSockets.IsWebSocketRequest) return Results.BadRequest(); using var ws = await context.WebSockets.AcceptWebSocketAsync(); - await clientScreenServer.HandleClient(ws); + await clientScreenServer.HandleClient(ws, player); return Results.Empty; }); diff --git a/TanksServer/TanksServer.csproj b/TanksServer/TanksServer.csproj index 3796240..bee9acf 100644 --- a/TanksServer/TanksServer.csproj +++ b/TanksServer/TanksServer.csproj @@ -41,7 +41,8 @@ + - + diff --git a/tank-frontend/.env b/tank-frontend/.env index dd5866f..8551df8 100644 --- a/tank-frontend/.env +++ b/tank-frontend/.env @@ -1,7 +1,3 @@ TANK_DOMAIN=vinzenz-lpt2 VITE_TANK_API=http://$TANK_DOMAIN VITE_TANK_WS=ws://$TANK_DOMAIN - -VITE_TANK_SCREEN_URL=$VITE_TANK_WS/screen -VITE_TANK_CONTROLS_URL=$VITE_TANK_WS/controls -VITE_TANK_PLAYER_URL=$VITE_TANK_API/player diff --git a/tank-frontend/src/App.tsx b/tank-frontend/src/App.tsx index 7579689..78a9104 100644 --- a/tank-frontend/src/App.tsx +++ b/tank-frontend/src/App.tsx @@ -41,7 +41,7 @@ export default function App() { {nameId.name !== '' &&