react frontend app (display screen, join)

This commit is contained in:
Vinzenz Schroeter 2024-04-07 00:33:54 +02:00
parent 1a20fa1fb6
commit abdfdf2ec0
23 changed files with 3917 additions and 5 deletions

View file

@ -0,0 +1,6 @@
namespace TanksServer;
public class ControlsServer
{
}

View file

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

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

View file

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

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

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -0,0 +1,7 @@
#screen {
aspect-ratio: calc(352 / 160);
flex-grow: 1;
object-fit: contain;
max-width: 100%;
}

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

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

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

View file

@ -0,0 +1,4 @@
.JoinForm {
display: flex;
flex-direction: row;
}

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

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

View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})