automatic rejoin on reload or server restart
This commit is contained in:
parent
feaf96c10e
commit
64a61ef2b3
|
@ -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;
|
||||||
|
|
|
@ -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) =>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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();
|
||||||
|
|
3
tank-frontend/src/Guid.ts
Normal file
3
tank-frontend/src/Guid.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export type Guid = `${string}-${string}-${string}-${string}-${string}`;
|
||||||
|
|
||||||
|
export const EmptyGuid: Guid = '00000000-0000-0000-0000-000000000000';
|
|
@ -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"
|
||||||
|
|
|
@ -2,11 +2,16 @@ 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(() => {
|
||||||
|
@ -32,7 +37,7 @@ export default function PlayerInfo({ playerId }: { playerId: string }) {
|
||||||
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'>
|
||||||
|
@ -40,7 +45,7 @@ export default function PlayerInfo({ playerId }: { playerId: string }) {
|
||||||
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>
|
||||||
|
|
|
@ -1,19 +1,34 @@
|
||||||
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}/>}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,18 @@
|
||||||
|
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 type NameId = {
|
||||||
|
name: string,
|
||||||
|
id: Guid
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchTyped<T>({url, method}: { url: URL; method: string; }) {
|
export async function fetchTyped<T>({url, method}: { url: URL; method: string; }) {
|
||||||
const response = await fetch(url, {method});
|
const response = await fetch(url, {method});
|
||||||
|
@ -12,14 +21,22 @@ export async function fetchTyped<T>({ url, method }: { url: URL; method: string;
|
||||||
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'});
|
||||||
|
}
|
||||||
|
|
35
tank-frontend/src/useStoredState.ts
Normal file
35
tank-frontend/src/useStoredState.ts
Normal 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];
|
||||||
|
}
|
Loading…
Reference in a new issue