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;
|
Width = width;
|
||||||
Height = height;
|
Height = height;
|
||||||
|
|
||||||
_pixels = new GamePixel[height, width];
|
_pixels = new GamePixel[width, height];
|
||||||
for (var row = 0; row < height; row++)
|
for (var y = 0; y < height; y++)
|
||||||
for (var column = 0; column < width; column++)
|
for (var x = 0; x < width; x++)
|
||||||
_pixels[row, column] = new GamePixel();
|
this[x, y] = new GamePixel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public GamePixel this[int x, int y]
|
public GamePixel this[int x, int y]
|
||||||
|
@ -26,8 +26,9 @@ internal sealed class GamePixelGrid : IEnumerable<GamePixel>
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
Debug.Assert(y * Width + x < _pixels.Length);
|
Debug.Assert(y * Width + x < _pixels.Length);
|
||||||
return _pixels[y, x];
|
return _pixels[x, y];
|
||||||
}
|
}
|
||||||
|
set => _pixels[x, y] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
|
@ -40,8 +41,8 @@ internal sealed class GamePixelGrid : IEnumerable<GamePixel>
|
||||||
|
|
||||||
public IEnumerator<GamePixel> GetEnumerator()
|
public IEnumerator<GamePixel> GetEnumerator()
|
||||||
{
|
{
|
||||||
for (var row = 0; row < Height; row++)
|
for (var y = 0; y < Height; y++)
|
||||||
for (var column = 0; column < Width; column++)
|
for (var x = 0; x < Width; x++)
|
||||||
yield return _pixels[row, column];
|
yield return this[x, y];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ internal sealed class ClientScreenServer(
|
||||||
return Task.WhenAll(_connections.Keys.Select(c => c.CloseAsync()));
|
return Task.WhenAll(_connections.Keys.Select(c => c.CloseAsync()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task HandleClient(WebSocket socket)
|
public Task HandleClient(WebSocket socket, Guid? playerGuid)
|
||||||
{
|
{
|
||||||
if (_closing)
|
if (_closing)
|
||||||
{
|
{
|
||||||
|
@ -30,8 +30,11 @@ internal sealed class ClientScreenServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogDebug("HandleClient");
|
logger.LogDebug("HandleClient");
|
||||||
var connection =
|
var connection = new ClientScreenServerConnection(
|
||||||
new ClientScreenServerConnection(socket, loggerFactory.CreateLogger<ClientScreenServerConnection>(), this);
|
socket,
|
||||||
|
loggerFactory.CreateLogger<ClientScreenServerConnection>(),
|
||||||
|
this,
|
||||||
|
playerGuid);
|
||||||
var added = _connections.TryAdd(connection, 0);
|
var added = _connections.TryAdd(connection, 0);
|
||||||
Debug.Assert(added);
|
Debug.Assert(added);
|
||||||
return connection.Done;
|
return connection.Done;
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using DisplayCommands;
|
using DisplayCommands;
|
||||||
using TanksServer.GameLogic;
|
|
||||||
using TanksServer.Graphics;
|
using TanksServer.Graphics;
|
||||||
|
|
||||||
namespace TanksServer.Interactivity;
|
namespace TanksServer.Interactivity;
|
||||||
|
@ -27,7 +26,7 @@ internal sealed class ClientScreenServerConnection : IDisposable
|
||||||
|
|
||||||
_playerGuid = playerGuid;
|
_playerGuid = playerGuid;
|
||||||
if (playerGuid.HasValue)
|
if (playerGuid.HasValue)
|
||||||
_playerScreenData = new PlayerScreenData();
|
_playerScreenData = new PlayerScreenData(logger);
|
||||||
|
|
||||||
_channel = new ByteChannelWebSocket(webSocket, logger, 0);
|
_channel = new ByteChannelWebSocket(webSocket, logger, 0);
|
||||||
Done = ReceiveAsync();
|
Done = ReceiveAsync();
|
||||||
|
@ -55,6 +54,7 @@ internal sealed class ClientScreenServerConnection : IDisposable
|
||||||
_logger.LogTrace("sending");
|
_logger.LogTrace("sending");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length);
|
||||||
await _channel.SendAsync(pixels.Data, _playerScreenData == null);
|
await _channel.SendAsync(pixels.Data, _playerScreenData == null);
|
||||||
if (_playerScreenData != null)
|
if (_playerScreenData != null)
|
||||||
await _channel.SendAsync(_playerScreenData.GetPacket());
|
await _channel.SendAsync(_playerScreenData.GetPacket());
|
||||||
|
@ -92,24 +92,3 @@ internal sealed class ClientScreenServerConnection : IDisposable
|
||||||
return _channel.CloseAsync();
|
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 ILogger<SendToServicePointDisplay> _logger;
|
||||||
private readonly PlayerServer _players;
|
private readonly PlayerServer _players;
|
||||||
private readonly Cp437Grid _scoresBuffer;
|
private readonly Cp437Grid _scoresBuffer;
|
||||||
|
|
||||||
private PixelGrid? _lastSentFrame;
|
|
||||||
private DateTime _nextFailLog = DateTime.Now;
|
private DateTime _nextFailLog = DateTime.Now;
|
||||||
|
|
||||||
public SendToServicePointDisplay(
|
public SendToServicePointDisplay(
|
||||||
|
@ -47,12 +45,8 @@ internal sealed class SendToServicePointDisplay : IFrameConsumer
|
||||||
RefreshScores();
|
RefreshScores();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await _displayConnection.SendBitmapLinearWindowAsync(0, 0, observerPixels);
|
||||||
await _displayConnection.SendCp437DataAsync(MapService.TilesPerRow, 0, _scoresBuffer);
|
await _displayConnection.SendCp437DataAsync(MapService.TilesPerRow, 0, _scoresBuffer);
|
||||||
|
|
||||||
if (_lastSentFrame == observerPixels)
|
|
||||||
return;
|
|
||||||
_lastSentFrame = observerPixels;
|
|
||||||
await _displayConnection.SendBitmapLinearWindowAsync(0, 0, _lastSentFrame);
|
|
||||||
}
|
}
|
||||||
catch (SocketException ex)
|
catch (SocketException ex)
|
||||||
{
|
{
|
||||||
|
|
|
@ -28,7 +28,7 @@ public static class Program
|
||||||
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider });
|
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider });
|
||||||
app.UseStaticFiles(new StaticFileOptions { 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();
|
name = name.Trim().ToUpperInvariant();
|
||||||
if (name == string.Empty)
|
if (name == string.Empty)
|
||||||
|
@ -36,7 +36,7 @@ public static class Program
|
||||||
if (name.Length > 12)
|
if (name.Length > 12)
|
||||||
return Results.BadRequest("name too long");
|
return Results.BadRequest("name too long");
|
||||||
|
|
||||||
var player = playerService.GetOrAdd(name, id);
|
var player = playerService.GetOrAdd(name, id ?? Guid.NewGuid());
|
||||||
return player != null
|
return player != null
|
||||||
? Results.Ok(new NameId(player.Name, player.Id))
|
? Results.Ok(new NameId(player.Name, player.Id))
|
||||||
: Results.Unauthorized();
|
: Results.Unauthorized();
|
||||||
|
@ -50,13 +50,13 @@ public static class Program
|
||||||
|
|
||||||
app.MapGet("/scores", () => playerService.GetAll());
|
app.MapGet("/scores", () => playerService.GetAll());
|
||||||
|
|
||||||
app.Map("/screen", async (HttpContext context) =>
|
app.Map("/screen", async (HttpContext context, [FromQuery] Guid? player) =>
|
||||||
{
|
{
|
||||||
if (!context.WebSockets.IsWebSocketRequest)
|
if (!context.WebSockets.IsWebSocketRequest)
|
||||||
return Results.BadRequest();
|
return Results.BadRequest();
|
||||||
|
|
||||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
await clientScreenServer.HandleClient(ws);
|
await clientScreenServer.HandleClient(ws, player);
|
||||||
return Results.Empty;
|
return Results.Empty;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,7 @@
|
||||||
</Content>
|
</Content>
|
||||||
<Content Include="../Makefile"/>
|
<Content Include="../Makefile"/>
|
||||||
<Content Include="../.editorconfig"/>
|
<Content Include="../.editorconfig"/>
|
||||||
|
<Content Include="../README.md"/>
|
||||||
<None Include="assets\maps\**" CopyToOutputDirectory="PreserveNewest"/>
|
<None Include="assets\maps\**" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
TANK_DOMAIN=vinzenz-lpt2
|
TANK_DOMAIN=vinzenz-lpt2
|
||||||
VITE_TANK_API=http://$TANK_DOMAIN
|
VITE_TANK_API=http://$TANK_DOMAIN
|
||||||
VITE_TANK_WS=ws://$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 !== '' &&
|
{nameId.name !== '' &&
|
||||||
<Button onClick={() => setNameId(getNewNameId)} text='logout'/>}
|
<Button onClick={() => setNameId(getNewNameId)} text='logout'/>}
|
||||||
</Row>
|
</Row>
|
||||||
<ClientScreen logout={logout} theme={theme}/>
|
<ClientScreen logout={logout} theme={theme} playerId={nameId.id}/>
|
||||||
{nameId.name === '' && <JoinForm setNameId={setNameId} clientId={nameId.id}/>}
|
{nameId.name === '' && <JoinForm setNameId={setNameId} clientId={nameId.id}/>}
|
||||||
<Row className='GadgetRows'>
|
<Row className='GadgetRows'>
|
||||||
{isLoggedIn && <Controls playerId={nameId.id} logout={logout}/>}
|
{isLoggedIn && <Controls playerId={nameId.id} logout={logout}/>}
|
||||||
|
|
|
@ -2,9 +2,13 @@ import useWebSocket from 'react-use-websocket';
|
||||||
import {useEffect, useRef} from 'react';
|
import {useEffect, useRef} from 'react';
|
||||||
import './ClientScreen.css';
|
import './ClientScreen.css';
|
||||||
import {hslToString, Theme} from "./theme.ts";
|
import {hslToString, Theme} from "./theme.ts";
|
||||||
|
import {Guid} from "./Guid.ts";
|
||||||
|
|
||||||
const pixelsPerRow = 352;
|
const pixelsPerRow = 352;
|
||||||
const pixelsPerCol = 160;
|
const pixelsPerCol = 160;
|
||||||
|
const observerMessageSize = pixelsPerCol * pixelsPerRow / 8;
|
||||||
|
|
||||||
|
const isPlayerMask = 1;
|
||||||
|
|
||||||
function getIndexes(bitIndex: number) {
|
function getIndexes(bitIndex: number) {
|
||||||
return {
|
return {
|
||||||
|
@ -19,42 +23,75 @@ function normalizeColor(context: CanvasRenderingContext2D, color: string) {
|
||||||
return context.getImageData(0, 0, 1, 1).data;
|
return context.getImageData(0, 0, 1, 1).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawPixelsToCanvas(pixels: Uint8Array, canvas: HTMLCanvasElement, theme: Theme) {
|
function drawPixelsToCanvas({context, width, height, pixels, additional, foreground, background, playerColor}: {
|
||||||
const drawContext = canvas.getContext('2d');
|
context: CanvasRenderingContext2D,
|
||||||
if (!drawContext)
|
width: number,
|
||||||
throw new Error('could not get draw context');
|
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));
|
let info;
|
||||||
const colorBackground = normalizeColor(drawContext, hslToString(theme.background));
|
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;
|
const data = imageData.data;
|
||||||
|
|
||||||
for (let y = 0; y < canvas.height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
const rowStartPixelIndex = y * pixelsPerRow;
|
for (let x = 0; x < width; x++) {
|
||||||
for (let x = 0; x < canvas.width; x++) {
|
const pixelIndex = y * pixelsPerRow + x;
|
||||||
const pixelIndex = rowStartPixelIndex + x;
|
|
||||||
const {byteIndex, bitInByteIndex} = getIndexes(pixelIndex);
|
const {byteIndex, bitInByteIndex} = getIndexes(pixelIndex);
|
||||||
const mask = (1 << bitInByteIndex);
|
const isOn = (pixels[byteIndex] & (1 << bitInByteIndex)) !== 0;
|
||||||
const isOn = (pixels[byteIndex] & mask) !== 0;
|
const color = nextPixelColor(isOn);
|
||||||
const color = isOn ? colorPrimary : colorBackground;
|
|
||||||
|
|
||||||
for (let colorChannel of [0, 1, 2, 3])
|
for (let colorChannel of [0, 1, 2, 3])
|
||||||
data[pixelIndex * 4 + colorChannel] = color[colorChannel];
|
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
const url = new URL('/screen', import.meta.env.VITE_TANK_WS);
|
||||||
|
if (playerId)
|
||||||
|
url.searchParams.set('player', playerId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
lastMessage,
|
lastMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
getWebSocket
|
getWebSocket
|
||||||
} = useWebSocket(import.meta.env.VITE_TANK_SCREEN_URL, {
|
} = useWebSocket(url.toString(), {
|
||||||
onError: logout,
|
onError: logout,
|
||||||
shouldReconnect: () => true,
|
shouldReconnect: () => true,
|
||||||
});
|
});
|
||||||
|
@ -69,7 +106,34 @@ export default function ClientScreen({logout, theme}: { logout: () => void, them
|
||||||
if (canvasRef.current === null)
|
if (canvasRef.current === null)
|
||||||
throw new Error('canvas 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('');
|
sendMessage('');
|
||||||
}, [lastMessage, canvasRef.current, theme]);
|
}, [lastMessage, canvasRef.current, theme]);
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ export default function Controls({playerId, logout}: {
|
||||||
playerId: string,
|
playerId: string,
|
||||||
logout: () => void
|
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);
|
url.searchParams.set('playerId', playerId);
|
||||||
const {
|
const {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
|
|
@ -38,7 +38,7 @@ export async function fetchTyped<T>({url, method}: { url: URL; method: string; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postPlayer({name, id}: NameId) {
|
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('name', name);
|
||||||
url.searchParams.set('id', id);
|
url.searchParams.set('id', id);
|
||||||
|
|
||||||
|
@ -46,7 +46,7 @@ export function postPlayer({name, id}: NameId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlayer(id: Guid) {
|
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);
|
url.searchParams.set('id', id);
|
||||||
|
|
||||||
return fetchTyped<Player>({url, method: 'GET'});
|
return fetchTyped<Player>({url, method: 'GET'});
|
||||||
|
|
Loading…
Reference in a new issue