render current player in secondary color
This commit is contained in:
parent
fbaad86555
commit
c4c4eb6358
106
README.md
Normal file
106
README.md
Normal 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
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
39
TanksServer/Interactivity/PlayerScreenData.cs
Normal file
39
TanksServer/Interactivity/PlayerScreenData.cs
Normal 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++;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
@ -41,7 +41,8 @@
|
|||
</Content>
|
||||
<Content Include="../Makefile"/>
|
||||
<Content Include="../.editorconfig"/>
|
||||
<Content Include="../README.md"/>
|
||||
<None Include="assets\maps\**" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}/>}
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'});
|
||||
|
|
Loading…
Reference in a new issue