add explosive bullet power up
This commit is contained in:
		
							parent
							
								
									3f4a301993
								
							
						
					
					
						commit
						a2d46bda92
					
				
					 30 changed files with 407 additions and 253 deletions
				
			
		|  | @ -104,3 +104,8 @@ There are other commands implemented as well, e.g. for changing the brightness. | |||
|     - 10: bullet | ||||
|     - 11: (reserved) | ||||
| - client responds with empty message to request the next frame | ||||
| 
 | ||||
| ## Backlog: Bugs, Wishes, Ideas | ||||
| - Generalize drawing of entities as there are multiple classes with pretty much the same code | ||||
| - Generalize hit box collision | ||||
| - BUG: when standing next to a wall, the bullet sometimes misses the first pixel | ||||
|  |  | |||
							
								
								
									
										76
									
								
								TanksServer/Endpoints.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								TanksServer/Endpoints.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,76 @@ | |||
| using Microsoft.AspNetCore.Builder; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using TanksServer.GameLogic; | ||||
| using TanksServer.Interactivity; | ||||
| 
 | ||||
| namespace TanksServer; | ||||
| 
 | ||||
| internal static class Endpoints | ||||
| { | ||||
|     public static void MapEndpoints(WebApplication app) | ||||
|     { | ||||
|         var clientScreenServer = app.Services.GetRequiredService<ClientScreenServer>(); | ||||
|         var playerService = app.Services.GetRequiredService<PlayerServer>(); | ||||
|         var controlsServer = app.Services.GetRequiredService<ControlsServer>(); | ||||
|         var mapService = app.Services.GetRequiredService<MapService>(); | ||||
| 
 | ||||
|         app.MapPost("/player", (string name, Guid? id) => | ||||
|         { | ||||
|             name = name.Trim().ToUpperInvariant(); | ||||
|             if (name == string.Empty) | ||||
|                 return Results.BadRequest("name cannot be blank"); | ||||
|             if (name.Length > 12) | ||||
|                 return Results.BadRequest("name too long"); | ||||
| 
 | ||||
|             var player = playerService.GetOrAdd(name, id ?? Guid.NewGuid()); | ||||
|             return player != null | ||||
|                 ? Results.Ok(new NameId(player.Name, player.Id)) | ||||
|                 : Results.Unauthorized(); | ||||
|         }); | ||||
| 
 | ||||
|         app.MapGet("/player", ([FromQuery] Guid id) => | ||||
|             playerService.TryGet(id, out var foundPlayer) | ||||
|                 ? Results.Ok((object?)foundPlayer) | ||||
|                 : Results.NotFound() | ||||
|         ); | ||||
| 
 | ||||
|         app.MapGet("/scores", () => playerService.GetAll()); | ||||
| 
 | ||||
|         app.Map("/screen", async (HttpContext context, [FromQuery] Guid? player) => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|                 return Results.BadRequest(); | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await clientScreenServer.HandleClient(ws, player); | ||||
|             return Results.Empty; | ||||
|         }); | ||||
| 
 | ||||
|         app.Map("/controls", async (HttpContext context, [FromQuery] Guid playerId) => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|                 return Results.BadRequest(); | ||||
| 
 | ||||
|             if (!playerService.TryGet(playerId, out var player)) | ||||
|                 return Results.NotFound(); | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await controlsServer.HandleClient(ws, player); | ||||
|             return Results.Empty; | ||||
|         }); | ||||
| 
 | ||||
|         app.MapGet("/map", () => mapService.MapNames); | ||||
| 
 | ||||
|         app.MapPost("/map", ([FromQuery] string name) => | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(name)) | ||||
|                 return Results.BadRequest("invalid map name"); | ||||
|             if (!mapService.TrySwitchTo(name)) | ||||
|                 return Results.NotFound("map with name not found"); | ||||
|             return Results.Ok(); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,13 +0,0 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class BulletManager | ||||
| { | ||||
|     private readonly HashSet<Bullet> _bullets = []; | ||||
| 
 | ||||
|     public void Spawn(Player tankOwner, FloatPosition position, double rotation) | ||||
|         => _bullets.Add(new Bullet(tankOwner, position, rotation)); | ||||
| 
 | ||||
|     public IEnumerable<Bullet> GetAll() => _bullets; | ||||
| 
 | ||||
|     public void RemoveWhere(Predicate<Bullet> predicate) => _bullets.RemoveWhere(predicate); | ||||
| } | ||||
							
								
								
									
										30
									
								
								TanksServer/GameLogic/CollectPowerUp.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										73
									
								
								TanksServer/GameLogic/CollideBullets.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								TanksServer/GameLogic/CollideBullets.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class CollideBullets( | ||||
|     MapEntityManager entityManager, | ||||
|     MapService map, | ||||
|     IOptions<GameRules> options, | ||||
|     TankSpawnQueue tankSpawnQueue | ||||
| ) : ITickStep | ||||
| { | ||||
|     private const int ExplosionRadius = 3; | ||||
| 
 | ||||
|     public Task TickAsync(TimeSpan _) | ||||
|     { | ||||
|         entityManager.RemoveBulletsWhere(BulletHitsTank); | ||||
|         entityManager.RemoveBulletsWhere(TryHitAndDestroyWall); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| 
 | ||||
|     private bool TryHitAndDestroyWall(Bullet bullet) | ||||
|     { | ||||
|         var pixel = bullet.Position.ToPixelPosition(); | ||||
|         if (!map.Current.IsWall(pixel)) | ||||
|             return false; | ||||
| 
 | ||||
|         var radius = bullet.IsExplosive ? ExplosionRadius : 0; | ||||
|         ExplodeAt(pixel, radius, bullet.Owner); | ||||
| 
 | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     private bool BulletHitsTank(Bullet bullet) | ||||
|     { | ||||
|         if (!TryHitTankAt(bullet.Position, bullet.Owner)) | ||||
|             return false; | ||||
| 
 | ||||
|         if (bullet.IsExplosive) | ||||
|             ExplodeAt(bullet.Position.ToPixelPosition(), ExplosionRadius, bullet.Owner); | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     private bool TryHitTankAt(FloatPosition position, Player owner) | ||||
|     { | ||||
|         foreach (var tank in entityManager.Tanks) | ||||
|         { | ||||
|             var (topLeft, bottomRight) = tank.Bounds; | ||||
|             if (position.X < topLeft.X || position.X > bottomRight.X || | ||||
|                 position.Y < topLeft.Y || position.Y > bottomRight.Y) | ||||
|                 continue; | ||||
| 
 | ||||
|             if (owner != tank.Owner) | ||||
|                 owner.Scores.Kills++; | ||||
|             tank.Owner.Scores.Deaths++; | ||||
| 
 | ||||
|             entityManager.Remove(tank); | ||||
|             tankSpawnQueue.EnqueueForDelayedSpawn(tank.Owner); | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     private void ExplodeAt(PixelPosition pixel, int i, Player owner) | ||||
|     { | ||||
|         for (var x = pixel.X - i; x <= pixel.X + i; x++) | ||||
|         for (var y = pixel.Y - i; y <= pixel.Y + i; y++) | ||||
|         { | ||||
|             var offsetPixel = new PixelPosition(x, y); | ||||
|             if (options.Value.DestructibleWalls) | ||||
|                 map.Current.DestroyWallAt(offsetPixel); | ||||
|             TryHitTankAt(offsetPixel.ToFloatPosition(), owner); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,25 +0,0 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class CollideBulletsWithMap( | ||||
|     BulletManager bullets, | ||||
|     MapService map, | ||||
|     IOptions<GameRulesConfiguration> options | ||||
| ) : ITickStep | ||||
| { | ||||
|     public Task TickAsync(TimeSpan _) | ||||
|     { | ||||
|         bullets.RemoveWhere(TryHitAndDestroyWall); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| 
 | ||||
|     private bool TryHitAndDestroyWall(Bullet bullet) | ||||
|     { | ||||
|         var pixel = bullet.Position.ToPixelPosition(); | ||||
|         if (!map.Current.IsWall(pixel)) | ||||
|             return false; | ||||
| 
 | ||||
|         if (options.Value.DestructibleWalls) | ||||
|             map.Current.DestroyWallAt(pixel); | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
|  | @ -1,36 +0,0 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class CollideBulletsWithTanks( | ||||
|     BulletManager bullets, | ||||
|     TankManager tanks, | ||||
|     SpawnQueue spawnQueue | ||||
| ) : ITickStep | ||||
| { | ||||
|     public Task TickAsync(TimeSpan _) | ||||
|     { | ||||
|         bullets.RemoveWhere(BulletHitsTank); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| 
 | ||||
|     private bool BulletHitsTank(Bullet bullet) | ||||
|     { | ||||
|         foreach (var tank in tanks) | ||||
|         { | ||||
|             var (topLeft, bottomRight) = tank.Bounds; | ||||
|             if (bullet.Position.X < topLeft.X || bullet.Position.X > bottomRight.X || | ||||
|                 bullet.Position.Y < topLeft.Y || bullet.Position.Y > bottomRight.Y) | ||||
|                 continue; | ||||
| 
 | ||||
|             if (bullet.Owner != tank.Owner) | ||||
|                 bullet.Owner.Scores.Kills++; | ||||
|             tank.Owner.Scores.Deaths++; | ||||
| 
 | ||||
|             tanks.Remove(tank); | ||||
|             spawnQueue.EnqueueForDelayedSpawn(tank.Owner); | ||||
| 
 | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|  | @ -1,6 +1,8 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| public class GameRulesConfiguration | ||||
| internal sealed class GameRules | ||||
| { | ||||
|     public bool DestructibleWalls { get; set; } = true; | ||||
| 
 | ||||
|     public double PowerUpSpawnChance { get; set; } | ||||
| } | ||||
							
								
								
									
										65
									
								
								TanksServer/GameLogic/MapEntityManager.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								TanksServer/GameLogic/MapEntityManager.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,65 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class MapEntityManager( | ||||
|     ILogger<MapEntityManager> logger, | ||||
|     MapService map | ||||
| ) | ||||
| { | ||||
|     private readonly HashSet<Bullet> _bullets = []; | ||||
|     private readonly HashSet<Tank> _tanks = []; | ||||
|     private readonly HashSet<PowerUp> _powerUps = []; | ||||
| 
 | ||||
|     public IEnumerable<Bullet> Bullets => _bullets; | ||||
|     public IEnumerable<Tank> Tanks => _tanks; | ||||
|     public IEnumerable<PowerUp> PowerUps => _powerUps; | ||||
| 
 | ||||
|     public void SpawnBullet(Player tankOwner, FloatPosition position, double rotation, bool isExplosive) | ||||
|         => _bullets.Add(new Bullet(tankOwner, position, rotation, isExplosive)); | ||||
| 
 | ||||
|     public void RemoveBulletsWhere(Predicate<Bullet> predicate) => _bullets.RemoveWhere(predicate); | ||||
| 
 | ||||
|     public void SpawnTank(Player player) | ||||
|     { | ||||
|         _tanks.Add(new Tank(player, ChooseSpawnPosition()) | ||||
|         { | ||||
|             Rotation = Random.Shared.NextDouble() | ||||
|         }); | ||||
|         logger.LogInformation("Tank added for player {}", player.Id); | ||||
|     } | ||||
| 
 | ||||
|     public void SpawnPowerUp() => _powerUps.Add(new PowerUp(ChooseSpawnPosition())); | ||||
| 
 | ||||
|     public void RemoveWhere(Predicate<PowerUp> predicate) => _powerUps.RemoveWhere(predicate); | ||||
| 
 | ||||
|     public void Remove(Tank tank) | ||||
|     { | ||||
|         logger.LogInformation("Tank removed for player {}", tank.Owner.Id); | ||||
|         _tanks.Remove(tank); | ||||
|     } | ||||
| 
 | ||||
|     public FloatPosition ChooseSpawnPosition() | ||||
|     { | ||||
|         Dictionary<TilePosition, double> candidates = []; | ||||
| 
 | ||||
|         for (ushort x = 1; x < MapService.TilesPerRow - 1; x++) | ||||
|         for (ushort y = 1; y < MapService.TilesPerColumn - 1; y++) | ||||
|         { | ||||
|             var tile = new TilePosition(x, y); | ||||
|             if (map.Current.IsWall(tile)) | ||||
|                 continue; | ||||
| 
 | ||||
|             var tilePixelCenter = tile.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); | ||||
| 
 | ||||
|             var minDistance = Bullets | ||||
|                 .Cast<IMapEntity>() | ||||
|                 .Concat(Tanks) | ||||
|                 .Select(entity => entity.Position.Distance(tilePixelCenter)) | ||||
|                 .Aggregate(double.MaxValue, Math.Min); | ||||
| 
 | ||||
|             candidates.Add(tile, minDistance); | ||||
|         } | ||||
| 
 | ||||
|         var min = candidates.MaxBy(pair => pair.Value).Key; | ||||
|         return min.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); | ||||
|     } | ||||
| } | ||||
|  | @ -1,10 +1,13 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class MoveBullets(BulletManager bullets, IOptions<TanksConfiguration> config) : ITickStep | ||||
| internal sealed class MoveBullets( | ||||
|     MapEntityManager entityManager, | ||||
|     IOptions<TanksConfiguration> config | ||||
| ) : ITickStep | ||||
| { | ||||
|     public Task TickAsync(TimeSpan delta) | ||||
|     { | ||||
|         foreach (var bullet in bullets.GetAll()) | ||||
|         foreach (var bullet in entityManager.Bullets) | ||||
|             MoveBullet(bullet, delta); | ||||
| 
 | ||||
|         return Task.CompletedTask; | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class MoveTanks( | ||||
|     TankManager tanks, | ||||
|     MapEntityManager entityManager, | ||||
|     IOptions<TanksConfiguration> options, | ||||
|     MapService map | ||||
| ) : ITickStep | ||||
|  | @ -10,7 +10,7 @@ internal sealed class MoveTanks( | |||
| 
 | ||||
|     public Task TickAsync(TimeSpan delta) | ||||
|     { | ||||
|         foreach (var tank in tanks) | ||||
|         foreach (var tank in entityManager.Tanks) | ||||
|             tank.Moved = TryMoveTank(tank, delta); | ||||
| 
 | ||||
|         return Task.CompletedTask; | ||||
|  | @ -59,13 +59,13 @@ internal sealed class MoveTanks( | |||
|     } | ||||
| 
 | ||||
|     private bool HitsTank(Tank tank, FloatPosition newPosition) => | ||||
|         tanks | ||||
|         entityManager.Tanks | ||||
|             .Where(otherTank => otherTank != tank) | ||||
|             .Any(otherTank => newPosition.Distance(otherTank.Position) < MapService.TileSize); | ||||
| 
 | ||||
|     private bool HitsWall(FloatPosition newPosition) | ||||
|     { | ||||
|         var (topLeft, _) = Tank.GetBoundsForCenter(newPosition); | ||||
|         var (topLeft, _) = newPosition.GetBoundsForCenter(MapService.TileSize); | ||||
| 
 | ||||
|         for (short y = 0; y < MapService.TileSize; y++) | ||||
|         for (short x = 0; x < MapService.TileSize; x++) | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class RotateTanks( | ||||
|     TankManager tanks, | ||||
|     MapEntityManager entityManager, | ||||
|     IOptions<TanksConfiguration> options, | ||||
|     ILogger<RotateTanks> logger | ||||
| ) : ITickStep | ||||
|  | @ -10,7 +10,7 @@ internal sealed class RotateTanks( | |||
| 
 | ||||
|     public Task TickAsync(TimeSpan delta) | ||||
|     { | ||||
|         foreach (var tank in tanks) | ||||
|         foreach (var tank in entityManager.Tanks) | ||||
|         { | ||||
|             var player = tank.Owner; | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,16 +3,15 @@ using System.Diagnostics; | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class ShootFromTanks( | ||||
|     TankManager tanks, | ||||
|     IOptions<TanksConfiguration> options, | ||||
|     BulletManager bulletManager | ||||
|     MapEntityManager entityManager | ||||
| ) : ITickStep | ||||
| { | ||||
|     private readonly TanksConfiguration _config = options.Value; | ||||
| 
 | ||||
|     public Task TickAsync(TimeSpan _) | ||||
|     { | ||||
|         foreach (var tank in tanks.Where(t => !t.Moved)) | ||||
|         foreach (var tank in entityManager.Tanks.Where(t => !t.Moved)) | ||||
|             Shoot(tank); | ||||
| 
 | ||||
|         return Task.CompletedTask; | ||||
|  | @ -30,7 +29,7 @@ internal sealed class ShootFromTanks( | |||
|         var rotation = tank.Orientation / 16d; | ||||
|         var angle = rotation * 2d * Math.PI; | ||||
| 
 | ||||
|         /* TODO: when standing next to a wall, the bullet sometimes misses the first pixel. | ||||
|         /* When standing next to a wall, the bullet sometimes misses the first pixel. | ||||
|          Spawning the bullet to close to the tank instead means the tank instantly hits itself. | ||||
|          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. */ | ||||
|  | @ -47,6 +46,13 @@ internal sealed class ShootFromTanks( | |||
|             tank.Position.Y - Math.Cos(angle) * distance | ||||
|         ); | ||||
| 
 | ||||
|         bulletManager.Spawn(tank.Owner, position, rotation); | ||||
|         var explosive = false; | ||||
|         if (tank.ExplosiveBullets > 0) | ||||
|         { | ||||
|             tank.ExplosiveBullets--; | ||||
|             explosive = true; | ||||
|         } | ||||
| 
 | ||||
|         entityManager.SpawnBullet(tank.Owner, position, rotation, explosive); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,48 +0,0 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class SpawnNewTanks( | ||||
|     TankManager tanks, | ||||
|     MapService map, | ||||
|     SpawnQueue queue, | ||||
|     BulletManager bullets | ||||
| ) : ITickStep | ||||
| { | ||||
|     public Task TickAsync(TimeSpan _) | ||||
|     { | ||||
|         if (!queue.TryDequeueNext(out var player)) | ||||
|             return Task.CompletedTask; | ||||
| 
 | ||||
|         tanks.Add(new Tank(player, ChooseSpawnPosition()) | ||||
|         { | ||||
|             Rotation = Random.Shared.NextDouble() | ||||
|         }); | ||||
| 
 | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| 
 | ||||
|     private FloatPosition ChooseSpawnPosition() | ||||
|     { | ||||
|         Dictionary<TilePosition, double> candidates = []; | ||||
| 
 | ||||
|         for (ushort x = 1; x < MapService.TilesPerRow - 1; x++) | ||||
|         for (ushort y = 1; y < MapService.TilesPerColumn - 1; y++) | ||||
|         { | ||||
|             var tile = new TilePosition(x, y); | ||||
|             if (map.Current.IsWall(tile)) | ||||
|                 continue; | ||||
| 
 | ||||
|             var tilePixelCenter = tile.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); | ||||
| 
 | ||||
|             var minDistance = bullets.GetAll() | ||||
|                 .Cast<IMapEntity>() | ||||
|                 .Concat(tanks) | ||||
|                 .Select(entity => entity.Position.Distance(tilePixelCenter)) | ||||
|                 .Aggregate(double.MaxValue, Math.Min); | ||||
| 
 | ||||
|             candidates.Add(tile, minDistance); | ||||
|         } | ||||
| 
 | ||||
|         var min = candidates.MaxBy(kvp => kvp.Value).Key; | ||||
|         return min.ToPixelPosition().GetPixelRelative(4, 4).ToFloatPosition(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										18
									
								
								TanksServer/GameLogic/SpawnPowerUp.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								TanksServer/GameLogic/SpawnPowerUp.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class SpawnPowerUp( | ||||
|     IOptions<GameRules> options, | ||||
|     MapEntityManager entityManager | ||||
| ) : ITickStep | ||||
| { | ||||
|     private readonly double _spawnChance = options.Value.PowerUpSpawnChance; | ||||
| 
 | ||||
|     public Task TickAsync(TimeSpan delta) | ||||
|     { | ||||
|         if (Random.Shared.NextDouble() > _spawnChance * delta.TotalSeconds) | ||||
|             return Task.CompletedTask; | ||||
| 
 | ||||
|         entityManager.SpawnPowerUp(); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| } | ||||
|  | @ -1,23 +0,0 @@ | |||
| using System.Collections; | ||||
| 
 | ||||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class TankManager(ILogger<TankManager> logger) : IEnumerable<Tank> | ||||
| { | ||||
|     private readonly ConcurrentDictionary<Tank, byte> _tanks = new(); | ||||
| 
 | ||||
|     public void Add(Tank tank) | ||||
|     { | ||||
|         logger.LogInformation("Tank added for player {}", tank.Owner.Id); | ||||
|         _tanks.TryAdd(tank, 0); | ||||
|     } | ||||
| 
 | ||||
|     IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); | ||||
|     public IEnumerator<Tank> GetEnumerator() => _tanks.Keys.GetEnumerator(); | ||||
| 
 | ||||
|     public void Remove(Tank tank) | ||||
|     { | ||||
|         logger.LogInformation("Tank removed for player {}", tank.Owner.Id); | ||||
|         _tanks.Remove(tank, out _); | ||||
|     } | ||||
| } | ||||
|  | @ -2,9 +2,10 @@ using System.Diagnostics.CodeAnalysis; | |||
| 
 | ||||
| namespace TanksServer.GameLogic; | ||||
| 
 | ||||
| internal sealed class SpawnQueue( | ||||
|     IOptions<PlayersConfiguration> options | ||||
| ) | ||||
| internal sealed class TankSpawnQueue( | ||||
|     IOptions<PlayersConfiguration> options, | ||||
|     MapEntityManager entityManager | ||||
| ): ITickStep | ||||
| { | ||||
|     private readonly ConcurrentQueue<Player> _queue = new(); | ||||
|     private readonly ConcurrentDictionary<Player, DateTime> _spawnTimes = new(); | ||||
|  | @ -41,4 +42,13 @@ internal sealed class SpawnQueue( | |||
| 
 | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     public Task TickAsync(TimeSpan _) | ||||
|     { | ||||
|         if (!TryDequeueNext(out var player)) | ||||
|             return Task.CompletedTask; | ||||
| 
 | ||||
|         entityManager.SpawnTank(player); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
| } | ||||
|  | @ -2,11 +2,11 @@ using TanksServer.GameLogic; | |||
| 
 | ||||
| namespace TanksServer.Graphics; | ||||
| 
 | ||||
| internal sealed class DrawBulletsStep(BulletManager bullets) : IDrawStep | ||||
| internal sealed class DrawBulletsStep(MapEntityManager entityManager) : IDrawStep | ||||
| { | ||||
|     public void Draw(GamePixelGrid pixels) | ||||
|     { | ||||
|         foreach (var bullet in bullets.GetAll()) | ||||
|         foreach (var bullet in entityManager.Bullets) | ||||
|         { | ||||
|             var position = bullet.Position.ToPixelPosition(); | ||||
|             pixels[position.X, position.Y].EntityType = GamePixelEntityType.Bullet; | ||||
|  |  | |||
							
								
								
									
										52
									
								
								TanksServer/Graphics/DrawPowerUpsStep.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								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; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -6,13 +6,13 @@ namespace TanksServer.Graphics; | |||
| 
 | ||||
| internal sealed class DrawTanksStep : IDrawStep | ||||
| { | ||||
|     private readonly TankManager _tanks; | ||||
|     private readonly MapEntityManager _entityManager; | ||||
|     private readonly bool[] _tankSprite; | ||||
|     private readonly int _tankSpriteWidth; | ||||
| 
 | ||||
|     public DrawTanksStep(TankManager tanks) | ||||
|     public DrawTanksStep(MapEntityManager entityManager) | ||||
|     { | ||||
|         _tanks = tanks; | ||||
|         _entityManager = entityManager; | ||||
| 
 | ||||
|         using var tankImage = Image.Load<Rgba32>("assets/tank.png"); | ||||
|         _tankSprite = new bool[tankImage.Height * tankImage.Width]; | ||||
|  | @ -28,7 +28,7 @@ internal sealed class DrawTanksStep : IDrawStep | |||
| 
 | ||||
|     public void Draw(GamePixelGrid pixels) | ||||
|     { | ||||
|         foreach (var tank in _tanks) | ||||
|         foreach (var tank in _entityManager.Tanks) | ||||
|         { | ||||
|             var tankPosition = tank.Bounds.TopLeft; | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,5 +4,6 @@ internal enum GamePixelEntityType : byte | |||
| { | ||||
|     Wall = 0x0, | ||||
|     Tank = 0x1, | ||||
|     Bullet = 0x2 | ||||
|     Bullet = 0x2, | ||||
|     PowerUp = 0x3 | ||||
| } | ||||
|  |  | |||
|  | @ -26,7 +26,7 @@ internal sealed class PlayerScreenData(ILogger logger) | |||
|     { | ||||
|         var result = (byte)(isCurrentPlayer ? 0x1 : 0x0); | ||||
|         var kind = (byte)entityKind; | ||||
|         Debug.Assert(kind < 3); | ||||
|         Debug.Assert(kind <= 3); | ||||
|         result += (byte)(kind << 2); | ||||
| 
 | ||||
|         var index = _count / 2; | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ using TanksServer.GameLogic; | |||
| 
 | ||||
| namespace TanksServer.Interactivity; | ||||
| 
 | ||||
| internal sealed class PlayerServer(ILogger<PlayerServer> logger, SpawnQueue spawnQueue) | ||||
| internal sealed class PlayerServer(ILogger<PlayerServer> logger, TankSpawnQueue tankSpawnQueue) | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, Player> _players = new(); | ||||
| 
 | ||||
|  | @ -12,7 +12,7 @@ internal sealed class PlayerServer(ILogger<PlayerServer> logger, SpawnQueue spaw | |||
|         Player AddAndSpawn() | ||||
|         { | ||||
|             var player = new Player(name, id); | ||||
|             spawnQueue.EnqueueForImmediateSpawn(player); | ||||
|             tankSpawnQueue.EnqueueForImmediateSpawn(player); | ||||
|             return player; | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,12 +1,14 @@ | |||
| namespace TanksServer.Models; | ||||
| 
 | ||||
| internal sealed class Bullet(Player tankOwner, FloatPosition position, double rotation) : IMapEntity | ||||
| internal sealed class Bullet(Player tankOwner, FloatPosition position, double rotation, bool isExplosive) : IMapEntity | ||||
| { | ||||
|     public Player Owner { get; } = tankOwner; | ||||
| 
 | ||||
|     public double Rotation { get; set; } = rotation; | ||||
|     public double Rotation { get; } = rotation; | ||||
| 
 | ||||
|     public FloatPosition Position { get; set; } = position; | ||||
| 
 | ||||
|     public bool IsExplosive { get; } = isExplosive; | ||||
| 
 | ||||
|     public PixelBounds Bounds => new (Position.ToPixelPosition(), Position.ToPixelPosition()); | ||||
| } | ||||
|  |  | |||
|  | @ -28,4 +28,15 @@ internal static class PositionHelpers | |||
|             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
									
								
								TanksServer/Models/PowerUp.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								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); | ||||
| } | ||||
|  | @ -1,4 +1,5 @@ | |||
| using System.Diagnostics; | ||||
| using TanksServer.GameLogic; | ||||
| 
 | ||||
| namespace TanksServer.Models; | ||||
| 
 | ||||
|  | @ -25,16 +26,9 @@ internal sealed class Tank(Player player, FloatPosition spawnPosition) : IMapEnt | |||
| 
 | ||||
|     public FloatPosition Position { get; set; } = spawnPosition; | ||||
| 
 | ||||
|     public PixelBounds Bounds => GetBoundsForCenter(Position); | ||||
|     public PixelBounds Bounds => Position.GetBoundsForCenter(MapService.TileSize); | ||||
| 
 | ||||
|     public int Orientation => (int)Math.Round(Rotation * 16) % 16; | ||||
| 
 | ||||
|     public static PixelBounds GetBoundsForCenter(FloatPosition position) | ||||
|     { | ||||
|         var pixelPosition = position.ToPixelPosition(); | ||||
|         return new PixelBounds( | ||||
|             pixelPosition.GetPixelRelative(-4, -4), | ||||
|             pixelPosition.GetPixelRelative(3, 3) | ||||
|         ); | ||||
|     } | ||||
|     public byte ExplosiveBullets { get; set; } | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,6 @@ | |||
| using System.IO; | ||||
| using DisplayCommands; | ||||
| using Microsoft.AspNetCore.Builder; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.FileProviders; | ||||
|  | @ -20,70 +18,11 @@ public static class Program | |||
|     { | ||||
|         var app = Configure(args); | ||||
| 
 | ||||
|         var clientScreenServer = app.Services.GetRequiredService<ClientScreenServer>(); | ||||
|         var playerService = app.Services.GetRequiredService<PlayerServer>(); | ||||
|         var controlsServer = app.Services.GetRequiredService<ControlsServer>(); | ||||
|         var mapService = app.Services.GetRequiredService<MapService>(); | ||||
| 
 | ||||
|         var clientFileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "client")); | ||||
|         app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = clientFileProvider }); | ||||
|         app.UseStaticFiles(new StaticFileOptions { FileProvider = clientFileProvider }); | ||||
| 
 | ||||
|         app.MapPost("/player", (string name, Guid? id) => | ||||
|         { | ||||
|             name = name.Trim().ToUpperInvariant(); | ||||
|             if (name == string.Empty) | ||||
|                 return Results.BadRequest("name cannot be blank"); | ||||
|             if (name.Length > 12) | ||||
|                 return Results.BadRequest("name too long"); | ||||
| 
 | ||||
|             var player = playerService.GetOrAdd(name, id ?? Guid.NewGuid()); | ||||
|             return player != null | ||||
|                 ? Results.Ok(new NameId(player.Name, player.Id)) | ||||
|                 : Results.Unauthorized(); | ||||
|         }); | ||||
| 
 | ||||
|         app.MapGet("/player", ([FromQuery] Guid id) => | ||||
|             playerService.TryGet(id, out var foundPlayer) | ||||
|                 ? Results.Ok((object?)foundPlayer) | ||||
|                 : Results.NotFound() | ||||
|         ); | ||||
| 
 | ||||
|         app.MapGet("/scores", () => playerService.GetAll()); | ||||
| 
 | ||||
|         app.Map("/screen", async (HttpContext context, [FromQuery] Guid? player) => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|                 return Results.BadRequest(); | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await clientScreenServer.HandleClient(ws, player); | ||||
|             return Results.Empty; | ||||
|         }); | ||||
| 
 | ||||
|         app.Map("/controls", async (HttpContext context, [FromQuery] Guid playerId) => | ||||
|         { | ||||
|             if (!context.WebSockets.IsWebSocketRequest) | ||||
|                 return Results.BadRequest(); | ||||
| 
 | ||||
|             if (!playerService.TryGet(playerId, out var player)) | ||||
|                 return Results.NotFound(); | ||||
| 
 | ||||
|             using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||
|             await controlsServer.HandleClient(ws, player); | ||||
|             return Results.Empty; | ||||
|         }); | ||||
| 
 | ||||
|         app.MapGet("/map", () => mapService.MapNames); | ||||
| 
 | ||||
|         app.MapPost("/map", ([FromQuery] string name) => | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(name)) | ||||
|                 return Results.BadRequest("invalid map name"); | ||||
|             if (!mapService.TrySwitchTo(name)) | ||||
|                 return Results.NotFound("map with name not found"); | ||||
|             return Results.Ok(); | ||||
|         }); | ||||
|         Endpoints.MapEndpoints(app); | ||||
| 
 | ||||
|         app.Run(); | ||||
|     } | ||||
|  | @ -119,27 +58,28 @@ public static class Program | |||
|             throw new InvalidOperationException("'Host' configuration missing"); | ||||
| 
 | ||||
|         builder.Services.AddSingleton<MapService>(); | ||||
|         builder.Services.AddSingleton<BulletManager>(); | ||||
|         builder.Services.AddSingleton<TankManager>(); | ||||
|         builder.Services.AddSingleton<MapEntityManager>(); | ||||
|         builder.Services.AddSingleton<ControlsServer>(); | ||||
|         builder.Services.AddSingleton<PlayerServer>(); | ||||
|         builder.Services.AddSingleton<ClientScreenServer>(); | ||||
|         builder.Services.AddSingleton<SpawnQueue>(); | ||||
|         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, CollideBulletsWithTanks>(); | ||||
|         builder.Services.AddSingleton<ITickStep, CollideBulletsWithMap>(); | ||||
|         builder.Services.AddSingleton<ITickStep, CollideBullets>(); | ||||
|         builder.Services.AddSingleton<ITickStep, RotateTanks>(); | ||||
|         builder.Services.AddSingleton<ITickStep, MoveTanks>(); | ||||
|         builder.Services.AddSingleton<ITickStep, ShootFromTanks>(); | ||||
|         builder.Services.AddSingleton<ITickStep, SpawnNewTanks>(); | ||||
|         builder.Services.AddSingleton<ITickStep, CollectPowerUp>(); | ||||
|         builder.Services.AddSingleton<ITickStep>(sp => sp.GetRequiredService<TankSpawnQueue>()); | ||||
|         builder.Services.AddSingleton<ITickStep, SpawnPowerUp>(); | ||||
|         builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>(); | ||||
| 
 | ||||
|         builder.Services.AddSingleton<IDrawStep, DrawMapStep>(); | ||||
|         builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>(); | ||||
|         builder.Services.AddSingleton<IDrawStep, DrawTanksStep>(); | ||||
|         builder.Services.AddSingleton<IDrawStep, DrawBulletsStep>(); | ||||
| 
 | ||||
|  | @ -150,7 +90,7 @@ public static class Program | |||
|             builder.Configuration.GetSection("Tanks")); | ||||
|         builder.Services.Configure<PlayersConfiguration>( | ||||
|             builder.Configuration.GetSection("Players")); | ||||
|         builder.Services.Configure<GameRulesConfiguration>(builder.Configuration.GetSection("GameRules")); | ||||
|         builder.Services.Configure<GameRules>(builder.Configuration.GetSection("GameRules")); | ||||
| 
 | ||||
|         if (hostConfiguration.EnableServicePointDisplay) | ||||
|         { | ||||
|  |  | |||
|  | @ -26,7 +26,8 @@ | |||
|         "BulletSpeed": 75 | ||||
|     }, | ||||
|     "GameRules": { | ||||
|         "DestructibleWalls": true | ||||
|         "DestructibleWalls": true, | ||||
|         "PowerUpSpawnChance": 0.1 | ||||
|     }, | ||||
|     "Players": { | ||||
|         "SpawnDelayMs": 3000, | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								TanksServer/assets/powerup_explosive.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								TanksServer/assets/powerup_explosive.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 130 B | 
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Vinzenz Schroeter
						Vinzenz Schroeter