Add MrGameEng.Net: WebSocket transport + delta component replication
CI / build-test (push) Successful in 1m16s
CI / build-test (push) Successful in 1m16s
Browsers can't speak UDP, so WebSocket is the engine's one transport. The server side is a dependency-free RFC 6455 implementation over TcpListener (handshake, frame codec with masking and fragmentation, ping/pong — unit-tested against the RFC example vectors); the client wraps ClientWebSocket, which works on desktop and maps to the browser WebSocket in Blazor WASM. Both sit behind the poll-based INetConnection so simulation systems drain messages from their own thread; client sends are chained fire-and-forget (no blocking — wasm-safe). Replication is server-authoritative: games register unmanaged component types in a ReplicationSchema (same order both sides, up to 32 types), ReplicationServer snapshots entities carrying NetId once per send and ships each connection only the components that changed since its last snapshot — a reliable ordered transport needs no acks for deltas. New connections receive the full state through the same path; despawns are tracked by set difference. ReplicationClient applies snapshots to a local EntityStore and raises EntitySpawned so the game can decorate replicated entities with presentation components. Covered by 16 tests including a real loopback exchange between WebSocketClient and WebSocketServer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
10898b08a0
commit
3438ed77f6
@@ -0,0 +1,14 @@
|
||||
using Friflo.Engine.ECS;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an entity as replicated and identifies it across the network. The server assigns
|
||||
/// values (see <see cref="ReplicationServer.NextNetId"/>); the client creates a local
|
||||
/// entity with the same <see cref="Value"/> when the first snapshot arrives.
|
||||
/// </summary>
|
||||
public struct NetId : IComponent
|
||||
{
|
||||
/// <summary>Network-wide entity id, unique per server world.</summary>
|
||||
public int Value;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Friflo.Engine.ECS;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Client side of replication: applies snapshot messages from a
|
||||
/// <see cref="ReplicationServer"/> to a local <see cref="EntityStore"/>. Unknown net ids
|
||||
/// spawn local entities (carrying <see cref="NetId"/>), known ones get their changed
|
||||
/// components overwritten, despawns delete. The game decorates replicated entities with
|
||||
/// presentation components (sprites etc.) on top — replication never touches types outside
|
||||
/// its <see cref="ReplicationSchema"/>.
|
||||
/// </summary>
|
||||
public sealed class ReplicationClient
|
||||
{
|
||||
/// <summary>Number of replicated entities currently alive locally.</summary>
|
||||
public int EntityCount => _entities.Count;
|
||||
|
||||
/// <summary>Raised after an entity is created from a snapshot. Hook presentation setup here.</summary>
|
||||
public event Action<Entity>? EntitySpawned;
|
||||
|
||||
private readonly ReplicationSchema _schema;
|
||||
private readonly EntityStore _store;
|
||||
private readonly Dictionary<int, Entity> _entities = [];
|
||||
|
||||
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
|
||||
public ReplicationClient(ReplicationSchema schema, EntityStore store)
|
||||
{
|
||||
_schema = schema;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
/// <summary>Applies every message queued on <paramref name="connection"/>.</summary>
|
||||
public void Pump(INetConnection connection)
|
||||
{
|
||||
while (connection.TryReceive(out var message))
|
||||
{
|
||||
Apply(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies one snapshot message to the local store.</summary>
|
||||
public void Apply(byte[] message)
|
||||
{
|
||||
using var reader = new BinaryReader(new MemoryStream(message));
|
||||
if (reader.ReadByte() != ReplicationMessage.Snapshot)
|
||||
{
|
||||
return; // незнакомый тип сообщения — пропускаем, это не снапшот
|
||||
}
|
||||
|
||||
var slots = _schema.Slots;
|
||||
var count = reader.ReadInt32();
|
||||
for (var record = 0; record < count; record++)
|
||||
{
|
||||
var netId = reader.ReadInt32();
|
||||
var op = reader.ReadByte();
|
||||
if (op == ReplicationMessage.OpDespawn)
|
||||
{
|
||||
if (_entities.Remove(netId, out var dead))
|
||||
{
|
||||
dead.DeleteEntity();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var mask = reader.ReadUInt32();
|
||||
var spawned = false;
|
||||
if (!_entities.TryGetValue(netId, out var entity))
|
||||
{
|
||||
entity = _store.CreateEntity(new NetId { Value = netId });
|
||||
_entities[netId] = entity;
|
||||
spawned = true;
|
||||
}
|
||||
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
if ((mask & (1u << slot.Bit)) == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = reader.ReadBytes(slot.Size);
|
||||
slot.Apply(entity, data, 0);
|
||||
}
|
||||
|
||||
if (spawned)
|
||||
{
|
||||
EntitySpawned?.Invoke(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using Friflo.Engine.ECS;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// The set of component types a game replicates, registered in the same order on the
|
||||
/// server and every client (the order defines the wire ids). Components must be
|
||||
/// unmanaged structs — they are blitted to the wire as raw bytes, so server and client
|
||||
/// must run on the same engine version. Up to 32 types.
|
||||
/// </summary>
|
||||
public sealed class ReplicationSchema
|
||||
{
|
||||
internal sealed class ComponentSlot
|
||||
{
|
||||
public required int Bit;
|
||||
public required int Size;
|
||||
public required Func<Entity, byte[], bool> TryWrite;
|
||||
public required Action<Entity, byte[], int> Apply;
|
||||
}
|
||||
|
||||
internal readonly List<ComponentSlot> Slots = [];
|
||||
|
||||
/// <summary>
|
||||
/// Registers component type <typeparamref name="T"/> for replication. Returns this
|
||||
/// schema for fluent chaining.
|
||||
/// </summary>
|
||||
public ReplicationSchema Register<T>()
|
||||
where T : unmanaged, IComponent
|
||||
{
|
||||
if (Slots.Count == 32)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ReplicationSchema supports at most 32 component types."
|
||||
);
|
||||
}
|
||||
|
||||
var size = Unsafe.SizeOf<T>();
|
||||
Slots.Add(
|
||||
new ComponentSlot
|
||||
{
|
||||
Bit = Slots.Count,
|
||||
Size = size,
|
||||
TryWrite = (entity, buffer) =>
|
||||
{
|
||||
if (!entity.HasComponent<T>())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
MemoryMarshal.Write(buffer, in entity.GetComponent<T>());
|
||||
return true;
|
||||
},
|
||||
Apply = (entity, data, offset) =>
|
||||
{
|
||||
var value = MemoryMarshal.Read<T>(data.AsSpan(offset, size));
|
||||
entity.AddComponent(value);
|
||||
},
|
||||
}
|
||||
);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Friflo.Engine.ECS;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Server-authoritative component replication. Each call to <see cref="Send"/> snapshots
|
||||
/// every entity carrying <see cref="NetId"/> and sends each connection only what changed
|
||||
/// since that connection's previous snapshot (per-component deltas; a new connection gets
|
||||
/// the full state the same way). Deltas need no acknowledgements because the transport is
|
||||
/// reliable and ordered. Call at the desired send rate (e.g. every Nth simulation tick),
|
||||
/// from the simulation thread.
|
||||
/// </summary>
|
||||
public sealed class ReplicationServer
|
||||
{
|
||||
private sealed class ConnectionState
|
||||
{
|
||||
// По netId: последний отправленный блоб каждого зарегистрированного компонента.
|
||||
public readonly Dictionary<int, byte[]?[]> LastSent = [];
|
||||
}
|
||||
|
||||
private readonly ReplicationSchema _schema;
|
||||
private readonly ArchetypeQuery<NetId> _query;
|
||||
private readonly Dictionary<INetConnection, ConnectionState> _states = [];
|
||||
|
||||
// Снапшот текущего тика, переиспользуется между соединениями.
|
||||
private readonly List<(int NetId, byte[]?[] Components)> _current = [];
|
||||
private readonly HashSet<int> _currentIds = [];
|
||||
private int _nextNetId;
|
||||
|
||||
/// <summary>Creates a replication server over <paramref name="store"/>.</summary>
|
||||
public ReplicationServer(ReplicationSchema schema, EntityStore store)
|
||||
{
|
||||
_schema = schema;
|
||||
_query = store.Query<NetId>();
|
||||
}
|
||||
|
||||
/// <summary>Allocates the next free network id for a newly spawned replicated entity.</summary>
|
||||
public int NextNetId() => ++_nextNetId;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots the world once and sends per-connection deltas. Closed connections are
|
||||
/// forgotten; brand-new ones receive the full state.
|
||||
/// </summary>
|
||||
public void Send(IReadOnlyList<INetConnection> connections)
|
||||
{
|
||||
CaptureCurrentState();
|
||||
|
||||
foreach (var connection in connections)
|
||||
{
|
||||
if (!connection.IsOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_states.TryGetValue(connection, out var state))
|
||||
{
|
||||
state = new ConnectionState();
|
||||
_states[connection] = state;
|
||||
}
|
||||
|
||||
var message = BuildDelta(state);
|
||||
if (message is not null)
|
||||
{
|
||||
connection.Send(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Забываем состояние умерших соединений, чтобы не копить мусор.
|
||||
foreach (var dead in _states.Keys.Where(c => !c.IsOpen).ToList())
|
||||
{
|
||||
_states.Remove(dead);
|
||||
}
|
||||
}
|
||||
|
||||
private void CaptureCurrentState()
|
||||
{
|
||||
_current.Clear();
|
||||
_currentIds.Clear();
|
||||
var slots = _schema.Slots;
|
||||
_query.ForEachEntity(
|
||||
(ref NetId netId, Entity entity) =>
|
||||
{
|
||||
var components = new byte[]?[slots.Count];
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
var buffer = new byte[slot.Size];
|
||||
components[slot.Bit] = slot.TryWrite(entity, buffer) ? buffer : null;
|
||||
}
|
||||
|
||||
_current.Add((netId.Value, components));
|
||||
_currentIds.Add(netId.Value);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private byte[]? BuildDelta(ConnectionState state)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream);
|
||||
writer.Write(ReplicationMessage.Snapshot);
|
||||
var countPosition = stream.Position;
|
||||
writer.Write(0); // количество записей, допишем в конце
|
||||
var records = 0;
|
||||
|
||||
foreach (var (netId, components) in _current)
|
||||
{
|
||||
if (!state.LastSent.TryGetValue(netId, out var lastSent))
|
||||
{
|
||||
lastSent = new byte[]?[components.Length];
|
||||
state.LastSent[netId] = lastSent;
|
||||
}
|
||||
|
||||
uint mask = 0;
|
||||
for (var bit = 0; bit < components.Length; bit++)
|
||||
{
|
||||
var current = components[bit];
|
||||
if (current is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastSent[bit] is null || !current.AsSpan().SequenceEqual(lastSent[bit]))
|
||||
{
|
||||
mask |= 1u << bit;
|
||||
lastSent[bit] = current;
|
||||
}
|
||||
}
|
||||
|
||||
if (mask == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.Write(netId);
|
||||
writer.Write(ReplicationMessage.OpUpsert);
|
||||
writer.Write(mask);
|
||||
for (var bit = 0; bit < components.Length; bit++)
|
||||
{
|
||||
if ((mask & (1u << bit)) != 0)
|
||||
{
|
||||
writer.Write(components[bit]!);
|
||||
}
|
||||
}
|
||||
|
||||
records++;
|
||||
}
|
||||
|
||||
// Сущности, которые соединение знает, а в мире их больше нет.
|
||||
foreach (var known in state.LastSent.Keys.Where(id => !_currentIds.Contains(id)).ToList())
|
||||
{
|
||||
state.LastSent.Remove(known);
|
||||
writer.Write(known);
|
||||
writer.Write(ReplicationMessage.OpDespawn);
|
||||
records++;
|
||||
}
|
||||
|
||||
if (records == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.Position = countPosition;
|
||||
writer.Write(records);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wire constants shared by <see cref="ReplicationServer"/> and <see cref="ReplicationClient"/>.</summary>
|
||||
internal static class ReplicationMessage
|
||||
{
|
||||
internal const byte Snapshot = 1;
|
||||
internal const byte OpUpsert = 0;
|
||||
internal const byte OpDespawn = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user