extract PixelGridCanvas component from ClientScreen

This commit is contained in:
Vinzenz Schroeter 2024-05-05 14:08:44 +02:00
parent 5f27739e7c
commit 5fc21494ef
3 changed files with 159 additions and 147 deletions

View file

@ -1,107 +1,14 @@
import {useEffect, useRef, useState} from 'react';
import './ClientScreen.css';
import {hslToString, Theme} from './theme.ts';
import {useEffect, useState} from 'react';
import {Theme} from './theme.ts';
import {makeApiUrl, useMyWebSocket} from './serverCalls.tsx';
import {ReadyState} from 'react-use-websocket';
import PixelGridCanvas from './components/PixelGridCanvas.tsx';
const pixelsPerRow = 352;
const pixelsPerCol = 160;
const observerMessageSize = pixelsPerCol * pixelsPerRow / 8;
enum GamePixelEntityType {
Wall = 0x0,
Tank = 0x1,
Bullet = 0x2
}
function getPixelDataIndexes(bitIndex: number) {
return {
byteIndex: Math.floor(bitIndex / 8),
bitInByteIndex: 7 - bitIndex % 8
};
}
function normalizeColor(context: CanvasRenderingContext2D, color: string) {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
return context.getImageData(0, 0, 1, 1).data;
}
function parseAdditionalDataNibble(nibble: number) {
const isPlayerMask = 1;
const entityTypeMask = 12;
return {
isCurrentPlayer: (nibble & isPlayerMask) != 0,
entityType: ((nibble & entityTypeMask) >> 2) as GamePixelEntityType,
};
}
function drawPixelsToCanvas(
{
context, width, height, pixels, additional, foreground, background, playerColor, otherTanksColor
}: {
context: CanvasRenderingContext2D,
width: number,
height: number,
pixels: Uint8ClampedArray,
additional: Uint8ClampedArray | null,
background: Uint8ClampedArray,
foreground: Uint8ClampedArray,
playerColor: Uint8ClampedArray,
otherTanksColor: Uint8ClampedArray
}
) {
let additionalDataIndex = 0;
let additionalDataByte: number | null = null;
const nextPixelColor = (isOn: boolean) => {
if (!isOn)
return background;
if (!additional)
return foreground;
let info;
if (additionalDataByte === null) {
additionalDataByte = additional[additionalDataIndex];
additionalDataIndex++;
info = parseAdditionalDataNibble(additionalDataByte);
} else {
info = parseAdditionalDataNibble(additionalDataByte >> 4);
additionalDataByte = null;
}
if (info.isCurrentPlayer)
return playerColor;
if (info.entityType == GamePixelEntityType.Tank)
return otherTanksColor;
return foreground;
};
const imageData = context.getImageData(0, 0, width, height, {colorSpace: 'srgb'});
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = y * pixelsPerRow + x;
const {byteIndex, bitInByteIndex} = getPixelDataIndexes(pixelIndex);
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];
}
}
context.putImageData(imageData, 0, 0);
}
export default function ClientScreen({theme, player}: {
theme: Theme,
player: string | null
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [shouldSendMessage, setShouldSendMessage] = useState(false);
const url = makeApiUrl('/screen', 'ws');
@ -114,7 +21,8 @@ export default function ClientScreen({theme, player}: {
getWebSocket,
readyState
} = useMyWebSocket(url.toString(), {
onOpen: _ => setShouldSendMessage(true)
onOpen: _ => setShouldSendMessage(true),
onMessage: _ => setShouldSendMessage(true)
});
const socket = getWebSocket();
@ -128,54 +36,9 @@ export default function ClientScreen({theme, player}: {
sendMessage('');
}, [readyState, shouldSendMessage]);
if (!lastMessage)
return <></>;
useEffect(() => {
if (lastMessage === null)
return;
let ignore = false;
const start = async () => {
const canvas = canvasRef.current;
if (canvas === null)
throw new Error('canvas null');
const drawContext = canvas.getContext('2d');
if (!drawContext)
throw new Error('could not get draw context');
let pixels = new Uint8ClampedArray(lastMessage.data);
let additionalData: Uint8ClampedArray | null = null;
if (pixels.length > observerMessageSize) {
additionalData = pixels.slice(observerMessageSize);
pixels = pixels.slice(0, observerMessageSize);
}
if (ignore)
return;
drawPixelsToCanvas({
context: drawContext,
width: canvas.width,
height: canvas.height,
pixels,
additional: additionalData,
background: normalizeColor(drawContext, hslToString(theme.background)),
foreground: normalizeColor(drawContext, hslToString(theme.primary)),
playerColor: normalizeColor(drawContext, hslToString(theme.secondary)),
otherTanksColor: normalizeColor(drawContext, hslToString(theme.tertiary))
});
if (ignore)
return;
setShouldSendMessage(true);
};
start();
return () => {
ignore = true;
};
}, [lastMessage, canvasRef.current, theme]);
return <canvas ref={canvasRef} id="screen" width={pixelsPerRow} height={pixelsPerCol}/>;
const pixels = new Uint8ClampedArray(lastMessage.data);
return <PixelGridCanvas pixels={pixels} theme={theme}/>;
}

View file

@ -1,5 +1,5 @@
#screen {
.PixelGridCanvas {
aspect-ratio: calc(352 / 160);
flex-grow: 1;
object-fit: contain;

View file

@ -0,0 +1,149 @@
import {hslToString, Theme} from '../theme.ts';
import {useEffect, useRef} from 'react';
import './PixelGridCanvas.css';
const pixelsPerRow = 352;
const pixelsPerCol = 160;
const observerMessageSize = pixelsPerCol * pixelsPerRow / 8;
enum GamePixelEntityType {
Wall = 0x0,
Tank = 0x1,
Bullet = 0x2
}
function getPixelDataIndexes(bitIndex: number) {
return {
byteIndex: Math.floor(bitIndex / 8),
bitInByteIndex: 7 - bitIndex % 8
};
}
function normalizeColor(context: CanvasRenderingContext2D, color: string) {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
return context.getImageData(0, 0, 1, 1).data;
}
function parseAdditionalDataNibble(nibble: number) {
const isPlayerMask = 1;
const entityTypeMask = 12;
return {
isCurrentPlayer: (nibble & isPlayerMask) != 0,
entityType: ((nibble & entityTypeMask) >> 2) as GamePixelEntityType,
};
}
function drawPixelsToCanvas(
{
context, width, height, pixels, additional, foreground, background, playerColor, otherTanksColor
}: {
context: CanvasRenderingContext2D,
width: number,
height: number,
pixels: Uint8ClampedArray,
additional: Uint8ClampedArray | null,
background: Uint8ClampedArray,
foreground: Uint8ClampedArray,
playerColor: Uint8ClampedArray,
otherTanksColor: Uint8ClampedArray
}
) {
let additionalDataIndex = 0;
let additionalDataByte: number | null = null;
const nextPixelColor = (isOn: boolean) => {
if (!isOn)
return background;
if (!additional)
return foreground;
let info;
if (additionalDataByte === null) {
additionalDataByte = additional[additionalDataIndex];
additionalDataIndex++;
info = parseAdditionalDataNibble(additionalDataByte);
} else {
info = parseAdditionalDataNibble(additionalDataByte >> 4);
additionalDataByte = null;
}
if (info.isCurrentPlayer)
return playerColor;
if (info.entityType == GamePixelEntityType.Tank)
return otherTanksColor;
return foreground;
};
const imageData = context.getImageData(0, 0, width, height, {colorSpace: 'srgb'});
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = y * pixelsPerRow + x;
const {byteIndex, bitInByteIndex} = getPixelDataIndexes(pixelIndex);
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];
}
}
context.putImageData(imageData, 0, 0);
}
export default function PixelGridCanvas({pixels, theme}: {
readonly pixels: Uint8ClampedArray;
readonly theme: Theme;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let ignore = false;
const start = async () => {
const canvas = canvasRef.current;
if (canvas === null)
throw new Error('canvas null');
const drawContext = canvas.getContext('2d');
if (!drawContext)
throw new Error('could not get draw context');
let additionalData: Uint8ClampedArray | null = null;
if (pixels.length > observerMessageSize) {
additionalData = pixels.slice(observerMessageSize);
pixels = pixels.slice(0, observerMessageSize);
}
if (ignore)
return;
drawPixelsToCanvas({
context: drawContext,
width: canvas.width,
height: canvas.height,
pixels,
additional: additionalData,
background: normalizeColor(drawContext, hslToString(theme.background)),
foreground: normalizeColor(drawContext, hslToString(theme.primary)),
playerColor: normalizeColor(drawContext, hslToString(theme.secondary)),
otherTanksColor: normalizeColor(drawContext, hslToString(theme.tertiary))
});
};
start();
return () => {
ignore = true;
};
}, [pixels, canvasRef.current, theme]);
return <canvas
ref={canvasRef}
className="PixelGridCanvas"
width={pixelsPerRow}
height={pixelsPerCol}
/>;
}