From 5fc21494ef17d7bb1cf7f7144c06be5595ceb555 Mon Sep 17 00:00:00 2001 From: Vinzenz Schroeter Date: Sun, 5 May 2024 14:08:44 +0200 Subject: [PATCH] extract PixelGridCanvas component from ClientScreen --- tank-frontend/src/ClientScreen.tsx | 155 +----------------- .../PixelGridCanvas.css} | 2 +- .../src/components/PixelGridCanvas.tsx | 149 +++++++++++++++++ 3 files changed, 159 insertions(+), 147 deletions(-) rename tank-frontend/src/{ClientScreen.css => components/PixelGridCanvas.css} (88%) create mode 100644 tank-frontend/src/components/PixelGridCanvas.tsx diff --git a/tank-frontend/src/ClientScreen.tsx b/tank-frontend/src/ClientScreen.tsx index ba78edd..ee6bab2 100644 --- a/tank-frontend/src/ClientScreen.tsx +++ b/tank-frontend/src/ClientScreen.tsx @@ -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(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 ; + const pixels = new Uint8ClampedArray(lastMessage.data); + return ; } diff --git a/tank-frontend/src/ClientScreen.css b/tank-frontend/src/components/PixelGridCanvas.css similarity index 88% rename from tank-frontend/src/ClientScreen.css rename to tank-frontend/src/components/PixelGridCanvas.css index 3304737..316596e 100644 --- a/tank-frontend/src/ClientScreen.css +++ b/tank-frontend/src/components/PixelGridCanvas.css @@ -1,5 +1,5 @@ -#screen { +.PixelGridCanvas { aspect-ratio: calc(352 / 160); flex-grow: 1; object-fit: contain; diff --git a/tank-frontend/src/components/PixelGridCanvas.tsx b/tank-frontend/src/components/PixelGridCanvas.tsx new file mode 100644 index 0000000..b1471e9 --- /dev/null +++ b/tank-frontend/src/components/PixelGridCanvas.tsx @@ -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(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 ; +}