Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Tells the renderer which visual to use for a snapshot entity.</summary>
|
||||
public enum EntityKind : byte
|
||||
{
|
||||
Unknown = 0,
|
||||
Player = 1,
|
||||
Obstacle = 2,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Protocol</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Bitmask of movement intents sent by the client each input frame.</summary>
|
||||
[Flags]
|
||||
public enum InputButtons : byte
|
||||
{
|
||||
None = 0,
|
||||
Up = 1 << 0,
|
||||
Down = 1 << 1,
|
||||
Left = 1 << 2,
|
||||
Right = 1 << 3,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// First byte of every frame. Client-to-server ids live in 0x00-0x7F,
|
||||
/// server-to-client ids in 0x80-0xFF, so a misrouted frame is obvious.
|
||||
/// </summary>
|
||||
public enum MessageType : byte
|
||||
{
|
||||
None = 0x00,
|
||||
|
||||
ClientHello = 0x01,
|
||||
ClientInput = 0x02,
|
||||
ClientPing = 0x03,
|
||||
|
||||
ServerWelcome = 0x81,
|
||||
ServerSnapshot = 0x82,
|
||||
ServerPong = 0x83,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>First frame from the client: protocol version handshake plus display name.</summary>
|
||||
public readonly record struct ClientHelloMessage(byte ProtocolVersion, string PlayerName);
|
||||
|
||||
/// <summary>
|
||||
/// Movement intent for one client frame. <paramref name="Sequence"/> is echoed back
|
||||
/// in future snapshots once client-side prediction lands.
|
||||
/// </summary>
|
||||
public readonly record struct ClientInputMessage(uint Sequence, InputButtons Buttons);
|
||||
|
||||
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
|
||||
public readonly record struct ClientPingMessage(long ClientTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Sent once per connection, before the first snapshot.
|
||||
/// <paramref name="PlayerEntityId"/> is the replication id of this client's own avatar,
|
||||
/// so the renderer can tell it apart from everyone else.
|
||||
/// </summary>
|
||||
public readonly record struct ServerWelcomeMessage(
|
||||
byte ProtocolVersion,
|
||||
uint PlayerEntityId,
|
||||
byte TickRate,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
|
||||
/// <summary>One entity inside a snapshot. Kept flat and blittable on purpose.</summary>
|
||||
public readonly record struct EntitySnapshot(
|
||||
uint Id,
|
||||
EntityKind Kind,
|
||||
float X,
|
||||
float Y,
|
||||
float Radius,
|
||||
uint Color);
|
||||
|
||||
/// <summary>Answer to <see cref="ClientPingMessage"/>, carrying the current server tick.</summary>
|
||||
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Little-endian cursor over a received frame. Mirror of <see cref="PacketWriter"/>.</summary>
|
||||
public ref struct PacketReader(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _buffer = buffer;
|
||||
private int _position = 0;
|
||||
|
||||
public readonly int Position => _position;
|
||||
|
||||
public readonly int Remaining => _buffer.Length - _position;
|
||||
|
||||
public byte ReadByte()
|
||||
{
|
||||
EnsureAvailable(sizeof(byte));
|
||||
var value = _buffer[_position];
|
||||
_position += sizeof(byte);
|
||||
return value;
|
||||
}
|
||||
|
||||
public MessageType ReadMessageType() => (MessageType)ReadByte();
|
||||
|
||||
public ushort ReadUInt16()
|
||||
{
|
||||
EnsureAvailable(sizeof(ushort));
|
||||
var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(ushort);
|
||||
return value;
|
||||
}
|
||||
|
||||
public uint ReadUInt32()
|
||||
{
|
||||
EnsureAvailable(sizeof(uint));
|
||||
var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(uint);
|
||||
return value;
|
||||
}
|
||||
|
||||
public long ReadInt64()
|
||||
{
|
||||
EnsureAvailable(sizeof(long));
|
||||
var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(long);
|
||||
return value;
|
||||
}
|
||||
|
||||
public float ReadSingle()
|
||||
{
|
||||
EnsureAvailable(sizeof(float));
|
||||
var value = BinaryPrimitives.ReadSingleLittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(float);
|
||||
return value;
|
||||
}
|
||||
|
||||
public string ReadShortString()
|
||||
{
|
||||
var byteCount = ReadByte();
|
||||
EnsureAvailable(byteCount);
|
||||
var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount));
|
||||
_position += byteCount;
|
||||
return value;
|
||||
}
|
||||
|
||||
private readonly void EnsureAvailable(int bytes)
|
||||
{
|
||||
if (_position + bytes > _buffer.Length)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"Truncated frame: need {bytes} bytes at offset {_position}, only {Remaining} available.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
|
||||
/// browser's <c>DataView</c> calls in <c>src/HSchool.Client/src/net/protocol.ts</c>.
|
||||
/// </summary>
|
||||
public ref struct PacketWriter(Span<byte> buffer)
|
||||
{
|
||||
private readonly Span<byte> _buffer = buffer;
|
||||
private int _position = 0;
|
||||
|
||||
public readonly int Position => _position;
|
||||
|
||||
public readonly ReadOnlySpan<byte> Written => _buffer[.._position];
|
||||
|
||||
public void WriteByte(byte value)
|
||||
{
|
||||
EnsureRoom(sizeof(byte));
|
||||
_buffer[_position] = value;
|
||||
_position += sizeof(byte);
|
||||
}
|
||||
|
||||
public void WriteMessageType(MessageType value) => WriteByte((byte)value);
|
||||
|
||||
public void WriteUInt16(ushort value)
|
||||
{
|
||||
EnsureRoom(sizeof(ushort));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(ushort);
|
||||
}
|
||||
|
||||
public void WriteUInt32(uint value)
|
||||
{
|
||||
EnsureRoom(sizeof(uint));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(uint);
|
||||
}
|
||||
|
||||
public void WriteInt64(long value)
|
||||
{
|
||||
EnsureRoom(sizeof(long));
|
||||
BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(long);
|
||||
}
|
||||
|
||||
public void WriteSingle(float value)
|
||||
{
|
||||
EnsureRoom(sizeof(float));
|
||||
BinaryPrimitives.WriteSingleLittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(float);
|
||||
}
|
||||
|
||||
/// <summary>Writes a UTF-8 string prefixed with a single length byte.</summary>
|
||||
public void WriteShortString(string value)
|
||||
{
|
||||
var byteCount = Encoding.UTF8.GetByteCount(value);
|
||||
if (byteCount > ProtocolConstants.MaxPlayerNameBytes)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"String is {byteCount} bytes, limit is {ProtocolConstants.MaxPlayerNameBytes}.");
|
||||
}
|
||||
|
||||
WriteByte((byte)byteCount);
|
||||
EnsureRoom(byteCount);
|
||||
Encoding.UTF8.GetBytes(value, _buffer[_position..]);
|
||||
_position += byteCount;
|
||||
}
|
||||
|
||||
private readonly void EnsureRoom(int bytes)
|
||||
{
|
||||
if (_position + bytes > _buffer.Length)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The single place where the wire format is defined on the .NET side.
|
||||
/// Every change here must be mirrored in <c>src/HSchool.Client/src/net/protocol.ts</c>
|
||||
/// and documented in <c>docs/protocol.md</c>.
|
||||
/// </summary>
|
||||
public static class ProtocolCodec
|
||||
{
|
||||
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientHello);
|
||||
writer.WriteByte(message.ProtocolVersion);
|
||||
writer.WriteShortString(message.PlayerName);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WriteInput(Span<byte> destination, in ClientInputMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientInput);
|
||||
writer.WriteUInt32(message.Sequence);
|
||||
writer.WriteByte((byte)message.Buttons);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WritePing(Span<byte> destination, in ClientPingMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientPing);
|
||||
writer.WriteInt64(message.ClientTimeMs);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerWelcome);
|
||||
writer.WriteByte(message.ProtocolVersion);
|
||||
writer.WriteUInt32(message.PlayerEntityId);
|
||||
writer.WriteByte(message.TickRate);
|
||||
writer.WriteSingle(message.WorldWidth);
|
||||
writer.WriteSingle(message.WorldHeight);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WritePong(Span<byte> destination, in ServerPongMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerPong);
|
||||
writer.WriteInt64(message.ClientTimeMs);
|
||||
writer.WriteUInt32(message.ServerTick);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
/// <summary>Writes a full-state snapshot; entities missing from it are despawned by the client.</summary>
|
||||
public static int WriteSnapshot(Span<byte> destination, uint tick, ReadOnlySpan<EntitySnapshot> entities)
|
||||
{
|
||||
if (entities.Length > ushort.MaxValue)
|
||||
{
|
||||
throw new ProtocolException($"Snapshot holds {entities.Length} entities, limit is {ushort.MaxValue}.");
|
||||
}
|
||||
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerSnapshot);
|
||||
writer.WriteUInt32(tick);
|
||||
writer.WriteUInt16((ushort)entities.Length);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
writer.WriteUInt32(entity.Id);
|
||||
writer.WriteByte((byte)entity.Kind);
|
||||
writer.WriteSingle(entity.X);
|
||||
writer.WriteSingle(entity.Y);
|
||||
writer.WriteSingle(entity.Radius);
|
||||
writer.WriteUInt32(entity.Color);
|
||||
}
|
||||
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
/// <summary>Exact byte size of a snapshot frame for <paramref name="entityCount"/> entities.</summary>
|
||||
public static int SnapshotSize(int entityCount) =>
|
||||
ProtocolConstants.SnapshotHeaderSize + (entityCount * ProtocolConstants.EntitySnapshotSize);
|
||||
|
||||
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
|
||||
source.IsEmpty ? MessageType.None : (MessageType)source[0];
|
||||
|
||||
public static ClientHelloMessage ReadHello(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientHello);
|
||||
var version = reader.ReadByte();
|
||||
var name = reader.ReadShortString();
|
||||
return new ClientHelloMessage(version, name);
|
||||
}
|
||||
|
||||
public static ClientInputMessage ReadInput(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientInput);
|
||||
var sequence = reader.ReadUInt32();
|
||||
var buttons = (InputButtons)reader.ReadByte();
|
||||
return new ClientInputMessage(sequence, buttons);
|
||||
}
|
||||
|
||||
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientPing);
|
||||
return new ClientPingMessage(reader.ReadInt64());
|
||||
}
|
||||
|
||||
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerWelcome);
|
||||
var version = reader.ReadByte();
|
||||
var playerEntityId = reader.ReadUInt32();
|
||||
var tickRate = reader.ReadByte();
|
||||
var width = reader.ReadSingle();
|
||||
var height = reader.ReadSingle();
|
||||
return new ServerWelcomeMessage(version, playerEntityId, tickRate, width, height);
|
||||
}
|
||||
|
||||
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerPong);
|
||||
var clientTime = reader.ReadInt64();
|
||||
var serverTick = reader.ReadUInt32();
|
||||
return new ServerPongMessage(clientTime, serverTick);
|
||||
}
|
||||
|
||||
/// <summary>Reads a snapshot into <paramref name="destination"/> and returns the entity count.</summary>
|
||||
public static int ReadSnapshot(ReadOnlySpan<byte> source, Span<EntitySnapshot> destination, out uint tick)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerSnapshot);
|
||||
tick = reader.ReadUInt32();
|
||||
var count = reader.ReadUInt16();
|
||||
|
||||
if (count > destination.Length)
|
||||
{
|
||||
throw new ProtocolException($"Snapshot holds {count} entities, destination fits {destination.Length}.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
destination[i] = new EntitySnapshot(
|
||||
reader.ReadUInt32(),
|
||||
(EntityKind)reader.ReadByte(),
|
||||
reader.ReadSingle(),
|
||||
reader.ReadSingle(),
|
||||
reader.ReadSingle(),
|
||||
reader.ReadUInt32());
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void Expect(ref PacketReader reader, MessageType expected)
|
||||
{
|
||||
var actual = reader.ReadMessageType();
|
||||
if (actual != expected)
|
||||
{
|
||||
throw new ProtocolException($"Expected {expected} (0x{(byte)expected:X2}) but got 0x{(byte)actual:X2}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Wire-format constants shared by the server and the browser client.</summary>
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
||||
public const byte Version = 1;
|
||||
|
||||
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
||||
public const int MaxMessageSize = 64 * 1024;
|
||||
|
||||
/// <summary>Bytes of a single entity inside a snapshot payload.</summary>
|
||||
public const int EntitySnapshotSize = sizeof(uint) + sizeof(byte) + (sizeof(float) * 3) + sizeof(uint);
|
||||
|
||||
/// <summary>Bytes of the snapshot header: message type + tick + entity count.</summary>
|
||||
public const int SnapshotHeaderSize = sizeof(byte) + sizeof(uint) + sizeof(ushort);
|
||||
|
||||
/// <summary>Maximum UTF-8 byte length of a player name.</summary>
|
||||
public const int MaxPlayerNameBytes = 32;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Thrown when a frame is truncated, oversized or otherwise unreadable.</summary>
|
||||
public sealed class ProtocolException(string message) : Exception(message);
|
||||
Reference in New Issue
Block a user