render current player in secondary color

This commit is contained in:
Vinzenz Schroeter 2024-04-16 00:07:44 +02:00
parent fbaad86555
commit c4c4eb6358
13 changed files with 255 additions and 72 deletions

106
README.md Normal file
View file

@ -0,0 +1,106 @@
# CCCB-Tanks
<!-- TODO: image -->
## Service point display
<!-- TODO: image -->
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
<!-- TODO: image -->
- 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
<!-- TODO: image -->
- 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

View file

@ -15,10 +15,10 @@ internal sealed class GamePixelGrid : IEnumerable<GamePixel>
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<GamePixel>
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<GamePixel>
public IEnumerator<GamePixel> 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];
}
}

View file

@ -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<ClientScreenServerConnection>(), this);
var connection = new ClientScreenServerConnection(
socket,
loggerFactory.CreateLogger<ClientScreenServerConnection>(),
this,
playerGuid);
var added = _connections.TryAdd(connection, 0);
Debug.Assert(added);
return connection.Done;

View file

@ -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<byte> _data = new byte[MapService.PixelsPerRow * MapService.PixelsPerColumn];
public int Count { get; private set; } = 0;
public void Clear() => Count = 0;
public ReadOnlyMemory<byte> 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++;
}
}

View file

@ -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<byte> _data = new byte[MapService.PixelsPerRow * MapService.PixelsPerColumn / 2];
private int _count = 0;
public void Clear()
{
_count = 0;
_data.Span.Clear();
}
public ReadOnlyMemory<byte> 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++;
}
}

View file

@ -16,8 +16,6 @@ internal sealed class SendToServicePointDisplay : IFrameConsumer
private readonly ILogger<SendToServicePointDisplay> _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)
{

View file

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

View file

@ -41,7 +41,8 @@
</Content>
<Content Include="../Makefile"/>
<Content Include="../.editorconfig"/>
<Content Include="../README.md"/>
<None Include="assets\maps\**" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
</Project>

View file

@ -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

View file

@ -41,7 +41,7 @@ export default function App() {
{nameId.name !== '' &&
<Button onClick={() => setNameId(getNewNameId)} text='logout'/>}
</Row>
<ClientScreen logout={logout} theme={theme}/>
<ClientScreen logout={logout} theme={theme} playerId={nameId.id}/>
{nameId.name === '' && <JoinForm setNameId={setNameId} clientId={nameId.id}/>}
<Row className='GadgetRows'>
{isLoggedIn && <Controls playerId={nameId.id} logout={logout}/>}

View file

@ -2,9 +2,13 @@ import useWebSocket from 'react-use-websocket';
import {useEffect, useRef} from 'react';
import './ClientScreen.css';
import {hslToString, Theme} from "./theme.ts";
import {Guid} from "./Guid.ts";
const pixelsPerRow = 352;
const pixelsPerCol = 160;
const observerMessageSize = pixelsPerCol * pixelsPerRow / 8;
const isPlayerMask = 1;
function getIndexes(bitIndex: number) {
return {
@ -19,42 +23,75 @@ function normalizeColor(context: CanvasRenderingContext2D, color: string) {
return context.getImageData(0, 0, 1, 1).data;
}
function drawPixelsToCanvas(pixels: Uint8Array, canvas: HTMLCanvasElement, theme: Theme) {
const drawContext = canvas.getContext('2d');
if (!drawContext)
throw new Error('could not get draw context');
function drawPixelsToCanvas({context, width, height, pixels, additional, foreground, background, playerColor}: {
context: CanvasRenderingContext2D,
width: number,
height: number,
pixels: Uint8ClampedArray,
additional: Uint8ClampedArray | null,
background: Uint8ClampedArray,
foreground: Uint8ClampedArray,
playerColor: Uint8ClampedArray
}) {
let additionalDataIndex = 0;
let additionalDataByte: number | null = null;
const nextPixelColor = (isOn: boolean) => {
if (!isOn)
return background;
if (!additional)
return foreground;
const colorPrimary = normalizeColor(drawContext, hslToString(theme.primary));
const colorBackground = normalizeColor(drawContext, hslToString(theme.background));
let info;
if (additionalDataByte === null) {
additionalDataByte = additional[additionalDataIndex];
additionalDataIndex++;
info = additionalDataByte;
} else {
info = additionalDataByte >> 4;
additionalDataByte = null;
}
const imageData = drawContext.getImageData(0, 0, canvas.width, canvas.height, {colorSpace: 'srgb'});
if ((info & isPlayerMask) != 0) {
return playerColor;
}
return foreground;
}
const imageData = context.getImageData(0, 0, width, height, {colorSpace: 'srgb'});
const data = imageData.data;
for (let y = 0; y < canvas.height; y++) {
const rowStartPixelIndex = y * pixelsPerRow;
for (let x = 0; x < canvas.width; x++) {
const pixelIndex = rowStartPixelIndex + x;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = y * pixelsPerRow + x;
const {byteIndex, bitInByteIndex} = getIndexes(pixelIndex);
const mask = (1 << bitInByteIndex);
const isOn = (pixels[byteIndex] & mask) !== 0;
const color = isOn ? colorPrimary : colorBackground;
const isOn = (pixels[byteIndex] & (1 << bitInByteIndex)) !== 0;
const color = nextPixelColor(isOn);
for (let colorChannel of [0, 1, 2, 3])
data[pixelIndex * 4 + colorChannel] = color[colorChannel];
}
}
drawContext.putImageData(imageData, 0, 0);
context.putImageData(imageData, 0, 0);
}
export default function ClientScreen({logout, theme}: { logout: () => void, theme: Theme }) {
export default function ClientScreen({logout, theme, playerId}: {
logout: () => void,
theme: Theme,
playerId?: Guid
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const url = new URL('/screen', import.meta.env.VITE_TANK_WS);
if (playerId)
url.searchParams.set('player', playerId);
const {
lastMessage,
sendMessage,
getWebSocket
} = useWebSocket(import.meta.env.VITE_TANK_SCREEN_URL, {
} = useWebSocket(url.toString(), {
onError: logout,
shouldReconnect: () => true,
});
@ -69,7 +106,34 @@ export default function ClientScreen({logout, theme}: { logout: () => void, them
if (canvasRef.current === null)
throw new Error('canvas null');
drawPixelsToCanvas(new Uint8Array(lastMessage.data), canvasRef.current, theme);
const canvas = canvasRef.current;
const drawContext = canvas.getContext('2d');
if (!drawContext)
throw new Error('could not get draw context');
const colorBackground = normalizeColor(drawContext, hslToString(theme.background));
const colorPrimary = normalizeColor(drawContext, hslToString(theme.primary));
const colorSecondary = normalizeColor(drawContext, hslToString(theme.secondary));
let pixels = new Uint8ClampedArray(lastMessage.data);
let additionalData: Uint8ClampedArray | null = null;
if (pixels.length > observerMessageSize) {
additionalData = pixels.slice(observerMessageSize);
pixels = pixels.slice(0, observerMessageSize);
}
console.log('', {pixelLength: pixels.length, additionalLength: additionalData?.length});
drawPixelsToCanvas({
context: drawContext,
width: canvas.width,
height: canvas.height,
pixels,
additional: additionalData,
background: colorBackground,
foreground: colorPrimary,
playerColor: colorSecondary
});
sendMessage('');
}, [lastMessage, canvasRef.current, theme]);

View file

@ -6,7 +6,7 @@ export default function Controls({playerId, logout}: {
playerId: string,
logout: () => void
}) {
const url = new URL(import.meta.env.VITE_TANK_CONTROLS_URL);
const url = new URL('controls', import.meta.env.VITE_TANK_WS);
url.searchParams.set('playerId', playerId);
const {
sendMessage,

View file

@ -38,7 +38,7 @@ export async function fetchTyped<T>({url, method}: { url: URL; method: string; }
}
export function postPlayer({name, id}: NameId) {
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
const url = new URL('/player', import.meta.env.VITE_TANK_API);
url.searchParams.set('name', name);
url.searchParams.set('id', id);
@ -46,7 +46,7 @@ export function postPlayer({name, id}: NameId) {
}
export function getPlayer(id: Guid) {
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
const url = new URL('/player', import.meta.env.VITE_TANK_API);
url.searchParams.set('id', id);
return fetchTyped<Player>({url, method: 'GET'});