theme editor (#20)
This commit is contained in:
commit
a7ee3855f5
|
@ -62,8 +62,8 @@ There are other commands implemented as well, e.g. for changing the brightness.
|
|||
- runs all game logic
|
||||
- sends image and text to the service point display
|
||||
- sends image to clients
|
||||
- currently, the game has a fixed tick and frame rate of 25/s
|
||||
- One frame is ~7KB, not including the text
|
||||
- The game has a dynamic update rate. Hundreds of updates per second on a laptop are expected.
|
||||
- One frame is ~7KB, not including the text and player specific data
|
||||
- maps can be loaded from png files containing black and white pixels or simple text files
|
||||
- some values (like tank speed) can be configured but are fixed at run time
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {useState} from 'react';
|
||||
|
||||
import ClientScreen from './ClientScreen';
|
||||
import Controls from './Controls.tsx';
|
||||
import JoinForm from './JoinForm.tsx';
|
||||
|
@ -7,23 +9,22 @@ import Row from './components/Row.tsx';
|
|||
import Scoreboard from './Scoreboard.tsx';
|
||||
import Button from './components/Button.tsx';
|
||||
import MapChooser from './MapChooser.tsx';
|
||||
import {ThemeProvider} from './theme.tsx';
|
||||
import './App.css';
|
||||
import {ThemeContext, getRandomTheme, useStoredTheme} from './theme.ts';
|
||||
import {useState} from 'react';
|
||||
import ThemeChooser from './ThemeChooser.tsx';
|
||||
|
||||
export default function App() {
|
||||
const [theme, setTheme] = useStoredTheme();
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
|
||||
return <ThemeContext.Provider value={theme}>
|
||||
return <ThemeProvider>
|
||||
<Column className="flex-grow">
|
||||
|
||||
<ClientScreen player={name}/>
|
||||
|
||||
<Row>
|
||||
<h1 className="flex-grow">CCCB-Tanks!</h1>
|
||||
<MapChooser />
|
||||
<Button text="☼ change colors" onClick={() => setTheme(_ => getRandomTheme())}/>
|
||||
<MapChooser/>
|
||||
<ThemeChooser/>
|
||||
<Button
|
||||
onClick={() => window.open('https://github.com/kaesaecracker/cccb-tanks-cs', '_blank')?.focus()}
|
||||
text="⌂ source"/>
|
||||
|
@ -40,5 +41,5 @@ export default function App() {
|
|||
</Row>
|
||||
|
||||
</Column>
|
||||
</ThemeContext.Provider>;
|
||||
</ThemeProvider>;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import {useMutation} from '@tanstack/react-query';
|
|||
|
||||
import {makeApiUrl} from './serverCalls';
|
||||
import Button from './components/Button.tsx';
|
||||
import TextInput from './components/TextInput.tsx';
|
||||
import {TextInput} from './components/Input.tsx';
|
||||
import Dialog from './components/Dialog.tsx';
|
||||
import './JoinForm.css';
|
||||
|
||||
|
@ -30,12 +30,11 @@ export default function JoinForm({onDone}: {
|
|||
|
||||
const confirm = () => postPlayer.mutate({name});
|
||||
|
||||
return <Dialog className="JoinForm">
|
||||
<h3> Enter your name to play </h3>
|
||||
return <Dialog className="JoinForm" title="Enter your name to play">
|
||||
<TextInput
|
||||
value={name}
|
||||
placeholder="player name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
onChange={n => setName(n)}
|
||||
onEnter={confirm}
|
||||
/>
|
||||
<Button
|
||||
|
|
|
@ -1,15 +1,8 @@
|
|||
|
||||
.MapChooser-DropDown {
|
||||
border: solid var(--border-size-thin);
|
||||
padding: var(--padding-normal);
|
||||
|
||||
background: var(--color-background);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.MapChooser-Row {
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
}
|
||||
|
||||
.MapChooser-Preview {
|
||||
|
@ -21,3 +14,7 @@
|
|||
.MapChooser-Preview-Highlight {
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.MapChooser-Dialog {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import Column from './components/Column.tsx';
|
|||
import Button from './components/Button.tsx';
|
||||
import Row from './components/Row.tsx';
|
||||
import './MapChooser.css';
|
||||
import Spacer from './components/Spacer.tsx';
|
||||
|
||||
function base64ToArrayBuffer(base64: string) {
|
||||
const binaryString = atob(base64);
|
||||
|
@ -57,8 +58,11 @@ function MapChooserDialog({mapNames, onClose, onConfirm}: {
|
|||
readonly onClose: () => void;
|
||||
}) {
|
||||
const [chosenMap, setChosenMap] = useState<string>();
|
||||
return <Dialog>
|
||||
<h3>Choose a map</h3>
|
||||
return <Dialog
|
||||
className='MapChooser-Dialog'
|
||||
title="Choose a map"
|
||||
onClose={onClose}
|
||||
>
|
||||
<Row className="MapChooser-Row overflow-scroll">
|
||||
{mapNames.map(name => <MapPreview
|
||||
key={name}
|
||||
|
@ -68,7 +72,7 @@ function MapChooserDialog({mapNames, onClose, onConfirm}: {
|
|||
/>)}
|
||||
</Row>
|
||||
<Row>
|
||||
<div className="flex-grow"/>
|
||||
<Spacer/>
|
||||
<Button text="« cancel" onClick={onClose}/>
|
||||
<Button text="√ confirm" disabled={!chosenMap} onClick={() => chosenMap && onConfirm(chosenMap)}/>
|
||||
</Row>
|
||||
|
|
6
tank-frontend/src/ThemeChooser.css
Normal file
6
tank-frontend/src/ThemeChooser.css
Normal file
|
@ -0,0 +1,6 @@
|
|||
.HslEditor-Inputs {
|
||||
display: grid;
|
||||
column-gap: var(--padding-normal);
|
||||
grid-template-columns: auto auto auto;
|
||||
grid-template-rows: auto;
|
||||
}
|
115
tank-frontend/src/ThemeChooser.tsx
Normal file
115
tank-frontend/src/ThemeChooser.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import Button from './components/Button.tsx';
|
||||
import {getRandomTheme, HSL, hslToString, useHslTheme} from './theme.tsx';
|
||||
import Dialog from './components/Dialog.tsx';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {NumberInput, RangeInput, TextInput} from './components/Input.tsx';
|
||||
import Row from './components/Row.tsx';
|
||||
import Column from './components/Column.tsx';
|
||||
import './ThemeChooser.css';
|
||||
import Spacer from './components/Spacer.tsx';
|
||||
|
||||
function HslEditor({name, value, setValue}: {
|
||||
name: string;
|
||||
value: HSL;
|
||||
setValue: (value: HSL) => void
|
||||
}) {
|
||||
const setH = (h: number) => setValue({...value, h});
|
||||
const setS = (s: number) => setValue({...value, s});
|
||||
const setL = (l: number) => setValue({...value, l});
|
||||
|
||||
return <Column>
|
||||
<Row>
|
||||
<div className="" style={{background: hslToString(value), border: '1px solid white', aspectRatio: '1'}}/>
|
||||
<p>{name}</p>
|
||||
</Row>
|
||||
<div className="HslEditor-Inputs">
|
||||
<p>Hue</p>
|
||||
<NumberInput value={Math.round(value.h)} onChange={setH}/>
|
||||
<RangeInput value={Math.round(value.h)} min={0} max={360} onChange={setH}/>
|
||||
|
||||
<p>Saturation</p>
|
||||
<NumberInput value={Math.round(value.s)} onChange={setS}/>
|
||||
<RangeInput value={Math.round(value.s)} min={0} max={100} onChange={setS}/>
|
||||
|
||||
<p>Lightness</p>
|
||||
<NumberInput value={Math.round(value.l)} onChange={setL}/>
|
||||
<RangeInput value={Math.round(value.l)} min={0} max={100} onChange={setL}/>
|
||||
</div>
|
||||
</Column>;
|
||||
}
|
||||
|
||||
function ThemeChooserDialog({onClose}: {
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const {hslTheme, setHslTheme} = useHslTheme();
|
||||
const [themeString, setThemeString] = useState<string>(JSON.stringify(hslTheme));
|
||||
const [errorMsg, setErrorMsg] = useState<string>();
|
||||
|
||||
useMemo(() => {
|
||||
setThemeString(JSON.stringify(hslTheme));
|
||||
}, [hslTheme]);
|
||||
|
||||
return <Dialog title="Theme editor" onClose={onClose}>
|
||||
<Row>
|
||||
<Button
|
||||
text="? randomize"
|
||||
onClick={() => setHslTheme(_ => getRandomTheme())}/>
|
||||
<Spacer/>
|
||||
</Row>
|
||||
<Row>
|
||||
<TextInput
|
||||
value={themeString}
|
||||
onChange={setThemeString}
|
||||
className="flex-grow"/>
|
||||
<Button text="» import" onClick={() => {
|
||||
try {
|
||||
const theme = JSON.parse(themeString);
|
||||
setHslTheme(old => ({...old, theme}));
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message);
|
||||
}
|
||||
}}/>
|
||||
{errorMsg &&
|
||||
<Dialog
|
||||
title="Error"
|
||||
onClose={() => setErrorMsg(undefined)}
|
||||
>
|
||||
<p>{errorMsg}</p>
|
||||
</Dialog>
|
||||
}
|
||||
</Row>
|
||||
<Column className="overflow-scroll">
|
||||
<HslEditor
|
||||
name="background"
|
||||
value={hslTheme.background}
|
||||
setValue={value => setHslTheme(old => ({...old, background: value}))}/>
|
||||
<HslEditor
|
||||
name="primary"
|
||||
value={hslTheme.primary}
|
||||
setValue={value => setHslTheme(old => ({...old, primary: value}))}/>
|
||||
<HslEditor
|
||||
name="secondary"
|
||||
value={hslTheme.secondary}
|
||||
setValue={value => setHslTheme(old => ({...old, secondary: value}))}/>
|
||||
<HslEditor
|
||||
name="tertiary"
|
||||
value={hslTheme.tertiary}
|
||||
setValue={value => setHslTheme(old => ({...old, tertiary: value}))}/>
|
||||
<HslEditor
|
||||
name="text"
|
||||
value={hslTheme.text}
|
||||
setValue={value => setHslTheme(old => ({...old, text: value}))}/>
|
||||
</Column>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export default function ThemeChooser({}: {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return <>
|
||||
<Button
|
||||
text="☼ change colors"
|
||||
onClick={() => setOpen(true)}/>
|
||||
{open && <ThemeChooserDialog onClose={() => setOpen(false)}/>}
|
||||
</>;
|
||||
}
|
|
@ -1,12 +1,22 @@
|
|||
import {ReactNode} from 'react';
|
||||
import Column from './Column.tsx';
|
||||
import Row from './Row.tsx';
|
||||
import Button from './Button.tsx';
|
||||
import './Dialog.css';
|
||||
|
||||
export default function Dialog({children, className}: {
|
||||
children: ReactNode;
|
||||
export default function Dialog({children, className, title, onClose}: {
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return <Column className={'Dialog overflow-scroll ' + (className ?? '')}>
|
||||
{children}
|
||||
</Column>
|
||||
return <Column className={'Dialog ' + (className ?? '')}>
|
||||
<Row>
|
||||
<h3 className="flex-grow">{title}</h3>
|
||||
{onClose && <Button text="x" onClick={onClose}/>}
|
||||
</Row>
|
||||
<Column className='overflow-scroll'>
|
||||
{children}
|
||||
</Column>
|
||||
</Column>;
|
||||
}
|
||||
|
|
9
tank-frontend/src/components/Input.css
Normal file
9
tank-frontend/src/components/Input.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.Input {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
padding: var(--padding-normal);
|
||||
}
|
||||
|
||||
.RangeInput {
|
||||
appearance: auto;
|
||||
}
|
80
tank-frontend/src/components/Input.tsx
Normal file
80
tank-frontend/src/components/Input.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import './Input.css';
|
||||
|
||||
export function TextInput({onChange, className, value, placeholder, onEnter}: {
|
||||
onChange?: (value: string) => void;
|
||||
className?: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onEnter?: () => void;
|
||||
}) {
|
||||
return <input
|
||||
type="text"
|
||||
className={'Input ' + (className ?? '')}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={event => {
|
||||
if (!onChange)
|
||||
return;
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
onKeyUp={event => {
|
||||
if (!onEnter || event.key !== 'Enter')
|
||||
return;
|
||||
onEnter();
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function NumberInput({onChange, className, value, placeholder, onEnter}: {
|
||||
onChange?: (value: number) => void;
|
||||
className?: string;
|
||||
value: number;
|
||||
placeholder?: string;
|
||||
onEnter?: () => void;
|
||||
}) {
|
||||
return <input
|
||||
type="number"
|
||||
className={'Input ' + (className ?? '')}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={event => {
|
||||
if (!onChange)
|
||||
return;
|
||||
onChange(parseFloat(event.target.value));
|
||||
}}
|
||||
onKeyUp={event => {
|
||||
if (!onEnter || event.key !== 'Enter')
|
||||
return;
|
||||
onEnter();
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function RangeInput({onChange, className, value, placeholder, onEnter, min, max}: {
|
||||
onChange?: (value: number) => void;
|
||||
className?: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
placeholder?: string;
|
||||
onEnter?: () => void;
|
||||
}) {
|
||||
return <input
|
||||
type="range"
|
||||
className={'Input RangeInput ' + (className ?? '')}
|
||||
value={value} min={min} max={max}
|
||||
placeholder={placeholder}
|
||||
onChange={event => {
|
||||
if (!onChange)
|
||||
return;
|
||||
onChange(parseFloat(event.target.value));
|
||||
}}
|
||||
onKeyUp={event => {
|
||||
if (!onEnter || event.key !== 'Enter')
|
||||
return;
|
||||
onEnter();
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import {hslToString, ThemeContext} from '../theme.ts';
|
||||
import {useContext, useEffect, useRef} from 'react';
|
||||
import {useEffect, useRef} from 'react';
|
||||
import './PixelGridCanvas.css';
|
||||
import {useRgbaTheme} from '../theme.tsx';
|
||||
|
||||
const pixelsPerRow = 352;
|
||||
const pixelsPerCol = 160;
|
||||
|
@ -20,12 +20,6 @@ function getPixelDataIndexes(bitIndex: number) {
|
|||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -107,7 +101,7 @@ function drawPixelsToCanvas(
|
|||
export default function PixelGridCanvas({pixels}: {
|
||||
readonly pixels: Uint8ClampedArray;
|
||||
}) {
|
||||
const theme = useContext(ThemeContext);
|
||||
const theme = useRgbaTheme();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -137,12 +131,12 @@ export default function PixelGridCanvas({pixels}: {
|
|||
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))
|
||||
background: theme.background,
|
||||
foreground: theme.primary,
|
||||
player: theme.secondary,
|
||||
tanks: theme.tertiary,
|
||||
powerUps: theme.tertiary,
|
||||
bullets: theme.tertiary
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import {ReactNode} from "react";
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
import './Row.css';
|
||||
|
||||
export default function Row({children, className}: { children: ReactNode, className?: string }) {
|
||||
export default function Row({children, className}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={'Row flex-row ' + (className ?? '')}>
|
||||
{children}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
4
tank-frontend/src/components/Spacer.tsx
Normal file
4
tank-frontend/src/components/Spacer.tsx
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
export default function Spacer() {
|
||||
return <div className='flex-grow' />;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
.TextInput {
|
||||
padding: var(--padding-normal);
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
import {ChangeEventHandler} from "react";
|
||||
import './TextInput.css';
|
||||
|
||||
export default function TextInput(props: {
|
||||
onChange?: ChangeEventHandler<HTMLInputElement> | undefined;
|
||||
className?: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onEnter?: () => void;
|
||||
}) {
|
||||
return <input
|
||||
{...props}
|
||||
type="text"
|
||||
className={'TextInput ' + (props.className?? '')}
|
||||
|
||||
onKeyUp={event => {
|
||||
if (props.onEnter && event.key === 'Enter')
|
||||
props.onEnter();
|
||||
}}
|
||||
/>;
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
import {useStoredObjectState} from './useStoredState.ts';
|
||||
import {createContext} from 'react';
|
||||
|
||||
export type Theme = {
|
||||
primary: HSL;
|
||||
secondary: HSL;
|
||||
background: HSL;
|
||||
tertiary: HSL;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const rootStyle = document.querySelector(':root')?.style;
|
||||
|
||||
function getRandom(min: number, max: number) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
type HSL = {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
function getRandomHsl(params: {
|
||||
minHue?: number,
|
||||
maxHue?: number,
|
||||
minSaturation?: number,
|
||||
maxSaturation?: number,
|
||||
minLightness?: number,
|
||||
maxLightness?: number,
|
||||
}): HSL {
|
||||
const values = {
|
||||
minHue: 0,
|
||||
maxHue: 360,
|
||||
minSaturation: 0,
|
||||
maxSaturation: 100,
|
||||
minLightness: 0,
|
||||
maxLightness: 100,
|
||||
...params
|
||||
};
|
||||
const h = getRandom(values.minHue, values.maxHue);
|
||||
const s = getRandom(values.minSaturation, values.maxSaturation);
|
||||
const l = getRandom(values.minLightness, values.maxLightness);
|
||||
return {h, s, l};
|
||||
}
|
||||
|
||||
export function hslToString({h, s, l}: HSL) {
|
||||
return `hsl(${h},${s}%,${l}%)`;
|
||||
}
|
||||
|
||||
function angle(a: number) {
|
||||
return ((a % 360.0) + 360) % 360;
|
||||
}
|
||||
|
||||
export function getRandomTheme(): Theme {
|
||||
const goldenAngle = 180 * (3 - Math.sqrt(5));
|
||||
|
||||
const background = getRandomHsl({maxSaturation: 50, minLightness: 10, maxLightness: 30});
|
||||
|
||||
const otherColorParams = {
|
||||
minSaturation: background.s,
|
||||
maxSaturation: 90,
|
||||
minLightness: background.l + 20,
|
||||
maxLightness: 90
|
||||
};
|
||||
|
||||
const primary = getRandomHsl(otherColorParams);
|
||||
primary.h = angle(-1 * goldenAngle + primary.h);
|
||||
|
||||
const secondary = getRandomHsl(otherColorParams);
|
||||
primary.h = angle(+1 * goldenAngle + primary.h);
|
||||
|
||||
const tertiary = getRandomHsl(otherColorParams);
|
||||
primary.h = angle(+3 * goldenAngle + primary.h);
|
||||
|
||||
return {background, primary, secondary, tertiary};
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
console.log('apply theme', theme);
|
||||
rootStyle.setProperty('--color-primary', hslToString(theme.primary));
|
||||
rootStyle.setProperty('--color-secondary', hslToString(theme.secondary));
|
||||
rootStyle.setProperty('--color-background', hslToString(theme.background));
|
||||
}
|
||||
|
||||
export function useStoredTheme() {
|
||||
return useStoredObjectState<Theme>('theme', getRandomTheme, {
|
||||
load: applyTheme,
|
||||
save: applyTheme
|
||||
});
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<Theme>(getRandomTheme());
|
169
tank-frontend/src/theme.tsx
Normal file
169
tank-frontend/src/theme.tsx
Normal file
|
@ -0,0 +1,169 @@
|
|||
import {useStoredObjectState} from './useStoredState.ts';
|
||||
import {createContext, ReactNode, useContext, useMemo, useRef, useState} from 'react';
|
||||
|
||||
export type HSL = {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
export type HslTheme = {
|
||||
primary: HSL;
|
||||
secondary: HSL;
|
||||
background: HSL;
|
||||
tertiary: HSL;
|
||||
text: HSL;
|
||||
}
|
||||
|
||||
type Rgba = Uint8ClampedArray;
|
||||
|
||||
export type RgbaTheme = {
|
||||
primary: Rgba;
|
||||
secondary: Rgba;
|
||||
background: Rgba;
|
||||
tertiary: Rgba;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const rootStyle = document.querySelector(':root')?.style;
|
||||
|
||||
function getRandom(min: number, max: number) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function getRandomHsl(params: {
|
||||
minHue?: number,
|
||||
maxHue?: number,
|
||||
minSaturation?: number,
|
||||
maxSaturation?: number,
|
||||
minLightness?: number,
|
||||
maxLightness?: number,
|
||||
}): HSL {
|
||||
const values = {
|
||||
minHue: 0,
|
||||
maxHue: 360,
|
||||
minSaturation: 0,
|
||||
maxSaturation: 100,
|
||||
minLightness: 0,
|
||||
maxLightness: 100,
|
||||
...params
|
||||
};
|
||||
const h = getRandom(values.minHue, values.maxHue);
|
||||
const s = getRandom(values.minSaturation, values.maxSaturation);
|
||||
const l = getRandom(values.minLightness, values.maxLightness);
|
||||
return {h, s, l};
|
||||
}
|
||||
|
||||
export function hslToString({h, s, l}: HSL) {
|
||||
return `hsl(${h},${s}%,${l}%)`;
|
||||
}
|
||||
|
||||
function angle(a: number) {
|
||||
return ((a % 360.0) + 360) % 360;
|
||||
}
|
||||
|
||||
export function getRandomTheme(): HslTheme {
|
||||
const goldenAngle = 180 * (3 - Math.sqrt(5));
|
||||
|
||||
const background = getRandomHsl({maxSaturation: 50, minLightness: 10, maxLightness: 30});
|
||||
|
||||
const otherColorParams = {
|
||||
minSaturation: background.s,
|
||||
maxSaturation: 90,
|
||||
minLightness: background.l + 20,
|
||||
maxLightness: 90
|
||||
};
|
||||
|
||||
const primary = getRandomHsl(otherColorParams);
|
||||
primary.h = angle(-1 * goldenAngle + primary.h);
|
||||
|
||||
const secondary = getRandomHsl(otherColorParams);
|
||||
primary.h = angle(+1 * goldenAngle + primary.h);
|
||||
|
||||
const tertiary = getRandomHsl(otherColorParams);
|
||||
primary.h = angle(+3 * goldenAngle + primary.h);
|
||||
|
||||
const text = {h: 0, s: 0, l: 100};
|
||||
|
||||
return {background, primary, secondary, tertiary, text};
|
||||
}
|
||||
|
||||
const dummyRgbaTheme: RgbaTheme = {
|
||||
primary: new Uint8ClampedArray([0, 0, 0, 0]),
|
||||
secondary: new Uint8ClampedArray([0, 0, 0, 0]),
|
||||
background: new Uint8ClampedArray([0, 0, 0, 0]),
|
||||
tertiary: new Uint8ClampedArray([0, 0, 0, 0])
|
||||
};
|
||||
|
||||
function hslToRgba(context: CanvasRenderingContext2D, color: HSL) {
|
||||
context.fillStyle = hslToString(color);
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
return context.getImageData(0, 0, 1, 1).data;
|
||||
}
|
||||
|
||||
const HslThemeContext = createContext<null | {
|
||||
hslTheme: HslTheme,
|
||||
setHslTheme: (mutator: (oldState: HslTheme) => HslTheme) => void
|
||||
}>(null);
|
||||
const RgbaThemeContext = createContext<RgbaTheme>(dummyRgbaTheme);
|
||||
|
||||
export function useRgbaTheme() {
|
||||
return useContext(RgbaThemeContext);
|
||||
}
|
||||
|
||||
export function useHslTheme() {
|
||||
const context = useContext(HslThemeContext);
|
||||
if (context === null) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ThemeProvider({children}: {
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [rgbaTheme, setRgbaTheme] = useState<RgbaTheme | null>(null);
|
||||
|
||||
function applyTheme(theme: HslTheme) {
|
||||
console.log('apply theme', theme);
|
||||
rootStyle.setProperty('--color-primary', hslToString(theme.primary));
|
||||
rootStyle.setProperty('--color-secondary', hslToString(theme.secondary));
|
||||
rootStyle.setProperty('--color-background', hslToString(theme.background));
|
||||
rootStyle.setProperty('--color-text', hslToString(theme.text));
|
||||
}
|
||||
|
||||
const [hslTheme, setHslTheme] = useStoredObjectState<HslTheme>('theme2', getRandomTheme, {
|
||||
load: applyTheme,
|
||||
save: applyTheme
|
||||
});
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
useMemo(() => {
|
||||
if (!canvas)
|
||||
return;
|
||||
|
||||
const drawContext = canvas.getContext('2d', {
|
||||
alpha: false,
|
||||
colorSpace: 'srgb',
|
||||
willReadFrequently: true
|
||||
});
|
||||
if (!drawContext)
|
||||
throw new Error('could not get draw context');
|
||||
setRgbaTheme({
|
||||
background: hslToRgba(drawContext, hslTheme.background),
|
||||
primary: hslToRgba(drawContext, hslTheme.primary),
|
||||
secondary: hslToRgba(drawContext, hslTheme.secondary),
|
||||
tertiary: hslToRgba(drawContext, hslTheme.tertiary),
|
||||
});
|
||||
}, [hslTheme, canvas]);
|
||||
|
||||
return <HslThemeContext.Provider value={{hslTheme, setHslTheme}}>
|
||||
<RgbaThemeContext.Provider value={rgbaTheme || dummyRgbaTheme}>
|
||||
<canvas hidden={true} ref={canvasRef} width={1} height={1}/>
|
||||
{children}
|
||||
</RgbaThemeContext.Provider>;
|
||||
</HslThemeContext.Provider>;
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using TanksServer.GameLogic;
|
||||
using TanksServer.Interactivity;
|
||||
|
||||
|
@ -25,6 +30,11 @@ internal sealed class Endpoints(
|
|||
app.MapGet("/map", () => mapService.MapNames);
|
||||
app.MapPost("/map", PostMap);
|
||||
app.MapGet("/map/{name}", GetMapByName);
|
||||
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
ResponseWriter = WriteJsonHealthCheckResponse
|
||||
});
|
||||
}
|
||||
|
||||
private Results<BadRequest<string>, NotFound<string>, Ok> PostMap([FromQuery] string name)
|
||||
|
@ -107,4 +117,41 @@ internal sealed class Endpoints(
|
|||
var mapInfo = new MapInfo(prototype.Name, prototype.GetType().Name, preview.Data);
|
||||
return TypedResults.Ok(mapInfo);
|
||||
}
|
||||
|
||||
private static Task WriteJsonHealthCheckResponse(HttpContext context, HealthReport healthReport)
|
||||
{
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
var options = new JsonWriterOptions { Indented = true };
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
using (var jsonWriter = new Utf8JsonWriter(memoryStream, options))
|
||||
{
|
||||
jsonWriter.WriteStartObject();
|
||||
jsonWriter.WriteString("status", healthReport.Status.ToString());
|
||||
jsonWriter.WriteStartObject("results");
|
||||
|
||||
foreach (var healthReportEntry in healthReport.Entries)
|
||||
{
|
||||
jsonWriter.WriteStartObject(healthReportEntry.Key);
|
||||
jsonWriter.WriteString("status",
|
||||
healthReportEntry.Value.Status.ToString());
|
||||
jsonWriter.WriteString("description",
|
||||
healthReportEntry.Value.Description);
|
||||
jsonWriter.WriteStartObject("data");
|
||||
|
||||
foreach (var item in healthReportEntry.Value.Data)
|
||||
jsonWriter.WriteString(item.Key, item.Value.ToString());
|
||||
|
||||
jsonWriter.WriteEndObject();
|
||||
jsonWriter.WriteEndObject();
|
||||
}
|
||||
|
||||
jsonWriter.WriteEndObject();
|
||||
jsonWriter.WriteEndObject();
|
||||
}
|
||||
|
||||
return context.Response.WriteAsync(
|
||||
Encoding.UTF8.GetString(memoryStream.ToArray()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using DisplayCommands;
|
||||
|
@ -13,8 +14,8 @@ internal sealed class MapService
|
|||
public const ushort PixelsPerRow = TilesPerRow * TileSize;
|
||||
public const ushort PixelsPerColumn = TilesPerColumn * TileSize;
|
||||
|
||||
private readonly Dictionary<string, MapPrototype> _mapPrototypes = new();
|
||||
private readonly Dictionary<string, PixelGrid> _mapPreviews = new();
|
||||
private readonly ConcurrentDictionary<string, MapPrototype> _mapPrototypes = new();
|
||||
private readonly ConcurrentDictionary<string, PixelGrid> _mapPreviews = new();
|
||||
|
||||
public IEnumerable<string> MapNames => _mapPrototypes.Keys;
|
||||
|
||||
|
@ -52,7 +53,8 @@ internal sealed class MapService
|
|||
{
|
||||
var name = MapNameFromFilePath(file);
|
||||
var prototype = new SpriteMapPrototype(name, Sprite.FromImageFile(file));
|
||||
_mapPrototypes.Add(name, prototype);
|
||||
var added = _mapPrototypes.TryAdd(name, prototype);
|
||||
Debug.Assert(added);
|
||||
}
|
||||
|
||||
private void LoadMapString(string file)
|
||||
|
@ -60,7 +62,7 @@ internal sealed class MapService
|
|||
var name = MapNameFromFilePath(file);
|
||||
var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim();
|
||||
var prototype = new TextMapPrototype(name, map);
|
||||
_mapPrototypes.Add(name, prototype);
|
||||
_mapPrototypes.TryAdd(name, prototype);
|
||||
}
|
||||
|
||||
private static string MapNameFromFilePath(string filePath) => Path.GetFileName(filePath).ToUpperInvariant();
|
||||
|
|
|
@ -1,13 +1,42 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class UpdatesPerSecondCounter(ILogger<UpdatesPerSecondCounter> logger) : ITickStep
|
||||
internal sealed class UpdatesPerSecondCounter(
|
||||
ILogger<UpdatesPerSecondCounter> logger
|
||||
) : ITickStep, IHealthCheck
|
||||
{
|
||||
private static readonly TimeSpan LongTime = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan CriticalUpdateTime = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private readonly Stopwatch _long = Stopwatch.StartNew();
|
||||
|
||||
private readonly record struct Statistics(
|
||||
ulong Updates,
|
||||
TimeSpan TotalTime,
|
||||
double AverageUpdatesPerSecond,
|
||||
TimeSpan MinFrameTime,
|
||||
TimeSpan AverageFrameTime,
|
||||
TimeSpan MaxFrameTime)
|
||||
{
|
||||
public override string ToString() =>
|
||||
$"{nameof(Updates)}: {Updates}, {nameof(TotalTime)}: {TotalTime}, {nameof(AverageUpdatesPerSecond)}: {AverageUpdatesPerSecond}, {nameof(MinFrameTime)}: {MinFrameTime}, {nameof(AverageFrameTime)}: {AverageFrameTime}, {nameof(MaxFrameTime)}: {MaxFrameTime}";
|
||||
|
||||
public Dictionary<string, object> ToDictionary() => new()
|
||||
{
|
||||
[nameof(Updates)] = Updates.ToString(),
|
||||
[nameof(TotalTime)] = TotalTime.ToString(),
|
||||
[nameof(AverageUpdatesPerSecond)] = AverageUpdatesPerSecond.ToString(CultureInfo.InvariantCulture),
|
||||
[nameof(MinFrameTime)] = MinFrameTime.ToString(),
|
||||
[nameof(AverageFrameTime)] = AverageFrameTime.ToString(),
|
||||
[nameof(MaxFrameTime)] = MaxFrameTime.ToString()
|
||||
};
|
||||
};
|
||||
|
||||
private Statistics? _currentStatistics = null;
|
||||
|
||||
private ulong _updatesSinceLongReset;
|
||||
private TimeSpan _minFrameTime = TimeSpan.MaxValue;
|
||||
private TimeSpan _maxFrameTime = TimeSpan.MinValue;
|
||||
|
@ -40,16 +69,20 @@ internal sealed class UpdatesPerSecondCounter(ILogger<UpdatesPerSecondCounter> l
|
|||
|
||||
private void LogCounters()
|
||||
{
|
||||
var time = _long.Elapsed;
|
||||
|
||||
_currentStatistics = new Statistics(
|
||||
_updatesSinceLongReset,
|
||||
time,
|
||||
_updatesSinceLongReset / time.TotalSeconds,
|
||||
_minFrameTime,
|
||||
time / _updatesSinceLongReset,
|
||||
_maxFrameTime);
|
||||
|
||||
if (!logger.IsEnabled(LogLevel.Debug))
|
||||
return;
|
||||
|
||||
var time = _long.Elapsed;
|
||||
var averageTime = Math.Round(time.TotalMilliseconds / _updatesSinceLongReset, 2);
|
||||
var averageUps = Math.Round(_updatesSinceLongReset / time.TotalSeconds, 2);
|
||||
var min = Math.Round(_minFrameTime.TotalMilliseconds, 2);
|
||||
var max = Math.Round(_maxFrameTime.TotalMilliseconds, 2);
|
||||
logger.LogDebug("count={}, time={}, avg={}ms, ups={}, min={}ms, max={}ms",
|
||||
_updatesSinceLongReset, time, averageTime, averageUps, min, max);
|
||||
logger.LogDebug("statistics: {}", _currentStatistics);
|
||||
}
|
||||
|
||||
private void ResetCounters()
|
||||
|
@ -59,4 +92,23 @@ internal sealed class UpdatesPerSecondCounter(ILogger<UpdatesPerSecondCounter> l
|
|||
_minFrameTime = TimeSpan.MaxValue;
|
||||
_maxFrameTime = TimeSpan.MinValue;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
|
||||
CancellationToken cancellationToken = new())
|
||||
{
|
||||
var stats = _currentStatistics;
|
||||
if (stats == null)
|
||||
{
|
||||
return Task.FromResult(
|
||||
HealthCheckResult.Degraded("no statistics available yet - this is expected shortly after start"));
|
||||
}
|
||||
|
||||
if (stats.Value.MaxFrameTime > CriticalUpdateTime)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("max frame time too high", null,
|
||||
stats.Value.ToDictionary()));
|
||||
}
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Healthy("", stats.Value.ToDictionary()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,9 @@ public static class Program
|
|||
|
||||
builder.Services.AddHttpLogging(_ => { });
|
||||
|
||||
var healthCheckBuilder = builder.Services.AddHealthChecks();
|
||||
healthCheckBuilder.AddCheck<UpdatesPerSecondCounter>("updates check");
|
||||
|
||||
builder.Services.Configure<HostConfiguration>(builder.Configuration.GetSection("Host"));
|
||||
var hostConfiguration = builder.Configuration.GetSection("Host").Get<HostConfiguration>();
|
||||
if (hostConfiguration == null)
|
||||
|
@ -66,12 +69,14 @@ public static class Program
|
|||
builder.Services.AddSingleton<BufferPool>();
|
||||
builder.Services.AddSingleton<EmptyTileFinder>();
|
||||
builder.Services.AddSingleton<ChangeToRequestedMap>();
|
||||
builder.Services.AddSingleton<UpdatesPerSecondCounter>();
|
||||
|
||||
builder.Services.AddHostedService<GameTickWorker>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ControlsServer>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ClientScreenServer>());
|
||||
|
||||
builder.Services.AddSingleton<ITickStep, ChangeToRequestedMap>(sp => sp.GetRequiredService<ChangeToRequestedMap>());
|
||||
builder.Services.AddSingleton<ITickStep, ChangeToRequestedMap>(sp =>
|
||||
sp.GetRequiredService<ChangeToRequestedMap>());
|
||||
builder.Services.AddSingleton<ITickStep, MoveBullets>();
|
||||
builder.Services.AddSingleton<ITickStep, CollideBullets>();
|
||||
builder.Services.AddSingleton<ITickStep, RotateTanks>();
|
||||
|
@ -82,7 +87,8 @@ public static class Program
|
|||
builder.Services.AddSingleton<ITickStep, SpawnPowerUp>();
|
||||
builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>();
|
||||
builder.Services.AddSingleton<ITickStep, PlayerServer>(sp => sp.GetRequiredService<PlayerServer>());
|
||||
builder.Services.AddSingleton<ITickStep, UpdatesPerSecondCounter>();
|
||||
builder.Services.AddSingleton<ITickStep, UpdatesPerSecondCounter>(sp =>
|
||||
sp.GetRequiredService<UpdatesPerSecondCounter>());
|
||||
|
||||
builder.Services.AddSingleton<IDrawStep, DrawMapStep>();
|
||||
builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>();
|
||||
|
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
20
tanks-backend/TanksServer/assets/maps/template.txt
Normal file
20
tanks-backend/TanksServer/assets/maps/template.txt
Normal file
|
@ -0,0 +1,20 @@
|
|||
############################################
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
#..........................................#
|
||||
############################################
|
Loading…
Reference in a new issue