move backend to subfolder
This commit is contained in:
parent
d4d1f2f981
commit
8d09663eff
80 changed files with 98 additions and 88 deletions
78
tanks-backend/TanksServer/Endpoints.cs
Normal file
78
tanks-backend/TanksServer/Endpoints.cs
Normal file
|
@ -0,0 +1,78 @@
|
|||
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");
|
||||
|
||||
if (!id.HasValue || id.Value == Guid.Empty)
|
||||
id = Guid.NewGuid();
|
||||
|
||||
var player = playerService.GetOrAdd(name, id.Value);
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
30
tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs
Normal file
30
tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs
Normal 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;
|
||||
}
|
||||
}
|
88
tanks-backend/TanksServer/GameLogic/CollideBullets.cs
Normal file
88
tanks-backend/TanksServer/GameLogic/CollideBullets.cs
Normal file
|
@ -0,0 +1,88 @@
|
|||
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);
|
||||
entityManager.RemoveBulletsWhere(TimeoutBullet);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool TimeoutBullet(Bullet bullet)
|
||||
{
|
||||
if (bullet.Timeout > DateTime.Now)
|
||||
return false;
|
||||
|
||||
var radius = bullet.IsExplosive ? ExplosionRadius : 0;
|
||||
ExplodeAt(bullet.Position.ToPixelPosition(), radius, bullet.Owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
owner.Scores.WallsDestroyed++;
|
||||
}
|
||||
|
||||
TryHitTankAt(offsetPixel.ToFloatPosition(), owner);
|
||||
}
|
||||
}
|
||||
}
|
24
tanks-backend/TanksServer/GameLogic/GameRules.cs
Normal file
24
tanks-backend/TanksServer/GameLogic/GameRules.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class GameRules
|
||||
{
|
||||
public bool DestructibleWalls { get; set; } = true;
|
||||
|
||||
public double PowerUpSpawnChance { get; set; }
|
||||
|
||||
public int MaxPowerUpCount { get; set; } = int.MaxValue;
|
||||
|
||||
public int BulletTimeoutMs { get; set; } = int.MaxValue;
|
||||
|
||||
public double MoveSpeed { get; set; }
|
||||
|
||||
public double TurnSpeed { get; set; }
|
||||
|
||||
public double ShootDelayMs { get; set; }
|
||||
|
||||
public double BulletSpeed { get; set; }
|
||||
|
||||
public int SpawnDelayMs { get; set; }
|
||||
|
||||
public int IdleTimeoutMs { get; set; }
|
||||
}
|
58
tanks-backend/TanksServer/GameLogic/GameTickWorker.cs
Normal file
58
tanks-backend/TanksServer/GameLogic/GameTickWorker.cs
Normal file
|
@ -0,0 +1,58 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class GameTickWorker(
|
||||
IEnumerable<ITickStep> steps,
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<GameTickWorker> logger
|
||||
) : IHostedService, IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private readonly List<ITickStep> _steps = steps.ToList();
|
||||
private Task? _run;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cancellation.Dispose();
|
||||
_run?.Dispose();
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_run = RunAsync();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _cancellation.CancelAsync();
|
||||
if (_run != null) await _run;
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = new Stopwatch();
|
||||
while (!_cancellation.IsCancellationRequested)
|
||||
{
|
||||
logger.LogTrace("since last frame: {}", sw.Elapsed);
|
||||
|
||||
var delta = sw.Elapsed;
|
||||
sw.Restart();
|
||||
|
||||
foreach (var step in _steps)
|
||||
await step.TickAsync(delta);
|
||||
|
||||
await Task.Delay(1);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "game tick service crashed");
|
||||
lifetime.StopApplication();
|
||||
}
|
||||
}
|
||||
}
|
6
tanks-backend/TanksServer/GameLogic/ITickStep.cs
Normal file
6
tanks-backend/TanksServer/GameLogic/ITickStep.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace TanksServer.GameLogic;
|
||||
|
||||
public interface ITickStep
|
||||
{
|
||||
Task TickAsync(TimeSpan delta);
|
||||
}
|
70
tanks-backend/TanksServer/GameLogic/MapEntityManager.cs
Normal file
70
tanks-backend/TanksServer/GameLogic/MapEntityManager.cs
Normal file
|
@ -0,0 +1,70 @@
|
|||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class MapEntityManager(
|
||||
ILogger<MapEntityManager> logger,
|
||||
MapService map,
|
||||
IOptions<GameRules> options
|
||||
)
|
||||
{
|
||||
private readonly HashSet<Bullet> _bullets = [];
|
||||
private readonly HashSet<Tank> _tanks = [];
|
||||
private readonly HashSet<PowerUp> _powerUps = [];
|
||||
private readonly TimeSpan _bulletTimeout = TimeSpan.FromMilliseconds(options.Value.BulletTimeoutMs);
|
||||
|
||||
public IEnumerable<Bullet> Bullets => _bullets;
|
||||
public IEnumerable<Tank> Tanks => _tanks;
|
||||
public IEnumerable<PowerUp> PowerUps => _powerUps;
|
||||
|
||||
public IEnumerable<IMapEntity> AllEntities => Bullets
|
||||
.Cast<IMapEntity>()
|
||||
.Concat(Tanks)
|
||||
.Concat(PowerUps);
|
||||
|
||||
public void SpawnBullet(Player tankOwner, FloatPosition position, double rotation, bool isExplosive)
|
||||
=> _bullets.Add(new Bullet(tankOwner, position, rotation, isExplosive, DateTime.Now + _bulletTimeout));
|
||||
|
||||
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 = AllEntities
|
||||
.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();
|
||||
}
|
||||
}
|
108
tanks-backend/TanksServer/GameLogic/MapService.cs
Normal file
108
tanks-backend/TanksServer/GameLogic/MapService.cs
Normal file
|
@ -0,0 +1,108 @@
|
|||
using System.IO;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class MapService
|
||||
{
|
||||
public const ushort TilesPerRow = 44;
|
||||
public const ushort TilesPerColumn = 20;
|
||||
public const ushort TileSize = 8;
|
||||
public const ushort PixelsPerRow = TilesPerRow * TileSize;
|
||||
public const ushort PixelsPerColumn = TilesPerColumn * TileSize;
|
||||
|
||||
private readonly Dictionary<string, bool[,]> _maps = new();
|
||||
|
||||
public IEnumerable<string> MapNames => _maps.Keys;
|
||||
|
||||
public Map Current { get; private set; }
|
||||
|
||||
public MapService()
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles("./assets/maps/", "*.txt"))
|
||||
LoadMapString(file);
|
||||
foreach (var file in Directory.EnumerateFiles("./assets/maps/", "*.png"))
|
||||
LoadMapPng(file);
|
||||
|
||||
var chosenMapIndex = Random.Shared.Next(_maps.Count);
|
||||
var chosenMapName = _maps.Keys.Skip(chosenMapIndex).First();
|
||||
Current = new Map(chosenMapName, _maps[chosenMapName]);
|
||||
}
|
||||
|
||||
private void LoadMapPng(string file)
|
||||
{
|
||||
using var image = Image.Load<Rgba32>(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);
|
||||
}
|
||||
|
||||
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 = 1; dx < MapService.TilesPerRow - 1; dx++)
|
||||
for (short dy = 1; dy < MapService.TilesPerColumn - 1; dy++)
|
||||
{
|
||||
if (IsWall(pixel.GetPixelRelative(dx, dy)))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void DestroyWallAt(PixelPosition pixel) => walls[pixel.X, pixel.Y] = false;
|
||||
}
|
25
tanks-backend/TanksServer/GameLogic/MoveBullets.cs
Normal file
25
tanks-backend/TanksServer/GameLogic/MoveBullets.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class MoveBullets(
|
||||
MapEntityManager entityManager,
|
||||
IOptions<GameRules> config
|
||||
) : ITickStep
|
||||
{
|
||||
public Task TickAsync(TimeSpan delta)
|
||||
{
|
||||
foreach (var bullet in entityManager.Bullets)
|
||||
MoveBullet(bullet, delta);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void MoveBullet(Bullet bullet, TimeSpan delta)
|
||||
{
|
||||
var speed = config.Value.BulletSpeed * delta.TotalSeconds;
|
||||
var angle = bullet.Rotation * 2 * Math.PI;
|
||||
bullet.Position = new FloatPosition(
|
||||
bullet.Position.X + Math.Sin(angle) * speed,
|
||||
bullet.Position.Y - Math.Cos(angle) * speed
|
||||
);
|
||||
}
|
||||
}
|
80
tanks-backend/TanksServer/GameLogic/MoveTanks.cs
Normal file
80
tanks-backend/TanksServer/GameLogic/MoveTanks.cs
Normal file
|
@ -0,0 +1,80 @@
|
|||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class MoveTanks(
|
||||
MapEntityManager entityManager,
|
||||
IOptions<GameRules> options,
|
||||
MapService map
|
||||
) : ITickStep
|
||||
{
|
||||
private readonly GameRules _config = options.Value;
|
||||
|
||||
public Task TickAsync(TimeSpan delta)
|
||||
{
|
||||
foreach (var tank in entityManager.Tanks)
|
||||
tank.Moved = TryMoveTank(tank, delta);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool TryMoveTank(Tank tank, TimeSpan delta)
|
||||
{
|
||||
var player = tank.Owner;
|
||||
|
||||
double speed;
|
||||
switch (player.Controls)
|
||||
{
|
||||
case { Forward: false, Backward: false }:
|
||||
case { Forward: true, Backward: true }:
|
||||
return false;
|
||||
case { Forward: true }:
|
||||
speed = +_config.MoveSpeed;
|
||||
break;
|
||||
case { Backward: true }:
|
||||
speed = -_config.MoveSpeed;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
speed *= delta.TotalSeconds;
|
||||
|
||||
var angle = tank.Orientation / 16d * 2d * Math.PI;
|
||||
var newX = tank.Position.X + Math.Sin(angle) * speed;
|
||||
var newY = tank.Position.Y - Math.Cos(angle) * speed;
|
||||
|
||||
return TryMoveTankTo(tank, new FloatPosition(newX, newY))
|
||||
|| TryMoveTankTo(tank, new FloatPosition(newX, tank.Position.Y))
|
||||
|| TryMoveTankTo(tank, new FloatPosition(tank.Position.X, newY));
|
||||
}
|
||||
|
||||
private bool TryMoveTankTo(Tank tank, FloatPosition newPosition)
|
||||
{
|
||||
if (HitsWall(newPosition))
|
||||
return false;
|
||||
if (HitsTank(tank, newPosition))
|
||||
return false;
|
||||
|
||||
tank.Position = newPosition;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool HitsTank(Tank tank, FloatPosition newPosition) =>
|
||||
entityManager.Tanks
|
||||
.Where(otherTank => otherTank != tank)
|
||||
.Any(otherTank => newPosition.Distance(otherTank.Position) < MapService.TileSize);
|
||||
|
||||
private bool HitsWall(FloatPosition newPosition)
|
||||
{
|
||||
var (topLeft, _) = newPosition.GetBoundsForCenter(MapService.TileSize);
|
||||
|
||||
for (short y = 0; y < MapService.TileSize; y++)
|
||||
for (short x = 0; x < MapService.TileSize; x++)
|
||||
{
|
||||
var pixelToCheck = topLeft.GetPixelRelative(x, y);
|
||||
if (map.Current.IsWall(pixelToCheck))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
35
tanks-backend/TanksServer/GameLogic/RotateTanks.cs
Normal file
35
tanks-backend/TanksServer/GameLogic/RotateTanks.cs
Normal file
|
@ -0,0 +1,35 @@
|
|||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class RotateTanks(
|
||||
MapEntityManager entityManager,
|
||||
IOptions<GameRules> options,
|
||||
ILogger<RotateTanks> logger
|
||||
) : ITickStep
|
||||
{
|
||||
private readonly GameRules _config = options.Value;
|
||||
|
||||
public Task TickAsync(TimeSpan delta)
|
||||
{
|
||||
foreach (var tank in entityManager.Tanks)
|
||||
{
|
||||
var player = tank.Owner;
|
||||
|
||||
switch (player.Controls)
|
||||
{
|
||||
case { TurnRight: true, TurnLeft: true }:
|
||||
case { TurnRight: false, TurnLeft: false }:
|
||||
continue;
|
||||
case { TurnLeft: true }:
|
||||
tank.Rotation -= _config.TurnSpeed * delta.TotalSeconds;
|
||||
break;
|
||||
case { TurnRight: true }:
|
||||
tank.Rotation += _config.TurnSpeed * delta.TotalSeconds;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.LogTrace("rotated tank to {}", tank.Rotation);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
58
tanks-backend/TanksServer/GameLogic/ShootFromTanks.cs
Normal file
58
tanks-backend/TanksServer/GameLogic/ShootFromTanks.cs
Normal file
|
@ -0,0 +1,58 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class ShootFromTanks(
|
||||
IOptions<GameRules> options,
|
||||
MapEntityManager entityManager
|
||||
) : ITickStep
|
||||
{
|
||||
private readonly GameRules _config = options.Value;
|
||||
|
||||
public Task TickAsync(TimeSpan _)
|
||||
{
|
||||
foreach (var tank in entityManager.Tanks.Where(t => !t.Moved))
|
||||
Shoot(tank);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Shoot(Tank tank)
|
||||
{
|
||||
if (!tank.Owner.Controls.Shoot)
|
||||
return;
|
||||
if (tank.NextShotAfter >= DateTime.Now)
|
||||
return;
|
||||
|
||||
tank.NextShotAfter = DateTime.Now.AddMilliseconds(_config.ShootDelayMs);
|
||||
|
||||
var rotation = tank.Orientation / 16d;
|
||||
var angle = rotation * 2d * Math.PI;
|
||||
|
||||
/* 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. */
|
||||
var distance = (tank.Orientation % 4) switch
|
||||
{
|
||||
0 => 4.4d,
|
||||
1 or 3 => 5.4d,
|
||||
2 => 6d,
|
||||
_ => throw new UnreachableException("this should not be possible")
|
||||
};
|
||||
|
||||
var position = new FloatPosition(
|
||||
tank.Position.X + Math.Sin(angle) * distance,
|
||||
tank.Position.Y - Math.Cos(angle) * distance
|
||||
);
|
||||
|
||||
var explosive = false;
|
||||
if (tank.ExplosiveBullets > 0)
|
||||
{
|
||||
tank.ExplosiveBullets--;
|
||||
explosive = true;
|
||||
}
|
||||
|
||||
entityManager.SpawnBullet(tank.Owner, position, rotation, explosive);
|
||||
}
|
||||
}
|
21
tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs
Normal file
21
tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class SpawnPowerUp(
|
||||
IOptions<GameRules> options,
|
||||
MapEntityManager entityManager
|
||||
) : ITickStep
|
||||
{
|
||||
private readonly double _spawnChance = options.Value.PowerUpSpawnChance;
|
||||
private readonly int _maxCount = options.Value.MaxPowerUpCount;
|
||||
|
||||
public Task TickAsync(TimeSpan delta)
|
||||
{
|
||||
if (entityManager.PowerUps.Count() >= _maxCount)
|
||||
return Task.CompletedTask;
|
||||
if (Random.Shared.NextDouble() > _spawnChance * delta.TotalSeconds)
|
||||
return Task.CompletedTask;
|
||||
|
||||
entityManager.SpawnPowerUp();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
54
tanks-backend/TanksServer/GameLogic/TankSpawnQueue.cs
Normal file
54
tanks-backend/TanksServer/GameLogic/TankSpawnQueue.cs
Normal file
|
@ -0,0 +1,54 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace TanksServer.GameLogic;
|
||||
|
||||
internal sealed class TankSpawnQueue(
|
||||
IOptions<GameRules> options,
|
||||
MapEntityManager entityManager
|
||||
): ITickStep
|
||||
{
|
||||
private readonly ConcurrentQueue<Player> _queue = new();
|
||||
private readonly ConcurrentDictionary<Player, DateTime> _spawnTimes = new();
|
||||
private readonly TimeSpan _spawnDelay = TimeSpan.FromMilliseconds(options.Value.SpawnDelayMs);
|
||||
private readonly TimeSpan _idleTimeout = TimeSpan.FromMilliseconds(options.Value.IdleTimeoutMs);
|
||||
|
||||
public void EnqueueForImmediateSpawn(Player player) => _queue.Enqueue(player);
|
||||
|
||||
public void EnqueueForDelayedSpawn(Player player)
|
||||
{
|
||||
_queue.Enqueue(player);
|
||||
_spawnTimes.AddOrUpdate(player, DateTime.MinValue, (_, _) => DateTime.Now + _spawnDelay);
|
||||
}
|
||||
|
||||
private bool TryDequeueNext([MaybeNullWhen(false)] out Player player)
|
||||
{
|
||||
if (!_queue.TryDequeue(out player))
|
||||
return false; // no one on queue
|
||||
|
||||
if (player.LastInput + _idleTimeout < DateTime.Now)
|
||||
{
|
||||
// player idle
|
||||
_queue.Enqueue(player);
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
if (_spawnTimes.GetOrAdd(player, DateTime.MinValue) > now)
|
||||
{
|
||||
// spawn delay
|
||||
_queue.Enqueue(player);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task TickAsync(TimeSpan _)
|
||||
{
|
||||
if (!TryDequeueNext(out var player))
|
||||
return Task.CompletedTask;
|
||||
|
||||
entityManager.SpawnTank(player);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
9
tanks-backend/TanksServer/GlobalUsings.cs
Normal file
9
tanks-backend/TanksServer/GlobalUsings.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
global using System;
|
||||
global using System.Collections.Concurrent;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using TanksServer.Models;
|
16
tanks-backend/TanksServer/Graphics/DrawBulletsStep.cs
Normal file
16
tanks-backend/TanksServer/Graphics/DrawBulletsStep.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Graphics;
|
||||
|
||||
internal sealed class DrawBulletsStep(MapEntityManager entityManager) : IDrawStep
|
||||
{
|
||||
public void Draw(GamePixelGrid pixels)
|
||||
{
|
||||
foreach (var bullet in entityManager.Bullets)
|
||||
{
|
||||
var position = bullet.Position.ToPixelPosition();
|
||||
pixels[position.X, position.Y].EntityType = GamePixelEntityType.Bullet;
|
||||
pixels[position.X, position.Y].BelongsTo = bullet.Owner;
|
||||
}
|
||||
}
|
||||
}
|
19
tanks-backend/TanksServer/Graphics/DrawMapStep.cs
Normal file
19
tanks-backend/TanksServer/Graphics/DrawMapStep.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Graphics;
|
||||
|
||||
internal sealed class DrawMapStep(MapService map) : IDrawStep
|
||||
{
|
||||
public void Draw(GamePixelGrid pixels)
|
||||
{
|
||||
for (ushort y = 0; y < MapService.PixelsPerColumn; y++)
|
||||
for (ushort x = 0; x < MapService.PixelsPerRow; x++)
|
||||
{
|
||||
var pixel = new PixelPosition(x, y);
|
||||
if (!map.Current.IsWall(pixel))
|
||||
continue;
|
||||
|
||||
pixels[x, y].EntityType = GamePixelEntityType.Wall;
|
||||
}
|
||||
}
|
||||
}
|
52
tanks-backend/TanksServer/Graphics/DrawPowerUpsStep.cs
Normal file
52
tanks-backend/TanksServer/Graphics/DrawPowerUpsStep.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
56
tanks-backend/TanksServer/Graphics/DrawTanksStep.cs
Normal file
56
tanks-backend/TanksServer/Graphics/DrawTanksStep.cs
Normal file
|
@ -0,0 +1,56 @@
|
|||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Graphics;
|
||||
|
||||
internal sealed class DrawTanksStep : IDrawStep
|
||||
{
|
||||
private readonly MapEntityManager _entityManager;
|
||||
private readonly bool[] _tankSprite;
|
||||
private readonly int _tankSpriteWidth;
|
||||
|
||||
public DrawTanksStep(MapEntityManager entityManager)
|
||||
{
|
||||
_entityManager = entityManager;
|
||||
|
||||
using var tankImage = Image.Load<Rgba32>("assets/tank.png");
|
||||
_tankSprite = new bool[tankImage.Height * tankImage.Width];
|
||||
|
||||
var whitePixel = new Rgba32(byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue);
|
||||
var i = 0;
|
||||
for (var y = 0; y < tankImage.Height; y++)
|
||||
for (var x = 0; x < tankImage.Width; x++, i++)
|
||||
_tankSprite[i] = tankImage[x, y] == whitePixel;
|
||||
|
||||
_tankSpriteWidth = tankImage.Width;
|
||||
}
|
||||
|
||||
public void Draw(GamePixelGrid pixels)
|
||||
{
|
||||
foreach (var tank in _entityManager.Tanks)
|
||||
{
|
||||
var tankPosition = tank.Bounds.TopLeft;
|
||||
|
||||
for (byte dy = 0; dy < MapService.TileSize; dy++)
|
||||
for (byte dx = 0; dx < MapService.TileSize; dx++)
|
||||
{
|
||||
if (!TankSpriteAt(dx, dy, tank.Orientation))
|
||||
continue;
|
||||
|
||||
var (x, y) = tankPosition.GetPixelRelative(dx, dy);
|
||||
pixels[x, y].EntityType = GamePixelEntityType.Tank;
|
||||
pixels[x, y].BelongsTo = tank.Owner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TankSpriteAt(int dx, int dy, int tankRotation)
|
||||
{
|
||||
var x = tankRotation % 4 * (MapService.TileSize + 1);
|
||||
var y = (int)Math.Floor(tankRotation / 4d) * (MapService.TileSize + 1);
|
||||
var index = (y + dy) * _tankSpriteWidth + x + dx;
|
||||
|
||||
return _tankSprite[index];
|
||||
}
|
||||
}
|
14
tanks-backend/TanksServer/Graphics/GamePixel.cs
Normal file
14
tanks-backend/TanksServer/Graphics/GamePixel.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
namespace TanksServer.Graphics;
|
||||
|
||||
internal sealed class GamePixel
|
||||
{
|
||||
public Player? BelongsTo { get; set; }
|
||||
|
||||
public GamePixelEntityType? EntityType { get; set; }
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
BelongsTo = null;
|
||||
EntityType = null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
namespace TanksServer.Graphics;
|
||||
|
||||
internal enum GamePixelEntityType : byte
|
||||
{
|
||||
Wall = 0x0,
|
||||
Tank = 0x1,
|
||||
Bullet = 0x2,
|
||||
PowerUp = 0x3
|
||||
}
|
48
tanks-backend/TanksServer/Graphics/GamePixelGrid.cs
Normal file
48
tanks-backend/TanksServer/Graphics/GamePixelGrid.cs
Normal file
|
@ -0,0 +1,48 @@
|
|||
using System.Collections;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace TanksServer.Graphics;
|
||||
|
||||
internal sealed class GamePixelGrid : IEnumerable<GamePixel>
|
||||
{
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
|
||||
private readonly GamePixel[,] _pixels;
|
||||
|
||||
public GamePixelGrid(int width, int height)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
|
||||
_pixels = new GamePixel[width, height];
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++)
|
||||
this[x, y] = new GamePixel();
|
||||
}
|
||||
|
||||
public GamePixel this[int x, int y]
|
||||
{
|
||||
get
|
||||
{
|
||||
Debug.Assert(y * Width + x < _pixels.Length);
|
||||
return _pixels[x, y];
|
||||
}
|
||||
set => _pixels[x, y] = value;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var pixel in _pixels)
|
||||
pixel.Clear();
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
public IEnumerator<GamePixel> GetEnumerator()
|
||||
{
|
||||
for (var y = 0; y < Height; y++)
|
||||
for (var x = 0; x < Width; x++)
|
||||
yield return this[x, y];
|
||||
}
|
||||
}
|
34
tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs
Normal file
34
tanks-backend/TanksServer/Graphics/GeneratePixelsTickStep.cs
Normal file
|
@ -0,0 +1,34 @@
|
|||
using DisplayCommands;
|
||||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Graphics;
|
||||
|
||||
internal sealed class GeneratePixelsTickStep(
|
||||
IEnumerable<IDrawStep> drawSteps,
|
||||
IEnumerable<IFrameConsumer> consumers
|
||||
) : ITickStep
|
||||
{
|
||||
private readonly List<IDrawStep> _drawSteps = drawSteps.ToList();
|
||||
private readonly List<IFrameConsumer> _consumers = consumers.ToList();
|
||||
|
||||
private readonly PixelGrid _observerPixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn);
|
||||
private readonly GamePixelGrid _gamePixelGrid = new(MapService.PixelsPerRow, MapService.PixelsPerColumn);
|
||||
|
||||
public async Task TickAsync(TimeSpan _)
|
||||
{
|
||||
_gamePixelGrid.Clear();
|
||||
foreach (var step in _drawSteps)
|
||||
step.Draw(_gamePixelGrid);
|
||||
|
||||
_observerPixelGrid.Clear();
|
||||
for (var y = 0; y < MapService.PixelsPerColumn; y++)
|
||||
for (var x = 0; x < MapService.PixelsPerRow; x++)
|
||||
{
|
||||
if (_gamePixelGrid[x, y].EntityType.HasValue)
|
||||
_observerPixelGrid[(ushort)x, (ushort)y] = true;
|
||||
}
|
||||
|
||||
foreach (var consumer in _consumers)
|
||||
await consumer.OnFrameDoneAsync(_gamePixelGrid, _observerPixelGrid);
|
||||
}
|
||||
}
|
6
tanks-backend/TanksServer/Graphics/IDrawStep.cs
Normal file
6
tanks-backend/TanksServer/Graphics/IDrawStep.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace TanksServer.Graphics;
|
||||
|
||||
internal interface IDrawStep
|
||||
{
|
||||
void Draw(GamePixelGrid pixels);
|
||||
}
|
8
tanks-backend/TanksServer/Graphics/IFrameConsumer.cs
Normal file
8
tanks-backend/TanksServer/Graphics/IFrameConsumer.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using DisplayCommands;
|
||||
|
||||
namespace TanksServer.Graphics;
|
||||
|
||||
internal interface IFrameConsumer
|
||||
{
|
||||
Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
[JsonSerializable(typeof(Player))]
|
||||
[JsonSerializable(typeof(IEnumerable<Player>))]
|
||||
[JsonSerializable(typeof(Guid))]
|
||||
[JsonSerializable(typeof(NameId))]
|
||||
[JsonSerializable(typeof(IEnumerable<string>))]
|
||||
internal sealed partial class AppSerializerContext : JsonSerializerContext;
|
|
@ -0,0 +1,68 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
internal sealed class ByteChannelWebSocket(WebSocket socket, ILogger logger, int messageSize)
|
||||
{
|
||||
private readonly byte[] _buffer = new byte[messageSize];
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) =>
|
||||
socket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage, CancellationToken.None);
|
||||
|
||||
public async IAsyncEnumerable<Memory<byte>> ReadAllAsync()
|
||||
{
|
||||
while (socket.State is WebSocketState.Open or WebSocketState.CloseSent)
|
||||
{
|
||||
if (await TryReadAsync())
|
||||
yield return _buffer.ToArray();
|
||||
}
|
||||
|
||||
if (socket.State is not WebSocketState.Closed and not WebSocketState.Aborted)
|
||||
Debugger.Break();
|
||||
}
|
||||
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
if (socket.State != WebSocketState.Open)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
|
||||
}
|
||||
catch (WebSocketException socketException)
|
||||
{
|
||||
logger.LogDebug(socketException, "could not close socket properly");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryReadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await socket.ReceiveAsync(_buffer, CancellationToken.None);
|
||||
if (response.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty,
|
||||
CancellationToken.None);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.Count != _buffer.Length)
|
||||
{
|
||||
await socket.CloseOutputAsync(WebSocketCloseStatus.InvalidPayloadData,
|
||||
"response has unexpected size",
|
||||
CancellationToken.None);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (WebSocketException socketException)
|
||||
{
|
||||
logger.LogDebug(socketException, "could not read");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.WebSockets;
|
||||
using DisplayCommands;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using TanksServer.Graphics;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
internal sealed class ClientScreenServer(
|
||||
ILogger<ClientScreenServer> logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
IOptions<HostConfiguration> hostConfig
|
||||
) : IHostedLifecycleService, IFrameConsumer
|
||||
{
|
||||
private readonly ConcurrentDictionary<ClientScreenServerConnection, byte> _connections = new();
|
||||
private readonly TimeSpan _minFrameTime = TimeSpan.FromMilliseconds(hostConfig.Value.ClientDisplayMinFrameTimeMs);
|
||||
private bool _closing;
|
||||
|
||||
public Task StoppingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("closing connections");
|
||||
_closing = true;
|
||||
return Task.WhenAll(_connections.Keys.Select(c => c.CloseAsync()));
|
||||
}
|
||||
|
||||
public Task HandleClient(WebSocket socket, Guid? playerGuid)
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
logger.LogWarning("ignoring request because connections are closing");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
logger.LogDebug("HandleClient");
|
||||
var connection = new ClientScreenServerConnection(
|
||||
socket,
|
||||
loggerFactory.CreateLogger<ClientScreenServerConnection>(),
|
||||
this,
|
||||
_minFrameTime,
|
||||
playerGuid);
|
||||
var added = _connections.TryAdd(connection, 0);
|
||||
Debug.Assert(added);
|
||||
return connection.Done;
|
||||
}
|
||||
|
||||
public void Remove(ClientScreenServerConnection connection) => _connections.TryRemove(connection, out _);
|
||||
|
||||
public IEnumerable<ClientScreenServerConnection> GetConnections() => _connections.Keys;
|
||||
|
||||
|
||||
public Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels)
|
||||
{
|
||||
var tasks = _connections.Keys
|
||||
.Select(c => c.SendAsync(observerPixels, gamePixelGrid));
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.WebSockets;
|
||||
using DisplayCommands;
|
||||
using TanksServer.Graphics;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
internal sealed class ClientScreenServerConnection : IDisposable
|
||||
{
|
||||
private readonly ByteChannelWebSocket _channel;
|
||||
private readonly ILogger<ClientScreenServerConnection> _logger;
|
||||
private readonly ClientScreenServer _server;
|
||||
private readonly SemaphoreSlim _wantedFrames = new(1);
|
||||
private readonly Guid? _playerGuid;
|
||||
private readonly PlayerScreenData? _playerScreenData;
|
||||
private readonly TimeSpan _minFrameTime;
|
||||
|
||||
private DateTime _nextFrameAfter = DateTime.Now;
|
||||
|
||||
public ClientScreenServerConnection(
|
||||
WebSocket webSocket,
|
||||
ILogger<ClientScreenServerConnection> logger,
|
||||
ClientScreenServer server,
|
||||
TimeSpan minFrameTime,
|
||||
Guid? playerGuid = null
|
||||
)
|
||||
{
|
||||
_server = server;
|
||||
_logger = logger;
|
||||
_minFrameTime = minFrameTime;
|
||||
|
||||
_playerGuid = playerGuid;
|
||||
if (playerGuid.HasValue)
|
||||
_playerScreenData = new PlayerScreenData(logger);
|
||||
|
||||
_channel = new ByteChannelWebSocket(webSocket, logger, 0);
|
||||
Done = ReceiveAsync();
|
||||
}
|
||||
|
||||
public Task Done { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_wantedFrames.Dispose();
|
||||
Done.Dispose();
|
||||
}
|
||||
|
||||
public async Task SendAsync(PixelGrid pixels, GamePixelGrid gamePixelGrid)
|
||||
{
|
||||
if (_nextFrameAfter > DateTime.Now)
|
||||
return;
|
||||
|
||||
if (!await _wantedFrames.WaitAsync(TimeSpan.Zero))
|
||||
{
|
||||
_logger.LogTrace("client does not want a frame yet");
|
||||
return;
|
||||
}
|
||||
|
||||
_nextFrameAfter = DateTime.Today + _minFrameTime;
|
||||
|
||||
if (_playerScreenData != null)
|
||||
RefreshPlayerSpecificData(gamePixelGrid);
|
||||
|
||||
_logger.LogTrace("sending");
|
||||
try
|
||||
{
|
||||
_logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length);
|
||||
await _channel.SendAsync(pixels.Data, _playerScreenData == null);
|
||||
if (_playerScreenData != null)
|
||||
await _channel.SendAsync(_playerScreenData.GetPacket());
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "send failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPlayerSpecificData(GamePixelGrid gamePixelGrid)
|
||||
{
|
||||
Debug.Assert(_playerScreenData != null);
|
||||
_playerScreenData.Clear();
|
||||
foreach (var gamePixel in gamePixelGrid)
|
||||
{
|
||||
if (!gamePixel.EntityType.HasValue)
|
||||
continue;
|
||||
_playerScreenData.Add(gamePixel.EntityType.Value, gamePixel.BelongsTo?.Id == _playerGuid);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
await foreach (var _ in _channel.ReadAllAsync())
|
||||
_wantedFrames.Release();
|
||||
|
||||
_logger.LogTrace("done receiving");
|
||||
_server.Remove(this);
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
_logger.LogDebug("closing connection");
|
||||
return _channel.CloseAsync();
|
||||
}
|
||||
}
|
115
tanks-backend/TanksServer/Interactivity/ControlsServer.cs
Normal file
115
tanks-backend/TanksServer/Interactivity/ControlsServer.cs
Normal file
|
@ -0,0 +1,115 @@
|
|||
using System.Net.WebSockets;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
internal sealed class ControlsServer(ILogger<ControlsServer> logger, ILoggerFactory loggerFactory)
|
||||
: IHostedLifecycleService
|
||||
{
|
||||
private readonly List<ControlsServerConnection> _connections = [];
|
||||
|
||||
public Task StoppingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.WhenAll(_connections.Select(c => c.CloseAsync()));
|
||||
}
|
||||
|
||||
public Task HandleClient(WebSocket ws, Player player)
|
||||
{
|
||||
logger.LogDebug("control client connected {}", player.Id);
|
||||
var clientLogger = loggerFactory.CreateLogger<ControlsServerConnection>();
|
||||
var sock = new ControlsServerConnection(ws, clientLogger, this, player);
|
||||
_connections.Add(sock);
|
||||
return sock.Done;
|
||||
}
|
||||
|
||||
private void Remove(ControlsServerConnection connection) => _connections.Remove(connection);
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private sealed class ControlsServerConnection
|
||||
{
|
||||
private readonly ByteChannelWebSocket _binaryWebSocket;
|
||||
private readonly ILogger<ControlsServerConnection> _logger;
|
||||
private readonly Player _player;
|
||||
private readonly ControlsServer _server;
|
||||
|
||||
public ControlsServerConnection(WebSocket socket, ILogger<ControlsServerConnection> logger,
|
||||
ControlsServer server, Player player)
|
||||
{
|
||||
_logger = logger;
|
||||
_server = server;
|
||||
_player = player;
|
||||
_binaryWebSocket = new ByteChannelWebSocket(socket, logger, 2);
|
||||
Done = ReceiveAsync();
|
||||
}
|
||||
|
||||
public Task Done { get; }
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
await foreach (var buffer in _binaryWebSocket.ReadAllAsync())
|
||||
{
|
||||
var type = (MessageType)buffer.Span[0];
|
||||
var control = (InputType)buffer.Span[1];
|
||||
|
||||
_logger.LogTrace("player input {} {} {}", _player.Id, type, control);
|
||||
|
||||
var isEnable = type switch
|
||||
{
|
||||
MessageType.Enable => true,
|
||||
MessageType.Disable => false,
|
||||
_ => throw new ArgumentException("invalid message type")
|
||||
};
|
||||
|
||||
_player.LastInput = DateTime.Now;
|
||||
|
||||
switch (control)
|
||||
{
|
||||
case InputType.Forward:
|
||||
_player.Controls.Forward = isEnable;
|
||||
break;
|
||||
case InputType.Backward:
|
||||
_player.Controls.Backward = isEnable;
|
||||
break;
|
||||
case InputType.Left:
|
||||
_player.Controls.TurnLeft = isEnable;
|
||||
break;
|
||||
case InputType.Right:
|
||||
_player.Controls.TurnRight = isEnable;
|
||||
break;
|
||||
case InputType.Shoot:
|
||||
_player.Controls.Shoot = isEnable;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("invalid control type");
|
||||
}
|
||||
}
|
||||
|
||||
_server.Remove(this);
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
return _binaryWebSocket.CloseAsync();
|
||||
}
|
||||
|
||||
private enum MessageType : byte
|
||||
{
|
||||
Enable = 0x01,
|
||||
Disable = 0x02
|
||||
}
|
||||
|
||||
private enum InputType : byte
|
||||
{
|
||||
Forward = 0x01,
|
||||
Backward = 0x02,
|
||||
Left = 0x03,
|
||||
Right = 0x04,
|
||||
Shoot = 0x05
|
||||
}
|
||||
}
|
||||
}
|
39
tanks-backend/TanksServer/Interactivity/PlayerScreenData.cs
Normal file
39
tanks-backend/TanksServer/Interactivity/PlayerScreenData.cs
Normal file
|
@ -0,0 +1,39 @@
|
|||
using System.Diagnostics;
|
||||
using TanksServer.GameLogic;
|
||||
using TanksServer.Graphics;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
internal sealed class PlayerScreenData(ILogger logger)
|
||||
{
|
||||
private readonly Memory<byte> _data = new byte[MapService.PixelsPerRow * MapService.PixelsPerColumn / 2];
|
||||
private int _count = 0;
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_count = 0;
|
||||
_data.Span.Clear();
|
||||
}
|
||||
|
||||
public ReadOnlyMemory<byte> GetPacket()
|
||||
{
|
||||
var index = _count / 2 + (_count % 2 == 0 ? 0 : 1);
|
||||
logger.LogTrace("packet length: {} (count={})", index, _count);
|
||||
return _data[..index];
|
||||
}
|
||||
|
||||
public void Add(GamePixelEntityType entityKind, bool isCurrentPlayer)
|
||||
{
|
||||
var result = (byte)(isCurrentPlayer ? 0x1 : 0x0);
|
||||
var kind = (byte)entityKind;
|
||||
Debug.Assert(kind <= 3);
|
||||
result += (byte)(kind << 2);
|
||||
|
||||
var index = _count / 2;
|
||||
if (_count % 2 != 0)
|
||||
_data.Span[index] |= (byte)(result << 4);
|
||||
else
|
||||
_data.Span[index] = result;
|
||||
_count++;
|
||||
}
|
||||
}
|
42
tanks-backend/TanksServer/Interactivity/PlayerServer.cs
Normal file
42
tanks-backend/TanksServer/Interactivity/PlayerServer.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
internal sealed class PlayerServer(ILogger<PlayerServer> logger, TankSpawnQueue tankSpawnQueue)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Player> _players = new();
|
||||
|
||||
public Player? GetOrAdd(string name, Guid id)
|
||||
{
|
||||
Player AddAndSpawn()
|
||||
{
|
||||
var player = new Player(name, id);
|
||||
tankSpawnQueue.EnqueueForImmediateSpawn(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
var player = _players.GetOrAdd(name, _ => AddAndSpawn());
|
||||
if (player.Id != id)
|
||||
return null;
|
||||
|
||||
logger.LogInformation("player {} (re)joined", player.Id);
|
||||
return player;
|
||||
}
|
||||
|
||||
public bool TryGet(Guid? playerId, [MaybeNullWhen(false)] out Player foundPlayer)
|
||||
{
|
||||
foreach (var player in _players.Values)
|
||||
{
|
||||
if (player.Id != playerId)
|
||||
continue;
|
||||
foundPlayer = player;
|
||||
return true;
|
||||
}
|
||||
|
||||
foundPlayer = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public IEnumerable<Player> GetAll() => _players.Values;
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using DisplayCommands;
|
||||
using TanksServer.GameLogic;
|
||||
using TanksServer.Graphics;
|
||||
|
||||
namespace TanksServer.Interactivity;
|
||||
|
||||
internal sealed class SendToServicePointDisplay : IFrameConsumer
|
||||
{
|
||||
private const int ScoresWidth = 12;
|
||||
private const int ScoresHeight = 20;
|
||||
private const int ScoresPlayerRows = ScoresHeight - 6;
|
||||
|
||||
private readonly IDisplayConnection _displayConnection;
|
||||
private readonly MapService _mapService;
|
||||
private readonly ILogger<SendToServicePointDisplay> _logger;
|
||||
private readonly PlayerServer _players;
|
||||
private readonly Cp437Grid _scoresBuffer;
|
||||
private readonly TimeSpan _minFrameTime;
|
||||
|
||||
private DateTime _nextFailLogAfter = DateTime.Now;
|
||||
private DateTime _nextFrameAfter = DateTime.Now;
|
||||
|
||||
public SendToServicePointDisplay(
|
||||
PlayerServer players,
|
||||
ILogger<SendToServicePointDisplay> logger,
|
||||
IDisplayConnection displayConnection,
|
||||
IOptions<HostConfiguration> hostOptions,
|
||||
MapService mapService
|
||||
)
|
||||
{
|
||||
_players = players;
|
||||
_logger = logger;
|
||||
_displayConnection = displayConnection;
|
||||
_mapService = mapService;
|
||||
_minFrameTime = TimeSpan.FromMilliseconds(hostOptions.Value.ServicePointDisplayMinFrameTimeMs);
|
||||
|
||||
var localIp = _displayConnection.GetLocalIPv4().Split('.');
|
||||
Debug.Assert(localIp.Length == 4);
|
||||
_scoresBuffer = new Cp437Grid(12, 20)
|
||||
{
|
||||
[00] = "== TANKS! ==",
|
||||
[01] = "-- scores --",
|
||||
[17] = "-- join --",
|
||||
[18] = string.Join('.', localIp[..2]),
|
||||
[19] = string.Join('.', localIp[2..])
|
||||
};
|
||||
}
|
||||
|
||||
public async Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels)
|
||||
{
|
||||
if (DateTime.Now < _nextFrameAfter)
|
||||
return;
|
||||
_nextFrameAfter = DateTime.Now + _minFrameTime;
|
||||
|
||||
RefreshScores();
|
||||
|
||||
try
|
||||
{
|
||||
await _displayConnection.SendBitmapLinearWindowAsync(0, 0, observerPixels);
|
||||
await _displayConnection.SendCp437DataAsync(MapService.TilesPerRow, 0, _scoresBuffer);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
if (DateTime.Now > _nextFailLogAfter)
|
||||
{
|
||||
_logger.LogWarning("could not send data to service point display: {}", ex.Message);
|
||||
_nextFailLogAfter = DateTime.Now + TimeSpan.FromSeconds(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshScores()
|
||||
{
|
||||
var playersToDisplay = _players.GetAll()
|
||||
.OrderByDescending(p => p.Scores.Kills)
|
||||
.Take(ScoresPlayerRows);
|
||||
|
||||
ushort row = 2;
|
||||
foreach (var p in playersToDisplay)
|
||||
{
|
||||
var score = p.Scores.Kills.ToString();
|
||||
var nameLength = Math.Min(p.Name.Length, ScoresWidth - score.Length - 1);
|
||||
|
||||
var name = p.Name[..nameLength];
|
||||
var spaces = new string(' ', ScoresWidth - score.Length - nameLength);
|
||||
|
||||
_scoresBuffer[row] = name + spaces + score;
|
||||
row++;
|
||||
}
|
||||
|
||||
for (; row < 16; row++)
|
||||
_scoresBuffer[row] = string.Empty;
|
||||
|
||||
_scoresBuffer[16] = _mapService.Current.Name[..(Math.Min(ScoresWidth, _mapService.Current.Name.Length) - 1)];
|
||||
}
|
||||
}
|
16
tanks-backend/TanksServer/Models/Bullet.cs
Normal file
16
tanks-backend/TanksServer/Models/Bullet.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
namespace TanksServer.Models;
|
||||
|
||||
internal sealed class Bullet(Player tankOwner, FloatPosition position, double rotation, bool isExplosive, DateTime timeout) : IMapEntity
|
||||
{
|
||||
public Player Owner { get; } = tankOwner;
|
||||
|
||||
public double Rotation { get; } = rotation;
|
||||
|
||||
public FloatPosition Position { get; set; } = position;
|
||||
|
||||
public bool IsExplosive { get; } = isExplosive;
|
||||
|
||||
public DateTime Timeout { get; } = timeout;
|
||||
|
||||
public PixelBounds Bounds => new (Position.ToPixelPosition(), Position.ToPixelPosition());
|
||||
}
|
11
tanks-backend/TanksServer/Models/FloatPosition.cs
Normal file
11
tanks-backend/TanksServer/Models/FloatPosition.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System.Diagnostics;
|
||||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Models;
|
||||
|
||||
[DebuggerDisplay("({X} | {Y})")]
|
||||
internal readonly struct FloatPosition(double x, double y)
|
||||
{
|
||||
public double X { get; } = (x + MapService.PixelsPerRow) % MapService.PixelsPerRow;
|
||||
public double Y { get; } = (y + MapService.PixelsPerColumn) % MapService.PixelsPerColumn;
|
||||
}
|
10
tanks-backend/TanksServer/Models/HostConfiguration.cs
Normal file
10
tanks-backend/TanksServer/Models/HostConfiguration.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace TanksServer.Models;
|
||||
|
||||
public class HostConfiguration
|
||||
{
|
||||
public bool EnableServicePointDisplay { get; set; } = true;
|
||||
|
||||
public int ServicePointDisplayMinFrameTimeMs { get; set; } = 25;
|
||||
|
||||
public int ClientDisplayMinFrameTimeMs { get; set; } = 25;
|
||||
}
|
8
tanks-backend/TanksServer/Models/IMapEntity.cs
Normal file
8
tanks-backend/TanksServer/Models/IMapEntity.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace TanksServer.Models;
|
||||
|
||||
internal interface IMapEntity
|
||||
{
|
||||
FloatPosition Position { get; set; }
|
||||
|
||||
PixelBounds Bounds { get; }
|
||||
}
|
6
tanks-backend/TanksServer/Models/PixelBounds.cs
Normal file
6
tanks-backend/TanksServer/Models/PixelBounds.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace TanksServer.Models;
|
||||
|
||||
[DebuggerDisplay("{TopLeft}, {BottomRight}")]
|
||||
internal record struct PixelBounds(PixelPosition TopLeft, PixelPosition BottomRight);
|
17
tanks-backend/TanksServer/Models/PixelPosition.cs
Normal file
17
tanks-backend/TanksServer/Models/PixelPosition.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System.Diagnostics;
|
||||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Models;
|
||||
|
||||
[DebuggerDisplay("({X} | {Y})")]
|
||||
internal readonly struct PixelPosition(int x, int y)
|
||||
{
|
||||
public int X { get; } = (x + MapService.PixelsPerRow) % MapService.PixelsPerRow;
|
||||
public int Y { get; } = (y + MapService.PixelsPerColumn) % MapService.PixelsPerColumn;
|
||||
|
||||
public void Deconstruct(out int x, out int y)
|
||||
{
|
||||
x = X;
|
||||
y = Y;
|
||||
}
|
||||
}
|
16
tanks-backend/TanksServer/Models/Player.cs
Normal file
16
tanks-backend/TanksServer/Models/Player.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TanksServer.Models;
|
||||
|
||||
internal sealed class Player(string name, Guid id)
|
||||
{
|
||||
public string Name => name;
|
||||
|
||||
[JsonIgnore] public Guid Id => id;
|
||||
|
||||
[JsonIgnore] public PlayerControls Controls { get; } = new();
|
||||
|
||||
public Scores Scores { get; } = new();
|
||||
|
||||
public DateTime LastInput { get; set; } = DateTime.Now;
|
||||
}
|
10
tanks-backend/TanksServer/Models/PlayerControls.cs
Normal file
10
tanks-backend/TanksServer/Models/PlayerControls.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace TanksServer.Models;
|
||||
|
||||
internal sealed class PlayerControls
|
||||
{
|
||||
public bool Forward { get; set; }
|
||||
public bool Backward { get; set; }
|
||||
public bool TurnLeft { get; set; }
|
||||
public bool TurnRight { get; set; }
|
||||
public bool Shoot { get; set; }
|
||||
}
|
42
tanks-backend/TanksServer/Models/PositionHelpers.cs
Normal file
42
tanks-backend/TanksServer/Models/PositionHelpers.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Models;
|
||||
|
||||
internal static class PositionHelpers
|
||||
{
|
||||
public static PixelPosition GetPixelRelative(this PixelPosition position, short subX, short subY)
|
||||
=> new(position.X + subX, position.Y + subY);
|
||||
|
||||
public static PixelPosition ToPixelPosition(this FloatPosition position)
|
||||
=> new((int)Math.Round(position.X), (int)Math.Round(position.Y));
|
||||
|
||||
public static PixelPosition ToPixelPosition(this TilePosition position) => new(
|
||||
(ushort)(position.X * MapService.TileSize),
|
||||
(ushort)(position.Y * MapService.TileSize)
|
||||
);
|
||||
|
||||
public static TilePosition ToTilePosition(this PixelPosition position) => new(
|
||||
(ushort)(position.X / MapService.TileSize),
|
||||
(ushort)(position.Y / MapService.TileSize)
|
||||
);
|
||||
|
||||
public static FloatPosition ToFloatPosition(this PixelPosition position) => new(position.X, position.Y);
|
||||
|
||||
|
||||
public static double Distance(this FloatPosition p1, FloatPosition p2)
|
||||
=> Math.Sqrt(
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
10
tanks-backend/TanksServer/Models/PowerUp.cs
Normal file
10
tanks-backend/TanksServer/Models/PowerUp.cs
Normal 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);
|
||||
}
|
22
tanks-backend/TanksServer/Models/Scores.cs
Normal file
22
tanks-backend/TanksServer/Models/Scores.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
namespace TanksServer.Models;
|
||||
|
||||
internal sealed record class Scores(int Kills = 0, int Deaths = 0)
|
||||
{
|
||||
public int Kills { get; set; } = Kills;
|
||||
|
||||
public int Deaths { get; set; } = Deaths;
|
||||
|
||||
public double Ratio
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Kills == 0)
|
||||
return 0;
|
||||
if (Deaths == 0)
|
||||
return Kills;
|
||||
return Kills / (double)Deaths;
|
||||
}
|
||||
}
|
||||
|
||||
public int WallsDestroyed { get; set; }
|
||||
}
|
34
tanks-backend/TanksServer/Models/Tank.cs
Normal file
34
tanks-backend/TanksServer/Models/Tank.cs
Normal file
|
@ -0,0 +1,34 @@
|
|||
using System.Diagnostics;
|
||||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Models;
|
||||
|
||||
internal sealed class Tank(Player player, FloatPosition spawnPosition) : IMapEntity
|
||||
{
|
||||
private double _rotation;
|
||||
|
||||
public Player Owner { get; } = player;
|
||||
|
||||
public double Rotation
|
||||
{
|
||||
get => _rotation;
|
||||
set
|
||||
{
|
||||
var newRotation = (value % 1d + 1d) % 1d;
|
||||
Debug.Assert(newRotation is >= 0 and < 1);
|
||||
_rotation = newRotation;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime NextShotAfter { get; set; }
|
||||
|
||||
public bool Moved { get; set; }
|
||||
|
||||
public FloatPosition Position { get; set; } = spawnPosition;
|
||||
|
||||
public PixelBounds Bounds => Position.GetBoundsForCenter(MapService.TileSize);
|
||||
|
||||
public int Orientation => (int)Math.Round(Rotation * 16) % 16;
|
||||
|
||||
public byte ExplosiveBullets { get; set; }
|
||||
}
|
11
tanks-backend/TanksServer/Models/TilePosition.cs
Normal file
11
tanks-backend/TanksServer/Models/TilePosition.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System.Diagnostics;
|
||||
using TanksServer.GameLogic;
|
||||
|
||||
namespace TanksServer.Models;
|
||||
|
||||
[DebuggerDisplay("({X} | {Y})")]
|
||||
internal readonly struct TilePosition(ushort x, ushort y)
|
||||
{
|
||||
public ushort X { get; } = (ushort)((x + MapService.TilesPerRow) % MapService.TilesPerRow);
|
||||
public ushort Y { get; } = (ushort)((y + MapService.TilesPerColumn) % MapService.TilesPerColumn);
|
||||
}
|
105
tanks-backend/TanksServer/Program.cs
Normal file
105
tanks-backend/TanksServer/Program.cs
Normal file
|
@ -0,0 +1,105 @@
|
|||
using System.IO;
|
||||
using DisplayCommands;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using TanksServer.GameLogic;
|
||||
using TanksServer.Graphics;
|
||||
using TanksServer.Interactivity;
|
||||
|
||||
namespace TanksServer;
|
||||
|
||||
internal sealed record class NameId(string Name, Guid Id);
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
var app = Configure(args);
|
||||
|
||||
var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client"));
|
||||
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider });
|
||||
app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider });
|
||||
|
||||
Endpoints.MapEndpoints(app);
|
||||
|
||||
await app.RunAsync();
|
||||
}
|
||||
|
||||
private static WebApplication Configure(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
builder.Logging.AddSimpleConsole(options =>
|
||||
{
|
||||
options.SingleLine = true;
|
||||
options.IncludeScopes = true;
|
||||
options.TimestampFormat = "HH:mm:ss ";
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options => options
|
||||
.AddDefaultPolicy(policy => policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyOrigin())
|
||||
);
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, new AppSerializerContext());
|
||||
});
|
||||
|
||||
builder.Services.AddHttpLogging(_ => { });
|
||||
|
||||
builder.Services.Configure<HostConfiguration>(builder.Configuration.GetSection("Host"));
|
||||
var hostConfiguration = builder.Configuration.GetSection("Host").Get<HostConfiguration>();
|
||||
if (hostConfiguration == null)
|
||||
throw new InvalidOperationException("'Host' configuration missing");
|
||||
|
||||
builder.Services.AddSingleton<MapService>();
|
||||
builder.Services.AddSingleton<MapEntityManager>();
|
||||
builder.Services.AddSingleton<ControlsServer>();
|
||||
builder.Services.AddSingleton<PlayerServer>();
|
||||
builder.Services.AddSingleton<ClientScreenServer>();
|
||||
builder.Services.AddSingleton<TankSpawnQueue>();
|
||||
|
||||
builder.Services.AddHostedService<GameTickWorker>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ControlsServer>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ClientScreenServer>());
|
||||
|
||||
builder.Services.AddSingleton<ITickStep, MoveBullets>();
|
||||
builder.Services.AddSingleton<ITickStep, CollideBullets>();
|
||||
builder.Services.AddSingleton<ITickStep, RotateTanks>();
|
||||
builder.Services.AddSingleton<ITickStep, MoveTanks>();
|
||||
builder.Services.AddSingleton<ITickStep, ShootFromTanks>();
|
||||
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<IDrawStep, DrawMapStep>();
|
||||
builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>();
|
||||
builder.Services.AddSingleton<IDrawStep, DrawTanksStep>();
|
||||
builder.Services.AddSingleton<IDrawStep, DrawBulletsStep>();
|
||||
|
||||
builder.Services.AddSingleton<IFrameConsumer, ClientScreenServer>(sp =>
|
||||
sp.GetRequiredService<ClientScreenServer>());
|
||||
|
||||
builder.Services.Configure<GameRules>(builder.Configuration.GetSection("GameRules"));
|
||||
|
||||
if (hostConfiguration.EnableServicePointDisplay)
|
||||
{
|
||||
builder.Services.AddSingleton<IFrameConsumer, SendToServicePointDisplay>();
|
||||
builder.Services.AddDisplay(builder.Configuration.GetSection("ServicePointDisplay"));
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
app.UseWebSockets();
|
||||
app.UseHttpLogging();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
12
tanks-backend/TanksServer/Properties/launchSettings.json
Normal file
12
tanks-backend/TanksServer/Properties/launchSettings.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
tanks-backend/TanksServer/TanksServer.csproj
Normal file
26
tanks-backend/TanksServer/TanksServer.csproj
Normal file
|
@ -0,0 +1,26 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<Import Project="../shared.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishAot>true</PublishAot>
|
||||
|
||||
<IlcDisableReflection>false</IlcDisableReflection>
|
||||
<StaticExecutable>true</StaticExecutable>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
<StaticallyLinked>true</StaticallyLinked>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0"/>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4"/>
|
||||
<ProjectReference Include="../DisplayCommands/DisplayCommands.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="./assets/tank.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always"/>
|
||||
<!-- TODO include maps in release -->
|
||||
<None Include="./assets/maps/**" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
39
tanks-backend/TanksServer/appsettings.json
Normal file
39
tanks-backend/TanksServer/appsettings.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"TanksServer": "Debug",
|
||||
"Microsoft.AspNetCore.HttpLogging": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServicePointDisplay": {
|
||||
"Hostname": "172.23.42.29",
|
||||
"Port": 2342
|
||||
},
|
||||
"GameRules": {
|
||||
"DestructibleWalls": true,
|
||||
"PowerUpSpawnChance": 0.1,
|
||||
"MaxPowerUpCount": 15,
|
||||
"BulletTimeoutMs": 30000,
|
||||
"SpawnDelayMs": 3000,
|
||||
"IdleTimeoutMs": 30000,
|
||||
"MoveSpeed": 37.5,
|
||||
"TurnSpeed": 0.5,
|
||||
"ShootDelayMs": 450,
|
||||
"BulletSpeed": 75
|
||||
},
|
||||
"Host": {
|
||||
"EnableServicePointDisplay": true,
|
||||
"ServicePointDisplayMinFrameTimeMs": 28,
|
||||
"ClientScreenMinFrameTime": 5
|
||||
}
|
||||
}
|
20
tanks-backend/TanksServer/assets/maps/buggie.txt
Normal file
20
tanks-backend/TanksServer/assets/maps/buggie.txt
Normal file
|
@ -0,0 +1,20 @@
|
|||
######......####################......######
|
||||
#..........................................#
|
||||
#.....#####......................#####.....#
|
||||
#..........#....................#..........#
|
||||
#...........#####..........#####...........#
|
||||
#..........................................#
|
||||
#.........##....................##.........#
|
||||
#............#..#..........#..#............#
|
||||
#..............#............#..............#
|
||||
#.............#..............#.............#
|
||||
#....................##....................#
|
||||
#....................##....................#
|
||||
#....................##....................#
|
||||
#.............#......##......#.............#
|
||||
#...........#..#............#..#...........#
|
||||
#.........#.....#..........#.....#.........#
|
||||
#.......#............##............#.......#
|
||||
#........#........................#........#
|
||||
#..........................................#
|
||||
######......####################......######
|
BIN
tanks-backend/TanksServer/assets/maps/chaosknoten.png
Normal file
BIN
tanks-backend/TanksServer/assets/maps/chaosknoten.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
20
tanks-backend/TanksServer/assets/maps/orig.txt
Normal file
20
tanks-backend/TanksServer/assets/maps/orig.txt
Normal file
|
@ -0,0 +1,20 @@
|
|||
#######.##########################.#########
|
||||
#...................##.....................#
|
||||
#...................##.....................#
|
||||
#.....####......................####.......#
|
||||
#..........................................#
|
||||
#............###...........###.............#
|
||||
#............#...............#.............#
|
||||
#...##.......#....#....#.....#......##.....#
|
||||
#....#..............................#......#
|
||||
.....#...#......................#...#.......
|
||||
.....#...#......................#...#.......
|
||||
#....#..............................#......#
|
||||
#...##.......#....#....#.....#......##.....#
|
||||
#............#...............#.............#
|
||||
#............###...........###.............#
|
||||
#..........................................#
|
||||
#.....####......................####.......#
|
||||
#...................##.....................#
|
||||
#...................##.....................#
|
||||
#######.##########################.#########
|
20
tanks-backend/TanksServer/assets/maps/tanks.txt
Normal file
20
tanks-backend/TanksServer/assets/maps/tanks.txt
Normal file
|
@ -0,0 +1,20 @@
|
|||
#####......................................#
|
||||
..#........................................#
|
||||
..#........................................#
|
||||
..#......###...............................#
|
||||
..#.....#...#..............................#
|
||||
........#####..............................#
|
||||
........#...#...#...#......................#
|
||||
........#...#...##..#......................#
|
||||
................#.# #......................#
|
||||
................#..##...#...#..............#
|
||||
................#...#...#..#...............#
|
||||
........................###................#
|
||||
........................#..#.....####......#
|
||||
........................#...#...#..........#
|
||||
.................................###.......#
|
||||
....................................#...#..#
|
||||
................................####....#..#
|
||||
........................................#..#
|
||||
...........................................#
|
||||
........................................#..#
|
BIN
tanks-backend/TanksServer/assets/maps/test2.png
Normal file
BIN
tanks-backend/TanksServer/assets/maps/test2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
20
tanks-backend/TanksServer/assets/maps/upside_down.txt
Normal file
20
tanks-backend/TanksServer/assets/maps/upside_down.txt
Normal file
|
@ -0,0 +1,20 @@
|
|||
............................................
|
||||
#.........##.....#.........................#
|
||||
#........#..#..............................#
|
||||
#.........##...............................#
|
||||
#..................#.......................#
|
||||
#.........#........##.............#........#
|
||||
#....#.........#....#............#.........#
|
||||
#....#.....................................#
|
||||
#............#.............#..........#....#
|
||||
#.........##.....#......................#..#
|
||||
#........#..#..............................#
|
||||
#.........##..................#............#
|
||||
#..........................................#
|
||||
#....#..............#...........#..........#
|
||||
#....................#...##...#............#
|
||||
#..........##..........#....#.........#....#
|
||||
#.........#...#........#....#..............#
|
||||
#.........#...#......#...##...#............#
|
||||
#..........##......#............#.......#..#
|
||||
............................................
|
BIN
tanks-backend/TanksServer/assets/powerup_explosive.png
Normal file
BIN
tanks-backend/TanksServer/assets/powerup_explosive.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 112 B |
BIN
tanks-backend/TanksServer/assets/tank.png
Normal file
BIN
tanks-backend/TanksServer/assets/tank.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 355 B |
17
tanks-backend/TanksServer/userRequests.http
Normal file
17
tanks-backend/TanksServer/userRequests.http
Normal file
|
@ -0,0 +1,17 @@
|
|||
POST localhost/player?name=test&id=a31d35c2-1d9e-4a34-93e5-680195c6a9d2
|
||||
|
||||
###
|
||||
|
||||
GET localhost/scores
|
||||
|
||||
###
|
||||
|
||||
GET localhost/map
|
||||
|
||||
###
|
||||
|
||||
POST localhost/map?name=chaosknoten.png
|
||||
|
||||
###
|
||||
|
||||
POST localhost/map?name=tanks.txt
|
Loading…
Add table
Add a link
Reference in a new issue