add explosive bullet power up

This commit is contained in:
Vinzenz Schroeter 2024-04-17 19:34:19 +02:00
parent 3f4a301993
commit a2d46bda92
30 changed files with 407 additions and 253 deletions

View file

@ -104,3 +104,8 @@ There are other commands implemented as well, e.g. for changing the brightness.
- 10: bullet - 10: bullet
- 11: (reserved) - 11: (reserved)
- client responds with empty message to request the next frame - 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

76
TanksServer/Endpoints.cs Normal file
View file

@ -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<ClientScreenServer>();
var playerService = app.Services.GetRequiredService<PlayerServer>();
var controlsServer = app.Services.GetRequiredService<ControlsServer>();
var mapService = app.Services.GetRequiredService<MapService>();
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();
});
}
}

View file

@ -1,13 +0,0 @@
namespace TanksServer.GameLogic;
internal sealed class BulletManager
{
private readonly HashSet<Bullet> _bullets = [];
public void Spawn(Player tankOwner, FloatPosition position, double rotation)
=> _bullets.Add(new Bullet(tankOwner, position, rotation));
public IEnumerable<Bullet> GetAll() => _bullets;
public void RemoveWhere(Predicate<Bullet> predicate) => _bullets.RemoveWhere(predicate);
}

View file

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

View file

@ -0,0 +1,73 @@
namespace TanksServer.GameLogic;
internal sealed class CollideBullets(
MapEntityManager entityManager,
MapService map,
IOptions<GameRules> 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);
}
}
}

View file

@ -1,25 +0,0 @@
namespace TanksServer.GameLogic;
internal sealed class CollideBulletsWithMap(
BulletManager bullets,
MapService map,
IOptions<GameRulesConfiguration> 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;
}
}

View file

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

View file

@ -1,6 +1,8 @@
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
public class GameRulesConfiguration internal sealed class GameRules
{ {
public bool DestructibleWalls { get; set; } = true; public bool DestructibleWalls { get; set; } = true;
public double PowerUpSpawnChance { get; set; }
} }

View file

@ -0,0 +1,65 @@
namespace TanksServer.GameLogic;
internal sealed class MapEntityManager(
ILogger<MapEntityManager> logger,
MapService map
)
{
private readonly HashSet<Bullet> _bullets = [];
private readonly HashSet<Tank> _tanks = [];
private readonly HashSet<PowerUp> _powerUps = [];
public IEnumerable<Bullet> Bullets => _bullets;
public IEnumerable<Tank> Tanks => _tanks;
public IEnumerable<PowerUp> 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<Bullet> 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<PowerUp> 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<TilePosition, double> 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<IMapEntity>()
.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();
}
}

View file

@ -1,10 +1,13 @@
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
internal sealed class MoveBullets(BulletManager bullets, IOptions<TanksConfiguration> config) : ITickStep internal sealed class MoveBullets(
MapEntityManager entityManager,
IOptions<TanksConfiguration> config
) : ITickStep
{ {
public Task TickAsync(TimeSpan delta) public Task TickAsync(TimeSpan delta)
{ {
foreach (var bullet in bullets.GetAll()) foreach (var bullet in entityManager.Bullets)
MoveBullet(bullet, delta); MoveBullet(bullet, delta);
return Task.CompletedTask; return Task.CompletedTask;

View file

@ -1,7 +1,7 @@
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
internal sealed class MoveTanks( internal sealed class MoveTanks(
TankManager tanks, MapEntityManager entityManager,
IOptions<TanksConfiguration> options, IOptions<TanksConfiguration> options,
MapService map MapService map
) : ITickStep ) : ITickStep
@ -10,7 +10,7 @@ internal sealed class MoveTanks(
public Task TickAsync(TimeSpan delta) public Task TickAsync(TimeSpan delta)
{ {
foreach (var tank in tanks) foreach (var tank in entityManager.Tanks)
tank.Moved = TryMoveTank(tank, delta); tank.Moved = TryMoveTank(tank, delta);
return Task.CompletedTask; return Task.CompletedTask;
@ -59,13 +59,13 @@ internal sealed class MoveTanks(
} }
private bool HitsTank(Tank tank, FloatPosition newPosition) => private bool HitsTank(Tank tank, FloatPosition newPosition) =>
tanks entityManager.Tanks
.Where(otherTank => otherTank != tank) .Where(otherTank => otherTank != tank)
.Any(otherTank => newPosition.Distance(otherTank.Position) < MapService.TileSize); .Any(otherTank => newPosition.Distance(otherTank.Position) < MapService.TileSize);
private bool HitsWall(FloatPosition newPosition) 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 y = 0; y < MapService.TileSize; y++)
for (short x = 0; x < MapService.TileSize; x++) for (short x = 0; x < MapService.TileSize; x++)

View file

@ -1,7 +1,7 @@
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
internal sealed class RotateTanks( internal sealed class RotateTanks(
TankManager tanks, MapEntityManager entityManager,
IOptions<TanksConfiguration> options, IOptions<TanksConfiguration> options,
ILogger<RotateTanks> logger ILogger<RotateTanks> logger
) : ITickStep ) : ITickStep
@ -10,7 +10,7 @@ internal sealed class RotateTanks(
public Task TickAsync(TimeSpan delta) public Task TickAsync(TimeSpan delta)
{ {
foreach (var tank in tanks) foreach (var tank in entityManager.Tanks)
{ {
var player = tank.Owner; var player = tank.Owner;

View file

@ -3,16 +3,15 @@ using System.Diagnostics;
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
internal sealed class ShootFromTanks( internal sealed class ShootFromTanks(
TankManager tanks,
IOptions<TanksConfiguration> options, IOptions<TanksConfiguration> options,
BulletManager bulletManager MapEntityManager entityManager
) : ITickStep ) : ITickStep
{ {
private readonly TanksConfiguration _config = options.Value; private readonly TanksConfiguration _config = options.Value;
public Task TickAsync(TimeSpan _) 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); Shoot(tank);
return Task.CompletedTask; return Task.CompletedTask;
@ -30,7 +29,7 @@ internal sealed class ShootFromTanks(
var rotation = tank.Orientation / 16d; var rotation = tank.Orientation / 16d;
var angle = rotation * 2d * Math.PI; 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. 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 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. */ enough to do later. These values mostly work. */
@ -47,6 +46,13 @@ internal sealed class ShootFromTanks(
tank.Position.Y - Math.Cos(angle) * distance 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);
} }
} }

View file

@ -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<TilePosition, double> 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<IMapEntity>()
.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();
}
}

View file

@ -0,0 +1,18 @@
namespace TanksServer.GameLogic;
internal sealed class SpawnPowerUp(
IOptions<GameRules> 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;
}
}

View file

@ -1,23 +0,0 @@
using System.Collections;
namespace TanksServer.GameLogic;
internal sealed class TankManager(ILogger<TankManager> logger) : IEnumerable<Tank>
{
private readonly ConcurrentDictionary<Tank, byte> _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<Tank> GetEnumerator() => _tanks.Keys.GetEnumerator();
public void Remove(Tank tank)
{
logger.LogInformation("Tank removed for player {}", tank.Owner.Id);
_tanks.Remove(tank, out _);
}
}

View file

@ -2,9 +2,10 @@ using System.Diagnostics.CodeAnalysis;
namespace TanksServer.GameLogic; namespace TanksServer.GameLogic;
internal sealed class SpawnQueue( internal sealed class TankSpawnQueue(
IOptions<PlayersConfiguration> options IOptions<PlayersConfiguration> options,
) MapEntityManager entityManager
): ITickStep
{ {
private readonly ConcurrentQueue<Player> _queue = new(); private readonly ConcurrentQueue<Player> _queue = new();
private readonly ConcurrentDictionary<Player, DateTime> _spawnTimes = new(); private readonly ConcurrentDictionary<Player, DateTime> _spawnTimes = new();
@ -41,4 +42,13 @@ internal sealed class SpawnQueue(
return true; return true;
} }
public Task TickAsync(TimeSpan _)
{
if (!TryDequeueNext(out var player))
return Task.CompletedTask;
entityManager.SpawnTank(player);
return Task.CompletedTask;
}
} }

View file

@ -2,11 +2,11 @@ using TanksServer.GameLogic;
namespace TanksServer.Graphics; namespace TanksServer.Graphics;
internal sealed class DrawBulletsStep(BulletManager bullets) : IDrawStep internal sealed class DrawBulletsStep(MapEntityManager entityManager) : IDrawStep
{ {
public void Draw(GamePixelGrid pixels) public void Draw(GamePixelGrid pixels)
{ {
foreach (var bullet in bullets.GetAll()) foreach (var bullet in entityManager.Bullets)
{ {
var position = bullet.Position.ToPixelPosition(); var position = bullet.Position.ToPixelPosition();
pixels[position.X, position.Y].EntityType = GamePixelEntityType.Bullet; pixels[position.X, position.Y].EntityType = GamePixelEntityType.Bullet;

View file

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

View file

@ -6,13 +6,13 @@ namespace TanksServer.Graphics;
internal sealed class DrawTanksStep : IDrawStep internal sealed class DrawTanksStep : IDrawStep
{ {
private readonly TankManager _tanks; private readonly MapEntityManager _entityManager;
private readonly bool[] _tankSprite; private readonly bool[] _tankSprite;
private readonly int _tankSpriteWidth; private readonly int _tankSpriteWidth;
public DrawTanksStep(TankManager tanks) public DrawTanksStep(MapEntityManager entityManager)
{ {
_tanks = tanks; _entityManager = entityManager;
using var tankImage = Image.Load<Rgba32>("assets/tank.png"); using var tankImage = Image.Load<Rgba32>("assets/tank.png");
_tankSprite = new bool[tankImage.Height * tankImage.Width]; _tankSprite = new bool[tankImage.Height * tankImage.Width];
@ -28,7 +28,7 @@ internal sealed class DrawTanksStep : IDrawStep
public void Draw(GamePixelGrid pixels) public void Draw(GamePixelGrid pixels)
{ {
foreach (var tank in _tanks) foreach (var tank in _entityManager.Tanks)
{ {
var tankPosition = tank.Bounds.TopLeft; var tankPosition = tank.Bounds.TopLeft;

View file

@ -4,5 +4,6 @@ internal enum GamePixelEntityType : byte
{ {
Wall = 0x0, Wall = 0x0,
Tank = 0x1, Tank = 0x1,
Bullet = 0x2 Bullet = 0x2,
PowerUp = 0x3
} }

View file

@ -26,7 +26,7 @@ internal sealed class PlayerScreenData(ILogger logger)
{ {
var result = (byte)(isCurrentPlayer ? 0x1 : 0x0); var result = (byte)(isCurrentPlayer ? 0x1 : 0x0);
var kind = (byte)entityKind; var kind = (byte)entityKind;
Debug.Assert(kind < 3); Debug.Assert(kind <= 3);
result += (byte)(kind << 2); result += (byte)(kind << 2);
var index = _count / 2; var index = _count / 2;

View file

@ -3,7 +3,7 @@ using TanksServer.GameLogic;
namespace TanksServer.Interactivity; namespace TanksServer.Interactivity;
internal sealed class PlayerServer(ILogger<PlayerServer> logger, SpawnQueue spawnQueue) internal sealed class PlayerServer(ILogger<PlayerServer> logger, TankSpawnQueue tankSpawnQueue)
{ {
private readonly ConcurrentDictionary<string, Player> _players = new(); private readonly ConcurrentDictionary<string, Player> _players = new();
@ -12,7 +12,7 @@ internal sealed class PlayerServer(ILogger<PlayerServer> logger, SpawnQueue spaw
Player AddAndSpawn() Player AddAndSpawn()
{ {
var player = new Player(name, id); var player = new Player(name, id);
spawnQueue.EnqueueForImmediateSpawn(player); tankSpawnQueue.EnqueueForImmediateSpawn(player);
return player; return player;
} }

View file

@ -1,12 +1,14 @@
namespace TanksServer.Models; 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 Player Owner { get; } = tankOwner;
public double Rotation { get; set; } = rotation; public double Rotation { get; } = rotation;
public FloatPosition Position { get; set; } = position; public FloatPosition Position { get; set; } = position;
public bool IsExplosive { get; } = isExplosive;
public PixelBounds Bounds => new (Position.ToPixelPosition(), Position.ToPixelPosition()); public PixelBounds Bounds => new (Position.ToPixelPosition(), Position.ToPixelPosition());
} }

View file

@ -28,4 +28,15 @@ internal static class PositionHelpers
Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.X - p2.X, 2) +
Math.Pow(p1.Y - p2.Y, 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)
);
}
} }

View file

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

View file

@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using TanksServer.GameLogic;
namespace TanksServer.Models; namespace TanksServer.Models;
@ -25,16 +26,9 @@ internal sealed class Tank(Player player, FloatPosition spawnPosition) : IMapEnt
public FloatPosition Position { get; set; } = spawnPosition; 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 int Orientation => (int)Math.Round(Rotation * 16) % 16;
public static PixelBounds GetBoundsForCenter(FloatPosition position) public byte ExplosiveBullets { get; set; }
{
var pixelPosition = position.ToPixelPosition();
return new PixelBounds(
pixelPosition.GetPixelRelative(-4, -4),
pixelPosition.GetPixelRelative(3, 3)
);
}
} }

View file

@ -1,8 +1,6 @@
using System.IO; using System.IO;
using DisplayCommands; using DisplayCommands;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
@ -20,70 +18,11 @@ public static class Program
{ {
var app = Configure(args); var app = Configure(args);
var clientScreenServer = app.Services.GetRequiredService<ClientScreenServer>();
var playerService = app.Services.GetRequiredService<PlayerServer>();
var controlsServer = app.Services.GetRequiredService<ControlsServer>();
var mapService = app.Services.GetRequiredService<MapService>();
var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client")); var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client"));
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider }); app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider });
app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider }); app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider });
app.MapPost("/player", (string name, Guid? id) => Endpoints.MapEndpoints(app);
{
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();
});
app.Run(); app.Run();
} }
@ -119,27 +58,28 @@ public static class Program
throw new InvalidOperationException("'Host' configuration missing"); throw new InvalidOperationException("'Host' configuration missing");
builder.Services.AddSingleton<MapService>(); builder.Services.AddSingleton<MapService>();
builder.Services.AddSingleton<BulletManager>(); builder.Services.AddSingleton<MapEntityManager>();
builder.Services.AddSingleton<TankManager>();
builder.Services.AddSingleton<ControlsServer>(); builder.Services.AddSingleton<ControlsServer>();
builder.Services.AddSingleton<PlayerServer>(); builder.Services.AddSingleton<PlayerServer>();
builder.Services.AddSingleton<ClientScreenServer>(); builder.Services.AddSingleton<ClientScreenServer>();
builder.Services.AddSingleton<SpawnQueue>(); builder.Services.AddSingleton<TankSpawnQueue>();
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, MoveBullets>(); builder.Services.AddSingleton<ITickStep, MoveBullets>();
builder.Services.AddSingleton<ITickStep, CollideBulletsWithTanks>(); builder.Services.AddSingleton<ITickStep, CollideBullets>();
builder.Services.AddSingleton<ITickStep, CollideBulletsWithMap>();
builder.Services.AddSingleton<ITickStep, RotateTanks>(); builder.Services.AddSingleton<ITickStep, RotateTanks>();
builder.Services.AddSingleton<ITickStep, MoveTanks>(); builder.Services.AddSingleton<ITickStep, MoveTanks>();
builder.Services.AddSingleton<ITickStep, ShootFromTanks>(); builder.Services.AddSingleton<ITickStep, ShootFromTanks>();
builder.Services.AddSingleton<ITickStep, SpawnNewTanks>(); builder.Services.AddSingleton<ITickStep, CollectPowerUp>();
builder.Services.AddSingleton<ITickStep>(sp => sp.GetRequiredService<TankSpawnQueue>());
builder.Services.AddSingleton<ITickStep, SpawnPowerUp>();
builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>(); builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>();
builder.Services.AddSingleton<IDrawStep, DrawMapStep>(); builder.Services.AddSingleton<IDrawStep, DrawMapStep>();
builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>();
builder.Services.AddSingleton<IDrawStep, DrawTanksStep>(); builder.Services.AddSingleton<IDrawStep, DrawTanksStep>();
builder.Services.AddSingleton<IDrawStep, DrawBulletsStep>(); builder.Services.AddSingleton<IDrawStep, DrawBulletsStep>();
@ -150,7 +90,7 @@ public static class Program
builder.Configuration.GetSection("Tanks")); builder.Configuration.GetSection("Tanks"));
builder.Services.Configure<PlayersConfiguration>( builder.Services.Configure<PlayersConfiguration>(
builder.Configuration.GetSection("Players")); builder.Configuration.GetSection("Players"));
builder.Services.Configure<GameRulesConfiguration>(builder.Configuration.GetSection("GameRules")); builder.Services.Configure<GameRules>(builder.Configuration.GetSection("GameRules"));
if (hostConfiguration.EnableServicePointDisplay) if (hostConfiguration.EnableServicePointDisplay)
{ {

View file

@ -26,7 +26,8 @@
"BulletSpeed": 75 "BulletSpeed": 75
}, },
"GameRules": { "GameRules": {
"DestructibleWalls": true "DestructibleWalls": true,
"PowerUpSpawnChance": 0.1
}, },
"Players": { "Players": {
"SpawnDelayMs": 3000, "SpawnDelayMs": 3000,

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B