move backend to subfolder

This commit is contained in:
Vinzenz Schroeter 2024-04-21 12:38:03 +02:00
parent d4d1f2f981
commit 8d09663eff
80 changed files with 98 additions and 88 deletions

View file

@ -0,0 +1,42 @@
using System.Diagnostics;
namespace DisplayCommands;
public sealed class ByteGrid(ushort width, ushort height) : IEquatable<ByteGrid>
{
public ushort Height { get; } = height;
public ushort Width { get; } = width;
internal Memory<byte> Data { get; } = new byte[width * height].AsMemory();
public byte this[ushort x, ushort y]
{
get => Data.Span[GetIndex(x, y)];
set => Data.Span[GetIndex(x, y)] = value;
}
public bool Equals(ByteGrid? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Height == other.Height && Width == other.Width && Data.Span.SequenceEqual(other.Data.Span);
}
private int GetIndex(ushort x, ushort y)
{
Debug.Assert(x < Width);
Debug.Assert(y < Height);
return x + y * Width;
}
public void Clear() => Data.Span.Clear();
public override bool Equals(object? obj) => ReferenceEquals(this, obj) || (obj is ByteGrid other && Equals(other));
public override int GetHashCode() => HashCode.Combine(Height, Width, Data);
public static bool operator ==(ByteGrid? left, ByteGrid? right) => Equals(left, right);
public static bool operator !=(ByteGrid? left, ByteGrid? right) => !Equals(left, right);
}

View file

@ -0,0 +1,71 @@
using System.Diagnostics;
using System.Text;
namespace DisplayCommands;
public sealed class Cp437Grid(ushort width, ushort height) : IEquatable<Cp437Grid>
{
private readonly ByteGrid _byteGrid = new(width, height);
private readonly Encoding _encoding = Encoding.GetEncoding(437);
public ushort Height { get; } = height;
public ushort Width { get; } = width;
internal Memory<byte> Data => _byteGrid.Data;
public char this[ushort x, ushort y]
{
get => ByteToChar(_byteGrid[x, y]);
set => _byteGrid[x, y] = CharToByte(value);
}
public string this[ushort row]
{
get
{
var rowStart = row * Width;
return _encoding.GetString(_byteGrid.Data[rowStart..(rowStart + Width)].Span);
}
set
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(row, Height, nameof(row));
ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Length, Width, nameof(value));
ushort x = 0;
for (; x < value.Length; x++)
_byteGrid[x, row] = CharToByte(value[x]);
for (; x < Width; x++)
_byteGrid[x, row] = CharToByte(' ');
}
}
private byte CharToByte(char c)
{
ReadOnlySpan<char> valuesStr = stackalloc char[] { c };
Span<byte> convertedStr = stackalloc byte[1];
var consumed = _encoding.GetBytes(valuesStr, convertedStr);
Debug.Assert(consumed == 1);
return convertedStr[0];
}
private char ByteToChar(byte b)
{
ReadOnlySpan<byte> valueBytes = stackalloc byte[] { b };
Span<char> resultStr = stackalloc char[1];
var consumed = _encoding.GetChars(valueBytes, resultStr);
Debug.Assert(consumed == 1);
return resultStr[0];
}
public bool Equals(Cp437Grid? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Height == other.Height && Width == other.Width && _byteGrid.Equals(other._byteGrid);
}
public override bool Equals(object? obj) => ReferenceEquals(this, obj) || (obj is Cp437Grid other && Equals(other));
public override int GetHashCode() => HashCode.Combine(_byteGrid, Height, Width);
public static bool operator ==(Cp437Grid? left, Cp437Grid? right) => Equals(left, right);
public static bool operator !=(Cp437Grid? left, Cp437Grid? right) => !Equals(left, right);
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../shared.props" />
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2"/>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0"/>
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1"/>
<ProjectReference Include="../EndiannessSourceGenerator/EndiannessSourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
namespace DisplayCommands;
public class DisplayConfiguration
{
public string Hostname { get; set; } = "172.23.42.29";
public int Port { get; set; } = 2342;
}

View file

@ -0,0 +1,25 @@
using System.Text;
using DisplayCommands.Internals;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace DisplayCommands;
public static class DisplayExtensions
{
static DisplayExtensions()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public static IServiceCollection AddDisplay(
this IServiceCollection services,
IConfigurationSection? configurationSection = null
)
{
services.AddSingleton<IDisplayConnection, DisplayConnection>();
if (configurationSection != null)
services.Configure<DisplayConfiguration>(configurationSection);
return services;
}
}

View file

@ -0,0 +1,2 @@
global using System;
global using System.Threading.Tasks;

View file

@ -0,0 +1,24 @@
namespace DisplayCommands;
public interface IDisplayConnection
{
ValueTask SendClearAsync();
ValueTask SendCp437DataAsync(ushort x, ushort y, Cp437Grid grid);
ValueTask SendBrightnessAsync(byte brightness);
ValueTask SendCharBrightnessAsync(ushort x, ushort y, ByteGrid luma);
ValueTask SendHardResetAsync();
ValueTask SendFadeOutAsync(byte loops);
public ValueTask SendBitmapLinearWindowAsync(ushort x, ushort y, PixelGrid pixels);
/// <summary>
/// Returns the IPv4 address that is associated with the interface with which the display is reachable.
/// </summary>
/// <returns>IPv4 as text</returns>
public string GetLocalIPv4();
}

View file

@ -0,0 +1,17 @@
namespace DisplayCommands.Internals;
internal enum DisplayCommand : ushort
{
Clear = 0x0002,
Cp437Data = 0x0003,
CharBrightness = 0x0005,
Brightness = 0x0007,
HardReset = 0x000b,
FadeOut = 0x000d,
[Obsolete("ignored by display code")] BitmapLegacy = 0x0010,
BitmapLinear = 0x0012,
BitmapLinearWin = 0x0013,
BitmapLinearAnd = 0x0014,
BitmapLinearOr = 0x0015,
BitmapLinearXor = 0x0016
}

View file

@ -0,0 +1,126 @@
using System.Buffers;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Options;
namespace DisplayCommands.Internals;
internal sealed class DisplayConnection(IOptions<DisplayConfiguration> options) : IDisplayConnection, IDisposable
{
private readonly ArrayPool<byte> _arrayPool = ArrayPool<byte>.Shared;
private readonly UdpClient _udpClient = new(options.Value.Hostname, options.Value.Port);
public ValueTask SendClearAsync()
{
var header = new HeaderWindow { Command = (ushort)DisplayCommand.Clear };
return SendAsync(header, Memory<byte>.Empty);
}
public ValueTask SendCp437DataAsync(ushort x, ushort y, Cp437Grid grid)
{
var header = new HeaderWindow
{
Command = (ushort)DisplayCommand.Cp437Data,
Height = grid.Height,
Width = grid.Width,
PosX = x,
PosY = y
};
return SendAsync(header, grid.Data);
}
public ValueTask SendCharBrightnessAsync(ushort x, ushort y, ByteGrid luma)
{
var header = new HeaderWindow
{
Command = (ushort)DisplayCommand.CharBrightness,
PosX = x,
PosY = y,
Height = luma.Height,
Width = luma.Width
};
return SendAsync(header, luma.Data);
}
public async ValueTask SendBrightnessAsync(byte brightness)
{
var header = new HeaderWindow { Command = (ushort)DisplayCommand.Brightness };
var payloadBuffer = _arrayPool.Rent(1);
var payload = payloadBuffer.AsMemory(0, 1);
payload.Span[0] = brightness;
await SendAsync(header, payload);
_arrayPool.Return(payloadBuffer);
}
public ValueTask SendHardResetAsync()
{
var header = new HeaderWindow { Command = (ushort)DisplayCommand.HardReset };
return SendAsync(header, Memory<byte>.Empty);
}
public async ValueTask SendFadeOutAsync(byte loops)
{
var header = new HeaderWindow { Command = (ushort)DisplayCommand.FadeOut };
var payloadBuffer = _arrayPool.Rent(1);
var payload = payloadBuffer.AsMemory(0, 1);
payload.Span[0] = loops;
await SendAsync(header, payload);
_arrayPool.Return(payloadBuffer);
}
public ValueTask SendBitmapLinearWindowAsync(ushort x, ushort y, PixelGrid pixels)
{
var header = new HeaderWindow
{
Command = (ushort)DisplayCommand.BitmapLinearWin,
PosX = x,
PosY = y,
Width = (ushort)(pixels.Width / 8),
Height = pixels.Height
};
return SendAsync(header, pixels.Data);
}
public string GetLocalIPv4()
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
socket.Connect(options.Value.Hostname, options.Value.Port);
var endPoint = socket.LocalEndPoint as IPEndPoint ?? throw new NotSupportedException();
return endPoint.Address.ToString();
}
private async ValueTask SendAsync(HeaderWindow header, Memory<byte> payload)
{
int headerSize;
unsafe
{
// because we specified the struct layout, no platform-specific padding will be added and this is be safe.
headerSize = sizeof(HeaderWindow);
}
Debug.Assert(headerSize == 10);
var messageSize = headerSize + payload.Length;
var buffer = _arrayPool.Rent(messageSize);
var message = buffer.AsMemory(0, messageSize);
MemoryMarshal.Write(message.Span, header);
payload.CopyTo(message[headerSize..]);
await _udpClient.SendAsync(message);
_arrayPool.Return(buffer);
}
public void Dispose() => _udpClient.Dispose();
}

View file

@ -0,0 +1,10 @@
namespace DisplayCommands.Internals;
internal enum DisplaySubCommand : ushort
{
BitmapNormal = 0x0,
BitmapCompressZ = 0x677a,
BitmapCompressBz = 0x627a,
BitmapCompressLz = 0x6c7a,
BitmapCompressZs = 0x7a73
}

View file

@ -0,0 +1,19 @@
using System.Runtime.InteropServices;
using EndiannessSourceGenerator;
namespace DisplayCommands.Internals;
[StructEndianness(IsLittleEndian = false)]
[StructLayout(LayoutKind.Sequential, Pack = 16, Size = 10)]
internal partial struct HeaderBitmap
{
private ushort _command;
private ushort _offset;
private ushort _length;
private ushort _subCommand;
private ushort _reserved;
}

View file

@ -0,0 +1,19 @@
using System.Runtime.InteropServices;
using EndiannessSourceGenerator;
namespace DisplayCommands.Internals;
[StructEndianness(IsLittleEndian = false)]
[StructLayout(LayoutKind.Sequential, Pack = 16, Size = 10)]
internal partial struct HeaderWindow
{
private ushort _command;
private ushort _posX;
private ushort _posY;
private ushort _width;
private ushort _height;
}

View file

@ -0,0 +1,59 @@
using System.Diagnostics;
namespace DisplayCommands;
public sealed class PixelGrid(ushort width, ushort height) : IEquatable<PixelGrid>
{
private readonly ByteGrid _byteGrid = new((ushort)(width / 8u), height);
public ushort Width { get; } = width;
public ushort Height { get; } = height;
public Memory<byte> Data => _byteGrid.Data;
public bool this[ushort x, ushort y]
{
get
{
Debug.Assert(y < Height);
var (byteIndex, bitInByteMask) = GetIndexes(x);
var byteVal = _byteGrid[byteIndex, y];
return (byteVal & bitInByteMask) != 0;
}
set
{
Debug.Assert(y < Height);
var (byteIndex, bitInByteMask) = GetIndexes(x);
if (value)
_byteGrid[byteIndex, y] |= bitInByteMask;
else
_byteGrid[byteIndex, y] &= (byte)(ushort.MaxValue ^ bitInByteMask);
}
}
public void Clear() => _byteGrid.Clear();
public bool Equals(PixelGrid? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Width == other.Width && Height == other.Height && _byteGrid.Equals(other._byteGrid);
}
public override bool Equals(object? obj) => ReferenceEquals(this, obj) || (obj is PixelGrid other && Equals(other));
public override int GetHashCode() => HashCode.Combine(_byteGrid, Width, Height);
public static bool operator ==(PixelGrid? left, PixelGrid? right) => Equals(left, right);
public static bool operator !=(PixelGrid? left, PixelGrid? right) => !Equals(left, right);
private (ushort byteIndex, byte bitInByteMask) GetIndexes(int x)
{
Debug.Assert(x < Width);
var byteIndex = (ushort)(x / 8);
Debug.Assert(byteIndex < Width);
var bitInByteIndex = (byte)(7 - x % 8);
Debug.Assert(bitInByteIndex < 8);
var bitInByteMask = (byte)(1 << bitInByteIndex);
return (byteIndex, bitInByteMask);
}
}

View file

@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
namespace EndiannessSourceGenerator;
internal class DebugMeException(string message) : Exception(message);
internal class InvalidUsageException(string message) : Exception(message);
[Generator]
public class StructEndiannessSourceGenerator : ISourceGenerator
{
private const string Namespace = "EndiannessSourceGenerator";
private const string AttributeName = "StructEndiannessAttribute";
private const string IsLittleEndianProperty = "IsLittleEndian";
private const string AttributeSourceCode =
$$"""
// <auto-generated/>
namespace {{Namespace}}
{
[System.AttributeUsage(System.AttributeTargets.Struct)]
public class {{AttributeName}}: System.Attribute
{
public required bool {{IsLittleEndianProperty}} { get; init; }
}
}
""";
public void Initialize(GeneratorInitializationContext context)
{
// Register the attribute source
context.RegisterForPostInitialization(i => i.AddSource($"{AttributeName}.g.cs", AttributeSourceCode));
}
public void Execute(GeneratorExecutionContext context)
{
var treesWithStructsWithAttributes = context.Compilation.SyntaxTrees
.Where(st => st.GetRoot().DescendantNodes()
.OfType<StructDeclarationSyntax>()
.Any(p => p.DescendantNodes()
.OfType<AttributeSyntax>()
.Any()))
.ToList();
foreach (var tree in treesWithStructsWithAttributes)
{
var semanticModel = context.Compilation.GetSemanticModel(tree);
var structsWithAttributes = tree.GetRoot().DescendantNodes()
.OfType<StructDeclarationSyntax>()
.Where(cd => cd.DescendantNodes()
.OfType<AttributeSyntax>()
.Any());
foreach (var structDeclaration in structsWithAttributes)
{
var foundAttribute = GetEndiannessAttribute(structDeclaration, semanticModel);
if (foundAttribute == null)
continue; // not my type
var structIsLittleEndian = GetStructIsLittleEndian(foundAttribute);
HandleStruct(context, structDeclaration, semanticModel, structIsLittleEndian);
}
}
}
private static void HandleStruct(GeneratorExecutionContext context, TypeDeclarationSyntax structDeclaration,
SemanticModel semanticModel, bool structIsLittleEndian)
{
var isPartial = structDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
if (!isPartial)
throw new InvalidUsageException("struct is not marked partial");
var accessibilityModifier = structDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.InternalKeyword))
? Token(SyntaxKind.InternalKeyword)
: Token(SyntaxKind.PublicKeyword);
var structType = semanticModel.GetDeclaredSymbol(structDeclaration);
if (structType == null)
throw new DebugMeException("struct type info is null");
var structNamespace = structType.ContainingNamespace?.ToDisplayString();
if (structNamespace == null)
throw new InvalidUsageException("struct has to be contained in a namespace");
if (structDeclaration.Members.Any(m => m.IsKind(SyntaxKind.PropertyDeclaration)))
throw new InvalidUsageException("struct cannot have properties");
var fieldDeclarations = structDeclaration.Members
.Where(m => m.IsKind(SyntaxKind.FieldDeclaration)).OfType<FieldDeclarationSyntax>();
var generatedCode = CompilationUnit()
.WithUsings(List<UsingDirectiveSyntax>([
UsingDirective(IdentifierName("System")),
UsingDirective(IdentifierName("System.Buffers.Binary"))
]))
.WithMembers(List<MemberDeclarationSyntax>([
FileScopedNamespaceDeclaration(IdentifierName(structNamespace)),
StructDeclaration(structType.Name)
.WithModifiers(TokenList([accessibilityModifier, Token(SyntaxKind.PartialKeyword)]))
.WithMembers(GenerateStructProperties(fieldDeclarations, semanticModel, structIsLittleEndian))
]))
.NormalizeWhitespace()
.ToFullString();
context.AddSource(
$"{structNamespace}.{structType.Name}.g.cs",
SourceText.From(generatedCode, Encoding.UTF8)
);
}
private static SyntaxList<MemberDeclarationSyntax> GenerateStructProperties(
IEnumerable<FieldDeclarationSyntax> fieldDeclarations, SemanticModel semanticModel, bool structIsLittleEndian)
{
var result = new List<MemberDeclarationSyntax>();
foreach (var field in fieldDeclarations)
{
if (!field.Modifiers.Any(m => m.IsKind(SyntaxKind.PrivateKeyword)))
throw new InvalidUsageException("fields have to be private");
var variableDeclaration = field.DescendantNodes()
.OfType<VariableDeclarationSyntax>()
.FirstOrDefault();
if (variableDeclaration == null)
throw new DebugMeException("variable declaration of field declaration null");
var variableTypeInfo = semanticModel.GetTypeInfo(variableDeclaration.Type).Type;
if (variableTypeInfo == null)
throw new DebugMeException("variable type info of field declaration null");
var typeName = variableTypeInfo.ToDisplayString();
var fieldName = variableDeclaration.Variables.First().Identifier.ToString();
result.Add(GenerateProperty(typeName, structIsLittleEndian, fieldName));
}
return new SyntaxList<MemberDeclarationSyntax>(result);
}
private static PropertyDeclarationSyntax GenerateProperty(string typeName,
bool structIsLittleEndian, string fieldName)
{
var propertyName = GeneratePropertyName(fieldName);
var fieldIdentifier = IdentifierName(fieldName);
ExpressionSyntax condition = MemberAccessExpression(
kind: SyntaxKind.SimpleMemberAccessExpression,
expression: IdentifierName("BitConverter"),
name: IdentifierName("IsLittleEndian")
);
if (!structIsLittleEndian)
condition = PrefixUnaryExpression(SyntaxKind.LogicalNotExpression, condition);
var reverseEndiannessMethod = MemberAccessExpression(
kind: SyntaxKind.SimpleMemberAccessExpression,
expression: IdentifierName("BinaryPrimitives"),
name: IdentifierName("ReverseEndianness")
);
var valueIdentifier = IdentifierName("value");
return PropertyDeclaration(ParseTypeName(typeName), propertyName)
.WithModifiers(TokenList([Token(SyntaxKind.PublicKeyword)]))
.WithAccessorList(AccessorList(List<AccessorDeclarationSyntax>([
AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
.WithExpressionBody(ArrowExpressionClause(ConditionalExpression(
condition: condition,
whenTrue: fieldIdentifier,
whenFalse: InvocationExpression(
expression: reverseEndiannessMethod,
argumentList: ArgumentList(SingletonSeparatedList(
Argument(fieldIdentifier)
))
)
)))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
.WithExpressionBody(ArrowExpressionClause(AssignmentExpression(
kind: SyntaxKind.SimpleAssignmentExpression,
left: fieldIdentifier,
right: ConditionalExpression(
condition: condition,
whenTrue: valueIdentifier,
whenFalse: InvocationExpression(
expression: reverseEndiannessMethod,
argumentList: ArgumentList(SingletonSeparatedList(
Argument(valueIdentifier)
))
)
)
)))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken))
]))
);
}
private static SyntaxToken GeneratePropertyName(string fieldName)
{
var propertyName = fieldName;
if (propertyName.StartsWith("_"))
propertyName = propertyName.Substring(1);
if (!char.IsLetter(propertyName, 0) || char.IsUpper(propertyName, 0))
throw new InvalidUsageException("field names have to start with a lower case letter");
propertyName = propertyName.Substring(0, 1).ToUpperInvariant()
+ propertyName.Substring(1);
return Identifier(propertyName);
}
private static AttributeSyntax? GetEndiannessAttribute(SyntaxNode structDeclaration, SemanticModel semanticModel)
{
AttributeSyntax? foundAttribute = null;
foreach (var attributeSyntax in structDeclaration.DescendantNodes().OfType<AttributeSyntax>())
{
var attributeTypeInfo = semanticModel.GetTypeInfo(attributeSyntax).Type;
if (attributeTypeInfo == null)
throw new DebugMeException("attribute type info is null");
if (attributeTypeInfo.ContainingNamespace?.Name != Namespace)
continue;
if (attributeTypeInfo.Name != AttributeName)
continue;
foundAttribute = attributeSyntax;
break;
}
return foundAttribute;
}
private static bool GetStructIsLittleEndian(AttributeSyntax foundAttribute)
{
var endiannessArguments = foundAttribute.ArgumentList;
if (endiannessArguments == null)
throw new InvalidUsageException("endianness attribute has no arguments");
var isLittleEndianArgumentSyntax = endiannessArguments.Arguments
.FirstOrDefault(argumentSyntax =>
argumentSyntax.NameEquals?.Name.Identifier.ToString() == IsLittleEndianProperty);
if (isLittleEndianArgumentSyntax == null)
throw new InvalidUsageException("endianness attribute argument not found");
bool? structIsLittleEndian = isLittleEndianArgumentSyntax.Expression.Kind() switch
{
SyntaxKind.FalseLiteralExpression => false,
SyntaxKind.TrueLiteralExpression => true,
SyntaxKind.DefaultLiteralExpression => false,
_ => throw new InvalidUsageException($"{IsLittleEndianProperty} has to be set with a literal")
};
return structIsLittleEndian.Value;
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../shared.props" />
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<PackageId>EndiannessSourceGenerator</PackageId>
<IsAotCompatible>false</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" PrivateAssets="all" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,9 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"Generators": {
"commandName": "DebugRoslynComponent",
"targetProject": "../DisplayCommands/DisplayCommands.csproj"
}
}
}

View file

@ -0,0 +1,5 @@
# Endianness Source Generator
When annotating a struct with the `StructEndianness` attribute, this code generator will generate properties for the declared fields.
Each time a property is read or written, the endianness is converted from runtime endianness to struct endianness or vice-versa.

View file

@ -0,0 +1,34 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TanksServer", "TanksServer\TanksServer.csproj", "{D88BF376-47A4-4C72-ADD1-983F9285C351}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DisplayCommands", "DisplayCommands\DisplayCommands.csproj", "{B4B43561-7A2C-486B-99F7-E58A67BC370A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EndiannessSourceGenerator", "EndiannessSourceGenerator\EndiannessSourceGenerator.csproj", "{D77FE880-F2B8-43B6-8B33-B6FA089CC337}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shared", "shared", "{12DB7D48-1BB2-488B-B4D9-4126087D2F8C}"
ProjectSection(SolutionItems) = preProject
global.json = global.json
shared.props = shared.props
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D88BF376-47A4-4C72-ADD1-983F9285C351}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D88BF376-47A4-4C72-ADD1-983F9285C351}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D88BF376-47A4-4C72-ADD1-983F9285C351}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D88BF376-47A4-4C72-ADD1-983F9285C351}.Release|Any CPU.Build.0 = Release|Any CPU
{B4B43561-7A2C-486B-99F7-E58A67BC370A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4B43561-7A2C-486B-99F7-E58A67BC370A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4B43561-7A2C-486B-99F7-E58A67BC370A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4B43561-7A2C-486B-99F7-E58A67BC370A}.Release|Any CPU.Build.0 = Release|Any CPU
{D77FE880-F2B8-43B6-8B33-B6FA089CC337}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D77FE880-F2B8-43B6-8B33-B6FA089CC337}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D77FE880-F2B8-43B6-8B33-B6FA089CC337}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D77FE880-F2B8-43B6-8B33-B6FA089CC337}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View 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();
});
}
}

View file

@ -0,0 +1,30 @@
namespace TanksServer.GameLogic;
internal sealed class CollectPowerUp(
MapEntityManager entityManager
) : ITickStep
{
public Task TickAsync(TimeSpan delta)
{
entityManager.RemoveWhere(TryCollect);
return Task.CompletedTask;
}
private bool TryCollect(PowerUp obj)
{
var position = obj.Position;
foreach (var tank in entityManager.Tanks)
{
var (topLeft, bottomRight) = tank.Bounds;
if (position.X < topLeft.X || position.X > bottomRight.X ||
position.Y < topLeft.Y || position.Y > bottomRight.Y)
continue;
// this works because now the tank overlaps the power up
tank.ExplosiveBullets += 10;
return true;
}
return false;
}
}

View file

@ -0,0 +1,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);
}
}
}

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

View 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();
}
}
}

View file

@ -0,0 +1,6 @@
namespace TanksServer.GameLogic;
public interface ITickStep
{
Task TickAsync(TimeSpan delta);
}

View 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();
}
}

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

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

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

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

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

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

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

View 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;

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

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

View file

@ -0,0 +1,52 @@
using System.Diagnostics;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using TanksServer.GameLogic;
namespace TanksServer.Graphics;
internal sealed class DrawPowerUpsStep : IDrawStep
{
private readonly MapEntityManager _entityManager;
private readonly bool?[,] _explosiveSprite;
public DrawPowerUpsStep(MapEntityManager entityManager)
{
_entityManager = entityManager;
using var tankImage = Image.Load<Rgba32>("assets/powerup_explosive.png");
Debug.Assert(tankImage.Width == tankImage.Height && tankImage.Width == MapService.TileSize);
_explosiveSprite = new bool?[tankImage.Width, tankImage.Height];
var whitePixel = new Rgba32(byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue);
for (var y = 0; y < tankImage.Height; y++)
for (var x = 0; x < tankImage.Width; x++)
{
var pixelValue = tankImage[x, y];
_explosiveSprite[x, y] = pixelValue.A == 0
? null
: pixelValue == whitePixel;
}
}
public void Draw(GamePixelGrid pixels)
{
foreach (var powerUp in _entityManager.PowerUps)
{
var position = powerUp.Bounds.TopLeft;
for (byte dy = 0; dy < MapService.TileSize; dy++)
for (byte dx = 0; dx < MapService.TileSize; dx++)
{
var pixelState = _explosiveSprite[dx, dy];
if (!pixelState.HasValue)
continue;
var (x, y) = position.GetPixelRelative(dx, dy);
pixels[x, y].EntityType = pixelState.Value
? GamePixelEntityType.PowerUp
: null;
}
}
}
}

View file

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

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

View file

@ -0,0 +1,9 @@
namespace TanksServer.Graphics;
internal enum GamePixelEntityType : byte
{
Wall = 0x0,
Tank = 0x1,
Bullet = 0x2,
PowerUp = 0x3
}

View 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];
}
}

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

View file

@ -0,0 +1,6 @@
namespace TanksServer.Graphics;
internal interface IDrawStep
{
void Draw(GamePixelGrid pixels);
}

View file

@ -0,0 +1,8 @@
using DisplayCommands;
namespace TanksServer.Graphics;
internal interface IFrameConsumer
{
Task OnFrameDoneAsync(GamePixelGrid gamePixelGrid, PixelGrid observerPixels);
}

View file

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

View file

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

View file

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

View file

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

View 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
}
}
}

View 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++;
}
}

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

View file

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

View 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());
}

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

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

View file

@ -0,0 +1,8 @@
namespace TanksServer.Models;
internal interface IMapEntity
{
FloatPosition Position { get; set; }
PixelBounds Bounds { get; }
}

View file

@ -0,0 +1,6 @@
using System.Diagnostics;
namespace TanksServer.Models;
[DebuggerDisplay("{TopLeft}, {BottomRight}")]
internal record struct PixelBounds(PixelPosition TopLeft, PixelPosition BottomRight);

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

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

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

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

View file

@ -0,0 +1,10 @@
using TanksServer.GameLogic;
namespace TanksServer.Models;
internal sealed class PowerUp(FloatPosition position): IMapEntity
{
public FloatPosition Position { get; set; } = position;
public PixelBounds Bounds => Position.GetBoundsForCenter(MapService.TileSize);
}

View file

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

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

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

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

View file

@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View 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>

View 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
}
}

View file

@ -0,0 +1,20 @@
######......####################......######
#..........................................#
#.....#####......................#####.....#
#..........#....................#..........#
#...........#####..........#####...........#
#..........................................#
#.........##....................##.........#
#............#..#..........#..#............#
#..............#............#..............#
#.............#..............#.............#
#....................##....................#
#....................##....................#
#....................##....................#
#.............#......##......#.............#
#...........#..#............#..#...........#
#.........#.....#..........#.....#.........#
#.......#............##............#.......#
#........#........................#........#
#..........................................#
######......####################......######

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,20 @@
#######.##########################.#########
#...................##.....................#
#...................##.....................#
#.....####......................####.......#
#..........................................#
#............###...........###.............#
#............#...............#.............#
#...##.......#....#....#.....#......##.....#
#....#..............................#......#
.....#...#......................#...#.......
.....#...#......................#...#.......
#....#..............................#......#
#...##.......#....#....#.....#......##.....#
#............#...............#.............#
#............###...........###.............#
#..........................................#
#.....####......................####.......#
#...................##.....................#
#...................##.....................#
#######.##########################.#########

View file

@ -0,0 +1,20 @@
#####......................................#
..#........................................#
..#........................................#
..#......###...............................#
..#.....#...#..............................#
........#####..............................#
........#...#...#...#......................#
........#...#...##..#......................#
................#.# #......................#
................#..##...#...#..............#
................#...#...#..#...............#
........................###................#
........................#..#.....####......#
........................#...#...#..........#
.................................###.......#
....................................#...#..#
................................####....#..#
........................................#..#
...........................................#
........................................#..#

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,20 @@
............................................
#.........##.....#.........................#
#........#..#..............................#
#.........##...............................#
#..................#.......................#
#.........#........##.............#........#
#....#.........#....#............#.........#
#....#.....................................#
#............#.............#..........#....#
#.........##.....#......................#..#
#........#..#..............................#
#.........##..................#............#
#..........................................#
#....#..............#...........#..........#
#....................#...##...#............#
#..........##..........#....#.........#....#
#.........#...#........#....#..............#
#.........#...#......#...##...#............#
#..........##......#............#.......#..#
............................................

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 B

View 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

View file

@ -0,0 +1,7 @@
{
"sdk": {
"version": "8.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
}
}

View file

@ -0,0 +1,20 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<PropertyGroup>
<AnalysisMode>Recommended</AnalysisMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>CA1805,CA1848</NoWarn>
</PropertyGroup>
</Project>