remove guid, reduce latency (gets stuck sometimes tho)
This commit is contained in:
		
							parent
							
								
									6bc6a039bd
								
							
						
					
					
						commit
						7044ffda79
					
				
					 19 changed files with 291 additions and 251 deletions
				
			
		|  | @ -1,56 +1,38 @@ | |||
| import {useCallback, useState} from 'react'; | ||||
| import ClientScreen from './ClientScreen'; | ||||
| import Controls from './Controls.tsx'; | ||||
| import JoinForm from './JoinForm.tsx'; | ||||
| import PlayerInfo from './PlayerInfo.tsx'; | ||||
| import {useStoredObjectState} from './useStoredState.ts'; | ||||
| import {NameId, postPlayer} from './serverCalls.tsx'; | ||||
| import Column from "./components/Column.tsx"; | ||||
| import Row from "./components/Row.tsx"; | ||||
| import Scoreboard from "./Scoreboard.tsx"; | ||||
| import Button from "./components/Button.tsx"; | ||||
| import Column from './components/Column.tsx'; | ||||
| import Row from './components/Row.tsx'; | ||||
| import Scoreboard from './Scoreboard.tsx'; | ||||
| import Button from './components/Button.tsx'; | ||||
| import './App.css'; | ||||
| import {getRandomTheme, useStoredTheme} from "./theme.ts"; | ||||
| import {EmptyGuid} from "./Guid.ts"; | ||||
| 
 | ||||
| const getNewNameId = () => ({ | ||||
|     id: EmptyGuid, | ||||
|     name: '' | ||||
| }); | ||||
| import {getRandomTheme, useStoredTheme} from './theme.ts'; | ||||
| import {useState} from 'react'; | ||||
| 
 | ||||
| export default function App() { | ||||
|     const [theme, setTheme] = useStoredTheme(); | ||||
|     const [nameId, setNameId] = useStoredObjectState<NameId>('access', getNewNameId); | ||||
|     const [name, setName] = useState<string | null>(null); | ||||
| 
 | ||||
|     const [isLoggedIn, setLoggedIn] = useState<boolean>(false); | ||||
|     const logout = () => setLoggedIn(false); | ||||
|     return <Column className="flex-grow"> | ||||
| 
 | ||||
|     useCallback(async () => { | ||||
|         if (isLoggedIn) | ||||
|             return; | ||||
|         const result = await postPlayer(nameId); | ||||
|         setLoggedIn(result.ok); | ||||
|     }, [nameId, isLoggedIn])(); | ||||
| 
 | ||||
|     return <Column className='flex-grow'> | ||||
| 
 | ||||
|         <ClientScreen logout={logout} theme={theme} playerId={nameId.id}/> | ||||
|         <ClientScreen theme={theme} player={name}/> | ||||
| 
 | ||||
|         <Row> | ||||
|             <h1 className='flex-grow'>CCCB-Tanks!</h1> | ||||
|             <Button text='change colors' onClick={() => setTheme(_ => getRandomTheme())}/> | ||||
|             <h1 className="flex-grow">CCCB-Tanks!</h1> | ||||
|             <Button text="change colors" onClick={() => setTheme(_ => getRandomTheme())}/> | ||||
|             <Button | ||||
|                 onClick={() => window.open('https://github.com/kaesaecracker/cccb-tanks-cs', '_blank')?.focus()} | ||||
|                 text='GitHub'/> | ||||
|             {nameId.name !== '' && | ||||
|                 <Button onClick={() => setNameId(getNewNameId)} text='logout'/>} | ||||
|                 text="GitHub"/> | ||||
|             {name !== '' && | ||||
|                 <Button onClick={() => setName(_ => '')} text="logout"/>} | ||||
|         </Row> | ||||
| 
 | ||||
|         {nameId.name === '' && <JoinForm setNameId={setNameId} clientId={nameId.id}/>} | ||||
|         {name || <JoinForm onDone={name => setName(_ => name)}/>} | ||||
| 
 | ||||
|         <Row className='GadgetRows'> | ||||
|             {isLoggedIn && <Controls playerId={nameId.id}/>} | ||||
|             {isLoggedIn && <PlayerInfo playerId={nameId.id}/>} | ||||
|         <Row className="GadgetRows"> | ||||
|             {name && <Controls player={name}/>} | ||||
|             {name && <PlayerInfo player={name}/>} | ||||
|             <Scoreboard/> | ||||
|         </Row> | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,7 +2,6 @@ import useWebSocket from 'react-use-websocket'; | |||
| import {useEffect, useRef} from 'react'; | ||||
| import './ClientScreen.css'; | ||||
| import {hslToString, Theme} from "./theme.ts"; | ||||
| import {Guid} from "./Guid.ts"; | ||||
| import {makeApiUrl} from './serverCalls.tsx'; | ||||
| 
 | ||||
| const pixelsPerRow = 352; | ||||
|  | @ -98,23 +97,21 @@ function drawPixelsToCanvas( | |||
|     context.putImageData(imageData, 0, 0); | ||||
| } | ||||
| 
 | ||||
| export default function ClientScreen({logout, theme, playerId}: { | ||||
|     logout: () => void, | ||||
| export default function ClientScreen({theme, player}: { | ||||
|     theme: Theme, | ||||
|     playerId?: Guid | ||||
|     player: string | null | ||||
| }) { | ||||
|     const canvasRef = useRef<HTMLCanvasElement>(null); | ||||
| 
 | ||||
|     const url = makeApiUrl('/screen', 'ws'); | ||||
|     if (playerId) | ||||
|         url.searchParams.set('player', playerId); | ||||
|     if (player && player !== '') | ||||
|         url.searchParams.set('playerName', player); | ||||
| 
 | ||||
|     const { | ||||
|         lastMessage, | ||||
|         sendMessage, | ||||
|         getWebSocket | ||||
|     } = useWebSocket(url.toString(), { | ||||
|         onError: logout, | ||||
|         shouldReconnect: () => true, | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,12 +1,11 @@ | |||
| import './Controls.css'; | ||||
| import useWebSocket, {ReadyState} from 'react-use-websocket'; | ||||
| import {useEffect} from 'react'; | ||||
| import {Guid} from "./Guid.ts"; | ||||
| import {makeApiUrl} from './serverCalls.tsx'; | ||||
| 
 | ||||
| export default function Controls({playerId}: { playerId: Guid }) { | ||||
| export default function Controls({player}: { player: string }) { | ||||
|     const url = makeApiUrl('/controls', 'ws'); | ||||
|     url.searchParams.set('playerId', playerId); | ||||
|     url.searchParams.set('playerName', player); | ||||
| 
 | ||||
|     const { | ||||
|         sendMessage, | ||||
|  | @ -62,17 +61,17 @@ export default function Controls({playerId}: { playerId: Guid }) { | |||
|     }, [readyState]); | ||||
| 
 | ||||
|     return <div className="Controls flex-row"> | ||||
|         <div className='flex-column Controls-Container'> | ||||
|         <div className="flex-column Controls-Container"> | ||||
|             <h3>Move</h3> | ||||
|             <kbd>▲</kbd> | ||||
|             <div className='flex-row Controls-Container'> | ||||
|             <div className="flex-row Controls-Container"> | ||||
|                 <kbd>◄</kbd> | ||||
|                 <kbd>▼</kbd> | ||||
|                 <kbd>►</kbd> | ||||
|             </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='flex-column Controls-Container'> | ||||
|         <div className="flex-column Controls-Container"> | ||||
|             <h3>Fire</h3> | ||||
|             <kbd className="space">Space</kbd> | ||||
|         </div> | ||||
|  |  | |||
|  | @ -1,14 +1,12 @@ | |||
| import {useEffect, useState} from 'react'; | ||||
| import './JoinForm.css'; | ||||
| import {NameId, Player, postPlayer} from './serverCalls'; | ||||
| import {Guid} from './Guid.ts'; | ||||
| import Column from "./components/Column.tsx"; | ||||
| import Button from "./components/Button.tsx"; | ||||
| import TextInput from "./components/TextInput.tsx"; | ||||
| import {Player, postPlayer} from './serverCalls'; | ||||
| import Column from './components/Column.tsx'; | ||||
| import Button from './components/Button.tsx'; | ||||
| import TextInput from './components/TextInput.tsx'; | ||||
| 
 | ||||
| export default function JoinForm({setNameId, clientId}: { | ||||
|     setNameId: (mutator: (oldState: NameId) => NameId) => void, | ||||
|     clientId: Guid | ||||
| export default function JoinForm({onDone}: { | ||||
|     onDone: (name: string) => void; | ||||
| }) { | ||||
|     const [clicked, setClicked] = useState(false); | ||||
|     const [data, setData] = useState<Player | null>(null); | ||||
|  | @ -18,28 +16,27 @@ export default function JoinForm({setNameId, clientId}: { | |||
|         if (!clicked || data) | ||||
|             return; | ||||
| 
 | ||||
|         postPlayer({name, id: clientId}) | ||||
|             .then(response => { | ||||
|                 if (response.ok && response.successResult) { | ||||
|                     setNameId(_ => response.successResult!); | ||||
|                     setErrorText(null); | ||||
|                     return; | ||||
|                 } | ||||
|         postPlayer(name).then(response => { | ||||
|             if (response.ok && response.successResult) { | ||||
|                 onDone(response.successResult!.trim()); | ||||
|                 setErrorText(null); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|                 if (response.additionalErrorText) | ||||
|                     setErrorText(`${response.statusCode} (${response.statusText}): ${response.additionalErrorText}`); | ||||
|                 else | ||||
|                     setErrorText(`${response.statusCode} (${response.statusText})`); | ||||
|             if (response.additionalErrorText) | ||||
|                 setErrorText(`${response.statusCode} (${response.statusText}): ${response.additionalErrorText}`); | ||||
|             else | ||||
|                 setErrorText(`${response.statusCode} (${response.statusText})`); | ||||
| 
 | ||||
|                 setClicked(false); | ||||
|             }); | ||||
|     }, [clicked, setData, data, clientId, setClicked, setNameId, errorText]); | ||||
|             setClicked(false); | ||||
|         }); | ||||
|     }, [clicked, setData, data, setClicked, onDone, errorText]); | ||||
| 
 | ||||
|     const [name, setName] = useState(''); | ||||
|     const disableButtons = clicked || name.trim() === ''; | ||||
|     const setClickedTrue = () => setClicked(true); | ||||
| 
 | ||||
|     return <Column className='JoinForm'> | ||||
|     return <Column className="JoinForm"> | ||||
|         <h3> Enter your name to play </h3> | ||||
|         <TextInput | ||||
|             value={name} | ||||
|  | @ -50,7 +47,7 @@ export default function JoinForm({setNameId, clientId}: { | |||
|         <Button | ||||
|             onClick={setClickedTrue} | ||||
|             disabled={disableButtons} | ||||
|             text='INSERT COIN'/> | ||||
|             text="INSERT COIN"/> | ||||
|         {errorText && <p>{errorText}</p>} | ||||
|     </Column>; | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,4 @@ | |||
| import {makeApiUrl, Scores} from './serverCalls'; | ||||
| import {Guid} from './Guid.ts'; | ||||
| import Column from './components/Column.tsx'; | ||||
| import useWebSocket, {ReadyState} from 'react-use-websocket'; | ||||
| import {useEffect, useState} from 'react'; | ||||
|  | @ -37,11 +36,11 @@ type PlayerInfoMessage = { | |||
|     readonly tank?: TankInfo; | ||||
| } | ||||
| 
 | ||||
| export default function PlayerInfo({playerId}: { playerId: Guid }) { | ||||
| export default function PlayerInfo({player}: { player: string }) { | ||||
|     const [shouldSendMessage, setShouldSendMessage] = useState(false); | ||||
| 
 | ||||
|     const url = makeApiUrl('/player'); | ||||
|     url.searchParams.set('id', playerId); | ||||
|     url.searchParams.set('name', player); | ||||
| 
 | ||||
|     const {lastJsonMessage, readyState, sendMessage} = useWebSocket<PlayerInfoMessage>(url.toString(), { | ||||
|         onMessage: () => setShouldSendMessage(true), | ||||
|  |  | |||
|  | @ -54,8 +54,6 @@ export default function DataTable<T>({data, columns, className}: { | |||
| 
 | ||||
|     dataToDisplay.sort(actualSorter) | ||||
| 
 | ||||
|     console.log('sorted', {dataToDisplay}); | ||||
| 
 | ||||
|     return <div className={'DataTable ' + (className ?? '')}> | ||||
|         <table> | ||||
|             <thead className='DataTableHead'> | ||||
|  |  | |||
|  | @ -1,5 +1,3 @@ | |||
| import {Guid} from './Guid.ts'; | ||||
| 
 | ||||
| export function makeApiUrl(path: string, protocol: 'http' | 'ws' = 'http') { | ||||
|     return new URL(`${protocol}://${window.location.hostname}${path}`); | ||||
| } | ||||
|  | @ -22,15 +20,9 @@ export type Scores = { | |||
| 
 | ||||
| export type Player = { | ||||
|     readonly name: string; | ||||
|     readonly id: Guid; | ||||
|     readonly scores: Scores; | ||||
| }; | ||||
| 
 | ||||
| export type NameId = { | ||||
|     name: string, | ||||
|     id: Guid | ||||
| }; | ||||
| 
 | ||||
| export async function fetchTyped<T>({url, method}: { url: URL; method: string; }): Promise<ServerResponse<T>> { | ||||
|     const response = await fetch(url, {method}); | ||||
|     const result: ServerResponse<T> = { | ||||
|  | @ -46,10 +38,9 @@ export async function fetchTyped<T>({url, method}: { url: URL; method: string; } | |||
|     return result; | ||||
| } | ||||
| 
 | ||||
| export function postPlayer({name, id}: NameId) { | ||||
| export function postPlayer(name: string) { | ||||
|     const url = makeApiUrl('/player'); | ||||
|     url.searchParams.set('name', name); | ||||
|     url.searchParams.set('id', id); | ||||
| 
 | ||||
|     return fetchTyped<NameId>({url, method: 'POST'}); | ||||
|     return fetchTyped<string>({url, method: 'POST'}); | ||||
| } | ||||
|  |  | |||
|  | @ -1,85 +1,87 @@ | |||
| using Microsoft.AspNetCore.Builder; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.AspNetCore.Http.HttpResults; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using TanksServer.GameLogic; | ||||
| using TanksServer.Interactivity; | ||||
| 
 | ||||
| namespace TanksServer; | ||||
| 
 | ||||
| internal static class Endpoints | ||||
| internal sealed class Endpoints( | ||||
|     ClientScreenServer clientScreenServer, | ||||
|     PlayerServer playerService, | ||||
|     ControlsServer controlsServer, | ||||
|     MapService mapService | ||||
| ) | ||||
| { | ||||
|     public static void MapEndpoints(WebApplication app) | ||||
|     public void Map(WebApplication app) | ||||
|     { | ||||
|         var clientScreenServer = app.Services.GetRequiredService<ClientScreenServer>(); | ||||
|         var playerService = app.Services.GetRequiredService<PlayerServer>(); | ||||
|         var controlsServer = app.Services.GetRequiredService<ControlsServer>(); | ||||
|         var mapService = app.Services.GetRequiredService<MapService>(); | ||||
| 
 | ||||
|         app.MapPost("/player", (string name, Guid? id) => | ||||
|         { | ||||
|             name = name.Trim().ToUpperInvariant(); | ||||
|             if (name == string.Empty) | ||||
|                 return Results.BadRequest("name cannot be blank"); | ||||
|             if (name.Length > 12) | ||||
|                 return Results.BadRequest("name too long"); | ||||
| 
 | ||||
|             if (!id.HasValue || id.Value == Guid.Empty) | ||||
|                 id = Guid.NewGuid(); | ||||
| 
 | ||||
|             var player = playerService.GetOrAdd(name, id.Value); | ||||
|             return player != null | ||||
|                 ? Results.Ok(new NameId(player.Name, player.Id)) | ||||
|                 : Results.Unauthorized(); | ||||
|         }); | ||||
| 
 | ||||
|         app.MapGet("/player", async (HttpContext context, [FromQuery] Guid id) => | ||||
|         { | ||||
|             if (!playerService.TryGet(id, out var foundPlayer)) | ||||
|                 return Results.NotFound(); | ||||
| 
 | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|                 return Results.Ok((object?)foundPlayer); | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await playerService.HandleClientAsync(ws, foundPlayer); | ||||
|             return Results.Empty; | ||||
|         }); | ||||
| 
 | ||||
|         app.MapGet("/scores", () => playerService.GetAll()); | ||||
| 
 | ||||
|         app.Map("/screen", async (HttpContext context, [FromQuery] Guid? player) => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|                 return Results.BadRequest(); | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await clientScreenServer.HandleClientAsync(ws, player); | ||||
|             return Results.Empty; | ||||
|         }); | ||||
| 
 | ||||
|         app.Map("/controls", async (HttpContext context, [FromQuery] Guid playerId) => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|                 return Results.BadRequest(); | ||||
| 
 | ||||
|             if (!playerService.TryGet(playerId, out var player)) | ||||
|                 return Results.NotFound(); | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await controlsServer.HandleClientAsync(ws, player); | ||||
|             return Results.Empty; | ||||
|         }); | ||||
| 
 | ||||
|         app.MapPost("/player", PostPlayer); | ||||
|         app.MapGet("/player", GetPlayerAsync); | ||||
|         app.MapGet("/scores", () => playerService.GetAll() as IEnumerable<Player>); | ||||
|         app.Map("/screen", ConnectScreenAsync); | ||||
|         app.Map("/controls", ConnectControlsAsync); | ||||
|         app.MapGet("/map", () => mapService.MapNames); | ||||
|         app.MapPost("/map", PostMap); | ||||
|     } | ||||
| 
 | ||||
|         app.MapPost("/map", ([FromQuery] string name) => | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(name)) | ||||
|                 return Results.BadRequest("invalid map name"); | ||||
|             if (!mapService.TrySwitchTo(name)) | ||||
|                 return Results.NotFound("map with name not found"); | ||||
|             return Results.Ok(); | ||||
|         }); | ||||
|     private Results<BadRequest<string>, NotFound<string>, Ok> PostMap([FromQuery] string name) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|             return TypedResults.BadRequest("invalid map name"); | ||||
|         if (!mapService.TrySwitchTo(name)) | ||||
|             return TypedResults.NotFound("map with name not found"); | ||||
|         return TypedResults.Ok(); | ||||
|     } | ||||
| 
 | ||||
|     private async Task<Results<BadRequest, NotFound, EmptyHttpResult>> ConnectControlsAsync(HttpContext context, | ||||
|         [FromQuery] string playerName) | ||||
|     { | ||||
|         if (!context.WebSockets.IsWebSocketRequest) | ||||
|             return TypedResults.BadRequest(); | ||||
| 
 | ||||
|         if (!playerService.TryGet(playerName, out var player)) | ||||
|             return TypedResults.NotFound(); | ||||
| 
 | ||||
|         using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|         await controlsServer.HandleClientAsync(ws, player); | ||||
|         return TypedResults.Empty; | ||||
|     } | ||||
| 
 | ||||
|     private async Task<Results<BadRequest, EmptyHttpResult>> ConnectScreenAsync(HttpContext context, | ||||
|         [FromQuery] string? playerName) | ||||
|     { | ||||
|         if (!context.WebSockets.IsWebSocketRequest) | ||||
|             return TypedResults.BadRequest(); | ||||
| 
 | ||||
|         using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|         await clientScreenServer.HandleClientAsync(ws, playerName); | ||||
|         return TypedResults.Empty; | ||||
|     } | ||||
| 
 | ||||
|     private async Task<Results<NotFound, Ok<Player>, EmptyHttpResult>> GetPlayerAsync(HttpContext context, | ||||
|         [FromQuery] string name) | ||||
|     { | ||||
|         if (!playerService.TryGet(name, out var foundPlayer)) | ||||
|             return TypedResults.NotFound(); | ||||
| 
 | ||||
|         if (!context.WebSockets.IsWebSocketRequest) | ||||
|             return TypedResults.Ok(foundPlayer); | ||||
| 
 | ||||
|         using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|         await playerService.HandleClientAsync(ws, foundPlayer); | ||||
|         return TypedResults.Empty; | ||||
|     } | ||||
| 
 | ||||
|     private Results<BadRequest<string>, Ok<string>, UnauthorizedHttpResult> PostPlayer([FromQuery] string name) | ||||
|     { | ||||
|         name = name.Trim().ToUpperInvariant(); | ||||
|         if (name == string.Empty) return TypedResults.BadRequest("name cannot be blank"); | ||||
|         if (name.Length > 12) return TypedResults.BadRequest("name too long"); | ||||
| 
 | ||||
|         var player = playerService.GetOrAdd(name); | ||||
|         return player != null | ||||
|             ? TypedResults.Ok(player.Name) | ||||
|             : TypedResults.Unauthorized(); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -35,7 +35,7 @@ internal sealed class MapEntityManager( | |||
|             Rotation = Random.Shared.NextDouble() | ||||
|         }; | ||||
|         _playerTanks[player] = tank; | ||||
|         logger.LogInformation("Tank added for player {}", player.Id); | ||||
|         logger.LogInformation("Tank added for player {}", player.Name); | ||||
|     } | ||||
| 
 | ||||
|     public void SpawnPowerUp() => _powerUps.Add(new PowerUp(ChooseSpawnPosition())); | ||||
|  | @ -44,7 +44,7 @@ internal sealed class MapEntityManager( | |||
| 
 | ||||
|     public void Remove(Tank tank) | ||||
|     { | ||||
|         logger.LogInformation("Tank removed for player {}", tank.Owner.Id); | ||||
|         logger.LogInformation("Tank removed for player {}", tank.Owner.Name); | ||||
|         _playerTanks.Remove(tank.Owner); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,27 +8,26 @@ internal sealed class GeneratePixelsTickStep( | |||
|     IEnumerable<IFrameConsumer> consumers | ||||
| ) : ITickStep | ||||
| { | ||||
|     private readonly GamePixelGrid _gamePixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); | ||||
|     private readonly List<IDrawStep> _drawSteps = drawSteps.ToList(); | ||||
|     private readonly List<IFrameConsumer> _consumers = consumers.ToList(); | ||||
| 
 | ||||
|     private readonly PixelGrid _observerPixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); | ||||
|     private readonly GamePixelGrid _gamePixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); | ||||
| 
 | ||||
|     public async Task TickAsync(TimeSpan _) | ||||
|     { | ||||
|         PixelGrid observerPixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn); | ||||
| 
 | ||||
|         _gamePixelGrid.Clear(); | ||||
|         foreach (var step in _drawSteps) | ||||
|             step.Draw(_gamePixelGrid); | ||||
| 
 | ||||
|         _observerPixelGrid.Clear(); | ||||
|         for (var y = 0; y < MapService.PixelsPerColumn; y++) | ||||
|         for (var x = 0; x < MapService.PixelsPerRow; x++) | ||||
|         { | ||||
|             if (_gamePixelGrid[x, y].EntityType.HasValue) | ||||
|                 _observerPixelGrid[(ushort)x, (ushort)y] = true; | ||||
|                 observerPixelGrid[(ushort)x, (ushort)y] = true; | ||||
|         } | ||||
| 
 | ||||
|         foreach (var consumer in _consumers) | ||||
|             await consumer.OnFrameDoneAsync(_gamePixelGrid, _observerPixelGrid); | ||||
|             await consumer.OnFrameDoneAsync(_gamePixelGrid, observerPixelGrid); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -7,11 +7,31 @@ internal sealed class ByteChannelWebSocket(WebSocket socket, ILogger logger, int | |||
| { | ||||
|     private readonly byte[] _buffer = new byte[messageSize]; | ||||
| 
 | ||||
|     public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) => | ||||
|         socket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage, CancellationToken.None); | ||||
|     public async ValueTask SendBinaryAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             await socket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage, CancellationToken.None); | ||||
|         } | ||||
|         catch (WebSocketException e) | ||||
|         { | ||||
|             logger.LogError(e, "could not send binary message"); | ||||
|             await CloseAsync(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public ValueTask SendTextAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) => | ||||
|         socket.SendAsync(data, WebSocketMessageType.Text, endOfMessage, CancellationToken.None); | ||||
|     public async ValueTask SendTextAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             await socket.SendAsync(data, WebSocketMessageType.Text, endOfMessage, CancellationToken.None); | ||||
|         } | ||||
|         catch (WebSocketException e) | ||||
|         { | ||||
|             logger.LogError(e, "could not send text message"); | ||||
|             await CloseAsync(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public async IAsyncEnumerable<Memory<byte>> ReadAllAsync() | ||||
|     { | ||||
|  | @ -25,9 +45,12 @@ internal sealed class ByteChannelWebSocket(WebSocket socket, ILogger logger, int | |||
|             Debugger.Break(); | ||||
|     } | ||||
| 
 | ||||
|     public Task CloseWithErrorAsync(string error) | ||||
|         => socket.CloseOutputAsync(WebSocketCloseStatus.InternalServerError, error, CancellationToken.None); | ||||
| 
 | ||||
|     public async Task CloseAsync() | ||||
|     { | ||||
|         if (socket.State != WebSocketState.Open) | ||||
|         if (socket.State is not WebSocketState.Open and not WebSocketState.CloseReceived) | ||||
|             return; | ||||
| 
 | ||||
|         try | ||||
|  |  | |||
|  | @ -6,20 +6,16 @@ namespace TanksServer.Interactivity; | |||
| 
 | ||||
| internal sealed class ClientScreenServer( | ||||
|     ILogger<ClientScreenServer> logger, | ||||
|     ILoggerFactory loggerFactory, | ||||
|     IOptions<HostConfiguration> hostConfig | ||||
|     ILoggerFactory loggerFactory | ||||
| ) : WebsocketServer<ClientScreenServerConnection>(logger), IFrameConsumer | ||||
| { | ||||
|     private readonly TimeSpan _minFrameTime = TimeSpan.FromMilliseconds(hostConfig.Value.ClientDisplayMinFrameTimeMs); | ||||
| 
 | ||||
|     public Task HandleClientAsync(WebSocket socket, Guid? playerGuid) | ||||
|     public Task HandleClientAsync(WebSocket socket, string? player) | ||||
|         => base.HandleClientAsync(new( | ||||
|             socket, | ||||
|             loggerFactory.CreateLogger<ClientScreenServerConnection>(), | ||||
|             _minFrameTime, | ||||
|             playerGuid | ||||
|             player | ||||
|         )); | ||||
| 
 | ||||
|     public Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels) | ||||
|         => ParallelForEachConnectionAsync(c => c.SendAsync(observerPixels, gamePixelGrid)); | ||||
|         => ParallelForEachConnectionAsync(c => c.OnGameTickAsync(observerPixels, gamePixelGrid).AsTask()); | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,3 @@ | |||
| using System.Diagnostics; | ||||
| using System.Net.WebSockets; | ||||
| using DisplayCommands; | ||||
| using TanksServer.Graphics; | ||||
|  | @ -8,40 +7,88 @@ namespace TanksServer.Interactivity; | |||
| internal sealed class ClientScreenServerConnection( | ||||
|     WebSocket webSocket, | ||||
|     ILogger<ClientScreenServerConnection> logger, | ||||
|     TimeSpan minFrameTime, | ||||
|     Guid? playerGuid = null | ||||
|     string? playerName = null | ||||
| ) : WebsocketServerConnection(logger, new ByteChannelWebSocket(webSocket, logger, 0)), | ||||
|     IDisposable | ||||
| { | ||||
|     private readonly SemaphoreSlim _wantedFrames = new(1); | ||||
|     private readonly PlayerScreenData? _playerScreenData = playerGuid.HasValue ? new PlayerScreenData(logger) : null; | ||||
|     private DateTime _nextFrameAfter = DateTime.Now; | ||||
|     private readonly SemaphoreSlim _wantedFramesOnTick = new(0, 2); | ||||
|     private readonly SemaphoreSlim _mutex = new(1); | ||||
| 
 | ||||
|     public void Dispose() => _wantedFrames.Dispose(); | ||||
|     private PixelGrid? _lastSentPixels = null; | ||||
|     private PixelGrid? _nextPixels = null; | ||||
|     private readonly PlayerScreenData? _nextPlayerData = playerName != null ? new PlayerScreenData(logger) : null; | ||||
| 
 | ||||
|     public async Task SendAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid) | ||||
|     protected override async ValueTask HandleMessageAsync(Memory<byte> _) | ||||
|     { | ||||
|         if (_nextFrameAfter > DateTime.Now) | ||||
|             return; | ||||
| 
 | ||||
|         if (!await _wantedFrames.WaitAsync(TimeSpan.Zero)) | ||||
|         await _mutex.WaitAsync(); | ||||
|         try | ||||
|         { | ||||
|             Logger.LogTrace("client does not want a frame yet"); | ||||
|             return; | ||||
|             if (_nextPixels == null) | ||||
|             { | ||||
|                 _wantedFramesOnTick.Release(); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             _lastSentPixels = _nextPixels; | ||||
|             _nextPixels = null; | ||||
|             await SendNowAsync(_lastSentPixels); | ||||
|         } | ||||
|         catch (SemaphoreFullException) | ||||
|         { | ||||
|             logger.LogWarning("ignoring request for more frames"); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|         _nextFrameAfter = DateTime.Today + minFrameTime; | ||||
|     public async ValueTask OnGameTickAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid) | ||||
|     { | ||||
|         await _mutex.WaitAsync(); | ||||
|         try | ||||
|         { | ||||
|             if (pixels == _lastSentPixels) | ||||
|                 return; | ||||
| 
 | ||||
|         if (_playerScreenData != null) | ||||
|             RefreshPlayerSpecificData(gamePixelGrid); | ||||
|             if (_nextPlayerData != null) | ||||
|             { | ||||
|                 _nextPlayerData.Clear(); | ||||
|                 foreach (var gamePixel in gamePixelGrid) | ||||
|                 { | ||||
|                     if (!gamePixel.EntityType.HasValue) | ||||
|                         continue; | ||||
|                     _nextPlayerData.Add(gamePixel.EntityType.Value, gamePixel.BelongsTo?.Name == playerName); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             var sendImmediately = await _wantedFramesOnTick.WaitAsync(TimeSpan.Zero); | ||||
|             if (sendImmediately) | ||||
|             { | ||||
|                 await SendNowAsync(pixels); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             _wantedFramesOnTick.Release(); | ||||
|             _nextPixels = pixels; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async ValueTask SendNowAsync(PixelGrid pixels) | ||||
|     { | ||||
|         Logger.LogTrace("sending"); | ||||
|         try | ||||
|         { | ||||
|             Logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length); | ||||
|             await Socket.SendBinaryAsync(pixels.Data, _playerScreenData == null); | ||||
|             if (_playerScreenData != null) | ||||
|                 await Socket.SendBinaryAsync(_playerScreenData.GetPacket()); | ||||
|             await Socket.SendBinaryAsync(pixels.Data, _nextPlayerData == null); | ||||
|             if (_nextPlayerData != null) | ||||
|             { | ||||
|                 await Socket.SendBinaryAsync(_nextPlayerData.GetPacket()); | ||||
|             } | ||||
|         } | ||||
|         catch (WebSocketException ex) | ||||
|         { | ||||
|  | @ -49,21 +96,5 @@ internal sealed class ClientScreenServerConnection( | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private void RefreshPlayerSpecificData(GamePixelGrid gamePixelGrid) | ||||
|     { | ||||
|         Debug.Assert(_playerScreenData != null); | ||||
|         _playerScreenData.Clear(); | ||||
|         foreach (var gamePixel in gamePixelGrid) | ||||
|         { | ||||
|             if (!gamePixel.EntityType.HasValue) | ||||
|                 continue; | ||||
|             _playerScreenData.Add(gamePixel.EntityType.Value, gamePixel.BelongsTo?.Id == playerGuid); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected override ValueTask HandleMessageAsync(Memory<byte> _) | ||||
|     { | ||||
|         _wantedFrames.Release(); | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
|     public void Dispose() => _wantedFramesOnTick.Dispose(); | ||||
| } | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ internal sealed class ControlsServer( | |||
| { | ||||
|     public Task HandleClientAsync(WebSocket ws, Player player) | ||||
|     { | ||||
|         logger.LogDebug("control client connected {}", player.Id); | ||||
|         logger.LogDebug("control client connected {}", player.Name); | ||||
|         var clientLogger = loggerFactory.CreateLogger<ControlsServerConnection>(); | ||||
|         var sock = new ControlsServerConnection(ws, clientLogger, player); | ||||
|         return HandleClientAsync(sock); | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ internal sealed class ControlsServerConnection( | |||
|         var type = (MessageType)buffer.Span[0]; | ||||
|         var control = (InputType)buffer.Span[1]; | ||||
| 
 | ||||
|         Logger.LogTrace("player input {} {} {}", player.Id, type, control); | ||||
|         Logger.LogTrace("player input {} {} {}", player.Name, type, control); | ||||
| 
 | ||||
|         var isEnable = type switch | ||||
|         { | ||||
|  |  | |||
|  | @ -11,40 +11,58 @@ internal sealed class PlayerServer( | |||
|     MapEntityManager entityManager | ||||
| ) : WebsocketServer<PlayerInfoConnection>(logger), ITickStep | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, Player> _players = new(); | ||||
|     private readonly Dictionary<string, Player> _players = []; | ||||
|     private readonly SemaphoreSlim _mutex = new(1, 1); | ||||
| 
 | ||||
|     public Player? GetOrAdd(string name, Guid id) | ||||
|     public Player? GetOrAdd(string name) | ||||
|     { | ||||
|         var existingOrAddedPlayer = _players.GetOrAdd(name, _ => AddAndSpawn()); | ||||
|         if (existingOrAddedPlayer.Id != id) | ||||
|             return null; | ||||
| 
 | ||||
|         logger.LogInformation("player {} (re)joined", existingOrAddedPlayer.Id); | ||||
|         return existingOrAddedPlayer; | ||||
| 
 | ||||
|         Player AddAndSpawn() | ||||
|         _mutex.Wait(); | ||||
|         try | ||||
|         { | ||||
|             var newPlayer = new Player(name, id); | ||||
|             if (_players.TryGetValue(name, out var existingPlayer)) | ||||
|             { | ||||
|                 logger.LogInformation("player {} rejoined", existingPlayer.Name); | ||||
|                 return existingPlayer; | ||||
|             } | ||||
| 
 | ||||
|             var newPlayer = new Player { Name = name }; | ||||
|             logger.LogInformation("player {} joined", newPlayer.Name); | ||||
|             _players.Add(name, newPlayer); | ||||
|             tankSpawnQueue.EnqueueForImmediateSpawn(newPlayer); | ||||
|             return newPlayer; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public bool TryGet(Guid? playerId, [MaybeNullWhen(false)] out Player foundPlayer) | ||||
|     { | ||||
|         foreach (var player in _players.Values) | ||||
|         finally | ||||
|         { | ||||
|             if (player.Id != playerId) | ||||
|                 continue; | ||||
|             foundPlayer = player; | ||||
|             return true; | ||||
|             _mutex.Release(); | ||||
|         } | ||||
| 
 | ||||
|         foundPlayer = null; | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     public IEnumerable<Player> GetAll() => _players.Values; | ||||
|     public bool TryGet(string name, [MaybeNullWhen(false)] out Player foundPlayer) | ||||
|     { | ||||
|         _mutex.Wait(); | ||||
|         try | ||||
|         { | ||||
|             foundPlayer = _players.Values.FirstOrDefault(player => player.Name == name); | ||||
|             return foundPlayer != null; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public List<Player> GetAll() | ||||
|     { | ||||
|         _mutex.Wait(); | ||||
|         try | ||||
|         { | ||||
|             return _players.Values.ToList(); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public Task HandleClientAsync(WebSocket webSocket, Player player) | ||||
|         => HandleClientAsync(new PlayerInfoConnection(player, connectionLogger, webSocket, entityManager)); | ||||
|  |  | |||
|  | @ -5,6 +5,4 @@ public class HostConfiguration | |||
|     public bool EnableServicePointDisplay { get; set; } = true; | ||||
| 
 | ||||
|     public int ServicePointDisplayMinFrameTimeMs { get; set; } = 25; | ||||
| 
 | ||||
|     public int ClientDisplayMinFrameTimeMs { get; set; } = 25; | ||||
| } | ||||
|  |  | |||
|  | @ -2,15 +2,23 @@ using System.Text.Json.Serialization; | |||
| 
 | ||||
| namespace TanksServer.Models; | ||||
| 
 | ||||
| internal sealed class Player(string name, Guid id) | ||||
| internal sealed class Player : IEquatable<Player> | ||||
| { | ||||
|     public string Name => name; | ||||
| 
 | ||||
|     [JsonIgnore] public Guid Id => id; | ||||
|     public required string Name { get; init; } | ||||
| 
 | ||||
|     [JsonIgnore] public PlayerControls Controls { get; } = new(); | ||||
| 
 | ||||
|     public Scores Scores { get; } = new(); | ||||
| 
 | ||||
|     public DateTime LastInput { get; set; } = DateTime.Now; | ||||
| 
 | ||||
|     public override bool Equals(object? obj) => obj is Player p && Equals(p); | ||||
| 
 | ||||
|     public bool Equals(Player? other) => other?.Name == Name; | ||||
| 
 | ||||
|     public override int GetHashCode() => Name.GetHashCode(); | ||||
| 
 | ||||
|     public static bool operator ==(Player? left, Player? right) => Equals(left, right); | ||||
| 
 | ||||
|     public static bool operator !=(Player? left, Player? right) => !Equals(left, right); | ||||
| } | ||||
|  |  | |||
|  | @ -19,10 +19,11 @@ public static class Program | |||
|         var app = Configure(args); | ||||
| 
 | ||||
|         var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client")); | ||||
| 
 | ||||
|         app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider }); | ||||
|         app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider }); | ||||
| 
 | ||||
|         Endpoints.MapEndpoints(app); | ||||
|         app.Services.GetRequiredService<Endpoints>().Map(app); | ||||
| 
 | ||||
|         await app.RunAsync(); | ||||
|     } | ||||
|  | @ -63,6 +64,7 @@ public static class Program | |||
|         builder.Services.AddSingleton<PlayerServer>(); | ||||
|         builder.Services.AddSingleton<ClientScreenServer>(); | ||||
|         builder.Services.AddSingleton<TankSpawnQueue>(); | ||||
|         builder.Services.AddSingleton<Endpoints>(); | ||||
| 
 | ||||
|         builder.Services.AddHostedService<GameTickWorker>(); | ||||
|         builder.Services.AddHostedService(sp => sp.GetRequiredService<ControlsServer>()); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Vinzenz Schroeter
						Vinzenz Schroeter