send scores to big display

This commit is contained in:
Vinzenz Schroeter 2024-04-09 22:38:56 +02:00
parent a89392beb8
commit 7f00160780
22 changed files with 305 additions and 126 deletions

View file

@ -1,11 +1,12 @@
using TanksServer.Helpers; using TanksServer.Helpers;
using TanksServer.ServicePointDisplay;
using TanksServer.Services; using TanksServer.Services;
namespace TanksServer.DrawSteps; namespace TanksServer.DrawSteps;
internal sealed class BulletDrawer(BulletManager bullets): IDrawStep internal sealed class BulletDrawer(BulletManager bullets): IDrawStep
{ {
public void Draw(DisplayPixelBuffer buffer) public void Draw(PixelDisplayBufferView buffer)
{ {
foreach (var bullet in bullets.GetAll()) foreach (var bullet in bullets.GetAll())
buffer.Pixels[bullet.Position.ToPixelPosition().ToPixelIndex()] = true; buffer.Pixels[bullet.Position.ToPixelPosition().ToPixelIndex()] = true;

View file

@ -1,8 +1,8 @@
using TanksServer.Helpers; using TanksServer.ServicePointDisplay;
namespace TanksServer.DrawSteps; namespace TanksServer.DrawSteps;
internal interface IDrawStep internal interface IDrawStep
{ {
void Draw(DisplayPixelBuffer buffer); void Draw(PixelDisplayBufferView buffer);
} }

View file

@ -1,12 +1,13 @@
using TanksServer.Helpers; using TanksServer.Helpers;
using TanksServer.Models; using TanksServer.Models;
using TanksServer.ServicePointDisplay;
using TanksServer.Services; using TanksServer.Services;
namespace TanksServer.DrawSteps; namespace TanksServer.DrawSteps;
internal sealed class MapDrawer(MapService map) : IDrawStep internal sealed class MapDrawer(MapService map) : IDrawStep
{ {
public void Draw(DisplayPixelBuffer buffer) public void Draw(PixelDisplayBufferView buffer)
{ {
for (var tileY = 0; tileY < MapService.TilesPerColumn; tileY++) for (var tileY = 0; tileY < MapService.TilesPerColumn; tileY++)
for (var tileX = 0; tileX < MapService.TilesPerRow; tileX++) for (var tileX = 0; tileX < MapService.TilesPerRow; tileX++)

View file

@ -1,6 +1,7 @@
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using TanksServer.Helpers; using TanksServer.Helpers;
using TanksServer.ServicePointDisplay;
using TanksServer.Services; using TanksServer.Services;
namespace TanksServer.DrawSteps; namespace TanksServer.DrawSteps;
@ -29,7 +30,7 @@ internal sealed class TankDrawer : IDrawStep
_tankSpriteWidth = tankImage.Width; _tankSpriteWidth = tankImage.Width;
} }
public void Draw(DisplayPixelBuffer buffer) public void Draw(PixelDisplayBufferView buffer)
{ {
foreach (var tank in _tanks) foreach (var tank in _tanks)
{ {

View file

@ -1,55 +0,0 @@
namespace TanksServer.Helpers;
internal sealed class DisplayPixelBuffer(byte[] data)
{
public byte[] Data => data;
public byte Magic1
{
get => data[0];
set => data[0] = value;
}
public byte Magic2
{
get => data[1];
set => data[1] = value;
}
public ushort X
{
get => GetTwoBytes(2);
set => SetTwoBytes(2, value);
}
public ushort Y
{
get => GetTwoBytes(4);
set => SetTwoBytes(4, value);
}
public ushort WidthInTiles
{
get => GetTwoBytes(6);
set => SetTwoBytes(6, value);
}
public ushort HeightInPixels
{
get => GetTwoBytes(8);
set => SetTwoBytes(8, value);
}
public FixedSizeBitFieldView Pixels { get; } = new(data.AsMemory(10));
private ushort GetTwoBytes(int index)
{
return (ushort)(data[index] * byte.MaxValue + data[index + 1]);
}
private void SetTwoBytes(int index, ushort value)
{
data[index] = (byte)(value / byte.MaxValue);
data[index + 1] = (byte)(value % byte.MaxValue);
}
}

View file

@ -6,8 +6,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using TanksServer.DrawSteps; using TanksServer.DrawSteps;
using TanksServer.Helpers; using TanksServer.Helpers;
using TanksServer.Models;
using TanksServer.Servers; using TanksServer.Servers;
using TanksServer.ServicePointDisplay;
using TanksServer.Services; using TanksServer.Services;
using TanksServer.TickSteps; using TanksServer.TickSteps;
@ -81,7 +81,6 @@ internal static class Program
builder.Services.AddSingleton<MapService>(); builder.Services.AddSingleton<MapService>();
builder.Services.AddSingleton<BulletManager>(); builder.Services.AddSingleton<BulletManager>();
builder.Services.AddSingleton<TankManager>(); builder.Services.AddSingleton<TankManager>();
builder.Services.AddSingleton<SpawnNewTanks>();
builder.Services.AddSingleton<ControlsServer>(); builder.Services.AddSingleton<ControlsServer>();
builder.Services.AddSingleton<PlayerServer>(); builder.Services.AddSingleton<PlayerServer>();
builder.Services.AddSingleton<ClientScreenServer>(); builder.Services.AddSingleton<ClientScreenServer>();
@ -98,7 +97,7 @@ internal static class Program
builder.Services.AddSingleton<ITickStep, RotateTanks>(); builder.Services.AddSingleton<ITickStep, RotateTanks>();
builder.Services.AddSingleton<ITickStep, MoveTanks>(); builder.Services.AddSingleton<ITickStep, MoveTanks>();
builder.Services.AddSingleton<ITickStep, ShootFromTanks>(); builder.Services.AddSingleton<ITickStep, ShootFromTanks>();
builder.Services.AddSingleton<ITickStep>(sp => sp.GetRequiredService<SpawnNewTanks>()); builder.Services.AddSingleton<ITickStep, SpawnNewTanks>();
builder.Services.AddSingleton<ITickStep, DrawStateToFrame>(); builder.Services.AddSingleton<ITickStep, DrawStateToFrame>();
builder.Services.AddSingleton<ITickStep, SendToServicePointDisplay>(); builder.Services.AddSingleton<ITickStep, SendToServicePointDisplay>();
builder.Services.AddSingleton<ITickStep, SendToClientScreen>(); builder.Services.AddSingleton<ITickStep, SendToClientScreen>();

View file

@ -4,6 +4,7 @@ using System.Threading.Channels;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TanksServer.Helpers; using TanksServer.Helpers;
using TanksServer.ServicePointDisplay;
namespace TanksServer.Servers; namespace TanksServer.Servers;
@ -65,7 +66,7 @@ internal sealed class ClientScreenServer(
Done = ReceiveAsync(); Done = ReceiveAsync();
} }
public async Task SendAsync(DisplayPixelBuffer buf) public async Task SendAsync(PixelDisplayBufferView buf)
{ {
if (!await _wantedFrames.WaitAsync(TimeSpan.Zero)) if (!await _wantedFrames.WaitAsync(TimeSpan.Zero))
{ {

View file

@ -29,6 +29,8 @@ internal sealed class PlayerServer(ILogger<PlayerServer> logger, SpawnQueueProvi
foundPlayer = null; foundPlayer = null;
return false; return false;
} }
public IEnumerable<Player> GetAll() => _players.Values;
private Player AddAndSpawn(string name) private Player AddAndSpawn(string name)
{ {

View file

@ -0,0 +1,64 @@
using TanksServer.Models;
namespace TanksServer.ServicePointDisplay;
internal class DisplayBufferView(byte[] data)
{
public byte[] Data => data;
public ushort Mode
{
get => GetTwoBytes(0);
set => SetTwoBytes(0, value);
}
public ushort TileX
{
get => GetTwoBytes(2);
set => SetTwoBytes(2, value);
}
public ushort TileY
{
get => GetTwoBytes(4);
set => SetTwoBytes(4, value);
}
public ushort WidthInTiles
{
get => GetTwoBytes(6);
set => SetTwoBytes(6, value);
}
public ushort RowCount
{
get => GetTwoBytes(8);
set => SetTwoBytes(8, value);
}
public TilePosition Position
{
get => new(TileX, TileY);
set
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(value.X, ushort.MaxValue);
ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Y, ushort.MaxValue);
ArgumentOutOfRangeException.ThrowIfNegative(value.X);
ArgumentOutOfRangeException.ThrowIfNegative(value.Y);
TileX = (ushort)value.X;
TileY = (ushort)value.Y;
}
}
private ushort GetTwoBytes(int index)
{
return (ushort)(data[index] * byte.MaxValue + data[index + 1]);
}
private void SetTwoBytes(int index, ushort value)
{
data[index] = (byte)(value / byte.MaxValue);
data[index + 1] = (byte)(value % byte.MaxValue);
}
}

View file

@ -1,6 +1,6 @@
using System.Collections; using System.Collections;
namespace TanksServer.Helpers; namespace TanksServer.ServicePointDisplay;
internal sealed class FixedSizeBitFieldView(Memory<byte> data) : IList<bool> internal sealed class FixedSizeBitFieldView(Memory<byte> data) : IList<bool>
{ {
@ -8,6 +8,7 @@ internal sealed class FixedSizeBitFieldView(Memory<byte> data) : IList<bool>
public bool IsReadOnly => false; public bool IsReadOnly => false;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public IEnumerator<bool> GetEnumerator() public IEnumerator<bool> GetEnumerator()
{ {
return Enumerable().GetEnumerator(); return Enumerable().GetEnumerator();
@ -32,10 +33,17 @@ internal sealed class FixedSizeBitFieldView(Memory<byte> data) : IList<bool>
array[i + arrayIndex] = this[i]; array[i + arrayIndex] = this[i];
} }
private static (int byteIndex, int bitInByteIndex) GetIndexes(int bitIndex) private (int byteIndex, int bitInByteIndex) GetIndexes(int bitIndex)
{ {
var byteIndex = bitIndex / 8; var byteIndex = bitIndex / 8;
var bitInByteIndex = 7 - bitIndex % 8; var bitInByteIndex = 7 - bitIndex % 8;
if (byteIndex >= data.Length)
{
throw new ArgumentOutOfRangeException(nameof(bitIndex),
$"accessing this bit field at position {bitIndex} would result in an access to byte " +
$"{byteIndex} but byte length is {data.Length}");
}
return (byteIndex, bitInByteIndex); return (byteIndex, bitInByteIndex);
} }
@ -71,4 +79,4 @@ internal sealed class FixedSizeBitFieldView(Memory<byte> data) : IList<bool>
public int IndexOf(bool item) => throw new NotSupportedException(); public int IndexOf(bool item) => throw new NotSupportedException();
public void Insert(int index, bool item) => throw new NotSupportedException(); public void Insert(int index, bool item) => throw new NotSupportedException();
public void RemoveAt(int index) => throw new NotSupportedException(); public void RemoveAt(int index) => throw new NotSupportedException();
} }

View file

@ -0,0 +1,39 @@
using System.Text;
namespace TanksServer.ServicePointDisplay;
internal sealed class FixedSizeCharGridView(Memory<byte> data, ushort rowLength, ushort rowCount)
{
public char this[int x, int y]
{
get => (char)data.Span[x + y * rowLength];
set => data.Span[x + y * rowLength] = CharToByte(value);
}
public string this[int row]
{
get
{
var rowStart = row * rowLength;
return Encoding.UTF8.GetString(data[rowStart..(rowStart + rowLength)].Span);
}
set
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(row, rowCount, nameof(row));
ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Length, rowLength, nameof(value));
var x = 0;
for (; x < value.Length; x++)
this[x, row] = value[x];
for (; x < rowLength; x++)
this[x, row] = ' ';
}
}
private static byte CharToByte(char c)
{
ArgumentOutOfRangeException.ThrowIfNegative(c);
ArgumentOutOfRangeException.ThrowIfGreaterThan(c, (char)byte.MaxValue, nameof(c));
// c# strings are UTF-16
return (byte)c;
}
}

View file

@ -0,0 +1,29 @@
using TanksServer.Helpers;
using TanksServer.Services;
namespace TanksServer.ServicePointDisplay;
internal sealed class PixelDisplayBufferView : DisplayBufferView
{
private PixelDisplayBufferView(byte[] data) : base(data)
{
Pixels = new FixedSizeBitFieldView(Data.AsMemory(10));
}
// ReSharper disable once CollectionNeverQueried.Global (setting values in collection updates underlying byte array)
public FixedSizeBitFieldView Pixels { get; }
public static PixelDisplayBufferView New(ushort x, ushort y, ushort widthInTiles, ushort pixelRows)
{
// 10 bytes header, one byte per tile row (with one bit each pixel) after that
var size = 10 + widthInTiles * pixelRows;
return new PixelDisplayBufferView(new byte[size])
{
Mode = 19,
TileX = x,
TileY = y,
WidthInTiles = widthInTiles,
RowCount = pixelRows
};
}
}

View file

@ -0,0 +1,103 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using TanksServer.Servers;
using TanksServer.Services;
using TanksServer.TickSteps;
namespace TanksServer.ServicePointDisplay;
internal sealed class SendToServicePointDisplay : ITickStep, IDisposable
{
private readonly UdpClient? _udpClient;
private readonly LastFinishedFrameProvider _lastFinishedFrameProvider;
private readonly TextDisplayBuffer _scoresBuffer;
private readonly PlayerServer _players;
private readonly ILogger<SendToServicePointDisplay> _logger;
private const int ScoresWidth = 12;
private const int ScoresHeight = 20;
private const int ScoresPlayerRows = ScoresHeight - 5;
public SendToServicePointDisplay(
IOptions<ServicePointDisplayConfiguration> options,
LastFinishedFrameProvider lastFinishedFrameProvider,
PlayerServer players,
ILogger<SendToServicePointDisplay> logger
)
{
_lastFinishedFrameProvider = lastFinishedFrameProvider;
_players = players;
_logger = logger;
_udpClient = options.Value.Enable
? new UdpClient(options.Value.Hostname, options.Value.Port)
: null;
_scoresBuffer = new(new(MapService.TilesPerRow, 0), 12, 20);
_scoresBuffer.Rows[0] = "== TANKS! ==";
_scoresBuffer.Rows[1] = "-- scores --";
_scoresBuffer.Rows[17] = "-- join --";
var localIp = GetLocalIp(options.Value.Hostname, options.Value.Port).Split('.');
Debug.Assert(localIp.Length == 4); // were talking legacy ip
_scoresBuffer.Rows[18] = string.Join('.', localIp[..2]);
_scoresBuffer.Rows[19] = string.Join('.', localIp[2..]);
}
private static string GetLocalIp(string host, int port)
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
socket.Connect(host, port);
var endPoint = socket.LocalEndPoint as IPEndPoint ?? throw new NotSupportedException();
return endPoint.Address.ToString();
}
public Task TickAsync()
{
return _udpClient == null ? Task.CompletedTask : Core();
async Task Core()
{
RefreshScores();
try
{
await _udpClient.SendAsync(_scoresBuffer.Data);
await _udpClient.SendAsync(_lastFinishedFrameProvider.LastFrame.Data);
}
catch (SocketException ex)
{
_logger.LogWarning(ex, "could not send data to service point display");
}
}
}
private void RefreshScores()
{
var playersToDisplay = _players.GetAll()
.OrderByDescending(p => p.Kills)
.Take(ScoresPlayerRows);
var row = 2;
foreach (var p in playersToDisplay)
{
var score = p.Kills.ToString();
var nameLength = ScoresWidth - score.Length;
var name = p.Name[..nameLength];
var spaces = new string(' ', nameLength - name.Length + 1);
_scoresBuffer.Rows[row] = name + spaces + score;
row++;
}
for (; row < 17; row++)
_scoresBuffer.Rows[row] = string.Empty;
}
public void Dispose()
{
_udpClient?.Dispose();
}
}

View file

@ -1,4 +1,4 @@
namespace TanksServer.Models; namespace TanksServer.ServicePointDisplay;
internal sealed class ServicePointDisplayConfiguration internal sealed class ServicePointDisplayConfiguration
{ {

View file

@ -0,0 +1,18 @@
using TanksServer.Models;
namespace TanksServer.ServicePointDisplay;
internal sealed class TextDisplayBuffer : DisplayBufferView
{
public TextDisplayBuffer(TilePosition position, ushort charsPerRow, ushort rows)
: base(new byte[10 + charsPerRow * rows])
{
Mode = 3;
WidthInTiles = charsPerRow;
RowCount = rows;
Position = position;
Rows = new FixedSizeCharGridView(Data.AsMemory(10), charsPerRow, rows);
}
public FixedSizeCharGridView Rows { get; set; }
}

View file

@ -3,12 +3,16 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TanksServer.TickSteps; using TanksServer.TickSteps;
namespace TanksServer; namespace TanksServer.Services;
internal sealed class GameTickWorker( internal sealed class GameTickWorker(
IEnumerable<ITickStep> steps, IHostApplicationLifetime lifetime, ILogger<GameTickWorker> logger IEnumerable<ITickStep> steps,
IHostApplicationLifetime lifetime,
ILogger<GameTickWorker> logger
) : IHostedService, IDisposable ) : IHostedService, IDisposable
{ {
private const int TicksPerSecond = 25;
private static readonly TimeSpan TickPacing = TimeSpan.FromMilliseconds((int)(1000 / TicksPerSecond));
private readonly CancellationTokenSource _cancellation = new(); private readonly CancellationTokenSource _cancellation = new();
private readonly List<ITickStep> _steps = steps.ToList(); private readonly List<ITickStep> _steps = steps.ToList();
private Task? _run; private Task? _run;
@ -31,8 +35,10 @@ internal sealed class GameTickWorker(
foreach (var step in _steps) foreach (var step in _steps)
await step.TickAsync(); await step.TickAsync();
await Task.Delay(TimeSpan.FromMilliseconds(1000 / 25) - sw.Elapsed); var wantedDelay = TickPacing - sw.Elapsed;
if (wantedDelay.Ticks > 0)
await Task.Delay(wantedDelay);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -53,4 +59,4 @@ internal sealed class GameTickWorker(
_cancellation.Dispose(); _cancellation.Dispose();
_run?.Dispose(); _run?.Dispose();
} }
} }

View file

@ -1,12 +1,12 @@
using TanksServer.Helpers; using TanksServer.ServicePointDisplay;
namespace TanksServer.Services; namespace TanksServer.Services;
internal sealed class LastFinishedFrameProvider internal sealed class LastFinishedFrameProvider
{ {
private DisplayPixelBuffer? _lastFrame; private PixelDisplayBufferView? _lastFrame;
public DisplayPixelBuffer LastFrame public PixelDisplayBufferView LastFrame
{ {
get => _lastFrame ?? throw new InvalidOperationException("first frame not yet drawn"); get => _lastFrame ?? throw new InvalidOperationException("first frame not yet drawn");
set => _lastFrame = value; set => _lastFrame = value;

View file

@ -1,5 +1,5 @@
using TanksServer.DrawSteps; using TanksServer.DrawSteps;
using TanksServer.Helpers; using TanksServer.ServicePointDisplay;
using TanksServer.Services; using TanksServer.Services;
namespace TanksServer.TickSteps; namespace TanksServer.TickSteps;
@ -8,30 +8,14 @@ internal sealed class DrawStateToFrame(
IEnumerable<IDrawStep> drawSteps, LastFinishedFrameProvider lastFrameProvider IEnumerable<IDrawStep> drawSteps, LastFinishedFrameProvider lastFrameProvider
) : ITickStep ) : ITickStep
{ {
private const uint GameFieldPixelCount = MapService.PixelsPerRow * MapService.PixelsPerColumn;
private readonly List<IDrawStep> _drawSteps = drawSteps.ToList(); private readonly List<IDrawStep> _drawSteps = drawSteps.ToList();
public Task TickAsync() public Task TickAsync()
{ {
var buffer = CreateGameFieldPixelBuffer(); var buffer = PixelDisplayBufferView.New(0, 0, MapService.TilesPerRow, MapService.PixelsPerColumn);
foreach (var step in _drawSteps) foreach (var step in _drawSteps)
step.Draw(buffer); step.Draw(buffer);
lastFrameProvider.LastFrame = buffer; lastFrameProvider.LastFrame = buffer;
return Task.CompletedTask; return Task.CompletedTask;
} }
private static DisplayPixelBuffer CreateGameFieldPixelBuffer()
{
var data = new byte[10 + GameFieldPixelCount / 8];
var result = new DisplayPixelBuffer(data)
{
Magic1 = 0,
Magic2 = 19,
X = 0,
Y = 0,
WidthInTiles = MapService.TilesPerRow,
HeightInPixels = MapService.PixelsPerColumn
};
return result;
}
} }

View file

@ -1,25 +0,0 @@
using System.Net.Sockets;
using TanksServer.Models;
using TanksServer.Services;
namespace TanksServer.TickSteps;
internal sealed class SendToServicePointDisplay(
IOptions<ServicePointDisplayConfiguration> options,
LastFinishedFrameProvider lastFinishedFrameProvider
) : ITickStep, IDisposable
{
private readonly UdpClient? _udpClient = options.Value.Enable
? new(options.Value.Hostname, options.Value.Port)
: null;
public Task TickAsync()
{
return _udpClient?.SendAsync(lastFinishedFrameProvider.LastFrame.Data).AsTask() ?? Task.CompletedTask;
}
public void Dispose()
{
_udpClient?.Dispose();
}
}

View file

@ -4,7 +4,9 @@ using TanksServer.Services;
namespace TanksServer.TickSteps; namespace TanksServer.TickSteps;
internal sealed class ShootFromTanks( internal sealed class ShootFromTanks(
TankManager tanks, IOptions<TanksConfiguration> options, BulletManager bulletManager TankManager tanks,
IOptions<TanksConfiguration> options,
BulletManager bulletManager
) : ITickStep ) : ITickStep
{ {
private readonly TanksConfiguration _config = options.Value; private readonly TanksConfiguration _config = options.Value;
@ -34,4 +36,4 @@ internal sealed class ShootFromTanks(
bulletManager.Spawn(new Bullet(tank.Owner, position, tank.Rotation)); bulletManager.Spawn(new Bullet(tank.Owner, position, tank.Rotation));
} }
} }

View file

@ -15,8 +15,9 @@
} }
}, },
"ServicePointDisplay": { "ServicePointDisplay": {
"Enable": false, "Enable": true,
"Hostname": "172.23.42.29", //"Hostname": "172.23.42.29",
"Hostname": "localhost",
"Port": 2342 "Port": 2342
} }
} }

View file

@ -1,3 +1,3 @@
VITE_TANK_SCREEN_URL=ws://localhost:3000/screen VITE_TANK_SCREEN_URL=ws://vinzenz-lpt2/screen
VITE_TANK_CONTROLS_URL=ws://localhost:3000/controls VITE_TANK_CONTROLS_URL=ws://vinzenz-lpt2/controls
VITE_TANK_PLAYER_URL=http://localhost:3000/player VITE_TANK_PLAYER_URL=http://vinzenz-lpt2/player