diff --git a/README.md b/README.md index 7981903..20b4f72 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,8 @@ There are other commands implemented as well, e.g. for changing the brightness. - 10: bullet - 11: (reserved) - client responds with empty message to request the next frame + +## Backlog: Bugs, Wishes, Ideas +- Generalize drawing of entities as there are multiple classes with pretty much the same code +- Generalize hit box collision +- BUG: when standing next to a wall, the bullet sometimes misses the first pixel diff --git a/TanksServer/Endpoints.cs b/TanksServer/Endpoints.cs new file mode 100644 index 0000000..f139715 --- /dev/null +++ b/TanksServer/Endpoints.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using TanksServer.GameLogic; +using TanksServer.Interactivity; + +namespace TanksServer; + +internal static class Endpoints +{ + public static void MapEndpoints(WebApplication app) + { + var clientScreenServer = app.Services.GetRequiredService(); + var playerService = app.Services.GetRequiredService(); + var controlsServer = app.Services.GetRequiredService(); + var mapService = app.Services.GetRequiredService(); + + 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"); + + var player = playerService.GetOrAdd(name, id ?? Guid.NewGuid()); + return player != null + ? Results.Ok(new NameId(player.Name, player.Id)) + : Results.Unauthorized(); + }); + + app.MapGet("/player", ([FromQuery] Guid id) => + playerService.TryGet(id, out var foundPlayer) + ? Results.Ok((object?)foundPlayer) + : Results.NotFound() + ); + + 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.HandleClient(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.HandleClient(ws, player); + return Results.Empty; + }); + + app.MapGet("/map", () => mapService.MapNames); + + 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(); + }); + } + +} diff --git a/TanksServer/GameLogic/BulletManager.cs b/TanksServer/GameLogic/BulletManager.cs deleted file mode 100644 index 47fd579..0000000 --- a/TanksServer/GameLogic/BulletManager.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace TanksServer.GameLogic; - -internal sealed class BulletManager -{ - private readonly HashSet _bullets = []; - - public void Spawn(Player tankOwner, FloatPosition position, double rotation) - => _bullets.Add(new Bullet(tankOwner, position, rotation)); - - public IEnumerable GetAll() => _bullets; - - public void RemoveWhere(Predicate predicate) => _bullets.RemoveWhere(predicate); -} diff --git a/TanksServer/GameLogic/CollectPowerUp.cs b/TanksServer/GameLogic/CollectPowerUp.cs new file mode 100644 index 0000000..04afe67 --- /dev/null +++ b/TanksServer/GameLogic/CollectPowerUp.cs @@ -0,0 +1,30 @@ +namespace TanksServer.GameLogic; + +internal sealed class CollectPowerUp( + MapEntityManager entityManager +) : ITickStep +{ + public Task TickAsync(TimeSpan delta) + { + entityManager.RemoveWhere(TryCollect); + return Task.CompletedTask; + } + + private bool TryCollect(PowerUp obj) + { + var position = obj.Position; + foreach (var tank in entityManager.Tanks) + { + var (topLeft, bottomRight) = tank.Bounds; + if (position.X < topLeft.X || position.X > bottomRight.X || + position.Y < topLeft.Y || position.Y > bottomRight.Y) + continue; + + // this works because now the tank overlaps the power up + tank.ExplosiveBullets += 10; + return true; + } + + return false; + } +} diff --git a/TanksServer/GameLogic/CollideBullets.cs b/TanksServer/GameLogic/CollideBullets.cs new file mode 100644 index 0000000..59fc297 --- /dev/null +++ b/TanksServer/GameLogic/CollideBullets.cs @@ -0,0 +1,73 @@ +namespace TanksServer.GameLogic; + +internal sealed class CollideBullets( + MapEntityManager entityManager, + MapService map, + IOptions options, + TankSpawnQueue tankSpawnQueue +) : ITickStep +{ + private const int ExplosionRadius = 3; + + public Task TickAsync(TimeSpan _) + { + entityManager.RemoveBulletsWhere(BulletHitsTank); + entityManager.RemoveBulletsWhere(TryHitAndDestroyWall); + return Task.CompletedTask; + } + + private bool TryHitAndDestroyWall(Bullet bullet) + { + var pixel = bullet.Position.ToPixelPosition(); + if (!map.Current.IsWall(pixel)) + return false; + + var radius = bullet.IsExplosive ? ExplosionRadius : 0; + ExplodeAt(pixel, radius, bullet.Owner); + + return true; + } + + private bool BulletHitsTank(Bullet bullet) + { + if (!TryHitTankAt(bullet.Position, bullet.Owner)) + return false; + + if (bullet.IsExplosive) + ExplodeAt(bullet.Position.ToPixelPosition(), ExplosionRadius, bullet.Owner); + return true; + } + + private bool TryHitTankAt(FloatPosition position, Player owner) + { + foreach (var tank in entityManager.Tanks) + { + var (topLeft, bottomRight) = tank.Bounds; + if (position.X < topLeft.X || position.X > bottomRight.X || + position.Y < topLeft.Y || position.Y > bottomRight.Y) + continue; + + if (owner != tank.Owner) + owner.Scores.Kills++; + tank.Owner.Scores.Deaths++; + + entityManager.Remove(tank); + tankSpawnQueue.EnqueueForDelayedSpawn(tank.Owner); + return true; + } + + return false; + } + + private void ExplodeAt(PixelPosition pixel, int i, Player owner) + { + for (var x = pixel.X - i; x <= pixel.X + i; x++) + for (var y = pixel.Y - i; y <= pixel.Y + i; y++) + { + var offsetPixel = new PixelPosition(x, y); + if (options.Value.DestructibleWalls) + map.Current.DestroyWallAt(offsetPixel); + TryHitTankAt(offsetPixel.ToFloatPosition(), owner); + } + } +} diff --git a/TanksServer/GameLogic/CollideBulletsWithMap.cs b/TanksServer/GameLogic/CollideBulletsWithMap.cs deleted file mode 100644 index f4934a7..0000000 --- a/TanksServer/GameLogic/CollideBulletsWithMap.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace TanksServer.GameLogic; - -internal sealed class CollideBulletsWithMap( - BulletManager bullets, - MapService map, - IOptions options -) : ITickStep -{ - public Task TickAsync(TimeSpan _) - { - bullets.RemoveWhere(TryHitAndDestroyWall); - return Task.CompletedTask; - } - - private bool TryHitAndDestroyWall(Bullet bullet) - { - var pixel = bullet.Position.ToPixelPosition(); - if (!map.Current.IsWall(pixel)) - return false; - - if (options.Value.DestructibleWalls) - map.Current.DestroyWallAt(pixel); - return true; - } -} diff --git a/TanksServer/GameLogic/CollideBulletsWithTanks.cs b/TanksServer/GameLogic/CollideBulletsWithTanks.cs deleted file mode 100644 index 8c4518a..0000000 --- a/TanksServer/GameLogic/CollideBulletsWithTanks.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace TanksServer.GameLogic; - -internal sealed class CollideBulletsWithTanks( - BulletManager bullets, - TankManager tanks, - SpawnQueue spawnQueue -) : ITickStep -{ - public Task TickAsync(TimeSpan _) - { - bullets.RemoveWhere(BulletHitsTank); - return Task.CompletedTask; - } - - private bool BulletHitsTank(Bullet bullet) - { - foreach (var tank in tanks) - { - var (topLeft, bottomRight) = tank.Bounds; - if (bullet.Position.X < topLeft.X || bullet.Position.X > bottomRight.X || - bullet.Position.Y < topLeft.Y || bullet.Position.Y > bottomRight.Y) - continue; - - if (bullet.Owner != tank.Owner) - bullet.Owner.Scores.Kills++; - tank.Owner.Scores.Deaths++; - - tanks.Remove(tank); - spawnQueue.EnqueueForDelayedSpawn(tank.Owner); - - return true; - } - - return false; - } -} diff --git a/TanksServer/GameLogic/GameRulesConfiguration.cs b/TanksServer/GameLogic/GameRules.cs similarity index 52% rename from TanksServer/GameLogic/GameRulesConfiguration.cs rename to TanksServer/GameLogic/GameRules.cs index a52d793..b9e4e49 100644 --- a/TanksServer/GameLogic/GameRulesConfiguration.cs +++ b/TanksServer/GameLogic/GameRules.cs @@ -1,6 +1,8 @@ namespace TanksServer.GameLogic; -public class GameRulesConfiguration +internal sealed class GameRules { public bool DestructibleWalls { get; set; } = true; + + public double PowerUpSpawnChance { get; set; } } diff --git a/TanksServer/GameLogic/MapEntityManager.cs b/TanksServer/GameLogic/MapEntityManager.cs new file mode 100644 index 0000000..6796a23 --- /dev/null +++ b/TanksServer/GameLogic/MapEntityManager.cs @@ -0,0 +1,65 @@ +namespace TanksServer.GameLogic; + +internal sealed class MapEntityManager( + ILogger logger, + MapService map +) +{ + private readonly HashSet _bullets = []; + private readonly HashSet _tanks = []; + private readonly HashSet _powerUps = []; + + public IEnumerable Bullets => _bullets; + public IEnumerable Tanks => _tanks; + public IEnumerable PowerUps => _powerUps; + + public void SpawnBullet(Player tankOwner, FloatPosition position, double rotation, bool isExplosive) + => _bullets.Add(new Bullet(tankOwner, position, rotation, isExplosive)); + + public void RemoveBulletsWhere(Predicate predicate) => _bullets.RemoveWhere(predicate); + + public void SpawnTank(Player player) + { + _tanks.Add(new Tank(player, ChooseSpawnPosition()) + { + Rotation = Random.Shared.NextDouble() + }); + logger.LogInformation("Tank added for player {}", player.Id); + } + + public void SpawnPowerUp() => _powerUps.Add(new PowerUp(ChooseSpawnPosition())); + + public void RemoveWhere(Predicate predicate) => _powerUps.RemoveWhere(predicate); + + public void Remove(Tank tank) + { + logger.LogInformation("Tank removed for player {}", tank.Owner.Id); + _tanks.Remove(tank); + } + + public FloatPosition ChooseSpawnPosition() + { + Dictionary candidates = []; + + for (ushort x = 1; x < MapService.TilesPerRow - 1; x++) + for (ushort y = 1; y < MapService.TilesPerColumn - 1; y++) + { + var tile = new TilePosition(x, y); + if (map.Current.IsWall(tile)) + continue; + + var tilePixelCenter = tile.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); + + var minDistance = Bullets + .Cast() + .Concat(Tanks) + .Select(entity => entity.Position.Distance(tilePixelCenter)) + .Aggregate(double.MaxValue, Math.Min); + + candidates.Add(tile, minDistance); + } + + var min = candidates.MaxBy(pair => pair.Value).Key; + return min.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); + } +} diff --git a/TanksServer/GameLogic/MoveBullets.cs b/TanksServer/GameLogic/MoveBullets.cs index 1139266..aa1d7ff 100644 --- a/TanksServer/GameLogic/MoveBullets.cs +++ b/TanksServer/GameLogic/MoveBullets.cs @@ -1,10 +1,13 @@ namespace TanksServer.GameLogic; -internal sealed class MoveBullets(BulletManager bullets, IOptions config) : ITickStep +internal sealed class MoveBullets( + MapEntityManager entityManager, + IOptions config +) : ITickStep { public Task TickAsync(TimeSpan delta) { - foreach (var bullet in bullets.GetAll()) + foreach (var bullet in entityManager.Bullets) MoveBullet(bullet, delta); return Task.CompletedTask; diff --git a/TanksServer/GameLogic/MoveTanks.cs b/TanksServer/GameLogic/MoveTanks.cs index e23459d..31f11ca 100644 --- a/TanksServer/GameLogic/MoveTanks.cs +++ b/TanksServer/GameLogic/MoveTanks.cs @@ -1,7 +1,7 @@ namespace TanksServer.GameLogic; internal sealed class MoveTanks( - TankManager tanks, + MapEntityManager entityManager, IOptions options, MapService map ) : ITickStep @@ -10,7 +10,7 @@ internal sealed class MoveTanks( public Task TickAsync(TimeSpan delta) { - foreach (var tank in tanks) + foreach (var tank in entityManager.Tanks) tank.Moved = TryMoveTank(tank, delta); return Task.CompletedTask; @@ -59,13 +59,13 @@ internal sealed class MoveTanks( } private bool HitsTank(Tank tank, FloatPosition newPosition) => - tanks + entityManager.Tanks .Where(otherTank => otherTank != tank) .Any(otherTank => newPosition.Distance(otherTank.Position) < MapService.TileSize); private bool HitsWall(FloatPosition newPosition) { - var (topLeft, _) = Tank.GetBoundsForCenter(newPosition); + var (topLeft, _) = newPosition.GetBoundsForCenter(MapService.TileSize); for (short y = 0; y < MapService.TileSize; y++) for (short x = 0; x < MapService.TileSize; x++) diff --git a/TanksServer/GameLogic/RotateTanks.cs b/TanksServer/GameLogic/RotateTanks.cs index 5419b4b..f2c04a1 100644 --- a/TanksServer/GameLogic/RotateTanks.cs +++ b/TanksServer/GameLogic/RotateTanks.cs @@ -1,7 +1,7 @@ namespace TanksServer.GameLogic; internal sealed class RotateTanks( - TankManager tanks, + MapEntityManager entityManager, IOptions options, ILogger logger ) : ITickStep @@ -10,7 +10,7 @@ internal sealed class RotateTanks( public Task TickAsync(TimeSpan delta) { - foreach (var tank in tanks) + foreach (var tank in entityManager.Tanks) { var player = tank.Owner; diff --git a/TanksServer/GameLogic/ShootFromTanks.cs b/TanksServer/GameLogic/ShootFromTanks.cs index ea33921..5475426 100644 --- a/TanksServer/GameLogic/ShootFromTanks.cs +++ b/TanksServer/GameLogic/ShootFromTanks.cs @@ -3,16 +3,15 @@ using System.Diagnostics; namespace TanksServer.GameLogic; internal sealed class ShootFromTanks( - TankManager tanks, IOptions options, - BulletManager bulletManager + MapEntityManager entityManager ) : ITickStep { private readonly TanksConfiguration _config = options.Value; public Task TickAsync(TimeSpan _) { - foreach (var tank in tanks.Where(t => !t.Moved)) + foreach (var tank in entityManager.Tanks.Where(t => !t.Moved)) Shoot(tank); return Task.CompletedTask; @@ -30,7 +29,7 @@ internal sealed class ShootFromTanks( var rotation = tank.Orientation / 16d; var angle = rotation * 2d * Math.PI; - /* TODO: when standing next to a wall, the bullet sometimes misses the first pixel. + /* When standing next to a wall, the bullet sometimes misses the first pixel. Spawning the bullet to close to the tank instead means the tank instantly hits itself. Because the tank has a float position, but hit boxes are based on pixels, this problem has been deemed complex enough to do later. These values mostly work. */ @@ -47,6 +46,13 @@ internal sealed class ShootFromTanks( tank.Position.Y - Math.Cos(angle) * distance ); - bulletManager.Spawn(tank.Owner, position, rotation); + var explosive = false; + if (tank.ExplosiveBullets > 0) + { + tank.ExplosiveBullets--; + explosive = true; + } + + entityManager.SpawnBullet(tank.Owner, position, rotation, explosive); } } diff --git a/TanksServer/GameLogic/SpawnNewTanks.cs b/TanksServer/GameLogic/SpawnNewTanks.cs deleted file mode 100644 index faf6580..0000000 --- a/TanksServer/GameLogic/SpawnNewTanks.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace TanksServer.GameLogic; - -internal sealed class SpawnNewTanks( - TankManager tanks, - MapService map, - SpawnQueue queue, - BulletManager bullets -) : ITickStep -{ - public Task TickAsync(TimeSpan _) - { - if (!queue.TryDequeueNext(out var player)) - return Task.CompletedTask; - - tanks.Add(new Tank(player, ChooseSpawnPosition()) - { - Rotation = Random.Shared.NextDouble() - }); - - return Task.CompletedTask; - } - - private FloatPosition ChooseSpawnPosition() - { - Dictionary candidates = []; - - for (ushort x = 1; x < MapService.TilesPerRow - 1; x++) - for (ushort y = 1; y < MapService.TilesPerColumn - 1; y++) - { - var tile = new TilePosition(x, y); - if (map.Current.IsWall(tile)) - continue; - - var tilePixelCenter = tile.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); - - var minDistance = bullets.GetAll() - .Cast() - .Concat(tanks) - .Select(entity => entity.Position.Distance(tilePixelCenter)) - .Aggregate(double.MaxValue, Math.Min); - - candidates.Add(tile, minDistance); - } - - var min = candidates.MaxBy(kvp => kvp.Value).Key; - return min.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); - } -} diff --git a/TanksServer/GameLogic/SpawnPowerUp.cs b/TanksServer/GameLogic/SpawnPowerUp.cs new file mode 100644 index 0000000..16c418c --- /dev/null +++ b/TanksServer/GameLogic/SpawnPowerUp.cs @@ -0,0 +1,18 @@ +namespace TanksServer.GameLogic; + +internal sealed class SpawnPowerUp( + IOptions options, + MapEntityManager entityManager +) : ITickStep +{ + private readonly double _spawnChance = options.Value.PowerUpSpawnChance; + + public Task TickAsync(TimeSpan delta) + { + if (Random.Shared.NextDouble() > _spawnChance * delta.TotalSeconds) + return Task.CompletedTask; + + entityManager.SpawnPowerUp(); + return Task.CompletedTask; + } +} diff --git a/TanksServer/GameLogic/TankManager.cs b/TanksServer/GameLogic/TankManager.cs deleted file mode 100644 index a8cd345..0000000 --- a/TanksServer/GameLogic/TankManager.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections; - -namespace TanksServer.GameLogic; - -internal sealed class TankManager(ILogger logger) : IEnumerable -{ - private readonly ConcurrentDictionary _tanks = new(); - - public void Add(Tank tank) - { - logger.LogInformation("Tank added for player {}", tank.Owner.Id); - _tanks.TryAdd(tank, 0); - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - public IEnumerator GetEnumerator() => _tanks.Keys.GetEnumerator(); - - public void Remove(Tank tank) - { - logger.LogInformation("Tank removed for player {}", tank.Owner.Id); - _tanks.Remove(tank, out _); - } -} diff --git a/TanksServer/GameLogic/SpawnQueue.cs b/TanksServer/GameLogic/TankSpawnQueue.cs similarity index 78% rename from TanksServer/GameLogic/SpawnQueue.cs rename to TanksServer/GameLogic/TankSpawnQueue.cs index 7751b31..c0aadb7 100644 --- a/TanksServer/GameLogic/SpawnQueue.cs +++ b/TanksServer/GameLogic/TankSpawnQueue.cs @@ -2,9 +2,10 @@ using System.Diagnostics.CodeAnalysis; namespace TanksServer.GameLogic; -internal sealed class SpawnQueue( - IOptions options -) +internal sealed class TankSpawnQueue( + IOptions options, + MapEntityManager entityManager +): ITickStep { private readonly ConcurrentQueue _queue = new(); private readonly ConcurrentDictionary _spawnTimes = new(); @@ -41,4 +42,13 @@ internal sealed class SpawnQueue( return true; } + + public Task TickAsync(TimeSpan _) + { + if (!TryDequeueNext(out var player)) + return Task.CompletedTask; + + entityManager.SpawnTank(player); + return Task.CompletedTask; + } } diff --git a/TanksServer/Graphics/DrawBulletsStep.cs b/TanksServer/Graphics/DrawBulletsStep.cs index 9c00366..59ac177 100644 --- a/TanksServer/Graphics/DrawBulletsStep.cs +++ b/TanksServer/Graphics/DrawBulletsStep.cs @@ -2,11 +2,11 @@ using TanksServer.GameLogic; namespace TanksServer.Graphics; -internal sealed class DrawBulletsStep(BulletManager bullets) : IDrawStep +internal sealed class DrawBulletsStep(MapEntityManager entityManager) : IDrawStep { public void Draw(GamePixelGrid pixels) { - foreach (var bullet in bullets.GetAll()) + foreach (var bullet in entityManager.Bullets) { var position = bullet.Position.ToPixelPosition(); pixels[position.X, position.Y].EntityType = GamePixelEntityType.Bullet; diff --git a/TanksServer/Graphics/DrawPowerUpsStep.cs b/TanksServer/Graphics/DrawPowerUpsStep.cs new file mode 100644 index 0000000..dc898c7 --- /dev/null +++ b/TanksServer/Graphics/DrawPowerUpsStep.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using TanksServer.GameLogic; + +namespace TanksServer.Graphics; + +internal sealed class DrawPowerUpsStep : IDrawStep +{ + private readonly MapEntityManager _entityManager; + private readonly bool?[,] _explosiveSprite; + + public DrawPowerUpsStep(MapEntityManager entityManager) + { + _entityManager = entityManager; + + using var tankImage = Image.Load("assets/powerup_explosive.png"); + Debug.Assert(tankImage.Width == tankImage.Height && tankImage.Width == MapService.TileSize); + _explosiveSprite = new bool?[tankImage.Width, tankImage.Height]; + + var whitePixel = new Rgba32(byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue); + for (var y = 0; y < tankImage.Height; y++) + for (var x = 0; x < tankImage.Width; x++) + { + var pixelValue = tankImage[x, y]; + _explosiveSprite[x, y] = pixelValue.A == 0 + ? null + : pixelValue == whitePixel; + } + } + + 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 pixelState = _explosiveSprite[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/TanksServer/Graphics/DrawTanksStep.cs b/TanksServer/Graphics/DrawTanksStep.cs index 739b913..16c5b36 100644 --- a/TanksServer/Graphics/DrawTanksStep.cs +++ b/TanksServer/Graphics/DrawTanksStep.cs @@ -6,13 +6,13 @@ namespace TanksServer.Graphics; internal sealed class DrawTanksStep : IDrawStep { - private readonly TankManager _tanks; + private readonly MapEntityManager _entityManager; private readonly bool[] _tankSprite; private readonly int _tankSpriteWidth; - public DrawTanksStep(TankManager tanks) + public DrawTanksStep(MapEntityManager entityManager) { - _tanks = tanks; + _entityManager = entityManager; using var tankImage = Image.Load("assets/tank.png"); _tankSprite = new bool[tankImage.Height * tankImage.Width]; @@ -28,7 +28,7 @@ internal sealed class DrawTanksStep : IDrawStep public void Draw(GamePixelGrid pixels) { - foreach (var tank in _tanks) + foreach (var tank in _entityManager.Tanks) { var tankPosition = tank.Bounds.TopLeft; diff --git a/TanksServer/Graphics/GamePixelEntityType.cs b/TanksServer/Graphics/GamePixelEntityType.cs index 8c54386..0287db0 100644 --- a/TanksServer/Graphics/GamePixelEntityType.cs +++ b/TanksServer/Graphics/GamePixelEntityType.cs @@ -4,5 +4,6 @@ internal enum GamePixelEntityType : byte { Wall = 0x0, Tank = 0x1, - Bullet = 0x2 + Bullet = 0x2, + PowerUp = 0x3 } diff --git a/TanksServer/Interactivity/PlayerScreenData.cs b/TanksServer/Interactivity/PlayerScreenData.cs index c9a2ac1..e39fe4c 100644 --- a/TanksServer/Interactivity/PlayerScreenData.cs +++ b/TanksServer/Interactivity/PlayerScreenData.cs @@ -26,7 +26,7 @@ internal sealed class PlayerScreenData(ILogger logger) { var result = (byte)(isCurrentPlayer ? 0x1 : 0x0); var kind = (byte)entityKind; - Debug.Assert(kind < 3); + Debug.Assert(kind <= 3); result += (byte)(kind << 2); var index = _count / 2; diff --git a/TanksServer/Interactivity/PlayerServer.cs b/TanksServer/Interactivity/PlayerServer.cs index 26f33be..a78d4a9 100644 --- a/TanksServer/Interactivity/PlayerServer.cs +++ b/TanksServer/Interactivity/PlayerServer.cs @@ -3,7 +3,7 @@ using TanksServer.GameLogic; namespace TanksServer.Interactivity; -internal sealed class PlayerServer(ILogger logger, SpawnQueue spawnQueue) +internal sealed class PlayerServer(ILogger logger, TankSpawnQueue tankSpawnQueue) { private readonly ConcurrentDictionary _players = new(); @@ -12,7 +12,7 @@ internal sealed class PlayerServer(ILogger logger, SpawnQueue spaw Player AddAndSpawn() { var player = new Player(name, id); - spawnQueue.EnqueueForImmediateSpawn(player); + tankSpawnQueue.EnqueueForImmediateSpawn(player); return player; } diff --git a/TanksServer/Models/Bullet.cs b/TanksServer/Models/Bullet.cs index 4fded2b..11910ab 100644 --- a/TanksServer/Models/Bullet.cs +++ b/TanksServer/Models/Bullet.cs @@ -1,12 +1,14 @@ namespace TanksServer.Models; -internal sealed class Bullet(Player tankOwner, FloatPosition position, double rotation) : IMapEntity +internal sealed class Bullet(Player tankOwner, FloatPosition position, double rotation, bool isExplosive) : IMapEntity { public Player Owner { get; } = tankOwner; - public double Rotation { get; set; } = rotation; + public double Rotation { get; } = rotation; public FloatPosition Position { get; set; } = position; + public bool IsExplosive { get; } = isExplosive; + public PixelBounds Bounds => new (Position.ToPixelPosition(), Position.ToPixelPosition()); } diff --git a/TanksServer/Models/PositionHelpers.cs b/TanksServer/Models/PositionHelpers.cs index 86e1790..5af5073 100644 --- a/TanksServer/Models/PositionHelpers.cs +++ b/TanksServer/Models/PositionHelpers.cs @@ -28,4 +28,15 @@ internal static class PositionHelpers Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.Y - p2.Y, 2) ); + + public static PixelBounds GetBoundsForCenter(this FloatPosition position, ushort size) + { + var sub = (short)(-size / 2d); + var add = (short)(size / 2d - 1); + var pixelPosition = position.ToPixelPosition(); + return new PixelBounds( + pixelPosition.GetPixelRelative(sub, sub), + pixelPosition.GetPixelRelative(add, add) + ); + } } diff --git a/TanksServer/Models/PowerUp.cs b/TanksServer/Models/PowerUp.cs new file mode 100644 index 0000000..5fb8e56 --- /dev/null +++ b/TanksServer/Models/PowerUp.cs @@ -0,0 +1,10 @@ +using TanksServer.GameLogic; + +namespace TanksServer.Models; + +internal sealed class PowerUp(FloatPosition position): IMapEntity +{ + public FloatPosition Position { get; set; } = position; + + public PixelBounds Bounds => Position.GetBoundsForCenter(MapService.TileSize); +} diff --git a/TanksServer/Models/Tank.cs b/TanksServer/Models/Tank.cs index 2e2b220..74d236c 100644 --- a/TanksServer/Models/Tank.cs +++ b/TanksServer/Models/Tank.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using TanksServer.GameLogic; namespace TanksServer.Models; @@ -25,16 +26,9 @@ internal sealed class Tank(Player player, FloatPosition spawnPosition) : IMapEnt public FloatPosition Position { get; set; } = spawnPosition; - public PixelBounds Bounds => GetBoundsForCenter(Position); + public PixelBounds Bounds => Position.GetBoundsForCenter(MapService.TileSize); public int Orientation => (int)Math.Round(Rotation * 16) % 16; - public static PixelBounds GetBoundsForCenter(FloatPosition position) - { - var pixelPosition = position.ToPixelPosition(); - return new PixelBounds( - pixelPosition.GetPixelRelative(-4, -4), - pixelPosition.GetPixelRelative(3, 3) - ); - } + public byte ExplosiveBullets { get; set; } } diff --git a/TanksServer/Program.cs b/TanksServer/Program.cs index ca15443..02a959e 100644 --- a/TanksServer/Program.cs +++ b/TanksServer/Program.cs @@ -1,8 +1,6 @@ using System.IO; using DisplayCommands; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; @@ -20,70 +18,11 @@ public static class Program { var app = Configure(args); - var clientScreenServer = app.Services.GetRequiredService(); - var playerService = app.Services.GetRequiredService(); - var controlsServer = app.Services.GetRequiredService(); - var mapService = app.Services.GetRequiredService(); - var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client")); app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider }); app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider }); - 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"); - - var player = playerService.GetOrAdd(name, id ?? Guid.NewGuid()); - return player != null - ? Results.Ok(new NameId(player.Name, player.Id)) - : Results.Unauthorized(); - }); - - app.MapGet("/player", ([FromQuery] Guid id) => - playerService.TryGet(id, out var foundPlayer) - ? Results.Ok((object?)foundPlayer) - : Results.NotFound() - ); - - 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.HandleClient(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.HandleClient(ws, player); - return Results.Empty; - }); - - app.MapGet("/map", () => mapService.MapNames); - - 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(); - }); + Endpoints.MapEndpoints(app); app.Run(); } @@ -119,27 +58,28 @@ public static class Program throw new InvalidOperationException("'Host' configuration missing"); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -150,7 +90,7 @@ public static class Program builder.Configuration.GetSection("Tanks")); builder.Services.Configure( builder.Configuration.GetSection("Players")); - builder.Services.Configure(builder.Configuration.GetSection("GameRules")); + builder.Services.Configure(builder.Configuration.GetSection("GameRules")); if (hostConfiguration.EnableServicePointDisplay) { diff --git a/TanksServer/appsettings.json b/TanksServer/appsettings.json index 7e04eb8..a3ec103 100644 --- a/TanksServer/appsettings.json +++ b/TanksServer/appsettings.json @@ -26,7 +26,8 @@ "BulletSpeed": 75 }, "GameRules": { - "DestructibleWalls": true + "DestructibleWalls": true, + "PowerUpSpawnChance": 0.1 }, "Players": { "SpawnDelayMs": 3000, diff --git a/TanksServer/assets/powerup_explosive.png b/TanksServer/assets/powerup_explosive.png new file mode 100644 index 0000000..257ad67 Binary files /dev/null and b/TanksServer/assets/powerup_explosive.png differ