react frontend app (display screen, join)
This commit is contained in:
		
							parent
							
								
									1a20fa1fb6
								
							
						
					
					
						commit
						abdfdf2ec0
					
				
					 23 changed files with 3917 additions and 5 deletions
				
			
		
							
								
								
									
										6
									
								
								TanksServer/ControlsServer.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								TanksServer/ControlsServer.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| namespace TanksServer; | ||||
| 
 | ||||
| public class ControlsServer | ||||
| { | ||||
|      | ||||
| } | ||||
|  | @ -21,7 +21,7 @@ public class GameTickService(IEnumerable<ITickStep> steps) : IHostedService | |||
|         { | ||||
|             foreach (var step in _steps) | ||||
|                 await step.TickAsync(); | ||||
|             await Task.Delay(1000 / 250); | ||||
|             await Task.Delay(1000); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										18
									
								
								TanksServer/PlayerService.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								TanksServer/PlayerService.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| using System.Collections.Concurrent; | ||||
| using Microsoft.Extensions.Logging; | ||||
| 
 | ||||
| namespace TanksServer; | ||||
| 
 | ||||
| internal sealed class PlayerService(ILogger<PlayerService> logger) | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, Player> _players = new(); | ||||
| 
 | ||||
|     public Player GetOrAdd(string name) => _players.GetOrAdd(name, _ => new Player(name)); | ||||
| } | ||||
| 
 | ||||
| internal class Player(string name) | ||||
| { | ||||
|     public string Name => name; | ||||
| 
 | ||||
|     public Guid Id => Guid.NewGuid(); | ||||
| } | ||||
|  | @ -1,6 +1,8 @@ | |||
| using System.IO; | ||||
| using System.Text.Json.Serialization; | ||||
| using Microsoft.AspNetCore.Builder; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.FileProviders; | ||||
| 
 | ||||
|  | @ -12,13 +14,18 @@ internal static class Program | |||
|     { | ||||
|         var app = Configure(args); | ||||
| 
 | ||||
|         app.UseCors(); | ||||
|         app.UseWebSockets(); | ||||
| 
 | ||||
|         var clientScreenServer = app.Services.GetRequiredService<ClientScreenServer>(); | ||||
|         var playerService = app.Services.GetRequiredService<PlayerService>(); | ||||
| 
 | ||||
|         var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client")); | ||||
|         app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider }); | ||||
|         app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider }); | ||||
| 
 | ||||
|         app.UseWebSockets(); | ||||
|         app.MapGet("/player", playerService.GetOrAdd); | ||||
|          | ||||
|         app.Map("/screen", async context => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|  | @ -31,6 +38,19 @@ internal static class Program | |||
|             await clientScreenServer.HandleClient(ws); | ||||
|         }); | ||||
| 
 | ||||
|         app.Map("/controls", async (HttpContext context, [FromQuery] string playerId) => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|             { | ||||
|                 context.Response.StatusCode = StatusCodes.Status400BadRequest; | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await clientScreenServer.HandleClient(ws); | ||||
| 
 | ||||
|         }); | ||||
| 
 | ||||
|         await app.RunAsync(); | ||||
|     } | ||||
| 
 | ||||
|  | @ -38,18 +58,35 @@ internal static class Program | |||
|     { | ||||
|         var builder = WebApplication.CreateSlimBuilder(args); | ||||
| 
 | ||||
|         builder.Services.AddCors(options => options | ||||
|             .AddDefaultPolicy(policy => policy | ||||
|                 .AllowAnyHeader() | ||||
|                 .AllowAnyMethod() | ||||
|                 .AllowAnyOrigin()) | ||||
|         ); | ||||
| 
 | ||||
|         builder.Services.ConfigureHttpJsonOptions(options => | ||||
|         { | ||||
|             options.SerializerOptions.TypeInfoResolverChain.Insert(0, new AppSerializerContext()); | ||||
|         }); | ||||
| 
 | ||||
|         builder.Services.AddSingleton<ServicePointDisplay>(); | ||||
|         builder.Services.AddSingleton<MapService>(); | ||||
|          | ||||
| 
 | ||||
|         builder.Services.AddSingleton<MapDrawer>(); | ||||
|         builder.Services.AddSingleton<ITickStep, MapDrawer>(sp => sp.GetRequiredService<MapDrawer>()); | ||||
|          | ||||
| 
 | ||||
|         builder.Services.AddSingleton<ClientScreenServer>(); | ||||
|         builder.Services.AddHostedService<ClientScreenServer>(sp => sp.GetRequiredService<ClientScreenServer>()); | ||||
|         builder.Services.AddSingleton<ITickStep, ClientScreenServer>(sp => sp.GetRequiredService<ClientScreenServer>()); | ||||
| 
 | ||||
|         builder.Services.AddHostedService<GameTickService>(); | ||||
|          | ||||
| 
 | ||||
|         builder.Services.AddSingleton<PlayerService>(); | ||||
| 
 | ||||
|         return builder.Build(); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| [JsonSerializable(typeof(Player))] | ||||
| internal partial class AppSerializerContext: JsonSerializerContext; | ||||
|  |  | |||
							
								
								
									
										3
									
								
								tank-frontend/.env
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								tank-frontend/.env
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | |||
| VITE_TANK_SCREEN_URL=ws://localhost:3000/screen | ||||
| VITE_TANK_CONTROLS_URL=ws://localhost:3000/controls | ||||
| VITE_TANK_PLAYER_URL=http://localhost:3000/player | ||||
							
								
								
									
										42
									
								
								tank-frontend/.eslintrc.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								tank-frontend/.eslintrc.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| { | ||||
|     "root": true, | ||||
|     "env": { | ||||
|         "browser": true, | ||||
|         "es2021": true | ||||
|     }, | ||||
|     "extends":[ | ||||
|         "eslint:recommended", | ||||
|         "plugin:@typescript-eslint/recommended", | ||||
|         "plugin:react-hooks/recommended" | ||||
|     ], | ||||
|     "parser": "@typescript-eslint/parser", | ||||
|     "parserOptions": { | ||||
|         "ecmaVersion": "latest", | ||||
|         "sourceType": "module" | ||||
|     }, | ||||
|     "plugins": [ | ||||
|         "react-refresh" | ||||
|     ], | ||||
|     "rules": { | ||||
|         "react-refresh/only-export-components": [ | ||||
|             "warn", | ||||
|             {"allowConstantExport": true} | ||||
|         ], | ||||
|         "indent": [ | ||||
|             "error", | ||||
|             4 | ||||
|         ], | ||||
|         "linebreak-style": [ | ||||
|             "error", | ||||
|             "unix" | ||||
|         ], | ||||
|         "quotes": [ | ||||
|             "error", | ||||
|             "single" | ||||
|         ], | ||||
|         "semi": [ | ||||
|             "error", | ||||
|             "always" | ||||
|         ] | ||||
|     } | ||||
| } | ||||
							
								
								
									
										24
									
								
								tank-frontend/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								tank-frontend/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
| 
 | ||||
| node_modules | ||||
| dist | ||||
| dist-ssr | ||||
| *.local | ||||
| 
 | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| .DS_Store | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
							
								
								
									
										15
									
								
								tank-frontend/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								tank-frontend/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| <!doctype html> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <title>Tanks</title> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | ||||
| </head> | ||||
| <body> | ||||
|     <div id="root"></div> | ||||
| 
 | ||||
|     <script src="src/index.tsx" type="module"></script> | ||||
| 
 | ||||
|     <!-- | ||||
|     <div class="splash"></div> | ||||
|     --> | ||||
| </body> | ||||
							
								
								
									
										3280
									
								
								tank-frontend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3280
									
								
								tank-frontend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										29
									
								
								tank-frontend/package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								tank-frontend/package.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| { | ||||
|   "name": "tank-frontend", | ||||
|   "private": true, | ||||
|   "version": "0.0.0", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "tsc && vite build", | ||||
|     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-use-websocket": "^4.8.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/react": "^18.2.66", | ||||
|     "@types/react-dom": "^18.2.22", | ||||
|     "@typescript-eslint/eslint-plugin": "^7.2.0", | ||||
|     "@typescript-eslint/parser": "^7.2.0", | ||||
|     "@vitejs/plugin-react": "^4.2.1", | ||||
|     "eslint": "^8.57.0", | ||||
|     "eslint-plugin-react-hooks": "^4.6.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.6", | ||||
|     "typescript": "^5.2.2", | ||||
|     "vite": "^5.2.0" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										7
									
								
								tank-frontend/src/ClientScreen.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								tank-frontend/src/ClientScreen.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| 
 | ||||
| #screen { | ||||
|     aspect-ratio: calc(352 / 160); | ||||
|     flex-grow: 1; | ||||
|     object-fit: contain; | ||||
|     max-width: 100%; | ||||
| } | ||||
							
								
								
									
										91
									
								
								tank-frontend/src/ClientScreen.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								tank-frontend/src/ClientScreen.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,91 @@ | |||
| import useWebSocket, {ReadyState} from 'react-use-websocket'; | ||||
| import {useEffect, useRef} from 'react'; | ||||
| import './ClientScreen.css'; | ||||
| 
 | ||||
| const pixelsPerRow = 352; | ||||
| const pixelsPerCol = 160; | ||||
| 
 | ||||
| function getIndexes(bitIndex: number) { | ||||
|     return { | ||||
|         byteIndex: 10 + Math.floor(bitIndex / 8), | ||||
|         bitInByteIndex: 7 - bitIndex % 8 | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| function drawPixelsToCanvas(pixels: Uint8Array, canvas: HTMLCanvasElement) { | ||||
|     const drawContext = canvas.getContext('2d'); | ||||
|     if (!drawContext) | ||||
|         throw new Error('could not get draw context'); | ||||
| 
 | ||||
|     const imageData = drawContext.getImageData(0, 0, canvas.width, canvas.height, {colorSpace: 'srgb'}); | ||||
|     const data = imageData.data; | ||||
| 
 | ||||
|     console.log('draw', {width: canvas.width, height: canvas.height, dataLength: data.byteLength}); | ||||
| 
 | ||||
|     for (let y = 0; y < canvas.height; y++) { | ||||
|         const rowStartPixelIndex = y * pixelsPerRow; | ||||
|         for (let x = 0; x < canvas.width; x++) { | ||||
|             const pixelIndex = rowStartPixelIndex + x; | ||||
|             const {byteIndex, bitInByteIndex} = getIndexes(pixelIndex); | ||||
|             const byte = pixels[byteIndex]; | ||||
|             const mask = (1 << bitInByteIndex); | ||||
|             const bitCheck = byte & mask; | ||||
|             const isOn = bitCheck !== 0; | ||||
| 
 | ||||
|             const dataIndex = pixelIndex * 4; | ||||
|             if (isOn) { | ||||
|                 data[dataIndex] = 0; // r
 | ||||
|                 data[dataIndex + 1] = 180; // g
 | ||||
|                 data[dataIndex + 2] = 0; // b
 | ||||
|                 data[dataIndex + 3] = 255; // a
 | ||||
|             } else { | ||||
|                 data[dataIndex] = 0; // r
 | ||||
|                 data[dataIndex + 1] = 0; // g
 | ||||
|                 data[dataIndex + 2] = 0; // b
 | ||||
|                 data[dataIndex + 3] = 255; // a
 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     drawContext.putImageData(imageData, 0, 0); | ||||
| } | ||||
| 
 | ||||
| export default function ClientScreen({}: {}) { | ||||
|     const canvasRef = useRef<HTMLCanvasElement>(null); | ||||
| 
 | ||||
|     const { | ||||
|         readyState, | ||||
|         lastMessage, | ||||
|         sendMessage, | ||||
|         getWebSocket | ||||
|     } = useWebSocket(import.meta.env.VITE_TANK_SCREEN_URL, { | ||||
|         shouldReconnect: () => true, | ||||
|     }); | ||||
| 
 | ||||
|     const socket = getWebSocket(); | ||||
|     if (socket) | ||||
|         (socket as WebSocket).binaryType = 'arraybuffer'; | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (lastMessage === null) | ||||
|             return; | ||||
|         if (canvasRef.current === null) | ||||
|             throw new Error('canvas null'); | ||||
| 
 | ||||
|         drawPixelsToCanvas(new Uint8Array(lastMessage.data), canvasRef.current); | ||||
|         sendMessage(''); | ||||
|     }, [lastMessage, canvasRef.current]); | ||||
| 
 | ||||
|     const connectionStatus = { | ||||
|         [ReadyState.CONNECTING]: 'Connecting', | ||||
|         [ReadyState.OPEN]: 'Open', | ||||
|         [ReadyState.CLOSING]: 'Closing', | ||||
|         [ReadyState.CLOSED]: 'Closed', | ||||
|         [ReadyState.UNINSTANTIATED]: 'Uninstantiated', | ||||
|     }[readyState]; | ||||
| 
 | ||||
|     return <> | ||||
|         <span>The WebSocket is currently {connectionStatus}</span> | ||||
|         <canvas ref={canvasRef} id="screen" width={pixelsPerRow} height={pixelsPerCol}/> | ||||
|     </>; | ||||
| } | ||||
							
								
								
									
										63
									
								
								tank-frontend/src/Controls.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								tank-frontend/src/Controls.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,63 @@ | |||
| kbd { | ||||
|     background: hsl(0, 0%, 96%); | ||||
|     padding: 10px; | ||||
|     display: block; | ||||
|     border-radius: 5px; | ||||
|     margin: 5px; | ||||
|     width: 1.6em; | ||||
|     height: 1.3em; | ||||
|     text-align: center; | ||||
|     box-shadow: 0 1px rgba(0, 0, 0, .21); | ||||
|     user-select: none; | ||||
|     color: black; | ||||
| } | ||||
| 
 | ||||
| kbd.up { | ||||
|     margin-left: calc(1.6em + 35px); | ||||
| } | ||||
| 
 | ||||
| kbd.space { | ||||
|     margin-top: calc(1.3em + 35px); | ||||
|     width: 14em; | ||||
| } | ||||
| 
 | ||||
| kbd:active { | ||||
|     position: relative; | ||||
|     top: 2px; | ||||
|     background: hsl(0, 0%, 94%); | ||||
|     box-shadow: 0 1px rgba(0, 0, 0, .13) inset; | ||||
| } | ||||
| 
 | ||||
| .controls { | ||||
|     display: flex; | ||||
|     color: lightgray; | ||||
| } | ||||
| 
 | ||||
| .control { | ||||
|     margin: 0 1em; | ||||
| } | ||||
| 
 | ||||
| .control h3 { | ||||
|     text-align: center; | ||||
| } | ||||
| 
 | ||||
| .row { | ||||
|     display: flex; | ||||
| } | ||||
| 
 | ||||
| .splash { | ||||
|     position: absolute; | ||||
|     left: 0; | ||||
|     top: 0; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: radial-gradient(transparent, red); | ||||
|     opacity: 0; | ||||
|     pointer-events: none; | ||||
|     transition: opacity 500ms; | ||||
| } | ||||
| 
 | ||||
| .was-killed .splash { | ||||
|     opacity: 1; | ||||
|     transition: opacity 120ms; | ||||
| } | ||||
							
								
								
									
										73
									
								
								tank-frontend/src/Controls.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								tank-frontend/src/Controls.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | |||
| import './Controls.css'; | ||||
| import useWebSocket from 'react-use-websocket'; | ||||
| import {useEffect} from 'react'; | ||||
| 
 | ||||
| export default function Controls({playerId}: { | ||||
|     playerId: string | ||||
| }) { | ||||
|     const url = new URL(import.meta.env.VITE_TANK_CONTROLS_URL); | ||||
|     url.searchParams.set('playerId', playerId); | ||||
|     const { | ||||
|         sendMessage, | ||||
|         getWebSocket | ||||
|     } = useWebSocket(url.toString(), { | ||||
|         shouldReconnect: () => true, | ||||
|     }); | ||||
| 
 | ||||
|     const socket = getWebSocket(); | ||||
|     if (socket) | ||||
|         (socket as WebSocket).binaryType = 'arraybuffer'; | ||||
| 
 | ||||
|     const keyEventListener = (type: string) => (event: KeyboardEvent) => { | ||||
|         if (event.defaultPrevented) | ||||
|             return; | ||||
| 
 | ||||
|         const controls = { | ||||
|             'ArrowLeft': 'left', | ||||
|             'ArrowUp': 'up', | ||||
|             'ArrowRight': 'right', | ||||
|             'ArrowDown': 'down', | ||||
|             'Space': 'shoot', | ||||
|             'KeyW': 'up', | ||||
|             'KeyA': 'left', | ||||
|             'KeyS': 'down', | ||||
|             'KeyD': 'right', | ||||
|         }; | ||||
| 
 | ||||
|         // @ts-ignore
 | ||||
|         const value = controls[event.code]; | ||||
|         if (!value) | ||||
|             return; | ||||
| 
 | ||||
|         sendMessage(JSON.stringify({type, value})); | ||||
|     }; | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         const up = keyEventListener('input-off'); | ||||
|         const down = keyEventListener('input-on'); | ||||
|         window.onkeyup = up; | ||||
|         window.onkeydown = down; | ||||
|         return () => { | ||||
|             window.onkeydown = null; | ||||
|             window.onkeyup = null; | ||||
|         }; | ||||
|     }, []); | ||||
| 
 | ||||
|     return <div className="controls"> | ||||
|         <div className="control"> | ||||
|             <div className="row"> | ||||
|                 <kbd className="up">▲</kbd> | ||||
|             </div> | ||||
|             <div className="row"> | ||||
|                 <kbd>◀</kbd> | ||||
|                 <kbd>▼</kbd> | ||||
|                 <kbd>▶</kbd> | ||||
|             </div> | ||||
|             <h3>Move</h3> | ||||
|         </div> | ||||
|         <div className="control"> | ||||
|             <kbd className="space">Space</kbd> | ||||
|             <h3>Fire</h3> | ||||
|         </div> | ||||
|     </div>; | ||||
| } | ||||
							
								
								
									
										4
									
								
								tank-frontend/src/JoinForm.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								tank-frontend/src/JoinForm.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| .JoinForm { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
| } | ||||
							
								
								
									
										57
									
								
								tank-frontend/src/JoinForm.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								tank-frontend/src/JoinForm.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| import {useEffect, useState} from 'react'; | ||||
| import './JoinForm.css'; | ||||
| 
 | ||||
| type PlayerResponse = { | ||||
|     readonly name: string; | ||||
|     readonly id: string; | ||||
| }; | ||||
| 
 | ||||
| export async function fetchPlayer(name: string, options: RequestInit) { | ||||
|     const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL); | ||||
|     url.searchParams.set('name', name); | ||||
| 
 | ||||
|     const response = await fetch(url, options); | ||||
|     if (!response.ok) | ||||
|         return null; | ||||
| 
 | ||||
|     const json = await response.json() as PlayerResponse; | ||||
|     return json.id; | ||||
| } | ||||
| 
 | ||||
| export default function JoinForm({onDone}: { onDone: (id: string) => void }) { | ||||
|     const [name, setName] = useState(''); | ||||
|     const [clicked, setClicked] = useState(false); | ||||
|     const [data, setData] = useState<PlayerResponse | null>(null); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (!clicked || data) | ||||
|             return; | ||||
| 
 | ||||
|         try { | ||||
|             fetchPlayer(name, {}).then((value: string | null) => { | ||||
|                 if (value) | ||||
|                     onDone(value); | ||||
|                 else | ||||
|                     setClicked(false); | ||||
|             }); | ||||
|         } catch (e) { | ||||
|             console.log(e); | ||||
|             alert(e); | ||||
|         } | ||||
|     }, [clicked, setData, data]); | ||||
| 
 | ||||
|     const disableButtons = clicked || name.trim() === ''; | ||||
|     return <div className="JoinForm"> | ||||
|         <input | ||||
|             type="text" | ||||
|             value={name} | ||||
|             onChange={e => setName(e.target.value)} | ||||
|         /> | ||||
|         <button | ||||
|             onClick={() => setClicked(true)} | ||||
|             disabled={disableButtons} | ||||
|         > | ||||
|             join | ||||
|         </button> | ||||
|     </div>; | ||||
| } | ||||
							
								
								
									
										75
									
								
								tank-frontend/src/controls.js
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								tank-frontend/src/controls.js
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | |||
| import './controls.css'; | ||||
| 
 | ||||
| const body = document.querySelector('body'); | ||||
| const splash = document.querySelector('.splash'); | ||||
| 
 | ||||
| if (!splash || !body) | ||||
|     throw new Error('required element not found'); | ||||
| 
 | ||||
| splash.addEventListener('transitionend', function () { | ||||
|     body.classList.remove('was-killed'); | ||||
| }); | ||||
| 
 | ||||
| const connection = new WebSocket(`ws://${window.location.hostname}:3000`); | ||||
| connection.binaryType = 'blob'; | ||||
| 
 | ||||
| connection.onmessage = function (message) { | ||||
|     message = JSON.parse(message.data); | ||||
|     console.log('got message', {message}); | ||||
|     if (message.type === 'shot') | ||||
|         body.classList.add('was-killed'); | ||||
| }; | ||||
| 
 | ||||
| connection.onerror = event => { | ||||
|     console.log('error', event); | ||||
|     alert('connection error'); | ||||
| }; | ||||
| 
 | ||||
| connection.onclose = event => { | ||||
|     console.log('closed', event); | ||||
|     alert('connection closed - maybe a player with this name is already connected'); | ||||
| }; | ||||
| 
 | ||||
| const keyEventListener = (type) => (event) => { | ||||
|     if (event.defaultPrevented) | ||||
|         return; | ||||
| 
 | ||||
|     const controls = { | ||||
|         'ArrowLeft': 'left', | ||||
|         'ArrowUp': 'up', | ||||
|         'ArrowRight': 'right', | ||||
|         'ArrowDown': 'down', | ||||
|         'Space': 'shoot', | ||||
|         'KeyW': 'up', | ||||
|         'KeyA': 'left', | ||||
|         'KeyS': 'down', | ||||
|         'KeyD': 'right', | ||||
|     }; | ||||
| 
 | ||||
|     const value = controls[event.code]; | ||||
|     if (!value) | ||||
|         return; | ||||
| 
 | ||||
|     send({type, value}); | ||||
| }; | ||||
| 
 | ||||
| connection.onopen = () => { | ||||
|     let name = getPlayerName(); | ||||
|     send({type: 'name', value: name}); | ||||
| 
 | ||||
|     window.onkeyup = keyEventListener('input-off'); | ||||
|     window.onkeydown = keyEventListener('input-on'); | ||||
| 
 | ||||
|     console.log('connection opened, game ready'); | ||||
| }; | ||||
| 
 | ||||
| function getPlayerName() { | ||||
|     let name; | ||||
|     while (!name) | ||||
|         name = prompt('Player Name'); | ||||
|     return name; | ||||
| } | ||||
| 
 | ||||
| function send(obj) { | ||||
|     connection.send(JSON.stringify(obj)); | ||||
| } | ||||
							
								
								
									
										22
									
								
								tank-frontend/src/index.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								tank-frontend/src/index.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| 
 | ||||
| html, body { | ||||
|     height: 100%; | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|     font-family: sans-serif; | ||||
|     margin: 0; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| 
 | ||||
|     background-color: white; | ||||
| } | ||||
| 
 | ||||
| #root { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
| } | ||||
							
								
								
									
										22
									
								
								tank-frontend/src/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								tank-frontend/src/index.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| import React, {useState} from 'react'; | ||||
| import './index.css'; | ||||
| import ClientScreen from './ClientScreen'; | ||||
| import Controls from './Controls.tsx'; | ||||
| import JoinForm from './JoinForm.tsx'; | ||||
| import {createRoot} from 'react-dom/client'; | ||||
| 
 | ||||
| function App() { | ||||
|     const [id, setId] = useState<string | null>(null); | ||||
| 
 | ||||
|     return <> | ||||
|         {id === null && <JoinForm onDone={name => setId(name)}/>} | ||||
|         <ClientScreen/> | ||||
|         {id == null || <Controls playerId={id}/>} | ||||
|     </>; | ||||
| } | ||||
| 
 | ||||
| createRoot(document.getElementById('root')!).render( | ||||
|     <React.StrictMode> | ||||
|         <App/> | ||||
|     </React.StrictMode> | ||||
| ); | ||||
							
								
								
									
										1
									
								
								tank-frontend/src/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								tank-frontend/src/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| /// <reference types="vite/client" />
 | ||||
							
								
								
									
										25
									
								
								tank-frontend/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								tank-frontend/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
| 
 | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "resolveJsonModule": true, | ||||
|     "isolatedModules": true, | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
| 
 | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "noFallthroughCasesInSwitch": true | ||||
|   }, | ||||
|   "include": ["src"], | ||||
|   "references": [{ "path": "./tsconfig.node.json" }] | ||||
| } | ||||
							
								
								
									
										11
									
								
								tank-frontend/tsconfig.node.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								tank-frontend/tsconfig.node.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "composite": true, | ||||
|     "skipLibCheck": true, | ||||
|     "module": "ESNext", | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowSyntheticDefaultImports": true, | ||||
|     "strict": true | ||||
|   }, | ||||
|   "include": ["vite.config.ts"] | ||||
| } | ||||
							
								
								
									
										7
									
								
								tank-frontend/vite.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								tank-frontend/vite.config.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| import { defineConfig } from 'vite' | ||||
| import react from '@vitejs/plugin-react' | ||||
| 
 | ||||
| // https://vitejs.dev/config/
 | ||||
| export default defineConfig({ | ||||
|   plugins: [react()], | ||||
| }) | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Vinzenz Schroeter
						Vinzenz Schroeter