automatic rejoin on reload or server restart

This commit is contained in:
Vinzenz Schroeter 2024-04-13 19:50:37 +02:00
parent feaf96c10e
commit 64a61ef2b3
11 changed files with 141 additions and 58 deletions

View file

@ -5,4 +5,5 @@ namespace TanksServer.Interactivity;
[JsonSerializable(typeof(Player))] [JsonSerializable(typeof(Player))]
[JsonSerializable(typeof(IEnumerable<Player>))] [JsonSerializable(typeof(IEnumerable<Player>))]
[JsonSerializable(typeof(Guid))] [JsonSerializable(typeof(Guid))]
[JsonSerializable(typeof(NameId))]
internal sealed partial class AppSerializerContext : JsonSerializerContext; internal sealed partial class AppSerializerContext : JsonSerializerContext;

View file

@ -11,6 +11,8 @@ using TanksServer.Interactivity;
namespace TanksServer; namespace TanksServer;
internal sealed record class NameId(string Name, Guid Id);
public static class Program public static class Program
{ {
public static void Main(string[] args) public static void Main(string[] args)
@ -29,7 +31,7 @@ public static class Program
{ {
var player = playerService.GetOrAdd(name, id); var player = playerService.GetOrAdd(name, id);
return player != null return player != null
? Results.Ok(player.Id) ? Results.Ok(new NameId(name, id))
: Results.Unauthorized(); : Results.Unauthorized();
}); });
app.MapGet("/player", ([FromQuery] Guid id) => app.MapGet("/player", ([FromQuery] Guid id) =>

View file

@ -1,7 +1,7 @@
#VITE_TANK_SCREEN_URL=ws://172.23.43.79/screen TANK_DOMAIN=vinzenz-lpt2
#VITE_TANK_CONTROLS_URL=ws://172.23.43.79/controls VITE_TANK_API=http://$TANK_DOMAIN
#VITE_TANK_PLAYER_URL=http://172.23.43.79/player VITE_TANK_WS=ws://$TANK_DOMAIN
VITE_TANK_SCREEN_URL=ws://vinzenz-lpt2/screen VITE_TANK_SCREEN_URL=$VITE_TANK_WS/screen
VITE_TANK_CONTROLS_URL=ws://vinzenz-lpt2/controls VITE_TANK_CONTROLS_URL=$VITE_TANK_WS/controls
VITE_TANK_PLAYER_URL=http://vinzenz-lpt2/player VITE_TANK_PLAYER_URL=$VITE_TANK_API/player

View file

@ -41,7 +41,7 @@ function drawPixelsToCanvas(pixels: Uint8Array, canvas: HTMLCanvasElement) {
drawContext.putImageData(imageData, 0, 0); drawContext.putImageData(imageData, 0, 0);
} }
export default function ClientScreen({}: {}) { export default function ClientScreen({logout}: {logout: () => void}) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const { const {
@ -50,7 +50,7 @@ export default function ClientScreen({}: {}) {
sendMessage, sendMessage,
getWebSocket getWebSocket
} = useWebSocket(import.meta.env.VITE_TANK_SCREEN_URL, { } = useWebSocket(import.meta.env.VITE_TANK_SCREEN_URL, {
shouldReconnect: () => true, onError: logout
}); });
const socket = getWebSocket(); const socket = getWebSocket();

View file

@ -3,8 +3,9 @@ import useWebSocket from 'react-use-websocket';
import {useEffect} from 'react'; import {useEffect} from 'react';
import {statusTextForReadyState} from './statusTextForReadyState.tsx'; import {statusTextForReadyState} from './statusTextForReadyState.tsx';
export default function Controls({playerId}: { export default function Controls({playerId, logout}: {
playerId: string playerId: string,
logout: () => void
}) { }) {
const url = new URL(import.meta.env.VITE_TANK_CONTROLS_URL); const url = new URL(import.meta.env.VITE_TANK_CONTROLS_URL);
url.searchParams.set('playerId', playerId); url.searchParams.set('playerId', playerId);
@ -13,7 +14,7 @@ export default function Controls({playerId}: {
getWebSocket, getWebSocket,
readyState readyState
} = useWebSocket(url.toString(), { } = useWebSocket(url.toString(), {
shouldReconnect: () => true, onError: logout
}); });
const socket = getWebSocket(); const socket = getWebSocket();

View file

@ -0,0 +1,3 @@
export type Guid = `${string}-${string}-${string}-${string}-${string}`;
export const EmptyGuid: Guid = '00000000-0000-0000-0000-000000000000';

View file

@ -1,8 +1,12 @@
import { useEffect, useState } from 'react'; import {useEffect, useState} from 'react';
import './JoinForm.css'; import './JoinForm.css';
import { PlayerResponse, postPlayer } from './serverCalls'; import {NameId, PlayerResponse, postPlayer} from './serverCalls';
import {Guid} from './Guid.ts';
export default function JoinForm({ onDone }: { onDone: (id: string) => void }) { export default function JoinForm({setNameId, clientId}: {
setNameId: (mutator: (oldState: NameId) => NameId) => void,
clientId: Guid
}) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [clicked, setClicked] = useState(false); const [clicked, setClicked] = useState(false);
const [data, setData] = useState<PlayerResponse | null>(null); const [data, setData] = useState<PlayerResponse | null>(null);
@ -12,10 +16,10 @@ export default function JoinForm({ onDone }: { onDone: (id: string) => void }) {
return; return;
try { try {
postPlayer(name) postPlayer({name, id: clientId})
.then((value: PlayerResponse | null) => { .then(value => {
if (value) if (value)
onDone(value.id); setNameId(prev => ({...prev, ...value}));
else else
setClicked(false); setClicked(false);
}); });
@ -23,22 +27,22 @@ export default function JoinForm({ onDone }: { onDone: (id: string) => void }) {
console.log(e); console.log(e);
alert(e); alert(e);
} }
}, [clicked, setData, data]); }, [clicked, setData, data, clientId, setClicked, setNameId]);
const disableButtons = clicked || name.trim() === ''; const disableButtons = clicked || name.trim() === '';
return <div className='TankWelcome'> return <div className="TankWelcome">
<h1 className='JoinElems' style={{ "color": "white" }}> <h1 className="JoinElems" style={{'color': 'white'}}>
Tanks Tanks
</h1> </h1>
<p className='JoinElems' style={{ "color": "white" }}> Welcome and have fun!</p> <p className="JoinElems" style={{'color': 'white'}}> Welcome and have fun!</p>
<div className="JoinForm"> <div className="JoinForm">
<p className='JoinElems' style={{ "color": "white" }}> <p className="JoinElems" style={{'color': 'white'}}>
Enter your name to join the game! Enter your name to join the game!
</p> </p>
<input className="JoinElems" <input className="JoinElems"
type="text" type="text"
value={name} value={name}
placeholder='player name' placeholder="player name"
onChange={e => setName(e.target.value)} onChange={e => setName(e.target.value)}
/> />
<button className="JoinElems" <button className="JoinElems"

View file

@ -1,12 +1,17 @@
import { useEffect, useState } from 'react'; import {useEffect, useState} from 'react';
import './PlayerInfo.css' import './PlayerInfo.css'
import { PlayerResponse, getPlayer } from './serverCalls'; import {PlayerResponse, getPlayer} from './serverCalls';
export default function PlayerInfo({ playerId }: { playerId: string }) { export default function PlayerInfo({playerId, logout}: { playerId: string, logout: () => void }) {
const [player, setPlayer] = useState<PlayerResponse | null>(); const [player, setPlayer] = useState<PlayerResponse | null>();
const refresh = () => { const refresh = () => {
getPlayer(playerId).then(setPlayer); getPlayer(playerId).then(value => {
if (value)
setPlayer(value);
else
logout();
});
}; };
useEffect(() => { useEffect(() => {
@ -15,34 +20,34 @@ export default function PlayerInfo({ playerId }: { playerId: string }) {
}); });
return <div className='TankWelcome'> return <div className='TankWelcome'>
<h1 className='Elems' style={{ "color": "white" }}> <h1 className='Elems' style={{"color": "white"}}>
Tanks Tanks
</h1> </h1>
<div className="ScoreForm"> <div className="ScoreForm">
<div className='ElemGroup'> <div className='ElemGroup'>
<p className='Elems' style={{ "color": "white" }}> <p className='Elems' style={{"color": "white"}}>
name: name:
</p> </p>
<p className='Elems' style={{ "color": "white" }}> <p className='Elems' style={{"color": "white"}}>
{player?.name} {player?.name}
</p> </p>
</div> </div>
<div className='ElemGroup'> <div className='ElemGroup'>
<p className='Elems' style={{ "color": "white" }}> <p className='Elems' style={{"color": "white"}}>
kills: kills:
</p> </p>
<p className='Elems' style={{ "color": "white" }}> <p className='Elems' style={{"color": "white"}}>
{player?.kills} {player?.scores.kills}
</p> </p>
</div> </div>
<div className='ElemGroup'> <div className='ElemGroup'>
<p className='Elems' style={{ "color": "white" }}> <p className='Elems' style={{"color": "white"}}>
deaths: deaths:
</p> </p>
<p className='Elems' style={{ "color": "white" }}> <p className='Elems' style={{"color": "white"}}>
{player?.deaths} {player?.scores.deaths}
</p> </p>
</div> </div>
</div> </div>
</div >; </div>;
} }

View file

@ -1,24 +1,39 @@
import React, { useState } from 'react'; import React, {useCallback, useState} from 'react';
import './index.css'; import './index.css';
import ClientScreen from './ClientScreen'; import ClientScreen from './ClientScreen';
import Controls from './Controls.tsx'; import Controls from './Controls.tsx';
import JoinForm from './JoinForm.tsx'; import JoinForm from './JoinForm.tsx';
import { createRoot } from 'react-dom/client'; import {createRoot} from 'react-dom/client';
import PlayerInfo from './PlayerInfo.tsx'; import PlayerInfo from './PlayerInfo.tsx';
import {useStoredObjectState} from './useStoredState.ts';
import {NameId, postPlayer} from './serverCalls.tsx';
function App() { function App() {
const [id, setId] = useState<string | null>(null); const [nameId, setNameId] = useStoredObjectState<NameId>('access', () => ({
id: crypto.randomUUID(),
name: ''
}));
const [isLoggedIn, setLoggedIn] = useState<boolean>(false);
const logout = () => setLoggedIn(false);
useCallback(async () => {
if (isLoggedIn)
return;
const result = await postPlayer(nameId);
setLoggedIn(result !== null);
}, [nameId, isLoggedIn])();
return <> return <>
{id === null && <JoinForm onDone={name => setId(name)} />} {nameId.name === '' && <JoinForm setNameId={setNameId} clientId={nameId.id}/>}
{id == null || <PlayerInfo playerId={id} />} {isLoggedIn && <PlayerInfo playerId={nameId.id} logout={logout}/>}
<ClientScreen /> <ClientScreen logout={logout}/>
{id == null || <Controls playerId={id} />} {isLoggedIn && <Controls playerId={nameId.id} logout={logout}/>}
</>; </>;
} }
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App/>
</React.StrictMode> </React.StrictMode>
); );

View file

@ -1,25 +1,42 @@
import {Guid} from './Guid.ts';
export type PlayerResponse = { export type PlayerResponse = {
readonly name: string; readonly name: string;
readonly id: string; readonly id: string;
readonly scores: {
readonly kills: number; readonly kills: number;
readonly deaths: number; readonly deaths: number;
};
}; };
export async function fetchTyped<T>({ url, method }: { url: URL; method: string; }) { export type NameId = {
const response = await fetch(url, { method }); name: string,
id: Guid
};
export async function fetchTyped<T>({url, method}: { url: URL; method: string; }) {
const response = await fetch(url, {method});
if (!response.ok) if (!response.ok)
return null; return null;
return await response.json() as T; return await response.json() as T;
} }
export function postPlayer(name: string) { export function postPlayer({name, id}: NameId) {
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL); const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
url.searchParams.set('name', name); url.searchParams.set('name', name);
return fetchTyped<PlayerResponse>({ url, method: 'POST' }); url.searchParams.set('id', id);
return fetchTyped<NameId>({url, method: 'POST'});
} }
export function getPlayer(id: string) { export function getPlayer(id: string) {
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL); const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
url.searchParams.set('id', id); url.searchParams.set('id', id);
return fetchTyped<PlayerResponse>({ url, method: 'GET' });
return fetchTyped<PlayerResponse>({url, method: 'GET'});
}
export function getScores() {
const url = new URL('/scores', import.meta.env.VITE_TANK_API);
return fetchTyped<PlayerResponse[]>({url, method: 'GET'});
} }

View file

@ -0,0 +1,35 @@
import {useState} from 'react';
export function useStoredState(storageKey: string, initialState: () => string): [string, ((newState: string) => void)] {
const [state, setState] = useState<string>(() => localStorage.getItem(storageKey) || initialState());
const setSavedState = (newState: string) => {
localStorage.setItem(storageKey, newState);
setState(newState);
};
return [state, setSavedState];
}
export function useStoredObjectState<T>(
storageKey: string,
initialState: () => T
): [T, (mutator: (oldState: T) => T) => void] {
const getInitialState = () => {
const localStorageJson = localStorage.getItem(storageKey);
if (localStorageJson !== null && localStorageJson !== '') {
return JSON.parse(localStorageJson);
}
return initialState();
};
const [state, setState] = useState<T>(getInitialState);
const setSavedState = (mut: (oldState: T) => T) => {
localStorage.setItem(storageKey, JSON.stringify(mut(state)));
setState(mut);
};
return [state, setSavedState];
}