diff --git a/tanks-backend/TanksServer/Endpoints.cs b/tanks-backend/TanksServer/Endpoints.cs index d6242d7..a65b582 100644 --- a/tanks-backend/TanksServer/Endpoints.cs +++ b/tanks-backend/TanksServer/Endpoints.cs @@ -11,7 +11,8 @@ internal sealed class Endpoints( ClientScreenServer clientScreenServer, PlayerServer playerService, ControlsServer controlsServer, - MapService mapService + MapService mapService, + ChangeToRequestedMap changeToRequestedMap ) { public void Map(WebApplication app) @@ -29,8 +30,9 @@ internal sealed class Endpoints( { if (string.IsNullOrWhiteSpace(name)) return TypedResults.BadRequest("invalid map name"); - if (!mapService.TrySwitchTo(name)) + if (!mapService.TryGetMapByName(name, out var map)) return TypedResults.NotFound("map with name not found"); + changeToRequestedMap.Request(map); return TypedResults.Ok(); } diff --git a/tanks-backend/TanksServer/GameLogic/ChangeToRequestedMap.cs b/tanks-backend/TanksServer/GameLogic/ChangeToRequestedMap.cs new file mode 100644 index 0000000..60df0fa --- /dev/null +++ b/tanks-backend/TanksServer/GameLogic/ChangeToRequestedMap.cs @@ -0,0 +1,24 @@ +namespace TanksServer.GameLogic; + +internal sealed class ChangeToRequestedMap( + MapService mapService, + MapEntityManager entityManager, + EmptyTileFinder emptyTileFinder +) : ITickStep +{ + private MapPrototype? _requestedMap; + + public ValueTask TickAsync(TimeSpan delta) + { + var changeTo = Interlocked.Exchange(ref _requestedMap, null); + if (changeTo == null) + return ValueTask.CompletedTask; + + mapService.SwitchTo(changeTo); + foreach (var t in entityManager.Tanks) + t.Position = emptyTileFinder.ChooseEmptyTile().GetCenter().ToFloatPosition(); + return ValueTask.CompletedTask; + } + + public void Request(MapPrototype map) => _requestedMap = map; +} diff --git a/tanks-backend/TanksServer/GameLogic/EmptyTileFinder.cs b/tanks-backend/TanksServer/GameLogic/EmptyTileFinder.cs new file mode 100644 index 0000000..16a34cf --- /dev/null +++ b/tanks-backend/TanksServer/GameLogic/EmptyTileFinder.cs @@ -0,0 +1,32 @@ +namespace TanksServer.GameLogic; + +internal sealed class EmptyTileFinder( + MapEntityManager entityManager, + MapService mapService +) +{ + public TilePosition ChooseEmptyTile() + { + var maxMinDistance = 0d; + TilePosition spawnTile = default; + 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 (mapService.Current.IsWall(tile)) + continue; + + var tilePixelCenter = tile.GetCenter().ToFloatPosition(); + var minDistance = entityManager.AllEntities + .Select(entity => entity.Position.Distance(tilePixelCenter)) + .Aggregate(double.MaxValue, Math.Min); + if (minDistance <= maxMinDistance) + continue; + + maxMinDistance = minDistance; + spawnTile = tile; + } + + return spawnTile; + } +} diff --git a/tanks-backend/TanksServer/GameLogic/Map.cs b/tanks-backend/TanksServer/GameLogic/Map.cs new file mode 100644 index 0000000..d83a61d --- /dev/null +++ b/tanks-backend/TanksServer/GameLogic/Map.cs @@ -0,0 +1,32 @@ +namespace TanksServer.GameLogic; + +internal sealed class Map(string name, bool[,] walls) +{ + public string Name => name; + + public bool IsWall(int x, int y) => walls[x, y]; + + public bool IsWall(PixelPosition position) => walls[position.X, position.Y]; + + public bool IsWall(TilePosition position) + { + var pixel = position.ToPixelPosition(); + + for (short dx = 0; dx < MapService.TileSize; dx++) + for (short dy = 0; dy < MapService.TileSize; dy++) + { + if (IsWall(pixel.GetPixelRelative(dx, dy))) + return true; + } + + return false; + } + + public bool TryDestroyWallAt(PixelPosition pixel) + { + var result = walls[pixel.X, pixel.Y]; + if (result) + walls[pixel.X, pixel.Y] = false; + return result; + } +} diff --git a/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs b/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs index 42d30ff..e7b376d 100644 --- a/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs +++ b/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs @@ -2,7 +2,6 @@ namespace TanksServer.GameLogic; internal sealed class MapEntityManager( ILogger logger, - MapService map, IOptions options ) { @@ -34,12 +33,12 @@ internal sealed class MapEntityManager( public void RemoveWhere(Predicate predicate) => _bullets.RemoveWhere(predicate); - public void SpawnTank(Player player) + public void SpawnTank(Player player, FloatPosition position) { var tank = new Tank { Owner = player, - Position = ChooseSpawnPosition(), + Position = position, Rotation = Random.Shared.NextDouble(), Magazine = new Magazine(MagazineType.Basic, 0, _rules.MagazineSize) }; @@ -47,12 +46,16 @@ internal sealed class MapEntityManager( logger.LogInformation("Tank added for player {}", player.Name); } - public void SpawnPowerUp(PowerUpType type, MagazineType? magazineType) => _powerUps.Add(new PowerUp + public void SpawnPowerUp(FloatPosition position, PowerUpType type, MagazineType? magazineType) { - Position = ChooseSpawnPosition(), - Type = type, - MagazineType = magazineType - }); + var powerUp = new PowerUp + { + Position = position, + Type = type, + MagazineType = magazineType + }; + _powerUps.Add(powerUp); + } public void RemoveWhere(Predicate predicate) => _powerUps.RemoveWhere(predicate); @@ -64,33 +67,8 @@ internal sealed class MapEntityManager( public Tank? GetCurrentTankOfPlayer(Player player) => _playerTanks.GetValueOrDefault(player); - private IEnumerable AllEntities => Bullets + public IEnumerable AllEntities => Bullets .Cast() .Concat(Tanks) .Concat(PowerUps); - - private FloatPosition ChooseSpawnPosition() - { - var maxMinDistance = 0d; - TilePosition spawnTile = default; - 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 = AllEntities - .Select(entity => entity.Position.Distance(tilePixelCenter)) - .Aggregate(double.MaxValue, Math.Min); - if (minDistance <= maxMinDistance) - continue; - - maxMinDistance = minDistance; - spawnTile = tile; - } - - return spawnTile.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); - } } diff --git a/tanks-backend/TanksServer/GameLogic/MapService.cs b/tanks-backend/TanksServer/GameLogic/MapService.cs index 7910cdc..dccf166 100644 --- a/tanks-backend/TanksServer/GameLogic/MapService.cs +++ b/tanks-backend/TanksServer/GameLogic/MapService.cs @@ -1,9 +1,14 @@ +using System.Diagnostics.CodeAnalysis; using System.IO; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; +using TanksServer.Graphics; namespace TanksServer.GameLogic; +internal abstract class MapPrototype +{ + public abstract Map CreateInstance(); +} + internal sealed class MapService { public const ushort TilesPerRow = 44; @@ -12,7 +17,7 @@ internal sealed class MapService public const ushort PixelsPerRow = TilesPerRow * TileSize; public const ushort PixelsPerColumn = TilesPerColumn * TileSize; - private readonly Dictionary _maps = new(); + private readonly Dictionary _maps = new(); public IEnumerable MapNames => _maps.Keys; @@ -27,88 +32,26 @@ internal sealed class MapService var chosenMapIndex = Random.Shared.Next(_maps.Count); var chosenMapName = _maps.Keys.Skip(chosenMapIndex).First(); - Current = new Map(chosenMapName, _maps[chosenMapName]); + Current = _maps[chosenMapName].CreateInstance(); } + public bool TryGetMapByName(string name, [MaybeNullWhen(false)] out MapPrototype map) + => _maps.TryGetValue(name, out map); + + public void SwitchTo(MapPrototype prototype) => Current = prototype.CreateInstance(); + private void LoadMapPng(string file) { - using var image = Image.Load(file); - - if (image.Width != PixelsPerRow || image.Height != PixelsPerColumn) - throw new FileLoadException($"invalid image size in file {file}"); - - var whitePixel = new Rgba32(byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue); - var walls = new bool[PixelsPerRow, PixelsPerColumn]; - - for (var y = 0; y < image.Height; y++) - for (var x = 0; x < image.Width; x++) - walls[x, y] = image[x, y] == whitePixel; - - _maps.Add(Path.GetFileName(file), walls); + var name = Path.GetFileName(file); + var prototype = new SpriteMapPrototype(name, Sprite.FromImageFile(file)); + _maps.Add(Path.GetFileName(file), prototype); } private void LoadMapString(string file) { var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim(); - if (map.Length != TilesPerColumn * TilesPerRow) - throw new FileLoadException($"cannot load map {file}: invalid length"); - - var walls = new bool[PixelsPerRow, PixelsPerColumn]; - - for (ushort tileX = 0; tileX < TilesPerRow; tileX++) - for (ushort tileY = 0; tileY < TilesPerColumn; tileY++) - { - var tile = new TilePosition(tileX, tileY); - if (map[tileX + tileY * TilesPerRow] != '#') - continue; - - for (byte pixelInTileX = 0; pixelInTileX < TileSize; pixelInTileX++) - for (byte pixelInTileY = 0; pixelInTileY < TileSize; pixelInTileY++) - { - var (x, y) = tile.ToPixelPosition().GetPixelRelative(pixelInTileX, pixelInTileY); - walls[x, y] = true; - } - } - - _maps.Add(Path.GetFileName(file), walls); - } - - public bool TrySwitchTo(string name) - { - if (!_maps.TryGetValue(name, out var mapData)) - return false; - Current = new Map(name, (bool[,]) mapData.Clone()); - return true; - } -} - -internal sealed class Map(string name, bool[,] walls) -{ - public string Name => name; - - public bool IsWall(int x, int y) => walls[x, y]; - - public bool IsWall(PixelPosition position) => walls[position.X, position.Y]; - - public bool IsWall(TilePosition position) - { - var pixel = position.ToPixelPosition(); - - for (short dx = 0; dx < MapService.TileSize; dx++) - for (short dy = 0; dy < MapService.TileSize; dy++) - { - if (IsWall(pixel.GetPixelRelative(dx, dy))) - return true; - } - - return false; - } - - public bool TryDestroyWallAt(PixelPosition pixel) - { - var result = walls[pixel.X, pixel.Y]; - if (result) - walls[pixel.X, pixel.Y] = false; - return result; + var name = Path.GetFileName(file); + var prototype = new TextMapPrototype(name, map); + _maps.Add(name, prototype); } } diff --git a/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs b/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs index 263a1f6..72b0592 100644 --- a/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs +++ b/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs @@ -4,7 +4,8 @@ namespace TanksServer.GameLogic; internal sealed class SpawnPowerUp( IOptions options, - MapEntityManager entityManager + MapEntityManager entityManager, + EmptyTileFinder emptyTileFinder ) : ITickStep { private readonly double _spawnChance = options.Value.PowerUpSpawnChance; @@ -34,7 +35,8 @@ internal sealed class SpawnPowerUp( _ => null }; - entityManager.SpawnPowerUp(type, magazineType); + var position = emptyTileFinder.ChooseEmptyTile().GetCenter().ToFloatPosition(); + entityManager.SpawnPowerUp(position, type, magazineType); return ValueTask.CompletedTask; } } diff --git a/tanks-backend/TanksServer/GameLogic/SpriteMapPrototype.cs b/tanks-backend/TanksServer/GameLogic/SpriteMapPrototype.cs new file mode 100644 index 0000000..8d383eb --- /dev/null +++ b/tanks-backend/TanksServer/GameLogic/SpriteMapPrototype.cs @@ -0,0 +1,21 @@ +using System.IO; +using TanksServer.Graphics; + +namespace TanksServer.GameLogic; + +internal sealed class SpriteMapPrototype : MapPrototype +{ + private readonly string _name; + private readonly Sprite _sprite; + + public SpriteMapPrototype(string name, Sprite sprite) + { + if (sprite.Width != MapService.PixelsPerRow || sprite.Height != MapService.PixelsPerColumn) + throw new FileLoadException($"invalid image size in file {_name}"); + + _name = name; + _sprite = sprite; + } + + public override Map CreateInstance() => new(_name, _sprite.ToBoolArray()); +} diff --git a/tanks-backend/TanksServer/GameLogic/TankSpawnQueue.cs b/tanks-backend/TanksServer/GameLogic/TankSpawnQueue.cs index 1037623..9d329be 100644 --- a/tanks-backend/TanksServer/GameLogic/TankSpawnQueue.cs +++ b/tanks-backend/TanksServer/GameLogic/TankSpawnQueue.cs @@ -4,7 +4,8 @@ namespace TanksServer.GameLogic; internal sealed class TankSpawnQueue( IOptions options, - MapEntityManager entityManager + MapEntityManager entityManager, + EmptyTileFinder tileFinder ) : ITickStep { private readonly ConcurrentQueue _queue = new(); @@ -25,7 +26,8 @@ internal sealed class TankSpawnQueue( if (!TryDequeueNext(out var player)) return ValueTask.CompletedTask; - entityManager.SpawnTank(player); + var position = tileFinder.ChooseEmptyTile().GetCenter().ToFloatPosition(); + entityManager.SpawnTank(player, position); return ValueTask.CompletedTask; } diff --git a/tanks-backend/TanksServer/GameLogic/TextMapPrototype.cs b/tanks-backend/TanksServer/GameLogic/TextMapPrototype.cs new file mode 100644 index 0000000..3195453 --- /dev/null +++ b/tanks-backend/TanksServer/GameLogic/TextMapPrototype.cs @@ -0,0 +1,37 @@ +namespace TanksServer.GameLogic; + +internal sealed class TextMapPrototype : MapPrototype +{ + private readonly string _name; + private readonly string _text; + + public TextMapPrototype(string name, string text) + { + if (text.Length != MapService.TilesPerColumn * MapService.TilesPerRow) + throw new ArgumentException($"cannot load map {name}: invalid length"); + _name = name; + _text = text; + } + + public override Map CreateInstance() + { + var walls = new bool[MapService.PixelsPerRow, MapService.PixelsPerColumn]; + + for (ushort tileX = 0; tileX < MapService.TilesPerRow; tileX++) + for (ushort tileY = 0; tileY < MapService.TilesPerColumn; tileY++) + { + var tile = new TilePosition(tileX, tileY); + if (_text[tileX + tileY * MapService.TilesPerRow] != '#') + continue; + + for (byte pixelInTileX = 0; pixelInTileX < MapService.TileSize; pixelInTileX++) + for (byte pixelInTileY = 0; pixelInTileY < MapService.TileSize; pixelInTileY++) + { + var (x, y) = tile.ToPixelPosition().GetPixelRelative(pixelInTileX, pixelInTileY); + walls[x, y] = true; + } + } + + return new Map(_name, walls); + } +} diff --git a/tanks-backend/TanksServer/Graphics/Sprite.cs b/tanks-backend/TanksServer/Graphics/Sprite.cs index 422e5c3..8a8b854 100644 --- a/tanks-backend/TanksServer/Graphics/Sprite.cs +++ b/tanks-backend/TanksServer/Graphics/Sprite.cs @@ -26,5 +26,17 @@ internal sealed class Sprite(bool?[,] data) public bool? this[int x, int y] => data[x, y]; public int Width => data.GetLength(0); + public int Height => data.GetLength(1); + + public bool[,] ToBoolArray() + { + var result = new bool[Width, Height]; + + for (var y = 0; y < Height; y++) + for (var x = 0; x < Width; x++) + result[x, y] = this[x, y] ?? false; + + return result; + } } diff --git a/tanks-backend/TanksServer/Models/PositionHelpers.cs b/tanks-backend/TanksServer/Models/PositionHelpers.cs index 223a472..0596233 100644 --- a/tanks-backend/TanksServer/Models/PositionHelpers.cs +++ b/tanks-backend/TanksServer/Models/PositionHelpers.cs @@ -38,4 +38,7 @@ internal static class PositionHelpers pixelPosition.GetPixelRelative(add, add) ); } + + public static PixelPosition GetCenter(this TilePosition tile) + => tile.ToPixelPosition().GetPixelRelative(4, 4); } diff --git a/tanks-backend/TanksServer/Program.cs b/tanks-backend/TanksServer/Program.cs index 36126dc..40263f2 100644 --- a/tanks-backend/TanksServer/Program.cs +++ b/tanks-backend/TanksServer/Program.cs @@ -64,11 +64,14 @@ public static class Program 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(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton();