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 {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<HTMLCanvasElement>(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();

View file

@ -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<PlayerInfoMessage>(url.toString(), {
onMessage: () => setShouldSendMessage(true)
onMessage: () => setShouldSendMessage(true),
onOpen: _ => setShouldSendMessage(true)
});
useEffect(() => {

View file

@ -19,10 +19,10 @@ export type Player = {
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, {
shouldReconnect: () => true,
reconnectAttempts: 5,
reconnectAttempts: 2,
onReconnectStop: () => alert('server connection failed. please reload.'),
...options
});

View file

@ -6,16 +6,18 @@ internal sealed class CollectPowerUp(
MapEntityManager entityManager
) : ITickStep
{
private readonly Predicate<PowerUp> _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<Tank> 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();
}
}
}

View file

@ -2,20 +2,31 @@ using TanksServer.Graphics;
namespace TanksServer.GameLogic;
internal sealed class CollideBullets(
MapEntityManager entityManager,
MapService map,
IOptions<GameRules> options,
TankSpawnQueue tankSpawnQueue
) : ITickStep
internal sealed class CollideBullets : ITickStep
{
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 _)
{
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);
}
}
}

View file

@ -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;
}
}
}

View file

@ -5,15 +5,12 @@ using TanksServer.Graphics;
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 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<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)
{
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<byte>? 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<byte> Pixels,
IMemoryOwner<byte>? PlayerData
) : IDisposable
{
public void Dispose()
{
package.Pixels.Dispose();
package.PlayerData?.Dispose();
Pixels.Dispose();
PlayerData?.Dispose();
}
}
}

View file

@ -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();
}

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;
// 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<IMemoryOwner<byte>>
{
private readonly Player _player;
private readonly MapEntityManager _entityManager;
private readonly BufferPool _bufferPool;
private readonly MemoryStream _tempStream = new();
private int _wantsInfoOnTick = 1;
private IMemoryOwner<byte>? _lastMessage = null;
private IMemoryOwner<byte>? _nextMessage = null;
public PlayerInfoConnection(
Player player,
@ -33,39 +29,22 @@ internal sealed class PlayerInfoConnection : WebsocketServerConnection
_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()
{
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<IMemoryOwner<byte>> GenerateMessageAsync()
private async ValueTask<IMemoryOwner<byte>?> 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<byte> data)
protected override async ValueTask SendPackageAsync(IMemoryOwner<byte> data)
{
await Socket.SendTextAsync(data.Memory);
Interlocked.Exchange(ref _lastMessage, data)?.Dispose();

View file

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

View file

@ -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<byte> buffer);
public abstract void Dispose();
}

View file

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