diff --git a/tank-frontend/src/ClientScreen.tsx b/tank-frontend/src/ClientScreen.tsx index df0e871..ba78edd 100644 --- a/tank-frontend/src/ClientScreen.tsx +++ b/tank-frontend/src/ClientScreen.tsx @@ -1,7 +1,8 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useRef, useState} from 'react'; import './ClientScreen.css'; import {hslToString, Theme} from './theme.ts'; import {makeApiUrl, useMyWebSocket} from './serverCalls.tsx'; +import {ReadyState} from 'react-use-websocket'; const pixelsPerRow = 352; const pixelsPerCol = 160; @@ -101,6 +102,7 @@ export default function ClientScreen({theme, player}: { player: string | null }) { const canvasRef = useRef(null); + const [shouldSendMessage, setShouldSendMessage] = useState(false); const url = makeApiUrl('/screen', 'ws'); if (player && player !== '') @@ -109,13 +111,24 @@ export default function ClientScreen({theme, player}: { const { lastMessage, sendMessage, - getWebSocket - } = useMyWebSocket(url.toString(), {}); + getWebSocket, + readyState + } = useMyWebSocket(url.toString(), { + onOpen: _ => setShouldSendMessage(true) + }); const socket = getWebSocket(); if (socket) (socket as WebSocket).binaryType = 'arraybuffer'; + useEffect(() => { + if (!shouldSendMessage || readyState !== ReadyState.OPEN) + return; + setShouldSendMessage(false); + sendMessage(''); + }, [readyState, shouldSendMessage]); + + useEffect(() => { if (lastMessage === null) return; @@ -155,7 +168,7 @@ export default function ClientScreen({theme, player}: { if (ignore) return; - sendMessage(''); + setShouldSendMessage(true); }; start(); diff --git a/tank-frontend/src/PlayerInfo.tsx b/tank-frontend/src/PlayerInfo.tsx index 17e67d1..2916d04 100644 --- a/tank-frontend/src/PlayerInfo.tsx +++ b/tank-frontend/src/PlayerInfo.tsx @@ -28,7 +28,6 @@ type TankInfo = { readonly moving: boolean; } - type PlayerInfoMessage = { readonly name: string; readonly scores: Scores; @@ -48,7 +47,8 @@ export default function PlayerInfo({player}: { player: string }) { readyState, sendMessage } = useMyWebSocket(url.toString(), { - onMessage: () => setShouldSendMessage(true) + onMessage: () => setShouldSendMessage(true), + onOpen: _ => setShouldSendMessage(true) }); useEffect(() => { diff --git a/tank-frontend/src/serverCalls.tsx b/tank-frontend/src/serverCalls.tsx index d2cc9f5..6e003fb 100644 --- a/tank-frontend/src/serverCalls.tsx +++ b/tank-frontend/src/serverCalls.tsx @@ -19,10 +19,10 @@ export type Player = { readonly scores: Scores; }; -export function useMyWebSocket(url: string, options: Options) { +export function useMyWebSocket(url: string, options: Options = {}) { return useWebSocket(url, { shouldReconnect: () => true, - reconnectAttempts: 5, + reconnectAttempts: 2, onReconnectStop: () => alert('server connection failed. please reload.'), ...options }); diff --git a/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs b/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs index cf1fc04..3b3571f 100644 --- a/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs +++ b/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs @@ -6,16 +6,18 @@ internal sealed class CollectPowerUp( MapEntityManager entityManager ) : ITickStep { + private readonly Predicate _collectPredicate = b => TryCollect(b, entityManager.Tanks); + public ValueTask TickAsync(TimeSpan delta) { - entityManager.RemoveWhere(TryCollect); + entityManager.RemoveWhere(_collectPredicate); return ValueTask.CompletedTask; } - private bool TryCollect(PowerUp obj) + private static bool TryCollect(PowerUp powerUp, IEnumerable tanks) { - var position = obj.Position; - foreach (var tank in entityManager.Tanks) + var position = powerUp.Position; + foreach (var tank in tanks) { var (topLeft, bottomRight) = tank.Bounds; if (position.X < topLeft.X || position.X > bottomRight.X || @@ -24,36 +26,40 @@ internal sealed class CollectPowerUp( // now the tank overlaps the power up by at least 0.5 tiles - switch (obj.Type) - { - case PowerUpType.MagazineType: - if (obj.MagazineType == null) - throw new UnreachableException(); - - tank.Magazine = tank.Magazine with - { - Type = tank.Magazine.Type | obj.MagazineType.Value, - UsedBullets = 0 - }; - - if (tank.ReloadingUntil >= DateTime.Now) - tank.ReloadingUntil = DateTime.Now; - - break; - case PowerUpType.MagazineSize: - tank.Magazine = tank.Magazine with - { - MaxBullets = (byte)int.Clamp(tank.Magazine.MaxBullets + 1, 1, 32) - }; - break; - default: - throw new UnreachableException(); - } - + ApplyPowerUpEffect(powerUp, tank); tank.Owner.Scores.PowerUpsCollected++; return true; } return false; } + + private static void ApplyPowerUpEffect(PowerUp powerUp, Tank tank) + { + switch (powerUp.Type) + { + case PowerUpType.MagazineType: + if (powerUp.MagazineType == null) + throw new UnreachableException(); + + tank.Magazine = tank.Magazine with + { + Type = tank.Magazine.Type | powerUp.MagazineType.Value, + UsedBullets = 0 + }; + + if (tank.ReloadingUntil >= DateTime.Now) + tank.ReloadingUntil = DateTime.Now; + + break; + case PowerUpType.MagazineSize: + tank.Magazine = tank.Magazine with + { + MaxBullets = (byte)int.Clamp(tank.Magazine.MaxBullets + 1, 1, 32) + }; + break; + default: + throw new UnreachableException(); + } + } } diff --git a/tanks-backend/TanksServer/GameLogic/CollideBullets.cs b/tanks-backend/TanksServer/GameLogic/CollideBullets.cs index aefd13e..4be0dca 100644 --- a/tanks-backend/TanksServer/GameLogic/CollideBullets.cs +++ b/tanks-backend/TanksServer/GameLogic/CollideBullets.cs @@ -2,20 +2,31 @@ using TanksServer.Graphics; namespace TanksServer.GameLogic; -internal sealed class CollideBullets( - MapEntityManager entityManager, - MapService map, - IOptions options, - TankSpawnQueue tankSpawnQueue -) : ITickStep +internal sealed class CollideBullets : ITickStep { private readonly Sprite _explosiveSprite = Sprite.FromImageFile("assets/explosion.png"); + private readonly Predicate _removeBulletsPredicate; + private readonly MapEntityManager _entityManager; + private readonly MapService _map; + private readonly bool _destructibleWalls; + private readonly TankSpawnQueue _tankSpawnQueue; + + public CollideBullets(MapEntityManager entityManager, + MapService map, + IOptions options, + TankSpawnQueue tankSpawnQueue) + { + _entityManager = entityManager; + _map = map; + _tankSpawnQueue = tankSpawnQueue; + + _destructibleWalls = options.Value.DestructibleWalls; + _removeBulletsPredicate = b => BulletHitsTank(b) || BulletHitsWall(b) || BulletTimesOut(b); + } public ValueTask TickAsync(TimeSpan _) { - entityManager.RemoveWhere(BulletHitsTank); - entityManager.RemoveWhere(BulletHitsWall); - entityManager.RemoveWhere(BulletTimesOut); + _entityManager.RemoveWhere(_removeBulletsPredicate); return ValueTask.CompletedTask; } @@ -31,7 +42,7 @@ internal sealed class CollideBullets( private bool BulletHitsWall(Bullet bullet) { var pixel = bullet.Position.ToPixelPosition(); - if (!map.Current.IsWall(pixel)) + if (!_map.Current.IsWall(pixel)) return false; ExplodeAt(pixel, bullet.IsExplosive, bullet.Owner); @@ -50,7 +61,7 @@ internal sealed class CollideBullets( private Tank? GetTankAt(FloatPosition position, Player owner, bool canHitOwnTank) { - foreach (var tank in entityManager.Tanks) + foreach (var tank in _entityManager.Tanks) { var hitsOwnTank = owner == tank.Owner; if (hitsOwnTank && !canHitOwnTank) @@ -89,7 +100,7 @@ internal sealed class CollideBullets( void Core(PixelPosition position) { - if (options.Value.DestructibleWalls && map.Current.TryDestroyWallAt(position)) + if (_destructibleWalls && _map.Current.TryDestroyWallAt(position)) owner.Scores.WallsDestroyed++; var tank = GetTankAt(position.ToFloatPosition(), owner, true); @@ -100,8 +111,8 @@ internal sealed class CollideBullets( owner.Scores.Kills++; tank.Owner.Scores.Deaths++; - entityManager.Remove(tank); - tankSpawnQueue.EnqueueForDelayedSpawn(tank.Owner); + _entityManager.Remove(tank); + _tankSpawnQueue.EnqueueForDelayedSpawn(tank.Owner); } } } diff --git a/tanks-backend/TanksServer/Graphics/DrawPowerUpsStep.cs b/tanks-backend/TanksServer/Graphics/DrawPowerUpsStep.cs index c67a5e6..6d47060 100644 --- a/tanks-backend/TanksServer/Graphics/DrawPowerUpsStep.cs +++ b/tanks-backend/TanksServer/Graphics/DrawPowerUpsStep.cs @@ -4,26 +4,42 @@ namespace TanksServer.Graphics; internal sealed class DrawPowerUpsStep(MapEntityManager entityManager) : IDrawStep { + private readonly Sprite _genericSprite = Sprite.FromImageFile("assets/powerup_generic.png"); + private readonly Sprite _smartSprite = Sprite.FromImageFile("assets/powerup_smart.png"); + private readonly Sprite _magazineSprite = Sprite.FromImageFile("assets/powerup_magazine.png"); private readonly Sprite _explosiveSprite = Sprite.FromImageFile("assets/powerup_explosive.png"); + private readonly Sprite _fastSprite = Sprite.FromImageFile("assets/powerup_fastbullet.png"); public void Draw(GamePixelGrid pixels) { foreach (var powerUp in entityManager.PowerUps) { - var position = powerUp.Bounds.TopLeft; - - for (byte dy = 0; dy < MapService.TileSize; dy++) - for (byte dx = 0; dx < MapService.TileSize; dx++) + var sprite = powerUp switch { - var pixelState = _explosiveSprite[dx, dy]; - if (!pixelState.HasValue) - continue; + { Type: PowerUpType.MagazineSize } => _magazineSprite, + { Type: PowerUpType.MagazineType, MagazineType: MagazineType.Smart } => _smartSprite, + { Type: PowerUpType.MagazineType, MagazineType: MagazineType.Explosive } => _explosiveSprite, + { Type: PowerUpType.MagazineType, MagazineType: MagazineType.Fast } => _fastSprite, + _ => _genericSprite + }; - var (x, y) = position.GetPixelRelative(dx, dy); - pixels[x, y].EntityType = pixelState.Value - ? GamePixelEntityType.PowerUp - : null; - } + DrawPowerUp(pixels, sprite, powerUp.Bounds.TopLeft); + } + } + + private static void DrawPowerUp(GamePixelGrid pixels, Sprite sprite, PixelPosition position) + { + for (byte dy = 0; dy < MapService.TileSize; dy++) + for (byte dx = 0; dx < MapService.TileSize; dx++) + { + var pixelState = sprite[dx, dy]; + if (!pixelState.HasValue) + continue; + + var (x, y) = position.GetPixelRelative(dx, dy); + pixels[x, y].EntityType = pixelState.Value + ? GamePixelEntityType.PowerUp + : null; } } } diff --git a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs index 4cda7bb..6208cbf 100644 --- a/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/ClientScreenServerConnection.cs @@ -5,15 +5,12 @@ using TanksServer.Graphics; namespace TanksServer.Interactivity; -internal sealed class ClientScreenServerConnection : WebsocketServerConnection +internal sealed class ClientScreenServerConnection + : DroppablePackageRequestConnection { - private sealed record class Package(IMemoryOwner Pixels, IMemoryOwner? PlayerData); - private readonly BufferPool _bufferPool; private readonly PlayerScreenData? _playerDataBuilder; private readonly Player? _player; - private int _wantsFrameOnTick = 1; - private Package? _next; public ClientScreenServerConnection( WebSocket webSocket, @@ -30,55 +27,29 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection : new PlayerScreenData(logger, player); } - protected override ValueTask HandleMessageAsync(Memory _) - { - if (_wantsFrameOnTick != 0) - return ValueTask.CompletedTask; - - var package = Interlocked.Exchange(ref _next, null); - if (package != null) - return SendAndDisposeAsync(package); - - // the delay between one exchange and this set could be enough for another frame to complete - // this would mean the client simply drops a frame, so this should be fine - _wantsFrameOnTick = 1; - return ValueTask.CompletedTask; - } - public async Task OnGameTickAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid) { await Task.Yield(); + var next = BuildNextPackage(pixels, gamePixelGrid); + SetNextPackage(next); + } + private Package BuildNextPackage(PixelGrid pixels, GamePixelGrid gamePixelGrid) + { var nextPixels = _bufferPool.Rent(pixels.Data.Length); pixels.Data.CopyTo(nextPixels.Memory); - IMemoryOwner? nextPlayerData = null; - if (_playerDataBuilder != null) - { - var data = _playerDataBuilder.Build(gamePixelGrid); - nextPlayerData = _bufferPool.Rent(data.Length); - data.CopyTo(nextPlayerData.Memory); - } + if (_playerDataBuilder == null) + return new Package(nextPixels, null); - var next = new Package(nextPixels, nextPlayerData); - if (Interlocked.Exchange(ref _wantsFrameOnTick, 0) != 0) - { - await SendAndDisposeAsync(next); - return; - } + var data = _playerDataBuilder.Build(gamePixelGrid); + var nextPlayerData = _bufferPool.Rent(data.Length); + data.CopyTo(nextPlayerData.Memory); - var oldNext = Interlocked.Exchange(ref _next, next); - oldNext?.Pixels.Dispose(); - oldNext?.PlayerData?.Dispose(); + return new Package(nextPixels, nextPlayerData); } - public override ValueTask RemovedAsync() - { - _player?.DecrementConnectionCount(); - return ValueTask.CompletedTask; - } - - private async ValueTask SendAndDisposeAsync(Package package) + protected override async ValueTask SendPackageAsync(Package package) { try { @@ -90,10 +61,23 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection { Logger.LogWarning(ex, "send failed"); } - finally + } + + public override void Dispose() + { + base.Dispose(); + _player?.DecrementConnectionCount(); + } + + internal sealed record class Package( + IMemoryOwner Pixels, + IMemoryOwner? PlayerData + ) : IDisposable + { + public void Dispose() { - package.Pixels.Dispose(); - package.PlayerData?.Dispose(); + Pixels.Dispose(); + PlayerData?.Dispose(); } } } diff --git a/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs b/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs index edceb3d..3059dad 100644 --- a/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/ControlsServerConnection.cs @@ -69,9 +69,5 @@ internal sealed class ControlsServerConnection : WebsocketServerConnection return ValueTask.CompletedTask; } - public override ValueTask RemovedAsync() - { - _player.DecrementConnectionCount(); - return ValueTask.CompletedTask; - } + public override void Dispose() => _player.DecrementConnectionCount(); } diff --git a/tanks-backend/TanksServer/Interactivity/DroppablePackageRequestConnection.cs b/tanks-backend/TanksServer/Interactivity/DroppablePackageRequestConnection.cs new file mode 100644 index 0000000..19956a9 --- /dev/null +++ b/tanks-backend/TanksServer/Interactivity/DroppablePackageRequestConnection.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using DotNext.Threading; + +namespace TanksServer.Interactivity; + +internal abstract class DroppablePackageRequestConnection( + ILogger logger, + ByteChannelWebSocket socket +) : WebsocketServerConnection(logger, socket), IDisposable + where TPackage : class, IDisposable +{ + private readonly AsyncAutoResetEvent _nextPackageEvent = new(false, 1); + private int _runningMessageHandlers = 0; + private TPackage? _next; + + protected override ValueTask HandleMessageAsync(Memory _) + { + if (Interlocked.Increment(ref _runningMessageHandlers) == 1) + return Core(); + + // client has requested multiple frames, ignoring duplicate requests + Interlocked.Decrement(ref _runningMessageHandlers); + return ValueTask.CompletedTask; + + async ValueTask Core() + { + await _nextPackageEvent.WaitAsync(); + var package = Interlocked.Exchange(ref _next, null); + if (package == null) + throw new UnreachableException("package should be set here"); + await SendPackageAsync(package); + Interlocked.Decrement(ref _runningMessageHandlers); + } + } + + protected void SetNextPackage(TPackage next) + { + var oldNext = Interlocked.Exchange(ref _next, next); + _nextPackageEvent.Set(); + oldNext?.Dispose(); + } + + protected abstract ValueTask SendPackageAsync(TPackage package); + + public override void Dispose() + { + _nextPackageEvent.Dispose(); + Interlocked.Exchange(ref _next, null)?.Dispose(); + } +} diff --git a/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs index cd4d461..04661c3 100644 --- a/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs @@ -6,18 +6,14 @@ using TanksServer.GameLogic; namespace TanksServer.Interactivity; -// MemoryStream is IDisposable but does not need to be disposed -#pragma warning disable CA1001 -internal sealed class PlayerInfoConnection : WebsocketServerConnection -#pragma warning restore CA1001 +internal sealed class PlayerInfoConnection + : DroppablePackageRequestConnection> { private readonly Player _player; private readonly MapEntityManager _entityManager; private readonly BufferPool _bufferPool; private readonly MemoryStream _tempStream = new(); - private int _wantsInfoOnTick = 1; private IMemoryOwner? _lastMessage = null; - private IMemoryOwner? _nextMessage = null; public PlayerInfoConnection( Player player, @@ -33,39 +29,22 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection _player.IncrementConnectionCount(); } - protected override ValueTask HandleMessageAsync(Memory buffer) - { - var next = Interlocked.Exchange(ref _nextMessage, null); - if (next != null) - return SendAndDisposeAsync(next); - - _wantsInfoOnTick = 1; - return ValueTask.CompletedTask; - } - public async Task OnGameTickAsync() { await Task.Yield(); var response = await GenerateMessageAsync(); - var wantsNow = Interlocked.Exchange(ref _wantsInfoOnTick, 0) != 0; - - if (wantsNow) - { - await SendAndDisposeAsync(response); - return; - } - - Interlocked.Exchange(ref _nextMessage, response); + if (response != null) + SetNextPackage(response); } - public override ValueTask RemovedAsync() + public override void Dispose() { + base.Dispose(); _player.DecrementConnectionCount(); - return ValueTask.CompletedTask; } - private async ValueTask> GenerateMessageAsync() + private async ValueTask?> GenerateMessageAsync() { var tank = _entityManager.GetCurrentTankOfPlayer(_player); @@ -89,12 +68,18 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection var messageLength = (int)_tempStream.Position; var owner = _bufferPool.Rent(messageLength); + _tempStream.Position = 0; await _tempStream.ReadExactlyAsync(owner.Memory); - return owner; + + if (_lastMessage == null || !owner.Memory.Span.SequenceEqual(_lastMessage.Memory.Span)) + return owner; + + owner.Dispose(); + return null; } - private async ValueTask SendAndDisposeAsync(IMemoryOwner data) + protected override async ValueTask SendPackageAsync(IMemoryOwner data) { await Socket.SendTextAsync(data.Memory); Interlocked.Exchange(ref _lastMessage, data)?.Dispose(); diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs index 19f7e7a..0c33e4c 100644 --- a/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServer.cs @@ -37,7 +37,7 @@ internal abstract class WebsocketServer( await connection.ReceiveAsync(); _ = _connections.TryRemove(connection, out _); - await connection.RemovedAsync(); + connection.Dispose(); } public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs index 6be215c..893b7a5 100644 --- a/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/WebsocketServerConnection.cs @@ -3,7 +3,7 @@ namespace TanksServer.Interactivity; internal abstract class WebsocketServerConnection( ILogger logger, ByteChannelWebSocket socket -) +): IDisposable { protected readonly ByteChannelWebSocket Socket = socket; protected readonly ILogger Logger = logger; @@ -21,7 +21,7 @@ internal abstract class WebsocketServerConnection( Logger.LogTrace("done receiving"); } - public abstract ValueTask RemovedAsync(); - protected abstract ValueTask HandleMessageAsync(Memory buffer); + + public abstract void Dispose(); } diff --git a/tanks-backend/TanksServer/TanksServer.csproj b/tanks-backend/TanksServer/TanksServer.csproj index 307de71..4cce028 100644 --- a/tanks-backend/TanksServer/TanksServer.csproj +++ b/tanks-backend/TanksServer/TanksServer.csproj @@ -7,9 +7,10 @@ - - - + + + + diff --git a/tanks-backend/TanksServer/assets/powerup_explosive.png b/tanks-backend/TanksServer/assets/powerup_explosive.png index f1854d2..09cdc5b 100644 Binary files a/tanks-backend/TanksServer/assets/powerup_explosive.png and b/tanks-backend/TanksServer/assets/powerup_explosive.png differ diff --git a/tanks-backend/TanksServer/assets/powerup_fastbullet.png b/tanks-backend/TanksServer/assets/powerup_fastbullet.png new file mode 100644 index 0000000..256f6a8 Binary files /dev/null and b/tanks-backend/TanksServer/assets/powerup_fastbullet.png differ diff --git a/tanks-backend/TanksServer/assets/powerup_generic.png b/tanks-backend/TanksServer/assets/powerup_generic.png new file mode 100644 index 0000000..5ba69ec Binary files /dev/null and b/tanks-backend/TanksServer/assets/powerup_generic.png differ diff --git a/tanks-backend/TanksServer/assets/powerup_magazine.png b/tanks-backend/TanksServer/assets/powerup_magazine.png new file mode 100644 index 0000000..30c021c Binary files /dev/null and b/tanks-backend/TanksServer/assets/powerup_magazine.png differ diff --git a/tanks-backend/TanksServer/assets/powerup_smart.png b/tanks-backend/TanksServer/assets/powerup_smart.png new file mode 100644 index 0000000..c3ceff5 Binary files /dev/null and b/tanks-backend/TanksServer/assets/powerup_smart.png differ