wip scores, components

This commit is contained in:
Vinzenz Schroeter 2024-04-13 23:07:08 +02:00
parent 64a61ef2b3
commit b604c01e22
20 changed files with 250 additions and 115 deletions

View file

@ -3,5 +3,7 @@
aspect-ratio: calc(352 / 160); aspect-ratio: calc(352 / 160);
flex-grow: 1; flex-grow: 1;
object-fit: contain; object-fit: contain;
max-width: 100%; max-width: 100vw;
max-height: 100vh;
flex-shrink: 0;
} }

View file

@ -1,7 +1,6 @@
import useWebSocket from 'react-use-websocket'; import useWebSocket from 'react-use-websocket';
import {useEffect, useRef} from 'react'; import {useEffect, useRef} from 'react';
import './ClientScreen.css'; import './ClientScreen.css';
import {statusTextForReadyState} from './statusTextForReadyState.tsx';
const pixelsPerRow = 352; const pixelsPerRow = 352;
const pixelsPerCol = 160; const pixelsPerCol = 160;
@ -41,11 +40,10 @@ function drawPixelsToCanvas(pixels: Uint8Array, canvas: HTMLCanvasElement) {
drawContext.putImageData(imageData, 0, 0); drawContext.putImageData(imageData, 0, 0);
} }
export default function ClientScreen({logout}: {logout: () => void}) { export default function ClientScreen({logout}: { logout: () => void }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const { const {
readyState,
lastMessage, lastMessage,
sendMessage, sendMessage,
getWebSocket getWebSocket
@ -67,8 +65,5 @@ export default function ClientScreen({logout}: {logout: () => void}) {
sendMessage(''); sendMessage('');
}, [lastMessage, canvasRef.current]); }, [lastMessage, canvasRef.current]);
return <> return <canvas ref={canvasRef} id="screen" width={pixelsPerRow} height={pixelsPerCol}/>;
<span>The screen is currently {statusTextForReadyState[readyState]}</span>
<canvas ref={canvasRef} id="screen" width={pixelsPerRow} height={pixelsPerCol}/>
</>;
} }

View file

@ -1,3 +1,7 @@
.Controls * {
box-sizing: content-box;
}
kbd { kbd {
background: hsl(0, 0%, 96%); background: hsl(0, 0%, 96%);
padding: 10px; padding: 10px;

View file

@ -1,7 +1,7 @@
import './Controls.css'; import './Controls.css';
import useWebSocket from 'react-use-websocket'; import useWebSocket from 'react-use-websocket';
import {useEffect} from 'react'; import {useEffect} from 'react';
import {statusTextForReadyState} from './statusTextForReadyState.tsx'; import Column from "./components/Column.tsx";
export default function Controls({playerId, logout}: { export default function Controls({playerId, logout}: {
playerId: string, playerId: string,
@ -12,7 +12,6 @@ export default function Controls({playerId, logout}: {
const { const {
sendMessage, sendMessage,
getWebSocket, getWebSocket,
readyState
} = useWebSocket(url.toString(), { } = useWebSocket(url.toString(), {
onError: logout onError: logout
}); });
@ -44,6 +43,7 @@ export default function Controls({playerId, logout}: {
if (!value) if (!value)
return; return;
event.preventDefault();
const message = new Uint8Array([typeCode, value]); const message = new Uint8Array([typeCode, value]);
sendMessage(message); sendMessage(message);
}; };
@ -60,8 +60,7 @@ export default function Controls({playerId, logout}: {
}, [sendMessage]); }, [sendMessage]);
return <> return <>
<span>The controller is currently {statusTextForReadyState[readyState]}</span> <Column className="Controls">
<div className="controls">
<div className="control"> <div className="control">
<div className="row"> <div className="row">
<kbd className="up"></kbd> <kbd className="up"></kbd>
@ -71,12 +70,12 @@ export default function Controls({playerId, logout}: {
<kbd></kbd> <kbd></kbd>
<kbd></kbd> <kbd></kbd>
</div> </div>
<h3>Move</h3>
</div> </div>
<h3>Move</h3>
<div className="control"> <div className="control">
<kbd className="space">Space</kbd> <kbd className="space">Space</kbd>
<h3>Fire</h3>
</div> </div>
</div> <h3>Fire</h3>
</Column>
</>; </>;
} }

View file

@ -1,11 +1,4 @@
.TankWelcome {
display: flex;
flex-direction: column;
}
.JoinForm { .JoinForm {
display: flex;
flex-direction: column;
border: 2px solid rgb(76, 76, 76); border: 2px solid rgb(76, 76, 76);
border-radius: 4px; border-radius: 4px;
} }
@ -13,4 +6,4 @@
.JoinElems { .JoinElems {
padding: 8px 8px; padding: 8px 8px;
margin: 8px 8px; margin: 8px 8px;
} }

View file

@ -2,6 +2,7 @@ import {useEffect, useState} from 'react';
import './JoinForm.css'; import './JoinForm.css';
import {NameId, PlayerResponse, postPlayer} from './serverCalls'; import {NameId, PlayerResponse, postPlayer} from './serverCalls';
import {Guid} from './Guid.ts'; import {Guid} from './Guid.ts';
import Column from "./components/Column.tsx";
export default function JoinForm({setNameId, clientId}: { export default function JoinForm({setNameId, clientId}: {
setNameId: (mutator: (oldState: NameId) => NameId) => void, setNameId: (mutator: (oldState: NameId) => NameId) => void,
@ -30,27 +31,25 @@ export default function JoinForm({setNameId, clientId}: {
}, [clicked, setData, data, clientId, setClicked, setNameId]); }, [clicked, setData, data, clientId, setClicked, setNameId]);
const disableButtons = clicked || name.trim() === ''; const disableButtons = clicked || name.trim() === '';
return <div className="TankWelcome"> return <Column className='JoinForm'>
<h1 className="JoinElems" style={{'color': 'white'}}> <p className="JoinElems"> Enter your name to join the game! </p>
Tanks <input
</h1> className="JoinElems"
<p className="JoinElems" style={{'color': 'white'}}> Welcome and have fun!</p> type="text"
<div className="JoinForm"> value={name}
<p className="JoinElems" style={{'color': 'white'}}> placeholder="player name"
Enter your name to join the game! onChange={e => setName(e.target.value)}
</p> onKeyUp={event => {
<input className="JoinElems" if (event.key === 'Enter')
type="text" setClicked(true);
value={name} }}
placeholder="player name" />
onChange={e => setName(e.target.value)} <button
/> className="JoinElems"
<button className="JoinElems" onClick={() => setClicked(true)}
onClick={() => setClicked(true)} disabled={disableButtons}
disabled={disableButtons} >
> join
join </button>
</button> </Column>;
</div>
</div>;
} }

View file

@ -1,21 +1,14 @@
.Player { .PlayerInfo {
display: flex;
flex-direction: column;
border: 2px solid rgb(76, 76, 76); border: 2px solid rgb(76, 76, 76);
border-radius: 4px; border-radius: 4px;
} }
.ScoreForm {
display: flex;
flex-direction: column;
}
.ElemGroup {
display: flex;
flex-direction: row;
}
.Elems { .Elems {
padding: 8px 8px; padding: 8px 8px;
margin: 8px 8px; margin: 8px 8px;
} }
.PlayerInfo-Reset {
height: 4em;
width: 4em;
}

View file

@ -1,53 +1,46 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import './PlayerInfo.css' import './PlayerInfo.css'
import {PlayerResponse, getPlayer} from './serverCalls'; import {PlayerResponse, getPlayer} from './serverCalls';
import {Guid} from "./Guid.ts";
import Column from "./components/Column.tsx";
import Row from "./components/Row.tsx";
import Button from "./components/Button.tsx";
export default function PlayerInfo({playerId, logout}: { playerId: string, logout: () => void }) { export default function PlayerInfo({playerId, logout, reset}: {
playerId: Guid,
logout: () => void,
reset: () => void
}) {
const [player, setPlayer] = useState<PlayerResponse | null>(); const [player, setPlayer] = useState<PlayerResponse | null>();
const refresh = () => {
getPlayer(playerId).then(value => {
if (value)
setPlayer(value);
else
logout();
});
};
useEffect(() => { useEffect(() => {
const refresh = () => {
getPlayer(playerId).then(value => {
if (value)
setPlayer(value);
else
logout();
});
};
const timer = setInterval(refresh, 5000); const timer = setInterval(refresh, 5000);
return () => clearInterval(timer); return () => clearInterval(timer);
}); }, [playerId]);
return <div className='TankWelcome'> return <Column className='PlayerInfo'>
<h1 className='Elems' style={{"color": "white"}}> <Row>
Tanks <h3 className='grow'>
</h1> {player ? `Playing as "${player?.name}"` : 'loading...'}
<div className="ScoreForm"> </h3>
<div className='ElemGroup'> <Button className='PlayerInfo-Reset' onClick={() => reset()} text='x'/>
<p className='Elems' style={{"color": "white"}}> </Row>
name: <Row>
</p> <p className='Elems'> kills: </p>
<p className='Elems' style={{"color": "white"}}> <p className='Elems'>{player?.scores.kills}</p>
{player?.name} </Row>
</p> <Row>
</div> <p className='Elems'>deaths: </p>
<div className='ElemGroup'> <p className='Elems'>{player?.scores.deaths}</p>
<p className='Elems' style={{"color": "white"}}> </Row>
kills: </Column>;
</p>
<p className='Elems' style={{"color": "white"}}>
{player?.scores.kills}
</p>
</div>
<div className='ElemGroup'>
<p className='Elems' style={{"color": "white"}}>
deaths:
</p>
<p className='Elems' style={{"color": "white"}}>
{player?.scores.deaths}
</p>
</div>
</div>
</div>;
} }

View file

@ -0,0 +1,26 @@
import {getScores, PlayerResponse} from "./serverCalls.tsx";
import {useEffect, useState} from "react";
import DataTable from "./components/DataTable.tsx";
export default function Scoreboard({}: {}) {
const [players, setPlayers] = useState<PlayerResponse[]>([]);
useEffect(() => {
const refresh = () => {
getScores().then(value => {
if (value)
setPlayers(value);
});
};
const timer = setInterval(refresh, 5000);
return () => clearInterval(timer);
}, [players]);
return <DataTable<PlayerResponse> data={players} columns={[
{field: 'name'},
{field: 'kills', visualize: p => p.scores.kills},
{field: 'deaths', visualize: p => p.scores.deaths},
{field: 'k/d', visualize: p => p.scores.kills / p.scores.deaths}
]}/>
}

View file

@ -0,0 +1,7 @@
.Button {
border: solid 1px green;
border-radius: 12px;
color: green;
background: transparent;
}

View file

@ -0,0 +1,15 @@
import './Button.css';
import {MouseEventHandler} from "react";
export default function Button({text, onClick, className}: {
text: string,
onClick?: MouseEventHandler<HTMLButtonElement>,
className?: string
}) {
return <button
className={'Button ' + (className ?? '')}
onClick={onClick}
>
{text}
</button>
}

View file

@ -0,0 +1,7 @@
.Column {
display: flex;
flex-direction: column;
flex: auto;
max-height: 100%;
align-items: stretch;
}

View file

@ -0,0 +1,13 @@
import {ReactNode} from "react";
import './Column.css'
export default function Column({children, className}: {
children: ReactNode,
className?: string
}) {
return <div className={'Column ' + (className ?? '')}>
{children}
</div>
}

View file

@ -0,0 +1,3 @@
.DataTable {
flex-grow: 1;
}

View file

@ -0,0 +1,52 @@
import {ReactNode} from "react";
import './DataTable.css';
export type DataTableColumnDefinition<T> = {
field: string,
label?: string,
visualize?: (value: T) => ReactNode
};
function TableHead({columns}: { columns: DataTableColumnDefinition<any>[] }) {
return <thead className='DataTableHead'>
<tr>
{
columns.map(column =>
<th key={column.field}>
{column.label ?? column.field}
</th>)
}
</tr>
</thead>;
}
function DataTableRow({rowData, columns}: {
rowData: any,
columns: DataTableColumnDefinition<any>[]
}) {
return <tr>
{
columns.map(column => {
return <td key={column.field}>
{
column.visualize
? column.visualize(rowData)
: rowData[column.field]
}
</td>;
})
}
</tr>;
}
export default function DataTable<T>({data, columns}: {
data: T[],
columns: DataTableColumnDefinition<any>[]
}) {
return <table className='DataTable'>
<TableHead columns={columns}/>
<tbody>
{data.map((element, index) => <DataTableRow key={index} rowData={element} columns={columns}/>)}
</tbody>
</table>
}

View file

@ -0,0 +1,7 @@
.Row {
display: flex;
flex-direction: row;
flex: auto;
max-width: 100%;
align-items: stretch;
}

View file

@ -0,0 +1,9 @@
import {ReactNode} from "react";
import './Row.css';
export default function Row({children, className}: { children: ReactNode, className?: string }) {
return <div className={'Row ' + (className ?? '')}>
{children}
</div>
}

View file

@ -1,3 +1,6 @@
* {
box-sizing: border-box;
}
html, body { html, body {
height: 100%; height: 100%;
@ -11,12 +14,17 @@ body {
justify-content: center; justify-content: center;
background-color: black; background-color: black;
color: white;
} }
#root { #root {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
width: 100%; width: 100vw;
height: 100%; height: 100vh;
}
.grow {
flex-grow: 1;
} }

View file

@ -7,12 +7,17 @@ import {createRoot} from 'react-dom/client';
import PlayerInfo from './PlayerInfo.tsx'; import PlayerInfo from './PlayerInfo.tsx';
import {useStoredObjectState} from './useStoredState.ts'; import {useStoredObjectState} from './useStoredState.ts';
import {NameId, postPlayer} from './serverCalls.tsx'; import {NameId, postPlayer} from './serverCalls.tsx';
import Column from "./components/Column.tsx";
import Row from "./components/Row.tsx";
import Scoreboard from "./Scoreboard.tsx";
const getNewNameId = () => ({
id: crypto.randomUUID(),
name: ''
});
function App() { function App() {
const [nameId, setNameId] = useStoredObjectState<NameId>('access', () => ({ const [nameId, setNameId] = useStoredObjectState<NameId>('access', getNewNameId);
id: crypto.randomUUID(),
name: ''
}));
const [isLoggedIn, setLoggedIn] = useState<boolean>(false); const [isLoggedIn, setLoggedIn] = useState<boolean>(false);
const logout = () => setLoggedIn(false); const logout = () => setLoggedIn(false);
@ -24,12 +29,17 @@ function App() {
setLoggedIn(result !== null); setLoggedIn(result !== null);
}, [nameId, isLoggedIn])(); }, [nameId, isLoggedIn])();
return <> return <Column className='grow'>
<h1>Tanks!</h1>
{nameId.name === '' && <JoinForm setNameId={setNameId} clientId={nameId.id}/>} {nameId.name === '' && <JoinForm setNameId={setNameId} clientId={nameId.id}/>}
{isLoggedIn && <PlayerInfo playerId={nameId.id} logout={logout}/>}
<ClientScreen logout={logout}/> <ClientScreen logout={logout}/>
{isLoggedIn && <Controls playerId={nameId.id} logout={logout}/>} {isLoggedIn && <Row>
</>; <Controls playerId={nameId.id} logout={logout}/>
<PlayerInfo playerId={nameId.id} logout={logout} reset={() => setNameId(getNewNameId)}/>
<Scoreboard/>
</Row>
}
</Column>;
} }
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

View file

@ -2,7 +2,7 @@ import {Guid} from './Guid.ts';
export type PlayerResponse = { export type PlayerResponse = {
readonly name: string; readonly name: string;
readonly id: string; readonly id: Guid;
readonly scores: { readonly scores: {
readonly kills: number; readonly kills: number;
readonly deaths: number; readonly deaths: number;
@ -29,7 +29,7 @@ export function postPlayer({name, id}: NameId) {
return fetchTyped<NameId>({url, method: 'POST'}); return fetchTyped<NameId>({url, method: 'POST'});
} }
export function getPlayer(id: string) { export function getPlayer(id: Guid) {
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL); const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
url.searchParams.set('id', id); url.searchParams.set('id', id);