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)
|
||||
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…
Reference in a new issue