theme editor (#20)
This commit is contained in:
		
						commit
						a7ee3855f5
					
				
					 23 changed files with 581 additions and 180 deletions
				
			
		|  | @ -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…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 RobbersDaughter
						RobbersDaughter