extract PixelGridCanvas component from ClientScreen
This commit is contained in:
parent
5f27739e7c
commit
5fc21494ef
|
@ -1,107 +1,14 @@
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import './ClientScreen.css';
|
import {Theme} from './theme.ts';
|
||||||
import {hslToString, Theme} from './theme.ts';
|
|
||||||
import {makeApiUrl, useMyWebSocket} from './serverCalls.tsx';
|
import {makeApiUrl, useMyWebSocket} from './serverCalls.tsx';
|
||||||
import {ReadyState} from 'react-use-websocket';
|
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}: {
|
export default function ClientScreen({theme, player}: {
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
player: string | null
|
player: string | null
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const [shouldSendMessage, setShouldSendMessage] = useState(false);
|
const [shouldSendMessage, setShouldSendMessage] = useState(false);
|
||||||
|
|
||||||
const url = makeApiUrl('/screen', 'ws');
|
const url = makeApiUrl('/screen', 'ws');
|
||||||
|
@ -114,7 +21,8 @@ export default function ClientScreen({theme, player}: {
|
||||||
getWebSocket,
|
getWebSocket,
|
||||||
readyState
|
readyState
|
||||||
} = useMyWebSocket(url.toString(), {
|
} = useMyWebSocket(url.toString(), {
|
||||||
onOpen: _ => setShouldSendMessage(true)
|
onOpen: _ => setShouldSendMessage(true),
|
||||||
|
onMessage: _ => setShouldSendMessage(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
const socket = getWebSocket();
|
const socket = getWebSocket();
|
||||||
|
@ -128,54 +36,9 @@ export default function ClientScreen({theme, player}: {
|
||||||
sendMessage('');
|
sendMessage('');
|
||||||
}, [readyState, shouldSendMessage]);
|
}, [readyState, shouldSendMessage]);
|
||||||
|
|
||||||
|
if (!lastMessage)
|
||||||
|
return <></>;
|
||||||
|
|
||||||
useEffect(() => {
|
const pixels = new Uint8ClampedArray(lastMessage.data);
|
||||||
if (lastMessage === null)
|
return <PixelGridCanvas pixels={pixels} theme={theme}/>;
|
||||||
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}/>;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
#screen {
|
.PixelGridCanvas {
|
||||||
aspect-ratio: calc(352 / 160);
|
aspect-ratio: calc(352 / 160);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
149
tank-frontend/src/components/PixelGridCanvas.tsx
Normal file
149
tank-frontend/src/components/PixelGridCanvas.tsx
Normal 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}
|
||||||
|
/>;
|
||||||
|
}
|
Loading…
Reference in a new issue