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
|
- runs all game logic
|
||||||
- sends image and text to the service point display
|
- sends image and text to the service point display
|
||||||
- sends image to clients
|
- sends image to clients
|
||||||
- currently, the game has a fixed tick and frame rate of 25/s
|
- 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
|
- 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
|
- 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
|
- 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 ClientScreen from './ClientScreen';
|
||||||
import Controls from './Controls.tsx';
|
import Controls from './Controls.tsx';
|
||||||
import JoinForm from './JoinForm.tsx';
|
import JoinForm from './JoinForm.tsx';
|
||||||
|
@ -7,23 +9,22 @@ import Row from './components/Row.tsx';
|
||||||
import Scoreboard from './Scoreboard.tsx';
|
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 {ThemeProvider} from './theme.tsx';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import {ThemeContext, getRandomTheme, useStoredTheme} from './theme.ts';
|
import ThemeChooser from './ThemeChooser.tsx';
|
||||||
import {useState} from 'react';
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [theme, setTheme] = useStoredTheme();
|
|
||||||
const [name, setName] = useState<string | null>(null);
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={theme}>
|
return <ThemeProvider>
|
||||||
<Column className="flex-grow">
|
<Column className="flex-grow">
|
||||||
|
|
||||||
<ClientScreen 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())}/>
|
<ThemeChooser/>
|
||||||
<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="⌂ source"/>
|
text="⌂ source"/>
|
||||||
|
@ -40,5 +41,5 @@ export default function App() {
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
</Column>
|
</Column>
|
||||||
</ThemeContext.Provider>;
|
</ThemeProvider>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {useMutation} from '@tanstack/react-query';
|
||||||
|
|
||||||
import {makeApiUrl} from './serverCalls';
|
import {makeApiUrl} from './serverCalls';
|
||||||
import Button from './components/Button.tsx';
|
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 Dialog from './components/Dialog.tsx';
|
||||||
import './JoinForm.css';
|
import './JoinForm.css';
|
||||||
|
|
||||||
|
@ -30,12 +30,11 @@ export default function JoinForm({onDone}: {
|
||||||
|
|
||||||
const confirm = () => postPlayer.mutate({name});
|
const confirm = () => postPlayer.mutate({name});
|
||||||
|
|
||||||
return <Dialog className="JoinForm">
|
return <Dialog className="JoinForm" title="Enter your name to play">
|
||||||
<h3> Enter your name to play </h3>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
value={name}
|
value={name}
|
||||||
placeholder="player name"
|
placeholder="player name"
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={n => setName(n)}
|
||||||
onEnter={confirm}
|
onEnter={confirm}
|
||||||
/>
|
/>
|
||||||
<Button
|
<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 {
|
.MapChooser-Row {
|
||||||
flex-wrap: wrap;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.MapChooser-Preview {
|
.MapChooser-Preview {
|
||||||
|
@ -21,3 +14,7 @@
|
||||||
.MapChooser-Preview-Highlight {
|
.MapChooser-Preview-Highlight {
|
||||||
border-color: var(--color-secondary);
|
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 Button from './components/Button.tsx';
|
||||||
import Row from './components/Row.tsx';
|
import Row from './components/Row.tsx';
|
||||||
import './MapChooser.css';
|
import './MapChooser.css';
|
||||||
|
import Spacer from './components/Spacer.tsx';
|
||||||
|
|
||||||
function base64ToArrayBuffer(base64: string) {
|
function base64ToArrayBuffer(base64: string) {
|
||||||
const binaryString = atob(base64);
|
const binaryString = atob(base64);
|
||||||
|
@ -57,8 +58,11 @@ function MapChooserDialog({mapNames, onClose, onConfirm}: {
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [chosenMap, setChosenMap] = useState<string>();
|
const [chosenMap, setChosenMap] = useState<string>();
|
||||||
return <Dialog>
|
return <Dialog
|
||||||
<h3>Choose a map</h3>
|
className='MapChooser-Dialog'
|
||||||
|
title="Choose a map"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
<Row className="MapChooser-Row overflow-scroll">
|
<Row className="MapChooser-Row overflow-scroll">
|
||||||
{mapNames.map(name => <MapPreview
|
{mapNames.map(name => <MapPreview
|
||||||
key={name}
|
key={name}
|
||||||
|
@ -68,7 +72,7 @@ function MapChooserDialog({mapNames, onClose, onConfirm}: {
|
||||||
/>)}
|
/>)}
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<div className="flex-grow"/>
|
<Spacer/>
|
||||||
<Button text="« cancel" onClick={onClose}/>
|
<Button text="« cancel" onClick={onClose}/>
|
||||||
<Button text="√ confirm" disabled={!chosenMap} onClick={() => chosenMap && onConfirm(chosenMap)}/>
|
<Button text="√ confirm" disabled={!chosenMap} onClick={() => chosenMap && onConfirm(chosenMap)}/>
|
||||||
</Row>
|
</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 {ReactNode} from 'react';
|
||||||
import Column from './Column.tsx';
|
import Column from './Column.tsx';
|
||||||
|
import Row from './Row.tsx';
|
||||||
|
import Button from './Button.tsx';
|
||||||
import './Dialog.css';
|
import './Dialog.css';
|
||||||
|
|
||||||
export default function Dialog({children, className}: {
|
export default function Dialog({children, className, title, onClose}: {
|
||||||
children: ReactNode;
|
title?: string;
|
||||||
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return <Column className={'Dialog overflow-scroll ' + (className ?? '')}>
|
return <Column className={'Dialog ' + (className ?? '')}>
|
||||||
{children}
|
<Row>
|
||||||
</Column>
|
<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 {useEffect, useRef} from 'react';
|
||||||
import {useContext, useEffect, useRef} from 'react';
|
|
||||||
import './PixelGridCanvas.css';
|
import './PixelGridCanvas.css';
|
||||||
|
import {useRgbaTheme} from '../theme.tsx';
|
||||||
|
|
||||||
const pixelsPerRow = 352;
|
const pixelsPerRow = 352;
|
||||||
const pixelsPerCol = 160;
|
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) {
|
function parseAdditionalDataNibble(nibble: number) {
|
||||||
const isPlayerMask = 1;
|
const isPlayerMask = 1;
|
||||||
const entityTypeMask = 12;
|
const entityTypeMask = 12;
|
||||||
|
@ -107,7 +101,7 @@ function drawPixelsToCanvas(
|
||||||
export default function PixelGridCanvas({pixels}: {
|
export default function PixelGridCanvas({pixels}: {
|
||||||
readonly pixels: Uint8ClampedArray;
|
readonly pixels: Uint8ClampedArray;
|
||||||
}) {
|
}) {
|
||||||
const theme = useContext(ThemeContext);
|
const theme = useRgbaTheme();
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -137,12 +131,12 @@ export default function PixelGridCanvas({pixels}: {
|
||||||
pixels,
|
pixels,
|
||||||
additional: additionalData,
|
additional: additionalData,
|
||||||
colors: {
|
colors: {
|
||||||
background: normalizeColor(drawContext, hslToString(theme.background)),
|
background: theme.background,
|
||||||
foreground: normalizeColor(drawContext, hslToString(theme.primary)),
|
foreground: theme.primary,
|
||||||
player: normalizeColor(drawContext, hslToString(theme.secondary)),
|
player: theme.secondary,
|
||||||
tanks: normalizeColor(drawContext, hslToString(theme.tertiary)),
|
tanks: theme.tertiary,
|
||||||
powerUps: normalizeColor(drawContext, hslToString(theme.tertiary)),
|
powerUps: theme.tertiary,
|
||||||
bullets: normalizeColor(drawContext, hslToString(theme.tertiary))
|
bullets: theme.tertiary
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import {ReactNode} from "react";
|
import {ReactNode} from 'react';
|
||||||
|
|
||||||
import './Row.css';
|
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 ?? '')}>
|
return <div className={'Row flex-row ' + (className ?? '')}>
|
||||||
{children}
|
{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.Builder;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using TanksServer.GameLogic;
|
using TanksServer.GameLogic;
|
||||||
using TanksServer.Interactivity;
|
using TanksServer.Interactivity;
|
||||||
|
|
||||||
|
@ -25,6 +30,11 @@ internal sealed class Endpoints(
|
||||||
app.MapGet("/map", () => mapService.MapNames);
|
app.MapGet("/map", () => mapService.MapNames);
|
||||||
app.MapPost("/map", PostMap);
|
app.MapPost("/map", PostMap);
|
||||||
app.MapGet("/map/{name}", GetMapByName);
|
app.MapGet("/map/{name}", GetMapByName);
|
||||||
|
|
||||||
|
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||||
|
{
|
||||||
|
ResponseWriter = WriteJsonHealthCheckResponse
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Results<BadRequest<string>, NotFound<string>, Ok> PostMap([FromQuery] string name)
|
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);
|
var mapInfo = new MapInfo(prototype.Name, prototype.GetType().Name, preview.Data);
|
||||||
return TypedResults.Ok(mapInfo);
|
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.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using DisplayCommands;
|
using DisplayCommands;
|
||||||
|
@ -13,8 +14,8 @@ 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> _mapPrototypes = new();
|
private readonly ConcurrentDictionary<string, MapPrototype> _mapPrototypes = new();
|
||||||
private readonly Dictionary<string, PixelGrid> _mapPreviews = new();
|
private readonly ConcurrentDictionary<string, PixelGrid> _mapPreviews = new();
|
||||||
|
|
||||||
public IEnumerable<string> MapNames => _mapPrototypes.Keys;
|
public IEnumerable<string> MapNames => _mapPrototypes.Keys;
|
||||||
|
|
||||||
|
@ -52,7 +53,8 @@ internal sealed class MapService
|
||||||
{
|
{
|
||||||
var name = MapNameFromFilePath(file);
|
var name = MapNameFromFilePath(file);
|
||||||
var prototype = new SpriteMapPrototype(name, Sprite.FromImageFile(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)
|
private void LoadMapString(string file)
|
||||||
|
@ -60,7 +62,7 @@ internal sealed class MapService
|
||||||
var name = MapNameFromFilePath(file);
|
var name = MapNameFromFilePath(file);
|
||||||
var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim();
|
var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim();
|
||||||
var prototype = new TextMapPrototype(name, map);
|
var prototype = new TextMapPrototype(name, map);
|
||||||
_mapPrototypes.Add(name, prototype);
|
_mapPrototypes.TryAdd(name, prototype);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string MapNameFromFilePath(string filePath) => Path.GetFileName(filePath).ToUpperInvariant();
|
private static string MapNameFromFilePath(string filePath) => Path.GetFileName(filePath).ToUpperInvariant();
|
||||||
|
|
|
@ -1,13 +1,42 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
namespace TanksServer.GameLogic;
|
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 LongTime = TimeSpan.FromSeconds(5);
|
||||||
private static readonly TimeSpan CriticalUpdateTime = TimeSpan.FromMilliseconds(50);
|
private static readonly TimeSpan CriticalUpdateTime = TimeSpan.FromMilliseconds(50);
|
||||||
|
|
||||||
private readonly Stopwatch _long = Stopwatch.StartNew();
|
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 ulong _updatesSinceLongReset;
|
||||||
private TimeSpan _minFrameTime = TimeSpan.MaxValue;
|
private TimeSpan _minFrameTime = TimeSpan.MaxValue;
|
||||||
private TimeSpan _maxFrameTime = TimeSpan.MinValue;
|
private TimeSpan _maxFrameTime = TimeSpan.MinValue;
|
||||||
|
@ -40,16 +69,20 @@ internal sealed class UpdatesPerSecondCounter(ILogger<UpdatesPerSecondCounter> l
|
||||||
|
|
||||||
private void LogCounters()
|
private void LogCounters()
|
||||||
{
|
{
|
||||||
|
var time = _long.Elapsed;
|
||||||
|
|
||||||
|
_currentStatistics = new Statistics(
|
||||||
|
_updatesSinceLongReset,
|
||||||
|
time,
|
||||||
|
_updatesSinceLongReset / time.TotalSeconds,
|
||||||
|
_minFrameTime,
|
||||||
|
time / _updatesSinceLongReset,
|
||||||
|
_maxFrameTime);
|
||||||
|
|
||||||
if (!logger.IsEnabled(LogLevel.Debug))
|
if (!logger.IsEnabled(LogLevel.Debug))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var time = _long.Elapsed;
|
logger.LogDebug("statistics: {}", _currentStatistics);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResetCounters()
|
private void ResetCounters()
|
||||||
|
@ -59,4 +92,23 @@ internal sealed class UpdatesPerSecondCounter(ILogger<UpdatesPerSecondCounter> l
|
||||||
_minFrameTime = TimeSpan.MaxValue;
|
_minFrameTime = TimeSpan.MaxValue;
|
||||||
_maxFrameTime = TimeSpan.MinValue;
|
_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(_ => { });
|
builder.Services.AddHttpLogging(_ => { });
|
||||||
|
|
||||||
|
var healthCheckBuilder = builder.Services.AddHealthChecks();
|
||||||
|
healthCheckBuilder.AddCheck<UpdatesPerSecondCounter>("updates check");
|
||||||
|
|
||||||
builder.Services.Configure<HostConfiguration>(builder.Configuration.GetSection("Host"));
|
builder.Services.Configure<HostConfiguration>(builder.Configuration.GetSection("Host"));
|
||||||
var hostConfiguration = builder.Configuration.GetSection("Host").Get<HostConfiguration>();
|
var hostConfiguration = builder.Configuration.GetSection("Host").Get<HostConfiguration>();
|
||||||
if (hostConfiguration == null)
|
if (hostConfiguration == null)
|
||||||
|
@ -66,12 +69,14 @@ public static class Program
|
||||||
builder.Services.AddSingleton<BufferPool>();
|
builder.Services.AddSingleton<BufferPool>();
|
||||||
builder.Services.AddSingleton<EmptyTileFinder>();
|
builder.Services.AddSingleton<EmptyTileFinder>();
|
||||||
builder.Services.AddSingleton<ChangeToRequestedMap>();
|
builder.Services.AddSingleton<ChangeToRequestedMap>();
|
||||||
|
builder.Services.AddSingleton<UpdatesPerSecondCounter>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<GameTickWorker>();
|
builder.Services.AddHostedService<GameTickWorker>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ControlsServer>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<ControlsServer>());
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ClientScreenServer>());
|
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, MoveBullets>();
|
||||||
builder.Services.AddSingleton<ITickStep, CollideBullets>();
|
builder.Services.AddSingleton<ITickStep, CollideBullets>();
|
||||||
builder.Services.AddSingleton<ITickStep, RotateTanks>();
|
builder.Services.AddSingleton<ITickStep, RotateTanks>();
|
||||||
|
@ -82,7 +87,8 @@ public static class Program
|
||||||
builder.Services.AddSingleton<ITickStep, SpawnPowerUp>();
|
builder.Services.AddSingleton<ITickStep, SpawnPowerUp>();
|
||||||
builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>();
|
builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>();
|
||||||
builder.Services.AddSingleton<ITickStep, PlayerServer>(sp => sp.GetRequiredService<PlayerServer>());
|
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, DrawMapStep>();
|
||||||
builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>();
|
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