powerup icons, optimizations (#18)

This commit is contained in:
RobbersDaughter 2024-05-06 20:55:55 +02:00 committed by GitHub
commit 6a325afeef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 214 additions and 152 deletions

View file

@ -1,7 +1,8 @@
import {useEffect, useRef} from 'react'; import {useEffect, useRef, useState} from 'react';
import './ClientScreen.css'; import './ClientScreen.css';
import {hslToString, Theme} from './theme.ts'; import {hslToString, Theme} from './theme.ts';
import {makeApiUrl, useMyWebSocket} from './serverCalls.tsx'; import {makeApiUrl, useMyWebSocket} from './serverCalls.tsx';
import {ReadyState} from 'react-use-websocket';
const pixelsPerRow = 352; const pixelsPerRow = 352;
const pixelsPerCol = 160; const pixelsPerCol = 160;
@ -101,6 +102,7 @@ export default function ClientScreen({theme, player}: {
player: string | null player: string | null
}) { }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [shouldSendMessage, setShouldSendMessage] = useState(false);
const url = makeApiUrl('/screen', 'ws'); const url = makeApiUrl('/screen', 'ws');
if (player && player !== '') if (player && player !== '')
@ -109,13 +111,24 @@ export default function ClientScreen({theme, player}: {
const { const {
lastMessage, lastMessage,
sendMessage, sendMessage,
getWebSocket getWebSocket,
} = useMyWebSocket(url.toString(), {}); readyState
} = useMyWebSocket(url.toString(), {
onOpen: _ => setShouldSendMessage(true)
});
const socket = getWebSocket(); const socket = getWebSocket();
if (socket) if (socket)
(socket as WebSocket).binaryType = 'arraybuffer'; (socket as WebSocket).binaryType = 'arraybuffer';
useEffect(() => {
if (!shouldSendMessage || readyState !== ReadyState.OPEN)
return;
setShouldSendMessage(false);
sendMessage('');
}, [readyState, shouldSendMessage]);
useEffect(() => { useEffect(() => {
if (lastMessage === null) if (lastMessage === null)
return; return;
@ -155,7 +168,7 @@ export default function ClientScreen({theme, player}: {
if (ignore) if (ignore)
return; return;
sendMessage(''); setShouldSendMessage(true);
}; };
start(); start();

View file

@ -28,7 +28,6 @@ type TankInfo = {
readonly moving: boolean; readonly moving: boolean;
} }
type PlayerInfoMessage = { type PlayerInfoMessage = {
readonly name: string; readonly name: string;
readonly scores: Scores; readonly scores: Scores;
@ -48,7 +47,8 @@ export default function PlayerInfo({player}: { player: string }) {
readyState, readyState,
sendMessage sendMessage
} = useMyWebSocket<PlayerInfoMessage>(url.toString(), { } = useMyWebSocket<PlayerInfoMessage>(url.toString(), {
onMessage: () => setShouldSendMessage(true) onMessage: () => setShouldSendMessage(true),
onOpen: _ => setShouldSendMessage(true)
}); });
useEffect(() => { useEffect(() => {

View file

@ -19,10 +19,10 @@ export type Player = {
readonly scores: Scores; readonly scores: Scores;
}; };
export function useMyWebSocket<T = unknown>(url: string, options: Options) { export function useMyWebSocket<T = unknown>(url: string, options: Options = {}) {
return useWebSocket<T>(url, { return useWebSocket<T>(url, {
shouldReconnect: () => true, shouldReconnect: () => true,
reconnectAttempts: 5, reconnectAttempts: 2,
onReconnectStop: () => alert('server connection failed. please reload.'), onReconnectStop: () => alert('server connection failed. please reload.'),
...options ...options
}); });

View file

@ -6,16 +6,18 @@ internal sealed class CollectPowerUp(
MapEntityManager entityManager MapEntityManager entityManager
) : ITickStep ) : ITickStep
{ {
private readonly Predicate<PowerUp> _collectPredicate = b => TryCollect(b, entityManager.Tanks);
public ValueTask TickAsync(TimeSpan delta) public ValueTask TickAsync(TimeSpan delta)
{ {
entityManager.RemoveWhere(TryCollect); entityManager.RemoveWhere(_collectPredicate);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
private bool TryCollect(PowerUp obj) private static bool TryCollect(PowerUp powerUp, IEnumerable<Tank> tanks)
{ {
var position = obj.Position; var position = powerUp.Position;
foreach (var tank in entityManager.Tanks) foreach (var tank in tanks)
{ {
var (topLeft, bottomRight) = tank.Bounds; var (topLeft, bottomRight) = tank.Bounds;
if (position.X < topLeft.X || position.X > bottomRight.X || if (position.X < topLeft.X || position.X > bottomRight.X ||
@ -24,15 +26,25 @@ internal sealed class CollectPowerUp(
// now the tank overlaps the power up by at least 0.5 tiles // now the tank overlaps the power up by at least 0.5 tiles
switch (obj.Type) 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: case PowerUpType.MagazineType:
if (obj.MagazineType == null) if (powerUp.MagazineType == null)
throw new UnreachableException(); throw new UnreachableException();
tank.Magazine = tank.Magazine with tank.Magazine = tank.Magazine with
{ {
Type = tank.Magazine.Type | obj.MagazineType.Value, Type = tank.Magazine.Type | powerUp.MagazineType.Value,
UsedBullets = 0 UsedBullets = 0
}; };
@ -49,11 +61,5 @@ internal sealed class CollectPowerUp(
default: default:
throw new UnreachableException(); throw new UnreachableException();
} }
tank.Owner.Scores.PowerUpsCollected++;
return true;
}
return false;
} }
} }

View file

@ -2,20 +2,31 @@ using TanksServer.Graphics;
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
internal sealed class CollideBullets( internal sealed class CollideBullets : ITickStep
MapEntityManager entityManager,
MapService map,
IOptions<GameRules> options,
TankSpawnQueue tankSpawnQueue
) : ITickStep
{ {
private readonly Sprite _explosiveSprite = Sprite.FromImageFile("assets/explosion.png"); private readonly Sprite _explosiveSprite = Sprite.FromImageFile("assets/explosion.png");
private readonly Predicate<Bullet> _removeBulletsPredicate;
private readonly MapEntityManager _entityManager;
private readonly MapService _map;
private readonly bool _destructibleWalls;
private readonly TankSpawnQueue _tankSpawnQueue;
public CollideBullets(MapEntityManager entityManager,
MapService map,
IOptions<GameRules> 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 _) public ValueTask TickAsync(TimeSpan _)
{ {
entityManager.RemoveWhere(BulletHitsTank); _entityManager.RemoveWhere(_removeBulletsPredicate);
entityManager.RemoveWhere(BulletHitsWall);
entityManager.RemoveWhere(BulletTimesOut);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
@ -31,7 +42,7 @@ internal sealed class CollideBullets(
private bool BulletHitsWall(Bullet bullet) private bool BulletHitsWall(Bullet bullet)
{ {
var pixel = bullet.Position.ToPixelPosition(); var pixel = bullet.Position.ToPixelPosition();
if (!map.Current.IsWall(pixel)) if (!_map.Current.IsWall(pixel))
return false; return false;
ExplodeAt(pixel, bullet.IsExplosive, bullet.Owner); ExplodeAt(pixel, bullet.IsExplosive, bullet.Owner);
@ -50,7 +61,7 @@ internal sealed class CollideBullets(
private Tank? GetTankAt(FloatPosition position, Player owner, bool canHitOwnTank) 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; var hitsOwnTank = owner == tank.Owner;
if (hitsOwnTank && !canHitOwnTank) if (hitsOwnTank && !canHitOwnTank)
@ -89,7 +100,7 @@ internal sealed class CollideBullets(
void Core(PixelPosition position) void Core(PixelPosition position)
{ {
if (options.Value.DestructibleWalls && map.Current.TryDestroyWallAt(position)) if (_destructibleWalls && _map.Current.TryDestroyWallAt(position))
owner.Scores.WallsDestroyed++; owner.Scores.WallsDestroyed++;
var tank = GetTankAt(position.ToFloatPosition(), owner, true); var tank = GetTankAt(position.ToFloatPosition(), owner, true);
@ -100,8 +111,8 @@ internal sealed class CollideBullets(
owner.Scores.Kills++; owner.Scores.Kills++;
tank.Owner.Scores.Deaths++; tank.Owner.Scores.Deaths++;
entityManager.Remove(tank); _entityManager.Remove(tank);
tankSpawnQueue.EnqueueForDelayedSpawn(tank.Owner); _tankSpawnQueue.EnqueueForDelayedSpawn(tank.Owner);
} }
} }
} }

View file

@ -4,18 +4,35 @@ namespace TanksServer.Graphics;
internal sealed class DrawPowerUpsStep(MapEntityManager entityManager) : IDrawStep 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 _explosiveSprite = Sprite.FromImageFile("assets/powerup_explosive.png");
private readonly Sprite _fastSprite = Sprite.FromImageFile("assets/powerup_fastbullet.png");
public void Draw(GamePixelGrid pixels) public void Draw(GamePixelGrid pixels)
{ {
foreach (var powerUp in entityManager.PowerUps) foreach (var powerUp in entityManager.PowerUps)
{ {
var position = powerUp.Bounds.TopLeft; var sprite = powerUp switch
{
{ 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
};
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 dy = 0; dy < MapService.TileSize; dy++)
for (byte dx = 0; dx < MapService.TileSize; dx++) for (byte dx = 0; dx < MapService.TileSize; dx++)
{ {
var pixelState = _explosiveSprite[dx, dy]; var pixelState = sprite[dx, dy];
if (!pixelState.HasValue) if (!pixelState.HasValue)
continue; continue;
@ -25,5 +42,4 @@ internal sealed class DrawPowerUpsStep(MapEntityManager entityManager) : IDrawSt
: null; : null;
} }
} }
}
} }

View file

@ -5,15 +5,12 @@ using TanksServer.Graphics;
namespace TanksServer.Interactivity; namespace TanksServer.Interactivity;
internal sealed class ClientScreenServerConnection : WebsocketServerConnection internal sealed class ClientScreenServerConnection
: DroppablePackageRequestConnection<ClientScreenServerConnection.Package>
{ {
private sealed record class Package(IMemoryOwner<byte> Pixels, IMemoryOwner<byte>? PlayerData);
private readonly BufferPool _bufferPool; private readonly BufferPool _bufferPool;
private readonly PlayerScreenData? _playerDataBuilder; private readonly PlayerScreenData? _playerDataBuilder;
private readonly Player? _player; private readonly Player? _player;
private int _wantsFrameOnTick = 1;
private Package? _next;
public ClientScreenServerConnection( public ClientScreenServerConnection(
WebSocket webSocket, WebSocket webSocket,
@ -30,55 +27,29 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection
: new PlayerScreenData(logger, player); : new PlayerScreenData(logger, player);
} }
protected override ValueTask HandleMessageAsync(Memory<byte> _)
{
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) public async Task OnGameTickAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid)
{ {
await Task.Yield(); await Task.Yield();
var next = BuildNextPackage(pixels, gamePixelGrid);
SetNextPackage(next);
}
private Package BuildNextPackage(PixelGrid pixels, GamePixelGrid gamePixelGrid)
{
var nextPixels = _bufferPool.Rent(pixels.Data.Length); var nextPixels = _bufferPool.Rent(pixels.Data.Length);
pixels.Data.CopyTo(nextPixels.Memory); pixels.Data.CopyTo(nextPixels.Memory);
IMemoryOwner<byte>? nextPlayerData = null; if (_playerDataBuilder == null)
if (_playerDataBuilder != null) return new Package(nextPixels, null);
{
var data = _playerDataBuilder.Build(gamePixelGrid); var data = _playerDataBuilder.Build(gamePixelGrid);
nextPlayerData = _bufferPool.Rent(data.Length); var nextPlayerData = _bufferPool.Rent(data.Length);
data.CopyTo(nextPlayerData.Memory); data.CopyTo(nextPlayerData.Memory);
return new Package(nextPixels, nextPlayerData);
} }
var next = new Package(nextPixels, nextPlayerData); protected override async ValueTask SendPackageAsync(Package package)
if (Interlocked.Exchange(ref _wantsFrameOnTick, 0) != 0)
{
await SendAndDisposeAsync(next);
return;
}
var oldNext = Interlocked.Exchange(ref _next, next);
oldNext?.Pixels.Dispose();
oldNext?.PlayerData?.Dispose();
}
public override ValueTask RemovedAsync()
{
_player?.DecrementConnectionCount();
return ValueTask.CompletedTask;
}
private async ValueTask SendAndDisposeAsync(Package package)
{ {
try try
{ {
@ -90,10 +61,23 @@ internal sealed class ClientScreenServerConnection : WebsocketServerConnection
{ {
Logger.LogWarning(ex, "send failed"); Logger.LogWarning(ex, "send failed");
} }
finally }
public override void Dispose()
{ {
package.Pixels.Dispose(); base.Dispose();
package.PlayerData?.Dispose(); _player?.DecrementConnectionCount();
}
internal sealed record class Package(
IMemoryOwner<byte> Pixels,
IMemoryOwner<byte>? PlayerData
) : IDisposable
{
public void Dispose()
{
Pixels.Dispose();
PlayerData?.Dispose();
} }
} }
} }

View file

@ -69,9 +69,5 @@ internal sealed class ControlsServerConnection : WebsocketServerConnection
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
public override ValueTask RemovedAsync() public override void Dispose() => _player.DecrementConnectionCount();
{
_player.DecrementConnectionCount();
return ValueTask.CompletedTask;
}
} }

View file

@ -0,0 +1,50 @@
using System.Diagnostics;
using DotNext.Threading;
namespace TanksServer.Interactivity;
internal abstract class DroppablePackageRequestConnection<TPackage>(
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<byte> _)
{
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();
}
}

View file

@ -6,18 +6,14 @@ using TanksServer.GameLogic;
namespace TanksServer.Interactivity; namespace TanksServer.Interactivity;
// MemoryStream is IDisposable but does not need to be disposed internal sealed class PlayerInfoConnection
#pragma warning disable CA1001 : DroppablePackageRequestConnection<IMemoryOwner<byte>>
internal sealed class PlayerInfoConnection : WebsocketServerConnection
#pragma warning restore CA1001
{ {
private readonly Player _player; private readonly Player _player;
private readonly MapEntityManager _entityManager; private readonly MapEntityManager _entityManager;
private readonly BufferPool _bufferPool; private readonly BufferPool _bufferPool;
private readonly MemoryStream _tempStream = new(); private readonly MemoryStream _tempStream = new();
private int _wantsInfoOnTick = 1;
private IMemoryOwner<byte>? _lastMessage = null; private IMemoryOwner<byte>? _lastMessage = null;
private IMemoryOwner<byte>? _nextMessage = null;
public PlayerInfoConnection( public PlayerInfoConnection(
Player player, Player player,
@ -33,39 +29,22 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection
_player.IncrementConnectionCount(); _player.IncrementConnectionCount();
} }
protected override ValueTask HandleMessageAsync(Memory<byte> buffer)
{
var next = Interlocked.Exchange(ref _nextMessage, null);
if (next != null)
return SendAndDisposeAsync(next);
_wantsInfoOnTick = 1;
return ValueTask.CompletedTask;
}
public async Task OnGameTickAsync() public async Task OnGameTickAsync()
{ {
await Task.Yield(); await Task.Yield();
var response = await GenerateMessageAsync(); var response = await GenerateMessageAsync();
var wantsNow = Interlocked.Exchange(ref _wantsInfoOnTick, 0) != 0; if (response != null)
SetNextPackage(response);
if (wantsNow)
{
await SendAndDisposeAsync(response);
return;
} }
Interlocked.Exchange(ref _nextMessage, response); public override void Dispose()
}
public override ValueTask RemovedAsync()
{ {
base.Dispose();
_player.DecrementConnectionCount(); _player.DecrementConnectionCount();
return ValueTask.CompletedTask;
} }
private async ValueTask<IMemoryOwner<byte>> GenerateMessageAsync() private async ValueTask<IMemoryOwner<byte>?> GenerateMessageAsync()
{ {
var tank = _entityManager.GetCurrentTankOfPlayer(_player); var tank = _entityManager.GetCurrentTankOfPlayer(_player);
@ -89,12 +68,18 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection
var messageLength = (int)_tempStream.Position; var messageLength = (int)_tempStream.Position;
var owner = _bufferPool.Rent(messageLength); var owner = _bufferPool.Rent(messageLength);
_tempStream.Position = 0; _tempStream.Position = 0;
await _tempStream.ReadExactlyAsync(owner.Memory); await _tempStream.ReadExactlyAsync(owner.Memory);
if (_lastMessage == null || !owner.Memory.Span.SequenceEqual(_lastMessage.Memory.Span))
return owner; return owner;
owner.Dispose();
return null;
} }
private async ValueTask SendAndDisposeAsync(IMemoryOwner<byte> data) protected override async ValueTask SendPackageAsync(IMemoryOwner<byte> data)
{ {
await Socket.SendTextAsync(data.Memory); await Socket.SendTextAsync(data.Memory);
Interlocked.Exchange(ref _lastMessage, data)?.Dispose(); Interlocked.Exchange(ref _lastMessage, data)?.Dispose();

View file

@ -37,7 +37,7 @@ internal abstract class WebsocketServer<T>(
await connection.ReceiveAsync(); await connection.ReceiveAsync();
_ = _connections.TryRemove(connection, out _); _ = _connections.TryRemove(connection, out _);
await connection.RemovedAsync(); connection.Dispose();
} }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;

View file

@ -3,7 +3,7 @@ namespace TanksServer.Interactivity;
internal abstract class WebsocketServerConnection( internal abstract class WebsocketServerConnection(
ILogger logger, ILogger logger,
ByteChannelWebSocket socket ByteChannelWebSocket socket
) ): IDisposable
{ {
protected readonly ByteChannelWebSocket Socket = socket; protected readonly ByteChannelWebSocket Socket = socket;
protected readonly ILogger Logger = logger; protected readonly ILogger Logger = logger;
@ -21,7 +21,7 @@ internal abstract class WebsocketServerConnection(
Logger.LogTrace("done receiving"); Logger.LogTrace("done receiving");
} }
public abstract ValueTask RemovedAsync();
protected abstract ValueTask HandleMessageAsync(Memory<byte> buffer); protected abstract ValueTask HandleMessageAsync(Memory<byte> buffer);
public abstract void Dispose();
} }

View file

@ -7,9 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0"/> <PackageReference Include="DotNext.Threading" Version="5.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4"/> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<ProjectReference Include="../DisplayCommands/DisplayCommands.csproj"/> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<ProjectReference Include="../DisplayCommands/DisplayCommands.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 B

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B