react frontend app (display screen, join)
This commit is contained in:
parent
1a20fa1fb6
commit
abdfdf2ec0
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)
|
foreach (var step in _steps)
|
||||||
await step.TickAsync();
|
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.IO;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
|
||||||
|
@ -12,13 +14,18 @@ internal static class Program
|
||||||
{
|
{
|
||||||
var app = Configure(args);
|
var app = Configure(args);
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
app.UseWebSockets();
|
||||||
|
|
||||||
var clientScreenServer = app.Services.GetRequiredService<ClientScreenServer>();
|
var clientScreenServer = app.Services.GetRequiredService<ClientScreenServer>();
|
||||||
|
var playerService = app.Services.GetRequiredService<PlayerService>();
|
||||||
|
|
||||||
var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client"));
|
var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client"));
|
||||||
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider });
|
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider });
|
||||||
app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider });
|
app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider });
|
||||||
|
|
||||||
app.UseWebSockets();
|
app.MapGet("/player", playerService.GetOrAdd);
|
||||||
|
|
||||||
app.Map("/screen", async context =>
|
app.Map("/screen", async context =>
|
||||||
{
|
{
|
||||||
if (!context.WebSockets.IsWebSocketRequest)
|
if (!context.WebSockets.IsWebSocketRequest)
|
||||||
|
@ -31,6 +38,19 @@ internal static class Program
|
||||||
await clientScreenServer.HandleClient(ws);
|
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();
|
await app.RunAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,18 +58,35 @@ internal static class Program
|
||||||
{
|
{
|
||||||
var builder = WebApplication.CreateSlimBuilder(args);
|
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<ServicePointDisplay>();
|
||||||
builder.Services.AddSingleton<MapService>();
|
builder.Services.AddSingleton<MapService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<MapDrawer>();
|
builder.Services.AddSingleton<MapDrawer>();
|
||||||
builder.Services.AddSingleton<ITickStep, MapDrawer>(sp => sp.GetRequiredService<MapDrawer>());
|
builder.Services.AddSingleton<ITickStep, MapDrawer>(sp => sp.GetRequiredService<MapDrawer>());
|
||||||
|
|
||||||
builder.Services.AddSingleton<ClientScreenServer>();
|
builder.Services.AddSingleton<ClientScreenServer>();
|
||||||
builder.Services.AddHostedService<ClientScreenServer>(sp => sp.GetRequiredService<ClientScreenServer>());
|
builder.Services.AddHostedService<ClientScreenServer>(sp => sp.GetRequiredService<ClientScreenServer>());
|
||||||
builder.Services.AddSingleton<ITickStep, ClientScreenServer>(sp => sp.GetRequiredService<ClientScreenServer>());
|
builder.Services.AddSingleton<ITickStep, ClientScreenServer>(sp => sp.GetRequiredService<ClientScreenServer>());
|
||||||
|
|
||||||
builder.Services.AddHostedService<GameTickService>();
|
builder.Services.AddHostedService<GameTickService>();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<PlayerService>();
|
||||||
|
|
||||||
return builder.Build();
|
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…
Reference in a new issue