diff --git a/tank-frontend/src/PlayerInfo.tsx b/tank-frontend/src/PlayerInfo.tsx index e6f9b16..8a3cfad 100644 --- a/tank-frontend/src/PlayerInfo.tsx +++ b/tank-frontend/src/PlayerInfo.tsx @@ -22,7 +22,7 @@ function ScoreRow({name, value}: { } type TankInfo = { - readonly explosiveBullets: number; + readonly magazine: string; readonly position: { x: number; y: number }; readonly orientation: number; readonly moving: boolean; @@ -63,8 +63,8 @@ export default function PlayerInfo({player}: { player: string }) { + - diff --git a/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs b/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs index cc74e23..e3a68a5 100644 --- a/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs +++ b/tanks-backend/TanksServer/GameLogic/CollectPowerUp.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace TanksServer.GameLogic; internal sealed class CollectPowerUp( @@ -21,7 +23,33 @@ internal sealed class CollectPowerUp( continue; // now the tank overlaps the power up by at least 0.5 tiles - tank.ExplosiveBullets += 10; + + switch (obj.Type) + { + case PowerUpType.MagazineTypeUpgrade: + if (obj.MagazineType == null) + throw new UnreachableException(); + + tank.Magazine = tank.Magazine with + { + Type = tank.Magazine.Type | obj.MagazineType.Value, + UsedBullets = 0 + }; + + if (tank.ReloadingUntil >= DateTime.Now) + tank.ReloadingUntil = DateTime.Now; + + break; + case PowerUpType.MagazineSizeUpgrade: + tank.Magazine = tank.Magazine with + { + MaxBullets = (byte)int.Clamp(tank.Magazine.MaxBullets + 1, 1, 32) + }; + break; + default: + throw new UnreachableException(); + } + tank.Owner.Scores.PowerUpsCollected++; return true; } diff --git a/tanks-backend/TanksServer/GameLogic/GameRules.cs b/tanks-backend/TanksServer/GameLogic/GameRules.cs index 50d79e1..2284464 100644 --- a/tanks-backend/TanksServer/GameLogic/GameRules.cs +++ b/tanks-backend/TanksServer/GameLogic/GameRules.cs @@ -21,4 +21,10 @@ internal sealed class GameRules public int SpawnDelayMs { get; set; } public int IdleTimeoutMs { get; set; } + + public byte MagazineSize { get; set; } = 5; + + public int ReloadDelayMs { get; set; } = 3000; + + public double SmartBulletInertia { get; set; } = 1; } diff --git a/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs b/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs index ce63a05..42d30ff 100644 --- a/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs +++ b/tanks-backend/TanksServer/GameLogic/MapEntityManager.cs @@ -6,6 +6,7 @@ internal sealed class MapEntityManager( IOptions options ) { + private readonly GameRules _rules = options.Value; private readonly HashSet _bullets = []; private readonly HashSet _powerUps = []; private readonly Dictionary _playerTanks = []; @@ -15,30 +16,43 @@ internal sealed class MapEntityManager( public IEnumerable Tanks => _playerTanks.Values; public IEnumerable PowerUps => _powerUps; - public void SpawnBullet(Player tankOwner, FloatPosition position, double rotation, bool isExplosive) - => _bullets.Add(new Bullet + public void SpawnBullet(Player tankOwner, FloatPosition position, double rotation, MagazineType type) + { + var speed = _rules.BulletSpeed * (type.HasFlag(MagazineType.Fast) ? 2 : 1); + _bullets.Add(new Bullet { Owner = tankOwner, Position = position, Rotation = rotation, - IsExplosive = isExplosive, + IsExplosive = type.HasFlag(MagazineType.Explosive), Timeout = DateTime.Now + _bulletTimeout, OwnerCollisionAfter = DateTime.Now + TimeSpan.FromSeconds(1), + Speed = speed, + IsSmart = type.HasFlag(MagazineType.Smart) }); + } public void RemoveWhere(Predicate predicate) => _bullets.RemoveWhere(predicate); public void SpawnTank(Player player) { - var tank = new Tank(player, ChooseSpawnPosition()) + var tank = new Tank { - Rotation = Random.Shared.NextDouble() + Owner = player, + Position = ChooseSpawnPosition(), + Rotation = Random.Shared.NextDouble(), + Magazine = new Magazine(MagazineType.Basic, 0, _rules.MagazineSize) }; _playerTanks[player] = tank; logger.LogInformation("Tank added for player {}", player.Name); } - public void SpawnPowerUp() => _powerUps.Add(new PowerUp(ChooseSpawnPosition())); + public void SpawnPowerUp(PowerUpType type, MagazineType? magazineType) => _powerUps.Add(new PowerUp + { + Position = ChooseSpawnPosition(), + Type = type, + MagazineType = magazineType + }); public void RemoveWhere(Predicate predicate) => _powerUps.RemoveWhere(predicate); diff --git a/tanks-backend/TanksServer/GameLogic/MoveBullets.cs b/tanks-backend/TanksServer/GameLogic/MoveBullets.cs index 63e3661..96bde60 100644 --- a/tanks-backend/TanksServer/GameLogic/MoveBullets.cs +++ b/tanks-backend/TanksServer/GameLogic/MoveBullets.cs @@ -2,9 +2,11 @@ namespace TanksServer.GameLogic; internal sealed class MoveBullets( MapEntityManager entityManager, - IOptions config + IOptions options ) : ITickStep { + private readonly double _smartBulletInertia = options.Value.SmartBulletInertia; + public ValueTask TickAsync(TimeSpan delta) { foreach (var bullet in entityManager.Bullets) @@ -15,11 +17,38 @@ internal sealed class MoveBullets( private void MoveBullet(Bullet bullet, TimeSpan delta) { - var speed = config.Value.BulletSpeed * delta.TotalSeconds; + if (bullet.IsSmart && TryGetSmartRotation(bullet.Position, bullet.Owner, out var wantedRotation)) + { + var inertiaFactor = _smartBulletInertia * delta.TotalSeconds; + var difference = wantedRotation - bullet.Rotation; + bullet.Rotation += difference * inertiaFactor; + } + + var speed = bullet.Speed * 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 ); } + + private bool TryGetSmartRotation(FloatPosition position, Player bulletOwner, out double rotation) + { + var nearestEnemy = entityManager.Tanks + .Where(t => t.Owner != bulletOwner) + .MinBy(t => position.Distance(t.Position)); + + if (nearestEnemy == null) + { + rotation = double.NaN; + return false; + } + + var rotationRadians = Math.Atan2( + y: nearestEnemy.Position.Y - position.Y, + x: nearestEnemy.Position.X - position.X + ) + (Math.PI / 2); + rotation = rotationRadians / (2 * Math.PI); + return true; + } } diff --git a/tanks-backend/TanksServer/GameLogic/ShootFromTanks.cs b/tanks-backend/TanksServer/GameLogic/ShootFromTanks.cs index e6bb65e..9df8a64 100644 --- a/tanks-backend/TanksServer/GameLogic/ShootFromTanks.cs +++ b/tanks-backend/TanksServer/GameLogic/ShootFromTanks.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; - namespace TanksServer.GameLogic; internal sealed class ShootFromTanks( @@ -21,16 +19,31 @@ internal sealed class ShootFromTanks( { if (!tank.Owner.Controls.Shoot) return; - if (tank.NextShotAfter >= DateTime.Now) + + var now = DateTime.Now; + if (tank.NextShotAfter >= now) + return; + if (tank.ReloadingUntil >= now) return; - tank.NextShotAfter = DateTime.Now.AddMilliseconds(_config.ShootDelayMs); + if (tank.Magazine.Empty) + { + tank.ReloadingUntil = now.AddMilliseconds(_config.ReloadDelayMs); + tank.Magazine = tank.Magazine with + { + UsedBullets = 0, + Type = MagazineType.Basic + }; + return; + } - var explosive = tank.ExplosiveBullets > 0; - if (explosive) - tank.ExplosiveBullets--; + tank.NextShotAfter = now.AddMilliseconds(_config.ShootDelayMs); + tank.Magazine = tank.Magazine with + { + UsedBullets = (byte)(tank.Magazine.UsedBullets + 1) + }; tank.Owner.Scores.ShotsFired++; - entityManager.SpawnBullet(tank.Owner, tank.Position, tank.Orientation / 16d, explosive); + entityManager.SpawnBullet(tank.Owner, tank.Position, tank.Orientation / 16d, tank.Magazine.Type); } } diff --git a/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs b/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs index f14a60e..56c7ae7 100644 --- a/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs +++ b/tanks-backend/TanksServer/GameLogic/SpawnPowerUp.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace TanksServer.GameLogic; internal sealed class SpawnPowerUp( @@ -15,7 +17,24 @@ internal sealed class SpawnPowerUp( if (Random.Shared.NextDouble() > _spawnChance * delta.TotalSeconds) return ValueTask.CompletedTask; - entityManager.SpawnPowerUp(); + + var type = Random.Shared.Next(4) == 0 + ? PowerUpType.MagazineSizeUpgrade + : PowerUpType.MagazineTypeUpgrade; + + MagazineType? magazineType = type switch + { + PowerUpType.MagazineTypeUpgrade => Random.Shared.Next(0, 3) switch + { + 0 => MagazineType.Fast, + 1 => MagazineType.Explosive, + 2 => MagazineType.Smart, + _ => throw new UnreachableException() + }, + _ => null + }; + + entityManager.SpawnPowerUp(type, magazineType); return ValueTask.CompletedTask; } } diff --git a/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs index c8fbbe7..bc4956b 100644 --- a/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs +++ b/tanks-backend/TanksServer/Interactivity/PlayerInfoConnection.cs @@ -1,5 +1,4 @@ using System.Net.WebSockets; -using System.Text; using System.Text.Json; using TanksServer.GameLogic; @@ -46,10 +45,15 @@ internal sealed class PlayerInfoConnection( private byte[]? GetMessageToSend() { var tank = entityManager.GetCurrentTankOfPlayer(player); - var tankInfo = tank != null - ? new TankInfo(tank.Orientation, tank.ExplosiveBullets, tank.Position.ToPixelPosition(), tank.Moving) - : null; - var info = new PlayerInfo(player.Name, player.Scores, ControlsToString(player.Controls), tankInfo); + + TankInfo? tankInfo = null; + if (tank != null) + { + var magazine = tank.ReloadingUntil > DateTime.Now ? "[ RELOADING ]" : tank.Magazine.ToDisplayString(); + tankInfo = new TankInfo(tank.Orientation, magazine, tank.Position.ToPixelPosition(), tank.Moving); + } + + var info = new PlayerInfo(player.Name, player.Scores, player.Controls.ToDisplayString(), tankInfo); var response = JsonSerializer.SerializeToUtf8Bytes(info, _context.PlayerInfo); if (response.SequenceEqual(_lastMessage)) @@ -57,21 +61,4 @@ internal sealed class PlayerInfoConnection( return _lastMessage = response; } - - private static string ControlsToString(PlayerControls controls) - { - var str = new StringBuilder("[ "); - if (controls.Forward) - str.Append("▲ "); - if (controls.Backward) - str.Append("▼ "); - if (controls.TurnLeft) - str.Append("◄ "); - if (controls.TurnRight) - str.Append("► "); - if (controls.Shoot) - str.Append("• "); - str.Append(']'); - return str.ToString(); - } } diff --git a/tanks-backend/TanksServer/Models/Bullet.cs b/tanks-backend/TanksServer/Models/Bullet.cs index c6e87d9..9e17066 100644 --- a/tanks-backend/TanksServer/Models/Bullet.cs +++ b/tanks-backend/TanksServer/Models/Bullet.cs @@ -4,7 +4,7 @@ internal sealed class Bullet : IMapEntity { public required Player Owner { get; init; } - public required double Rotation { get; init; } + public required double Rotation { get; set; } public required FloatPosition Position { get; set; } @@ -15,4 +15,8 @@ internal sealed class Bullet : IMapEntity public PixelBounds Bounds => new(Position.ToPixelPosition(), Position.ToPixelPosition()); internal required DateTime OwnerCollisionAfter { get; init; } + + public required double Speed { get; init; } + + public required bool IsSmart { get; init; } } diff --git a/tanks-backend/TanksServer/Models/IMapEntity.cs b/tanks-backend/TanksServer/Models/IMapEntity.cs index 05b5700..a8ee53f 100644 --- a/tanks-backend/TanksServer/Models/IMapEntity.cs +++ b/tanks-backend/TanksServer/Models/IMapEntity.cs @@ -2,7 +2,7 @@ namespace TanksServer.Models; internal interface IMapEntity { - FloatPosition Position { get; set; } + FloatPosition Position { get; } PixelBounds Bounds { get; } } diff --git a/tanks-backend/TanksServer/Models/Magazine.cs b/tanks-backend/TanksServer/Models/Magazine.cs new file mode 100644 index 0000000..b3143b5 --- /dev/null +++ b/tanks-backend/TanksServer/Models/Magazine.cs @@ -0,0 +1,41 @@ +using System.Text; + +namespace TanksServer.Models; + +[Flags] +internal enum MagazineType +{ + Basic = 0, + Fast = 1 << 0, + Explosive = 1 << 1, + Smart = 1 << 2, + Mine = 1 << 3, +} + +internal readonly record struct Magazine(MagazineType Type, byte UsedBullets, byte MaxBullets) +{ + public bool Empty => UsedBullets >= MaxBullets; + + public string ToDisplayString() + { + var sb = new StringBuilder(); + + if (Type.HasFlag(MagazineType.Fast)) + sb.Append("» "); + if (Type.HasFlag(MagazineType.Explosive)) + sb.Append("* "); + if (Type.HasFlag(MagazineType.Smart)) + sb.Append("@ "); + if (Type.HasFlag(MagazineType.Mine)) + sb.Append("\u263c "); + + sb.Append("[ "); + for (var i = 0; i < UsedBullets; i++) + sb.Append("\u25cb "); + for (var i = UsedBullets; i < MaxBullets; i++) + sb.Append("• "); + sb.Append(']'); + + return sb.ToString(); + } +} diff --git a/tanks-backend/TanksServer/Models/PlayerControls.cs b/tanks-backend/TanksServer/Models/PlayerControls.cs index 82e0bef..b5b82dd 100644 --- a/tanks-backend/TanksServer/Models/PlayerControls.cs +++ b/tanks-backend/TanksServer/Models/PlayerControls.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace TanksServer.Models; internal sealed class PlayerControls @@ -7,4 +9,22 @@ internal sealed class PlayerControls public bool TurnLeft { get; set; } public bool TurnRight { get; set; } public bool Shoot { get; set; } -} \ No newline at end of file + + + public string ToDisplayString() + { + var str = new StringBuilder("[ "); + if (Forward) + str.Append("▲ "); + if (Backward) + str.Append("▼ "); + if (TurnLeft) + str.Append("◄ "); + if (TurnRight) + str.Append("► "); + if (Shoot) + str.Append("• "); + str.Append(']'); + return str.ToString(); + } +} diff --git a/tanks-backend/TanksServer/Models/PlayerInfo.cs b/tanks-backend/TanksServer/Models/PlayerInfo.cs index be46707..118eb42 100644 --- a/tanks-backend/TanksServer/Models/PlayerInfo.cs +++ b/tanks-backend/TanksServer/Models/PlayerInfo.cs @@ -1,13 +1,13 @@ namespace TanksServer.Models; -internal sealed record class TankInfo( +internal record struct TankInfo( int Orientation, - byte ExplosiveBullets, + string Magazine, PixelPosition Position, bool Moving ); -internal sealed record class PlayerInfo( +internal record struct PlayerInfo( string Name, Scores Scores, string Controls, diff --git a/tanks-backend/TanksServer/Models/PositionHelpers.cs b/tanks-backend/TanksServer/Models/PositionHelpers.cs index 5af5073..223a472 100644 --- a/tanks-backend/TanksServer/Models/PositionHelpers.cs +++ b/tanks-backend/TanksServer/Models/PositionHelpers.cs @@ -22,7 +22,6 @@ internal static class PositionHelpers 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) + diff --git a/tanks-backend/TanksServer/Models/PowerUp.cs b/tanks-backend/TanksServer/Models/PowerUp.cs index 5fb8e56..6305a2d 100644 --- a/tanks-backend/TanksServer/Models/PowerUp.cs +++ b/tanks-backend/TanksServer/Models/PowerUp.cs @@ -2,9 +2,19 @@ using TanksServer.GameLogic; namespace TanksServer.Models; -internal sealed class PowerUp(FloatPosition position): IMapEntity +internal enum PowerUpType { - public FloatPosition Position { get; set; } = position; + MagazineTypeUpgrade, + MagazineSizeUpgrade +} + +internal sealed class PowerUp: IMapEntity +{ + public required FloatPosition Position { get; init; } public PixelBounds Bounds => Position.GetBoundsForCenter(MapService.TileSize); + + public required PowerUpType Type { get; init; } + + public MagazineType? MagazineType { get; init; } } diff --git a/tanks-backend/TanksServer/Models/Tank.cs b/tanks-backend/TanksServer/Models/Tank.cs index 5f64a81..e9de961 100644 --- a/tanks-backend/TanksServer/Models/Tank.cs +++ b/tanks-backend/TanksServer/Models/Tank.cs @@ -3,11 +3,11 @@ using TanksServer.GameLogic; namespace TanksServer.Models; -internal sealed class Tank(Player player, FloatPosition spawnPosition) : IMapEntity +internal sealed class Tank : IMapEntity { private double _rotation; - public Player Owner { get; } = player; + public required Player Owner { get; init; } public double Rotation { @@ -24,11 +24,13 @@ internal sealed class Tank(Player player, FloatPosition spawnPosition) : IMapEnt public bool Moving { get; set; } - public FloatPosition Position { get; set; } = spawnPosition; + public required FloatPosition Position { get; set; } public PixelBounds Bounds => Position.GetBoundsForCenter(MapService.TileSize); public int Orientation => (int)Math.Round(Rotation * 16) % 16; - public byte ExplosiveBullets { get; set; } + public required Magazine Magazine { get; set; } + + public DateTime ReloadingUntil { get; set; } } diff --git a/tanks-backend/TanksServer/appsettings.json b/tanks-backend/TanksServer/appsettings.json index 045fd04..dd8aa6d 100644 --- a/tanks-backend/TanksServer/appsettings.json +++ b/tanks-backend/TanksServer/appsettings.json @@ -21,15 +21,16 @@ }, "GameRules": { "DestructibleWalls": true, - "PowerUpSpawnChance": 0.1, - "MaxPowerUpCount": 15, - "BulletTimeoutMs": 30000, + "PowerUpSpawnChance": 0.2, + "MaxPowerUpCount": 5, + "BulletTimeoutMs": 20000, "SpawnDelayMs": 3000, "IdleTimeoutMs": 30000, - "MoveSpeed": 37.5, + "MoveSpeed": 40, "TurnSpeed": 0.5, "ShootDelayMs": 450, - "BulletSpeed": 75 + "BulletSpeed": 75, + "SmartBulletHomingSpeed": 1.5 }, "Host": { "EnableServicePointDisplay": true,