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