react frontend app (display screen, join)

This commit is contained in:
Vinzenz Schroeter 2024-04-07 00:33:54 +02:00
parent 1a20fa1fb6
commit abdfdf2ec0
23 changed files with 3917 additions and 5 deletions

View file

@ -0,0 +1,7 @@
#screen {
aspect-ratio: calc(352 / 160);
flex-grow: 1;
object-fit: contain;
max-width: 100%;
}

View file

@ -0,0 +1,91 @@
import useWebSocket, {ReadyState} from 'react-use-websocket';
import {useEffect, useRef} from 'react';
import './ClientScreen.css';
const pixelsPerRow = 352;
const pixelsPerCol = 160;
function getIndexes(bitIndex: number) {
return {
byteIndex: 10 + Math.floor(bitIndex / 8),
bitInByteIndex: 7 - bitIndex % 8
};
}
function drawPixelsToCanvas(pixels: Uint8Array, canvas: HTMLCanvasElement) {
const drawContext = canvas.getContext('2d');
if (!drawContext)
throw new Error('could not get draw context');
const imageData = drawContext.getImageData(0, 0, canvas.width, canvas.height, {colorSpace: 'srgb'});
const data = imageData.data;
console.log('draw', {width: canvas.width, height: canvas.height, dataLength: data.byteLength});
for (let y = 0; y < canvas.height; y++) {
const rowStartPixelIndex = y * pixelsPerRow;
for (let x = 0; x < canvas.width; x++) {
const pixelIndex = rowStartPixelIndex + x;
const {byteIndex, bitInByteIndex} = getIndexes(pixelIndex);
const byte = pixels[byteIndex];
const mask = (1 << bitInByteIndex);
const bitCheck = byte & mask;
const isOn = bitCheck !== 0;
const dataIndex = pixelIndex * 4;
if (isOn) {
data[dataIndex] = 0; // r
data[dataIndex + 1] = 180; // g
data[dataIndex + 2] = 0; // b
data[dataIndex + 3] = 255; // a
} else {
data[dataIndex] = 0; // r
data[dataIndex + 1] = 0; // g
data[dataIndex + 2] = 0; // b
data[dataIndex + 3] = 255; // a
}
}
}
drawContext.putImageData(imageData, 0, 0);
}
export default function ClientScreen({}: {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const {
readyState,
lastMessage,
sendMessage,
getWebSocket
} = useWebSocket(import.meta.env.VITE_TANK_SCREEN_URL, {
shouldReconnect: () => true,
});
const socket = getWebSocket();
if (socket)
(socket as WebSocket).binaryType = 'arraybuffer';
useEffect(() => {
if (lastMessage === null)
return;
if (canvasRef.current === null)
throw new Error('canvas null');
drawPixelsToCanvas(new Uint8Array(lastMessage.data), canvasRef.current);
sendMessage('');
}, [lastMessage, canvasRef.current]);
const connectionStatus = {
[ReadyState.CONNECTING]: 'Connecting',
[ReadyState.OPEN]: 'Open',
[ReadyState.CLOSING]: 'Closing',
[ReadyState.CLOSED]: 'Closed',
[ReadyState.UNINSTANTIATED]: 'Uninstantiated',
}[readyState];
return <>
<span>The WebSocket is currently {connectionStatus}</span>
<canvas ref={canvasRef} id="screen" width={pixelsPerRow} height={pixelsPerCol}/>
</>;
}

View file

@ -0,0 +1,63 @@
kbd {
background: hsl(0, 0%, 96%);
padding: 10px;
display: block;
border-radius: 5px;
margin: 5px;
width: 1.6em;
height: 1.3em;
text-align: center;
box-shadow: 0 1px rgba(0, 0, 0, .21);
user-select: none;
color: black;
}
kbd.up {
margin-left: calc(1.6em + 35px);
}
kbd.space {
margin-top: calc(1.3em + 35px);
width: 14em;
}
kbd:active {
position: relative;
top: 2px;
background: hsl(0, 0%, 94%);
box-shadow: 0 1px rgba(0, 0, 0, .13) inset;
}
.controls {
display: flex;
color: lightgray;
}
.control {
margin: 0 1em;
}
.control h3 {
text-align: center;
}
.row {
display: flex;
}
.splash {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: radial-gradient(transparent, red);
opacity: 0;
pointer-events: none;
transition: opacity 500ms;
}
.was-killed .splash {
opacity: 1;
transition: opacity 120ms;
}

View file

@ -0,0 +1,73 @@
import './Controls.css';
import useWebSocket from 'react-use-websocket';
import {useEffect} from 'react';
export default function Controls({playerId}: {
playerId: string
}) {
const url = new URL(import.meta.env.VITE_TANK_CONTROLS_URL);
url.searchParams.set('playerId', playerId);
const {
sendMessage,
getWebSocket
} = useWebSocket(url.toString(), {
shouldReconnect: () => true,
});
const socket = getWebSocket();
if (socket)
(socket as WebSocket).binaryType = 'arraybuffer';
const keyEventListener = (type: string) => (event: KeyboardEvent) => {
if (event.defaultPrevented)
return;
const controls = {
'ArrowLeft': 'left',
'ArrowUp': 'up',
'ArrowRight': 'right',
'ArrowDown': 'down',
'Space': 'shoot',
'KeyW': 'up',
'KeyA': 'left',
'KeyS': 'down',
'KeyD': 'right',
};
// @ts-ignore
const value = controls[event.code];
if (!value)
return;
sendMessage(JSON.stringify({type, value}));
};
useEffect(() => {
const up = keyEventListener('input-off');
const down = keyEventListener('input-on');
window.onkeyup = up;
window.onkeydown = down;
return () => {
window.onkeydown = null;
window.onkeyup = null;
};
}, []);
return <div className="controls">
<div className="control">
<div className="row">
<kbd className="up"></kbd>
</div>
<div className="row">
<kbd></kbd>
<kbd></kbd>
<kbd></kbd>
</div>
<h3>Move</h3>
</div>
<div className="control">
<kbd className="space">Space</kbd>
<h3>Fire</h3>
</div>
</div>;
}

View file

@ -0,0 +1,4 @@
.JoinForm {
display: flex;
flex-direction: row;
}

View file

@ -0,0 +1,57 @@
import {useEffect, useState} from 'react';
import './JoinForm.css';
type PlayerResponse = {
readonly name: string;
readonly id: string;
};
export async function fetchPlayer(name: string, options: RequestInit) {
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
url.searchParams.set('name', name);
const response = await fetch(url, options);
if (!response.ok)
return null;
const json = await response.json() as PlayerResponse;
return json.id;
}
export default function JoinForm({onDone}: { onDone: (id: string) => void }) {
const [name, setName] = useState('');
const [clicked, setClicked] = useState(false);
const [data, setData] = useState<PlayerResponse | null>(null);
useEffect(() => {
if (!clicked || data)
return;
try {
fetchPlayer(name, {}).then((value: string | null) => {
if (value)
onDone(value);
else
setClicked(false);
});
} catch (e) {
console.log(e);
alert(e);
}
}, [clicked, setData, data]);
const disableButtons = clicked || name.trim() === '';
return <div className="JoinForm">
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
/>
<button
onClick={() => setClicked(true)}
disabled={disableButtons}
>
join
</button>
</div>;
}

75
tank-frontend/src/controls.js vendored Normal file
View file

@ -0,0 +1,75 @@
import './controls.css';
const body = document.querySelector('body');
const splash = document.querySelector('.splash');
if (!splash || !body)
throw new Error('required element not found');
splash.addEventListener('transitionend', function () {
body.classList.remove('was-killed');
});
const connection = new WebSocket(`ws://${window.location.hostname}:3000`);
connection.binaryType = 'blob';
connection.onmessage = function (message) {
message = JSON.parse(message.data);
console.log('got message', {message});
if (message.type === 'shot')
body.classList.add('was-killed');
};
connection.onerror = event => {
console.log('error', event);
alert('connection error');
};
connection.onclose = event => {
console.log('closed', event);
alert('connection closed - maybe a player with this name is already connected');
};
const keyEventListener = (type) => (event) => {
if (event.defaultPrevented)
return;
const controls = {
'ArrowLeft': 'left',
'ArrowUp': 'up',
'ArrowRight': 'right',
'ArrowDown': 'down',
'Space': 'shoot',
'KeyW': 'up',
'KeyA': 'left',
'KeyS': 'down',
'KeyD': 'right',
};
const value = controls[event.code];
if (!value)
return;
send({type, value});
};
connection.onopen = () => {
let name = getPlayerName();
send({type: 'name', value: name});
window.onkeyup = keyEventListener('input-off');
window.onkeydown = keyEventListener('input-on');
console.log('connection opened, game ready');
};
function getPlayerName() {
let name;
while (!name)
name = prompt('Player Name');
return name;
}
function send(obj) {
connection.send(JSON.stringify(obj));
}

View file

@ -0,0 +1,22 @@
html, body {
height: 100%;
}
body {
font-family: sans-serif;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: white;
}
#root {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,22 @@
import React, {useState} from 'react';
import './index.css';
import ClientScreen from './ClientScreen';
import Controls from './Controls.tsx';
import JoinForm from './JoinForm.tsx';
import {createRoot} from 'react-dom/client';
function App() {
const [id, setId] = useState<string | null>(null);
return <>
{id === null && <JoinForm onDone={name => setId(name)}/>}
<ClientScreen/>
{id == null || <Controls playerId={id}/>}
</>;
}
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App/>
</React.StrictMode>
);

1
tank-frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />