reposition tanks on map switch, rework map logic

This commit is contained in:
Vinzenz Schroeter 2024-05-03 15:47:33 +02:00 committed by RobbersDaughter
parent 3d65c81b8b
commit 97144ae3b8
13 changed files with 208 additions and 117 deletions

View file

@ -11,7 +11,8 @@ internal sealed class Endpoints(
ClientScreenServer clientScreenServer, ClientScreenServer clientScreenServer,
PlayerServer playerService, PlayerServer playerService,
ControlsServer controlsServer, ControlsServer controlsServer,
MapService mapService MapService mapService,
ChangeToRequestedMap changeToRequestedMap
) )
{ {
public void Map(WebApplication app) public void Map(WebApplication app)
@ -29,8 +30,9 @@ internal sealed class Endpoints(
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return TypedResults.BadRequest("invalid map 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"); return TypedResults.NotFound("map with name not found");
changeToRequestedMap.Request(map);
return TypedResults.Ok(); return TypedResults.Ok();
} }

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@ namespace TanksServer.GameLogic;
internal sealed class MapEntityManager( internal sealed class MapEntityManager(
ILogger<MapEntityManager> logger, ILogger<MapEntityManager> logger,
MapService map,
IOptions<GameRules> options IOptions<GameRules> options
) )
{ {
@ -34,12 +33,12 @@ internal sealed class MapEntityManager(
public void RemoveWhere(Predicate<Bullet> predicate) => _bullets.RemoveWhere(predicate); public void RemoveWhere(Predicate<Bullet> predicate) => _bullets.RemoveWhere(predicate);
public void SpawnTank(Player player) public void SpawnTank(Player player, FloatPosition position)
{ {
var tank = new Tank var tank = new Tank
{ {
Owner = player, Owner = player,
Position = ChooseSpawnPosition(), Position = position,
Rotation = Random.Shared.NextDouble(), Rotation = Random.Shared.NextDouble(),
Magazine = new Magazine(MagazineType.Basic, 0, _rules.MagazineSize) Magazine = new Magazine(MagazineType.Basic, 0, _rules.MagazineSize)
}; };
@ -47,12 +46,16 @@ internal sealed class MapEntityManager(
logger.LogInformation("Tank added for player {}", player.Name); 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(), var powerUp = new PowerUp
{
Position = position,
Type = type, Type = type,
MagazineType = magazineType MagazineType = magazineType
}); };
_powerUps.Add(powerUp);
}
public void RemoveWhere(Predicate<PowerUp> predicate) => _powerUps.RemoveWhere(predicate); public void RemoveWhere(Predicate<PowerUp> predicate) => _powerUps.RemoveWhere(predicate);
@ -64,33 +67,8 @@ internal sealed class MapEntityManager(
public Tank? GetCurrentTankOfPlayer(Player player) => _playerTanks.GetValueOrDefault(player); public Tank? GetCurrentTankOfPlayer(Player player) => _playerTanks.GetValueOrDefault(player);
private IEnumerable<IMapEntity> AllEntities => Bullets public IEnumerable<IMapEntity> AllEntities => Bullets
.Cast<IMapEntity>() .Cast<IMapEntity>()
.Concat(Tanks) .Concat(Tanks)
.Concat(PowerUps); .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();
}
} }

View file

@ -1,9 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using SixLabors.ImageSharp; using TanksServer.Graphics;
using SixLabors.ImageSharp.PixelFormats;
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
internal abstract class MapPrototype
{
public abstract Map CreateInstance();
}
internal sealed class MapService internal sealed class MapService
{ {
public const ushort TilesPerRow = 44; public const ushort TilesPerRow = 44;
@ -12,7 +17,7 @@ internal sealed class MapService
public const ushort PixelsPerRow = TilesPerRow * TileSize; public const ushort PixelsPerRow = TilesPerRow * TileSize;
public const ushort PixelsPerColumn = TilesPerColumn * TileSize; public const ushort PixelsPerColumn = TilesPerColumn * TileSize;
private readonly Dictionary<string, bool[,]> _maps = new(); private readonly Dictionary<string, MapPrototype> _maps = new();
public IEnumerable<string> MapNames => _maps.Keys; public IEnumerable<string> MapNames => _maps.Keys;
@ -27,88 +32,26 @@ internal sealed class MapService
var chosenMapIndex = Random.Shared.Next(_maps.Count); var chosenMapIndex = Random.Shared.Next(_maps.Count);
var chosenMapName = _maps.Keys.Skip(chosenMapIndex).First(); 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) private void LoadMapPng(string file)
{ {
using var image = Image.Load<Rgba32>(file); var name = Path.GetFileName(file);
var prototype = new SpriteMapPrototype(name, Sprite.FromImageFile(file));
if (image.Width != PixelsPerRow || image.Height != PixelsPerColumn) _maps.Add(Path.GetFileName(file), prototype);
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);
} }
private void LoadMapString(string file) private void LoadMapString(string file)
{ {
var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim(); var map = File.ReadAllText(file).ReplaceLineEndings(string.Empty).Trim();
if (map.Length != TilesPerColumn * TilesPerRow) var name = Path.GetFileName(file);
throw new FileLoadException($"cannot load map {file}: invalid length"); var prototype = new TextMapPrototype(name, map);
_maps.Add(name, prototype);
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;
} }
} }

View file

@ -4,7 +4,8 @@ namespace TanksServer.GameLogic;
internal sealed class SpawnPowerUp( internal sealed class SpawnPowerUp(
IOptions<GameRules> options, IOptions<GameRules> options,
MapEntityManager entityManager MapEntityManager entityManager,
EmptyTileFinder emptyTileFinder
) : ITickStep ) : ITickStep
{ {
private readonly double _spawnChance = options.Value.PowerUpSpawnChance; private readonly double _spawnChance = options.Value.PowerUpSpawnChance;
@ -34,7 +35,8 @@ internal sealed class SpawnPowerUp(
_ => null _ => null
}; };
entityManager.SpawnPowerUp(type, magazineType); var position = emptyTileFinder.ChooseEmptyTile().GetCenter().ToFloatPosition();
entityManager.SpawnPowerUp(position, type, magazineType);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
} }

View file

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

View file

@ -4,7 +4,8 @@ namespace TanksServer.GameLogic;
internal sealed class TankSpawnQueue( internal sealed class TankSpawnQueue(
IOptions<GameRules> options, IOptions<GameRules> options,
MapEntityManager entityManager MapEntityManager entityManager,
EmptyTileFinder tileFinder
) : ITickStep ) : ITickStep
{ {
private readonly ConcurrentQueue<Player> _queue = new(); private readonly ConcurrentQueue<Player> _queue = new();
@ -25,7 +26,8 @@ internal sealed class TankSpawnQueue(
if (!TryDequeueNext(out var player)) if (!TryDequeueNext(out var player))
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
entityManager.SpawnTank(player); var position = tileFinder.ChooseEmptyTile().GetCenter().ToFloatPosition();
entityManager.SpawnTank(player, position);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }

View file

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

View file

@ -26,5 +26,17 @@ internal sealed class Sprite(bool?[,] data)
public bool? this[int x, int y] => data[x, y]; public bool? this[int x, int y] => data[x, y];
public int Width => data.GetLength(0); public int Width => data.GetLength(0);
public int Height => data.GetLength(1); 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;
}
} }

View file

@ -38,4 +38,7 @@ internal static class PositionHelpers
pixelPosition.GetPixelRelative(add, add) pixelPosition.GetPixelRelative(add, add)
); );
} }
public static PixelPosition GetCenter(this TilePosition tile)
=> tile.ToPixelPosition().GetPixelRelative(4, 4);
} }

View file

@ -64,11 +64,14 @@ public static class Program
builder.Services.AddSingleton<TankSpawnQueue>(); builder.Services.AddSingleton<TankSpawnQueue>();
builder.Services.AddSingleton<Endpoints>(); builder.Services.AddSingleton<Endpoints>();
builder.Services.AddSingleton<BufferPool>(); builder.Services.AddSingleton<BufferPool>();
builder.Services.AddSingleton<EmptyTileFinder>();
builder.Services.AddSingleton<ChangeToRequestedMap>();
builder.Services.AddHostedService<GameTickWorker>(); builder.Services.AddHostedService<GameTickWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ControlsServer>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<ControlsServer>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<ClientScreenServer>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<ClientScreenServer>());
builder.Services.AddSingleton<ITickStep, ChangeToRequestedMap>(sp => sp.GetRequiredService<ChangeToRequestedMap>());
builder.Services.AddSingleton<ITickStep, MoveBullets>(); builder.Services.AddSingleton<ITickStep, MoveBullets>();
builder.Services.AddSingleton<ITickStep, CollideBullets>(); builder.Services.AddSingleton<ITickStep, CollideBullets>();
builder.Services.AddSingleton<ITickStep, RotateTanks>(); builder.Services.AddSingleton<ITickStep, RotateTanks>();