theme editor (#20)

This commit is contained in:
RobbersDaughter 2024-05-14 22:32:50 +02:00 committed by GitHub
commit a7ee3855f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 581 additions and 180 deletions

View file

@ -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

View file

@ -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>;
} }

View file

@ -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

View file

@ -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%;
}

View file

@ -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>

View file

@ -0,0 +1,6 @@
.HslEditor-Inputs {
display: grid;
column-gap: var(--padding-normal);
grid-template-columns: auto auto auto;
grid-template-rows: auto;
}

View 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)}/>}
</>;
}

View file

@ -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>;
} }

View file

@ -0,0 +1,9 @@
.Input {
background-color: var(--color-background);
color: var(--color-text);
padding: var(--padding-normal);
}
.RangeInput {
appearance: auto;
}

View 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();
}}
/>;
}

View file

@ -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
} }
}); });
}; };

View file

@ -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>;
} }

View file

@ -0,0 +1,4 @@
export default function Spacer() {
return <div className='flex-grow' />;
}

View file

@ -1,3 +0,0 @@
.TextInput {
padding: var(--padding-normal);
}

View file

@ -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();
}}
/>;
}

View file

@ -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
View 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>;
}

View file

@ -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()));
}
} }

View file

@ -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();

View file

@ -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()));
}
} }

View file

@ -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>();

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,20 @@
############################################
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
#..........................................#
############################################