map chooser dialog (#19)
This commit is contained in:
commit
102c084328
|
@ -8,26 +8,27 @@ import Scoreboard from './Scoreboard.tsx';
|
||||||
import Button from './components/Button.tsx';
|
import Button from './components/Button.tsx';
|
||||||
import MapChooser from './MapChooser.tsx';
|
import MapChooser from './MapChooser.tsx';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import {getRandomTheme, useStoredTheme} from "./theme.ts";
|
import {ThemeContext, getRandomTheme, useStoredTheme} from './theme.ts';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [theme, setTheme] = useStoredTheme();
|
const [theme, setTheme] = useStoredTheme();
|
||||||
const [name, setName] = useState<string | null>(null);
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
|
||||||
return <Column className="flex-grow">
|
return <ThemeContext.Provider value={theme}>
|
||||||
|
<Column className="flex-grow">
|
||||||
|
|
||||||
<ClientScreen theme={theme} player={name}/>
|
<ClientScreen player={name}/>
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
<h1 className="flex-grow">CCCB-Tanks!</h1>
|
<h1 className="flex-grow">CCCB-Tanks!</h1>
|
||||||
<MapChooser />
|
<MapChooser />
|
||||||
<Button text="change colors" onClick={() => setTheme(_ => getRandomTheme())}/>
|
<Button text="☼ change colors" onClick={() => setTheme(_ => getRandomTheme())}/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.open('https://github.com/kaesaecracker/cccb-tanks-cs', '_blank')?.focus()}
|
onClick={() => window.open('https://github.com/kaesaecracker/cccb-tanks-cs', '_blank')?.focus()}
|
||||||
text="GitHub"/>
|
text="⌂ source"/>
|
||||||
{name !== '' &&
|
{name !== '' &&
|
||||||
<Button onClick={() => setName(_ => '')} text="logout"/>}
|
<Button onClick={() => setName(_ => '')} text="∩ logout"/>}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{name || <JoinForm onDone={name => setName(_ => name)}/>}
|
{name || <JoinForm onDone={name => setName(_ => name)}/>}
|
||||||
|
@ -38,5 +39,6 @@ export default function App() {
|
||||||
<Scoreboard/>
|
<Scoreboard/>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
</Column>;
|
</Column>
|
||||||
|
</ThemeContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,107 +1,12 @@
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import './ClientScreen.css';
|
|
||||||
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 {
|
export default function ClientScreen({player}: {
|
||||||
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
|
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 +19,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 +34,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}/>;
|
||||||
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}/>;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,18 +9,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.JoinForm {
|
.JoinForm {
|
||||||
border: solid var(--border-size-thick) var(--color-secondary);
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
background: var(--color-background);
|
|
||||||
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
animation-duration: 1s;
|
animation-duration: 1s;
|
||||||
animation-name: BlinkJoinFormBorder;
|
animation-name: BlinkJoinFormBorder;
|
||||||
animation-iteration-count: infinite;
|
animation-iteration-count: infinite;
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import './JoinForm.css';
|
import {useMutation} from '@tanstack/react-query';
|
||||||
|
|
||||||
import {makeApiUrl} from './serverCalls';
|
import {makeApiUrl} from './serverCalls';
|
||||||
import Column from './components/Column.tsx';
|
|
||||||
import Button from './components/Button.tsx';
|
import Button from './components/Button.tsx';
|
||||||
import TextInput from './components/TextInput.tsx';
|
import TextInput from './components/TextInput.tsx';
|
||||||
import {useMutation} from '@tanstack/react-query';
|
import Dialog from './components/Dialog.tsx';
|
||||||
|
import './JoinForm.css';
|
||||||
|
|
||||||
export default function JoinForm({onDone}: {
|
export default function JoinForm({onDone}: {
|
||||||
onDone: (name: string) => void;
|
onDone: (name: string) => void;
|
||||||
|
@ -29,7 +30,7 @@ export default function JoinForm({onDone}: {
|
||||||
|
|
||||||
const confirm = () => postPlayer.mutate({name});
|
const confirm = () => postPlayer.mutate({name});
|
||||||
|
|
||||||
return <Column className="JoinForm">
|
return <Dialog className="JoinForm">
|
||||||
<h3> Enter your name to play </h3>
|
<h3> Enter your name to play </h3>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={name}
|
value={name}
|
||||||
|
@ -40,7 +41,7 @@ export default function JoinForm({onDone}: {
|
||||||
<Button
|
<Button
|
||||||
onClick={confirm}
|
onClick={confirm}
|
||||||
disabled={disableButtons}
|
disabled={disableButtons}
|
||||||
text="INSERT COIN"/>
|
text="¢ INSERT COIN"/>
|
||||||
{postPlayer.isError && <p>{postPlayer.error.message}</p>}
|
{postPlayer.isError && <p>{postPlayer.error.message}</p>}
|
||||||
</Column>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,3 +7,17 @@
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.MapChooser-Row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MapChooser-Preview {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: var(--padding-normal);
|
||||||
|
border: solid var(--border-size-thin) var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.MapChooser-Preview-Highlight {
|
||||||
|
border-color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,81 @@
|
||||||
import {ChangeEvent} from 'react';
|
import {useState} from 'react';
|
||||||
import {makeApiUrl} from './serverCalls';
|
|
||||||
import './MapChooser.css';
|
|
||||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||||
|
import {makeApiUrl, MapInfo} from './serverCalls';
|
||||||
|
import Dialog from './components/Dialog.tsx';
|
||||||
|
import PixelGridCanvas from './components/PixelGridCanvas.tsx';
|
||||||
|
import Column from './components/Column.tsx';
|
||||||
|
import Button from './components/Button.tsx';
|
||||||
|
import Row from './components/Row.tsx';
|
||||||
|
import './MapChooser.css';
|
||||||
|
|
||||||
export default function MapChooser() {
|
function base64ToArrayBuffer(base64: string) {
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const bytes = new Uint8ClampedArray(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapPreview({mapName, highlight, onClick}: {
|
||||||
|
readonly mapName: string,
|
||||||
|
readonly highlight: boolean,
|
||||||
|
readonly onClick: () => void
|
||||||
|
}) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['get-map', mapName],
|
||||||
|
queryFn: async () => {
|
||||||
|
const url = makeApiUrl(`/map/${mapName}`);
|
||||||
|
const response = await fetch(url, {method: 'GET'});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`response failed with code ${response.status} (${response.status})${await response.text()}`);
|
||||||
|
return await response.json() as MapInfo;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.isError)
|
||||||
|
return <p>{query.error.message}</p>;
|
||||||
|
else if (query.isPending)
|
||||||
|
return <p>loading...</p>;
|
||||||
|
|
||||||
|
const {name, preview} = query.data;
|
||||||
|
|
||||||
|
return <Column
|
||||||
|
key={mapName}
|
||||||
|
className={'MapChooser-Preview' + (highlight ? ' MapChooser-Preview-Highlight' : '')}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<PixelGridCanvas pixels={base64ToArrayBuffer(preview)}/>
|
||||||
|
<p>{name}</p>
|
||||||
|
</Column>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function MapChooserDialog({mapNames, onClose, onConfirm}: {
|
||||||
|
readonly mapNames: string[];
|
||||||
|
readonly onConfirm: (mapName: string) => void;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [chosenMap, setChosenMap] = useState<string>();
|
||||||
|
return <Dialog>
|
||||||
|
<h3>Choose a map</h3>
|
||||||
|
<Row className="MapChooser-Row overflow-scroll">
|
||||||
|
{mapNames.map(name => <MapPreview
|
||||||
|
key={name}
|
||||||
|
mapName={name}
|
||||||
|
highlight={chosenMap == name}
|
||||||
|
onClick={() => setChosenMap(name)}
|
||||||
|
/>)}
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<div className="flex-grow"/>
|
||||||
|
<Button text="« cancel" onClick={onClose}/>
|
||||||
|
<Button text="√ confirm" disabled={!chosenMap} onClick={() => chosenMap && onConfirm(chosenMap)}/>
|
||||||
|
</Row>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapChooser({}: {}) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['get-maps'],
|
queryKey: ['get-maps'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
@ -27,22 +99,21 @@ export default function MapChooser() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const onChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
const [open, setOpen] = useState(false);
|
||||||
if (event.target.selectedIndex < 1)
|
|
||||||
return;
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const chosenMap = event.target.options[event.target.selectedIndex].value;
|
return <>
|
||||||
postMap.mutate(chosenMap);
|
<Button text="▓ Change map"
|
||||||
};
|
disabled={!query.isSuccess || postMap.isPending}
|
||||||
|
onClick={() => setOpen(true)}/>
|
||||||
if (query.isError)
|
{query.isSuccess && open &&
|
||||||
return <></>;
|
<MapChooserDialog
|
||||||
|
mapNames={query.data!}
|
||||||
const disabled = !query.isSuccess || postMap.isPending;
|
onClose={() => setOpen(false)}
|
||||||
|
onConfirm={name => {
|
||||||
return <select className="MapChooser-DropDown" onChange={onChange} disabled={disabled}>
|
setOpen(false);
|
||||||
<option value="" defaultValue={''}>Choose map</option>
|
postMap.mutate(name);
|
||||||
{query.isSuccess && query.data.map(m => <option key={m} value={m}>{m}</option>)}
|
}}
|
||||||
</select>;
|
/>
|
||||||
|
}
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {makeApiUrl, Scores, useMyWebSocket} from './serverCalls';
|
import {makeApiUrl, PlayerInfoMessage, useMyWebSocket} from './serverCalls';
|
||||||
import Column from './components/Column.tsx';
|
import Column from './components/Column.tsx';
|
||||||
import {ReadyState} from 'react-use-websocket';
|
import {ReadyState} from 'react-use-websocket';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
|
@ -21,21 +21,6 @@ function ScoreRow({name, value}: {
|
||||||
</tr>;
|
</tr>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TankInfo = {
|
|
||||||
readonly magazine: string;
|
|
||||||
readonly position: { x: number; y: number };
|
|
||||||
readonly orientation: number;
|
|
||||||
readonly moving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlayerInfoMessage = {
|
|
||||||
readonly name: string;
|
|
||||||
readonly scores: Scores;
|
|
||||||
readonly controls: string;
|
|
||||||
readonly tank?: TankInfo;
|
|
||||||
readonly openConnections: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlayerInfo({player}: { player: string }) {
|
export default function PlayerInfo({player}: { player: string }) {
|
||||||
const [shouldSendMessage, setShouldSendMessage] = useState(false);
|
const [shouldSendMessage, setShouldSendMessage] = useState(false);
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import {ReactNode} from "react";
|
import {MouseEventHandler, ReactNode} from 'react';
|
||||||
|
|
||||||
import './Column.css'
|
import './Column.css'
|
||||||
|
|
||||||
export default function Column({children, className}: {
|
export default function Column({children, className, onClick}: {
|
||||||
children: ReactNode,
|
children: ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
|
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||||
}) {
|
}) {
|
||||||
return <div className={'Column flex-column ' + (className ?? '')}>
|
return <div className={'Column flex-column ' + (className ?? '')} onClick={onClick}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
16
tank-frontend/src/components/Dialog.css
Normal file
16
tank-frontend/src/components/Dialog.css
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
|
||||||
|
.Dialog {
|
||||||
|
border: solid var(--border-size-thick) var(--color-secondary);
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
background: var(--color-background);
|
||||||
|
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
max-height: 75vh;
|
||||||
|
max-width: 75vw;
|
||||||
|
}
|
12
tank-frontend/src/components/Dialog.tsx
Normal file
12
tank-frontend/src/components/Dialog.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import {ReactNode} from 'react';
|
||||||
|
import Column from './Column.tsx';
|
||||||
|
import './Dialog.css';
|
||||||
|
|
||||||
|
export default function Dialog({children, className}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <Column className={'Dialog overflow-scroll ' + (className ?? '')}>
|
||||||
|
{children}
|
||||||
|
</Column>
|
||||||
|
}
|
|
@ -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;
|
162
tank-frontend/src/components/PixelGridCanvas.tsx
Normal file
162
tank-frontend/src/components/PixelGridCanvas.tsx
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import {hslToString, ThemeContext} from '../theme.ts';
|
||||||
|
import {useContext, 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,
|
||||||
|
PowerUp = 0x3
|
||||||
|
}
|
||||||
|
|
||||||
|
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, colors
|
||||||
|
}: {
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixels: Uint8ClampedArray,
|
||||||
|
additional: Uint8ClampedArray | null,
|
||||||
|
colors: {
|
||||||
|
background: Uint8ClampedArray,
|
||||||
|
foreground: Uint8ClampedArray,
|
||||||
|
player: Uint8ClampedArray,
|
||||||
|
tanks: Uint8ClampedArray,
|
||||||
|
powerUps: Uint8ClampedArray,
|
||||||
|
bullets: Uint8ClampedArray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
let additionalDataIndex = 0;
|
||||||
|
let additionalDataByte: number | null = null;
|
||||||
|
const nextPixelColor = (isOn: boolean) => {
|
||||||
|
if (!isOn)
|
||||||
|
return colors.background;
|
||||||
|
if (!additional)
|
||||||
|
return colors.foreground;
|
||||||
|
|
||||||
|
let info;
|
||||||
|
if (additionalDataByte === null) {
|
||||||
|
additionalDataByte = additional[additionalDataIndex];
|
||||||
|
additionalDataIndex++;
|
||||||
|
info = parseAdditionalDataNibble(additionalDataByte);
|
||||||
|
} else {
|
||||||
|
info = parseAdditionalDataNibble(additionalDataByte >> 4);
|
||||||
|
additionalDataByte = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.isCurrentPlayer)
|
||||||
|
return colors.player;
|
||||||
|
|
||||||
|
if (info.entityType === GamePixelEntityType.Tank)
|
||||||
|
return colors.tanks;
|
||||||
|
if (info.entityType === GamePixelEntityType.PowerUp)
|
||||||
|
return colors.powerUps;
|
||||||
|
if (info.entityType === GamePixelEntityType.Bullet)
|
||||||
|
return colors.bullets;
|
||||||
|
|
||||||
|
return colors.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}: {
|
||||||
|
readonly pixels: Uint8ClampedArray;
|
||||||
|
}) {
|
||||||
|
const theme = useContext(ThemeContext);
|
||||||
|
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,
|
||||||
|
colors: {
|
||||||
|
background: normalizeColor(drawContext, hslToString(theme.background)),
|
||||||
|
foreground: normalizeColor(drawContext, hslToString(theme.primary)),
|
||||||
|
player: normalizeColor(drawContext, hslToString(theme.secondary)),
|
||||||
|
tanks: normalizeColor(drawContext, hslToString(theme.tertiary)),
|
||||||
|
powerUps: normalizeColor(drawContext, hslToString(theme.tertiary)),
|
||||||
|
bullets: normalizeColor(drawContext, hslToString(theme.tertiary))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [pixels, canvasRef.current, theme]);
|
||||||
|
|
||||||
|
return <canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="PixelGridCanvas"
|
||||||
|
width={pixelsPerRow}
|
||||||
|
height={pixelsPerCol}
|
||||||
|
/>;
|
||||||
|
}
|
|
@ -60,3 +60,7 @@ html, body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-scroll {
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,27 @@ export type Player = {
|
||||||
readonly scores: Scores;
|
readonly scores: Scores;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TankInfo = {
|
||||||
|
readonly magazine: string;
|
||||||
|
readonly position: { x: number; y: number };
|
||||||
|
readonly orientation: number;
|
||||||
|
readonly moving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerInfoMessage = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly scores: Scores;
|
||||||
|
readonly controls: string;
|
||||||
|
readonly tank?: TankInfo;
|
||||||
|
readonly openConnections: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapInfo = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly typeName: string;
|
||||||
|
readonly preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useMyWebSocket<T = unknown>(url: string, options: Options = {}) {
|
export function useMyWebSocket<T = unknown>(url: string, options: Options = {}) {
|
||||||
return useWebSocket<T>(url, {
|
return useWebSocket<T>(url, {
|
||||||
shouldReconnect: () => true,
|
shouldReconnect: () => true,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {useStoredObjectState} from "./useStoredState.ts";
|
import {useStoredObjectState} from './useStoredState.ts';
|
||||||
|
import {createContext} from 'react';
|
||||||
|
|
||||||
export type Theme = {
|
export type Theme = {
|
||||||
primary: HSL;
|
primary: HSL;
|
||||||
|
@ -88,3 +89,5 @@ export function useStoredTheme() {
|
||||||
save: applyTheme
|
save: applyTheme
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<Theme>(getRandomTheme());
|
||||||
|
|
|
@ -24,14 +24,18 @@ internal sealed class Endpoints(
|
||||||
app.Map("/controls", ConnectControlsAsync);
|
app.Map("/controls", ConnectControlsAsync);
|
||||||
app.MapGet("/map", () => mapService.MapNames);
|
app.MapGet("/map", () => mapService.MapNames);
|
||||||
app.MapPost("/map", PostMap);
|
app.MapPost("/map", PostMap);
|
||||||
|
app.MapGet("/map/{name}", GetMapByName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Results<BadRequest<string>, NotFound<string>, Ok> PostMap([FromQuery] string name)
|
private Results<BadRequest<string>, NotFound<string>, Ok> PostMap([FromQuery] string name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return TypedResults.BadRequest("invalid map name");
|
return TypedResults.BadRequest("invalid map name");
|
||||||
if (!mapService.TryGetMapByName(name, out var map))
|
|
||||||
|
name = name.Trim().ToUpperInvariant();
|
||||||
|
if (!mapService.TryGetPrototype(name, out var map))
|
||||||
return TypedResults.NotFound("map with name not found");
|
return TypedResults.NotFound("map with name not found");
|
||||||
|
|
||||||
changeToRequestedMap.Request(map);
|
changeToRequestedMap.Request(map);
|
||||||
return TypedResults.Ok();
|
return TypedResults.Ok();
|
||||||
}
|
}
|
||||||
|
@ -88,4 +92,19 @@ internal sealed class Endpoints(
|
||||||
var player = playerService.GetOrAdd(name);
|
var player = playerService.GetOrAdd(name);
|
||||||
return TypedResults.Ok(player.Name);
|
return TypedResults.Ok(player.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Results<Ok<MapInfo>, NotFound, BadRequest<string>> GetMapByName(string name)
|
||||||
|
{
|
||||||
|
name = name.Trim().ToUpperInvariant();
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
return TypedResults.BadRequest("map name cannot be empty");
|
||||||
|
|
||||||
|
if (!mapService.TryGetPrototype(name, out var prototype))
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
if (!mapService.TryGetPreview(name, out var preview))
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
|
||||||
|
var mapInfo = new MapInfo(prototype.Name, prototype.GetType().Name, preview.Data);
|
||||||
|
return TypedResults.Ok(mapInfo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
8
tanks-backend/TanksServer/GameLogic/MapPrototype.cs
Normal file
8
tanks-backend/TanksServer/GameLogic/MapPrototype.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace TanksServer.GameLogic;
|
||||||
|
|
||||||
|
internal abstract class MapPrototype
|
||||||
|
{
|
||||||
|
public abstract string Name { get; }
|
||||||
|
|
||||||
|
public abstract Map CreateInstance();
|
||||||
|
}
|
|
@ -1,14 +1,10 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using DisplayCommands;
|
||||||
using TanksServer.Graphics;
|
using TanksServer.Graphics;
|
||||||
|
|
||||||
namespace TanksServer.GameLogic;
|
namespace TanksServer.GameLogic;
|
||||||
|
|
||||||
internal abstract class MapPrototype
|
|
||||||
{
|
|
||||||
public abstract Map CreateInstance();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class MapService
|
internal sealed class MapService
|
||||||
{
|
{
|
||||||
public const ushort TilesPerRow = 44;
|
public const ushort TilesPerRow = 44;
|
||||||
|
@ -17,9 +13,10 @@ internal sealed class MapService
|
||||||
public const ushort PixelsPerRow = TilesPerRow * TileSize;
|
public const ushort PixelsPerRow = TilesPerRow * TileSize;
|
||||||
public const ushort PixelsPerColumn = TilesPerColumn * TileSize;
|
public const ushort PixelsPerColumn = TilesPerColumn * TileSize;
|
||||||
|
|
||||||
private readonly Dictionary<string, MapPrototype> _maps = new();
|
private readonly Dictionary<string, MapPrototype> _mapPrototypes = new();
|
||||||
|
private readonly Dictionary<string, PixelGrid> _mapPreviews = new();
|
||||||
|
|
||||||
public IEnumerable<string> MapNames => _maps.Keys;
|
public IEnumerable<string> MapNames => _mapPrototypes.Keys;
|
||||||
|
|
||||||
public Map Current { get; private set; }
|
public Map Current { get; private set; }
|
||||||
|
|
||||||
|
@ -29,29 +26,49 @@ internal sealed class MapService
|
||||||
LoadMapString(file);
|
LoadMapString(file);
|
||||||
foreach (var file in Directory.EnumerateFiles("./assets/maps/", "*.png"))
|
foreach (var file in Directory.EnumerateFiles("./assets/maps/", "*.png"))
|
||||||
LoadMapPng(file);
|
LoadMapPng(file);
|
||||||
|
Current = GetRandomMap();
|
||||||
var chosenMapIndex = Random.Shared.Next(_maps.Count);
|
|
||||||
var chosenMapName = _maps.Keys.Skip(chosenMapIndex).First();
|
|
||||||
Current = _maps[chosenMapName].CreateInstance();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGetMapByName(string name, [MaybeNullWhen(false)] out MapPrototype map)
|
public bool TryGetPrototype(string name, [MaybeNullWhen(false)] out MapPrototype map)
|
||||||
=> _maps.TryGetValue(name, out map);
|
=> _mapPrototypes.TryGetValue(name, out map);
|
||||||
|
|
||||||
public void SwitchTo(MapPrototype prototype) => Current = prototype.CreateInstance();
|
public void SwitchTo(MapPrototype prototype) => Current = prototype.CreateInstance();
|
||||||
|
|
||||||
|
public bool TryGetPreview(string name, [MaybeNullWhen(false)] out PixelGrid pixelGrid)
|
||||||
|
{
|
||||||
|
if (_mapPreviews.TryGetValue(name, out pixelGrid))
|
||||||
|
return true; // already generated
|
||||||
|
if (!_mapPrototypes.TryGetValue(name, out var prototype))
|
||||||
|
return false; // name not found
|
||||||
|
|
||||||
|
pixelGrid = new PixelGrid(PixelsPerRow, PixelsPerColumn);
|
||||||
|
DrawMapStep.Draw(pixelGrid, prototype.CreateInstance());
|
||||||
|
|
||||||
|
_mapPreviews.TryAdd(name, pixelGrid); // another thread may have added the map already
|
||||||
|
return true; // new preview
|
||||||
|
}
|
||||||
|
|
||||||
private void LoadMapPng(string file)
|
private void LoadMapPng(string file)
|
||||||
{
|
{
|
||||||
var name = Path.GetFileName(file);
|
var name = MapNameFromFilePath(file);
|
||||||
var prototype = new SpriteMapPrototype(name, Sprite.FromImageFile(file));
|
var prototype = new SpriteMapPrototype(name, Sprite.FromImageFile(file));
|
||||||
_maps.Add(Path.GetFileName(file), prototype);
|
_mapPrototypes.Add(name, prototype);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadMapString(string file)
|
private void LoadMapString(string file)
|
||||||
{
|
{
|
||||||
|
var name = MapNameFromFilePath(file);
|
||||||
var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim();
|
var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim();
|
||||||
var name = Path.GetFileName(file);
|
|
||||||
var prototype = new TextMapPrototype(name, map);
|
var prototype = new TextMapPrototype(name, map);
|
||||||
_maps.Add(name, prototype);
|
_mapPrototypes.Add(name, prototype);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapNameFromFilePath(string filePath) => Path.GetFileName(filePath).ToUpperInvariant();
|
||||||
|
|
||||||
|
private Map GetRandomMap()
|
||||||
|
{
|
||||||
|
var chosenMapIndex = Random.Shared.Next(_mapPrototypes.Count);
|
||||||
|
var chosenMapName = _mapPrototypes.Keys.Skip(chosenMapIndex).First();
|
||||||
|
return _mapPrototypes[chosenMapName].CreateInstance();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,17 +5,18 @@ namespace TanksServer.GameLogic;
|
||||||
|
|
||||||
internal sealed class SpriteMapPrototype : MapPrototype
|
internal sealed class SpriteMapPrototype : MapPrototype
|
||||||
{
|
{
|
||||||
private readonly string _name;
|
public override string Name { get; }
|
||||||
private readonly Sprite _sprite;
|
|
||||||
|
public Sprite Sprite { get; }
|
||||||
|
|
||||||
public SpriteMapPrototype(string name, Sprite sprite)
|
public SpriteMapPrototype(string name, Sprite sprite)
|
||||||
{
|
{
|
||||||
if (sprite.Width != MapService.PixelsPerRow || sprite.Height != MapService.PixelsPerColumn)
|
if (sprite.Width != MapService.PixelsPerRow || sprite.Height != MapService.PixelsPerColumn)
|
||||||
throw new FileLoadException($"invalid image size in file {_name}");
|
throw new FileLoadException($"invalid image size in file {Name}");
|
||||||
|
|
||||||
_name = name;
|
Name = name;
|
||||||
_sprite = sprite;
|
Sprite = sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Map CreateInstance() => new(_name, _sprite.ToBoolArray());
|
public override Map CreateInstance() => new(Name, Sprite.ToBoolArray());
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,17 +2,19 @@ namespace TanksServer.GameLogic;
|
||||||
|
|
||||||
internal sealed class TextMapPrototype : MapPrototype
|
internal sealed class TextMapPrototype : MapPrototype
|
||||||
{
|
{
|
||||||
private readonly string _name;
|
public override string Name { get; }
|
||||||
private readonly string _text;
|
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
public TextMapPrototype(string name, string text)
|
public TextMapPrototype(string name, string text)
|
||||||
{
|
{
|
||||||
if (text.Length != MapService.TilesPerColumn * MapService.TilesPerRow)
|
if (text.Length != MapService.TilesPerColumn * MapService.TilesPerRow)
|
||||||
throw new ArgumentException($"cannot load map {name}: invalid length");
|
throw new ArgumentException($"cannot load map {name}: invalid length");
|
||||||
_name = name;
|
Name = name;
|
||||||
_text = text;
|
Text = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public override Map CreateInstance()
|
public override Map CreateInstance()
|
||||||
{
|
{
|
||||||
var walls = new bool[MapService.PixelsPerRow, MapService.PixelsPerColumn];
|
var walls = new bool[MapService.PixelsPerRow, MapService.PixelsPerColumn];
|
||||||
|
@ -21,7 +23,7 @@ internal sealed class TextMapPrototype : MapPrototype
|
||||||
for (ushort tileY = 0; tileY < MapService.TilesPerColumn; tileY++)
|
for (ushort tileY = 0; tileY < MapService.TilesPerColumn; tileY++)
|
||||||
{
|
{
|
||||||
var tile = new TilePosition(tileX, tileY);
|
var tile = new TilePosition(tileX, tileY);
|
||||||
if (_text[tileX + tileY * MapService.TilesPerRow] != '#')
|
if (Text[tileX + tileY * MapService.TilesPerRow] != '#')
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
for (byte pixelInTileX = 0; pixelInTileX < MapService.TileSize; pixelInTileX++)
|
for (byte pixelInTileX = 0; pixelInTileX < MapService.TileSize; pixelInTileX++)
|
||||||
|
@ -32,6 +34,6 @@ internal sealed class TextMapPrototype : MapPrototype
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Map(_name, walls);
|
return new Map(Name, walls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,32 @@
|
||||||
|
using DisplayCommands;
|
||||||
using TanksServer.GameLogic;
|
using TanksServer.GameLogic;
|
||||||
|
|
||||||
namespace TanksServer.Graphics;
|
namespace TanksServer.Graphics;
|
||||||
|
|
||||||
internal sealed class DrawMapStep(MapService map) : IDrawStep
|
internal sealed class DrawMapStep(MapService map) : IDrawStep
|
||||||
{
|
{
|
||||||
public void Draw(GamePixelGrid pixels)
|
public void Draw(GamePixelGrid pixels) => Draw(pixels, map.Current);
|
||||||
|
|
||||||
|
private static void Draw(GamePixelGrid pixels, Map map)
|
||||||
{
|
{
|
||||||
for (ushort y = 0; y < MapService.PixelsPerColumn; y++)
|
for (ushort y = 0; y < MapService.PixelsPerColumn; y++)
|
||||||
for (ushort x = 0; x < MapService.PixelsPerRow; x++)
|
for (ushort x = 0; x < MapService.PixelsPerRow; x++)
|
||||||
{
|
{
|
||||||
if (!map.Current.IsWall(x, y))
|
if (!map.IsWall(x, y))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
pixels[x, y].EntityType = GamePixelEntityType.Wall;
|
pixels[x, y].EntityType = GamePixelEntityType.Wall;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void Draw(PixelGrid pixels, Map map)
|
||||||
|
{
|
||||||
|
for (ushort y = 0; y < MapService.PixelsPerColumn; y++)
|
||||||
|
for (ushort x = 0; x < MapService.PixelsPerRow; x++)
|
||||||
|
{
|
||||||
|
if (!map.IsWall(x, y))
|
||||||
|
continue;
|
||||||
|
pixels[x, y] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,5 +7,6 @@ namespace TanksServer.Interactivity;
|
||||||
[JsonSerializable(typeof(IEnumerable<Player>))]
|
[JsonSerializable(typeof(IEnumerable<Player>))]
|
||||||
[JsonSerializable(typeof(IEnumerable<string>))]
|
[JsonSerializable(typeof(IEnumerable<string>))]
|
||||||
[JsonSerializable(typeof(PlayerInfo))]
|
[JsonSerializable(typeof(PlayerInfo))]
|
||||||
|
[JsonSerializable(typeof(MapInfo))]
|
||||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||||
internal sealed partial class AppSerializerContext : JsonSerializerContext;
|
internal sealed partial class AppSerializerContext : JsonSerializerContext;
|
||||||
|
|
3
tanks-backend/TanksServer/Models/MapInfo.cs
Normal file
3
tanks-backend/TanksServer/Models/MapInfo.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace TanksServer.Models;
|
||||||
|
|
||||||
|
public record MapInfo(string Name, string TypeName, Memory<byte> Preview);
|
Loading…
Reference in a new issue