Compare commits
5
Commits
d360093be1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f3931f85 | ||
|
|
96e19c7c61 | ||
|
|
38bdfbbb81 | ||
|
|
08381703f7 | ||
|
|
f382fc98ea |
@@ -10,16 +10,21 @@ namespace MrGameEng.Core;
|
||||
public sealed class Calendar
|
||||
{
|
||||
private readonly GameClock _clock;
|
||||
private readonly double _startDay;
|
||||
private float _secondsPerDay;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calendar reading <paramref name="clock"/>; one day spans
|
||||
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive).
|
||||
/// <paramref name="startDay"/> offsets the calendar by (fractional) days at clock 0 — e.g.
|
||||
/// <c>7.0 / 24</c> starts the world at 07:00 instead of midnight. It shifts the time of day and
|
||||
/// the day/night phase that reads <see cref="DayProgress"/>, without touching the clock itself.
|
||||
/// </summary>
|
||||
public Calendar(GameClock clock, float secondsPerDay)
|
||||
public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
SecondsPerDay = secondsPerDay;
|
||||
_startDay = startDay;
|
||||
}
|
||||
|
||||
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
|
||||
@@ -36,8 +41,8 @@ public sealed class Calendar
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4).</summary>
|
||||
public double TotalDays => _clock.TotalTime / _secondsPerDay;
|
||||
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4), incl. the start offset.</summary>
|
||||
public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
|
||||
|
||||
/// <summary>The current day number, counting from 1.</summary>
|
||||
public int Day => (int)TotalDays + 1;
|
||||
@@ -68,11 +73,16 @@ public static class CalendarEngineExtensions
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a
|
||||
/// service. Call once per world. <paramref name="secondsPerDay"/> is the scaled-time length
|
||||
/// of one in-game day (must be positive).
|
||||
/// of one in-game day (must be positive); <paramref name="startDay"/> offsets the starting
|
||||
/// time of day in fractional days (e.g. <c>7.0 / 24</c> begins the world at 07:00).
|
||||
/// </summary>
|
||||
public static Calendar UseCalendar(this EngineContext context, float secondsPerDay)
|
||||
public static Calendar UseCalendar(
|
||||
this EngineContext context,
|
||||
float secondsPerDay,
|
||||
double startDay = 0.0
|
||||
)
|
||||
{
|
||||
var calendar = new Calendar(context.Clock, secondsPerDay);
|
||||
var calendar = new Calendar(context.Clock, secondsPerDay, startDay);
|
||||
context.Services.Add(calendar);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ public readonly record struct ClimateSettings
|
||||
/// <summary>Day of the year (0-based) with the highest temperature; defaults to mid-summer.</summary>
|
||||
public int WarmestDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Day of the year (0-based) that the calendar's day 0 maps to. Shifts the whole seasonal phase
|
||||
/// (season and temperature together) so a world can begin in a chosen part of the year — e.g. a
|
||||
/// warm late spring instead of the cold turn of the year. Defaults to 0 (year begins at day 0).
|
||||
/// </summary>
|
||||
public int StartDayOfYear { get; init; }
|
||||
|
||||
/// <summary>Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.</summary>
|
||||
public static ClimateSettings Default =>
|
||||
new()
|
||||
@@ -78,18 +85,21 @@ public sealed class Climate
|
||||
/// <summary>In-game days per year.</summary>
|
||||
public int DaysPerYear => _settings.DaysPerYear;
|
||||
|
||||
/// <summary>Elapsed days shifted by <see cref="ClimateSettings.StartDayOfYear"/> — the seasonal clock.</summary>
|
||||
private double YearDays => _calendar.TotalDays + _settings.StartDayOfYear;
|
||||
|
||||
/// <summary>Continuous position within the current year in <c>[0, 1)</c>.</summary>
|
||||
public double YearProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
var years = _calendar.TotalDays / _settings.DaysPerYear;
|
||||
var years = YearDays / _settings.DaysPerYear;
|
||||
return years - Math.Floor(years);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The current year, counting from 1.</summary>
|
||||
public int Year => (int)(_calendar.TotalDays / _settings.DaysPerYear) + 1;
|
||||
public int Year => (int)(YearDays / _settings.DaysPerYear) + 1;
|
||||
|
||||
/// <summary>Day within the current year, 0-based.</summary>
|
||||
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear);
|
||||
|
||||
@@ -33,6 +33,15 @@ public readonly struct CameraState
|
||||
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
|
||||
public required ViewportMapping Mapping { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// World point at the centre of the virtual screen — the camera's <em>effective</em> position
|
||||
/// after bounds-clamping, i.e. what the view is actually built around. Prefer this over the raw
|
||||
/// <see cref="Camera.Position"/> when anchoring zoom-to-cursor, so the reposition matches what is
|
||||
/// rendered even while the camera is clamped against <see cref="Camera.Bounds"/>.
|
||||
/// </summary>
|
||||
public Vector2 WorldCenter =>
|
||||
Vector2.Transform(new Vector2(VirtualWidth / 2f, VirtualHeight / 2f), InverseView);
|
||||
|
||||
/// <summary>Converts a physical screen point to world coordinates.</summary>
|
||||
public Vector2 ScreenToWorld(Vector2 screen)
|
||||
{
|
||||
|
||||
@@ -52,6 +52,26 @@ public sealed class DayNight
|
||||
/// <summary>Light intensity at a world point in <c>[0, 1]</c>. Global today; local (with shadows) later.</summary>
|
||||
public float SampleAt(Vector2 world) => Daylight;
|
||||
|
||||
/// <summary>
|
||||
/// Offset, in grid cells, of the shadow an occluder casts under the current sun: opposite the
|
||||
/// sun's east–west position and longest near sunrise/sunset (low sun), shrinking to zero at noon
|
||||
/// and at night. Feeds the lightmap's directional shadow pass; <paramref name="maxLength"/> caps
|
||||
/// the dawn/dusk shadow length. Tilted slightly "south" (down) so shadows fall in front of objects.
|
||||
/// </summary>
|
||||
public Vector2 SunShadow(float maxLength)
|
||||
{
|
||||
var day = (_calendar.DayProgress - 0.25f) / 0.5f; // daytime fraction over [06:00, 18:00]
|
||||
if (day <= 0f || day >= 1f)
|
||||
{
|
||||
return Vector2.Zero; // night — no sun; the ambient floor handles darkness
|
||||
}
|
||||
|
||||
var altitude = MathF.Sin(day * MathF.PI); // 0 at dawn/dusk, 1 at noon
|
||||
var direction = new Vector2(2f * day - 1f, 0.4f); // sun east→west ⇒ shadow west→east, tilted south
|
||||
direction.Normalize();
|
||||
return direction * (maxLength * (1f - altitude));
|
||||
}
|
||||
|
||||
/// <summary>Ambient tint for the scene: night color at night, day color at noon, eased between.</summary>
|
||||
public Color Ambient
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary>
|
||||
@@ -16,8 +18,10 @@ public static class LightmapBuilder
|
||||
|
||||
/// <summary>
|
||||
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
|
||||
/// ambient light, shading occluder cells, then adds each point light with grid-traced occlusion.
|
||||
/// Values end clamped to <c>[0, 1]</c>.
|
||||
/// ambient light, shading occluder cells, casts each occluder's directional sun shadow along
|
||||
/// <paramref name="sunShadow"/> (cells), then adds each point light with grid-traced occlusion.
|
||||
/// Values end clamped to <c>[0, 1]</c>. A zero <paramref name="sunShadow"/> or
|
||||
/// <paramref name="sunShadowStrength"/> skips the directional pass (e.g. at night/noon).
|
||||
/// </summary>
|
||||
public static void Build(
|
||||
float[] light,
|
||||
@@ -25,7 +29,9 @@ public static class LightmapBuilder
|
||||
int height,
|
||||
float ambient,
|
||||
ReadOnlySpan<bool> occluders,
|
||||
IReadOnlyList<LightSample> lights
|
||||
IReadOnlyList<LightSample> lights,
|
||||
Vector2 sunShadow = default,
|
||||
float sunShadowStrength = 0f
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < light.Length; i++)
|
||||
@@ -33,6 +39,8 @@ public static class LightmapBuilder
|
||||
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
|
||||
}
|
||||
|
||||
CastSunShadows(light, width, height, occluders, sunShadow, sunShadowStrength);
|
||||
|
||||
foreach (var l in lights)
|
||||
{
|
||||
if (l.Radius <= 0f || l.Intensity <= 0f)
|
||||
@@ -73,6 +81,62 @@ public static class LightmapBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// Направленная тень от солнца: каждый окклюдер (гора/зрелая крона) отбрасывает тень вдоль
|
||||
// вектора sunShadow (в клетках). Затемнение гуще у основания и тает к концу тени; сами клетки-
|
||||
// окклюдеры не трогаем (они уже затенены). Окклюдеры разрежены, так что проход дёшев.
|
||||
private static void CastSunShadows(
|
||||
float[] light,
|
||||
int width,
|
||||
int height,
|
||||
ReadOnlySpan<bool> occluders,
|
||||
Vector2 sunShadow,
|
||||
float strength
|
||||
)
|
||||
{
|
||||
if (strength <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var steps = (int)MathF.Ceiling(sunShadow.Length());
|
||||
if (steps <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var stepX = sunShadow.X / steps;
|
||||
var stepY = sunShadow.Y / steps;
|
||||
for (var oy = 0; oy < height; oy++)
|
||||
{
|
||||
for (var ox = 0; ox < width; ox++)
|
||||
{
|
||||
if (!occluders[oy * width + ox])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var s = 1; s <= steps; s++)
|
||||
{
|
||||
var cx = ox + (int)MathF.Round(stepX * s);
|
||||
var cy = oy + (int)MathF.Round(stepY * s);
|
||||
if (cx < 0 || cx >= width || cy < 0 || cy >= height)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var index = cy * width + cx;
|
||||
if (occluders[index])
|
||||
{
|
||||
continue; // тень проходит над другими окклюдерами — они и так тёмные
|
||||
}
|
||||
|
||||
var falloff = 1f - (float)(s - 1) / steps; // гуще у основания тени
|
||||
light[index] *= 1f - strength * falloff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
|
||||
// промежуточные клетки на окклюдер (концы исключены).
|
||||
private static bool Visible(
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace MrGameEng.Lighting;
|
||||
public sealed class LightmapSystem : BaseSystem
|
||||
{
|
||||
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
|
||||
private const float NightFloor = 0.18f; // ночь тусклая, но не чёрная (лунный свет)
|
||||
private const float NightFloor = 0.24f; // ночь тусклая, но не чёрная (лунный свет) — чуть светлее, чем было
|
||||
private const float MaxShadowCells = 7f; // макс. длина тени от солнца (на рассвете/закате)
|
||||
private const float SunShadowStrength = 0.5f; // насколько темнеет клетка у основания тени
|
||||
|
||||
private readonly Lightmap _lightmap;
|
||||
private readonly DayNight _dayNight;
|
||||
@@ -77,7 +79,9 @@ public sealed class LightmapSystem : BaseSystem
|
||||
_lightmap.Height,
|
||||
ambient,
|
||||
_occluders(),
|
||||
_lights
|
||||
_lights,
|
||||
_dayNight.SunShadow(MaxShadowCells),
|
||||
SunShadowStrength
|
||||
);
|
||||
_lightmap.Upload();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
@@ -21,6 +22,7 @@ public sealed class ReplicationClient
|
||||
private readonly ReplicationSchema _schema;
|
||||
private readonly EntityStore _store;
|
||||
private readonly Dictionary<int, Entity> _entities = [];
|
||||
private bool _warnedVersion;
|
||||
|
||||
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
|
||||
public ReplicationClient(ReplicationSchema schema, EntityStore store)
|
||||
@@ -38,55 +40,98 @@ public sealed class ReplicationClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes every replicated entity and forgets all net ids. Call before reconnecting: the
|
||||
/// fresh connection receives the full world again with a clean id space, so stale entities
|
||||
/// from the previous session don't linger as duplicates.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var entity in _entities.Values)
|
||||
{
|
||||
entity.DeleteEntity();
|
||||
}
|
||||
|
||||
_entities.Clear();
|
||||
}
|
||||
|
||||
/// <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)
|
||||
// Заголовок: тип(1) + версия(1). Короче — точно не наш снапшот.
|
||||
if (message.Length < 2 || reader.ReadByte() != ReplicationMessage.Snapshot)
|
||||
{
|
||||
return; // незнакомый тип сообщения — пропускаем, это не снапшот
|
||||
}
|
||||
|
||||
var slots = _schema.Slots;
|
||||
var count = reader.ReadInt32();
|
||||
for (var record = 0; record < count; record++)
|
||||
var version = reader.ReadByte();
|
||||
if (version != ReplicationMessage.ProtocolVersion)
|
||||
{
|
||||
var netId = reader.ReadInt32();
|
||||
var op = reader.ReadByte();
|
||||
if (op == ReplicationMessage.OpDespawn)
|
||||
if (!_warnedVersion)
|
||||
{
|
||||
if (_entities.Remove(netId, out var dead))
|
||||
{
|
||||
dead.DeleteEntity();
|
||||
}
|
||||
|
||||
continue;
|
||||
_warnedVersion = true;
|
||||
Log.Warning(
|
||||
$"Replication protocol mismatch: server v{version}, client "
|
||||
+ $"v{ReplicationMessage.ProtocolVersion} — snapshots dropped. Schemas out of sync."
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var slot in slots)
|
||||
var slots = _schema.Slots;
|
||||
try
|
||||
{
|
||||
var count = reader.ReadInt32();
|
||||
for (var record = 0; record < count; record++)
|
||||
{
|
||||
if ((mask & (1u << slot.Bit)) == 0)
|
||||
var netId = reader.ReadInt32();
|
||||
var op = reader.ReadByte();
|
||||
if (op == ReplicationMessage.OpDespawn)
|
||||
{
|
||||
if (_entities.Remove(netId, out var dead))
|
||||
{
|
||||
dead.DeleteEntity();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = reader.ReadBytes(slot.Size);
|
||||
slot.Apply(entity, data, 0);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (spawned)
|
||||
{
|
||||
EntitySpawned?.Invoke(entity);
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
if ((mask & (1u << slot.Bit)) == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = reader.ReadBytes(slot.Size);
|
||||
if (data.Length < slot.Size)
|
||||
{
|
||||
return; // снапшот оборван на полпути — дальше читать нечего
|
||||
}
|
||||
|
||||
slot.Apply(entity, data, 0);
|
||||
}
|
||||
|
||||
if (spawned)
|
||||
{
|
||||
EntitySpawned?.Invoke(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
// Структурно битый/усечённый снапшот — игнорируем остаток, соединение не роняем.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ public sealed class ReplicationServer
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream);
|
||||
writer.Write(ReplicationMessage.Snapshot);
|
||||
writer.Write(ReplicationMessage.ProtocolVersion); // версия формата — клиент отвергает чужую
|
||||
var countPosition = stream.Position;
|
||||
writer.Write(0); // количество записей, допишем в конце
|
||||
var records = 0;
|
||||
@@ -169,6 +170,14 @@ public sealed class ReplicationServer
|
||||
internal static class ReplicationMessage
|
||||
{
|
||||
internal const byte Snapshot = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format version. Bump whenever the snapshot layout or the meaning of the schema's
|
||||
/// component blits changes; a client receiving a mismatched version drops the message
|
||||
/// instead of decoding garbage (guards against a desync between server and client schemas).
|
||||
/// </summary>
|
||||
internal const byte ProtocolVersion = 1;
|
||||
|
||||
internal const byte OpUpsert = 0;
|
||||
internal const byte OpDespawn = 1;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ public sealed class WebSocketClient : INetConnection, IDisposable
|
||||
/// <summary>Closes the connection.</summary>
|
||||
public void Dispose() => Close();
|
||||
|
||||
/// <summary>Largest reassembled message accepted from the server before the connection is dropped.</summary>
|
||||
public const int MaxMessageBytes = 16 * 1024 * 1024;
|
||||
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
@@ -117,6 +120,11 @@ public sealed class WebSocketClient : INetConnection, IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.Length + result.Count > MaxMessageBytes)
|
||||
{
|
||||
break; // сервер шлёт ненормально большое сообщение — рвём соединение
|
||||
}
|
||||
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
|
||||
@@ -10,13 +10,25 @@ namespace MrGameEng.Net;
|
||||
/// dedicated servers. Accepting and reading happen on background tasks; the simulation
|
||||
/// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by
|
||||
/// polling each connection — nothing here touches the ECS world from another thread.
|
||||
/// Binary messages only; pings are answered automatically.
|
||||
/// Binary messages only; incoming pings are answered automatically and the server itself
|
||||
/// heartbeats each connection, closing any that goes silent past <see cref="IdleTimeout"/>
|
||||
/// (detects half-open TCP — a peer that vanished without a close frame). A reassembled
|
||||
/// message is capped at <see cref="MaxMessageBytes"/> so a peer can't exhaust memory.
|
||||
/// </summary>
|
||||
public sealed class WebSocketServer : IDisposable
|
||||
{
|
||||
/// <summary>Largest reassembled (possibly fragmented) message accepted from a peer.</summary>
|
||||
public const int MaxMessageBytes = WebSocketProtocol.MaxPayloadBytes;
|
||||
|
||||
/// <summary>The port the server listens on.</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>How often the server pings each connection to keep it alive and probe liveness.</summary>
|
||||
public TimeSpan HeartbeatInterval { get; }
|
||||
|
||||
/// <summary>A connection with no traffic for longer than this is considered dead and closed.</summary>
|
||||
public TimeSpan IdleTimeout { get; }
|
||||
|
||||
/// <summary>Snapshot of currently open connections.</summary>
|
||||
public IReadOnlyList<INetConnection> Connections
|
||||
{
|
||||
@@ -36,10 +48,22 @@ public sealed class WebSocketServer : IDisposable
|
||||
private int _nextConnectionId;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/> to listen.</summary>
|
||||
public WebSocketServer(int port)
|
||||
/// <summary>
|
||||
/// Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/>
|
||||
/// to listen. <paramref name="heartbeatInterval"/> (default 10 s) sets how often each
|
||||
/// connection is pinged; <paramref name="idleTimeout"/> (default 30 s) how long a silent
|
||||
/// connection lives before it's dropped as dead. The timeout must exceed the interval so a
|
||||
/// healthy peer's pong lands before it's judged idle.
|
||||
/// </summary>
|
||||
public WebSocketServer(
|
||||
int port,
|
||||
TimeSpan? heartbeatInterval = null,
|
||||
TimeSpan? idleTimeout = null
|
||||
)
|
||||
{
|
||||
Port = port;
|
||||
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromSeconds(10);
|
||||
IdleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
|
||||
_listener = new TcpListener(IPAddress.Any, port);
|
||||
}
|
||||
|
||||
@@ -54,6 +78,7 @@ public sealed class WebSocketServer : IDisposable
|
||||
_started = true;
|
||||
_listener.Start();
|
||||
Task.Run(AcceptLoop);
|
||||
Task.Run(HeartbeatLoop);
|
||||
Log.Info($"WebSocketServer listening on port {Port}");
|
||||
}
|
||||
|
||||
@@ -109,6 +134,48 @@ public sealed class WebSocketServer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Пингует живые соединения и закрывает те, что молчат дольше IdleTimeout (мёртвый peer
|
||||
// не отвечает pong'ом — его активность не обновляется и он отваливается по таймауту).
|
||||
private async Task HeartbeatLoop()
|
||||
{
|
||||
while (!_shutdown.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(HeartbeatInterval, _shutdown.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ServerConnection[] snapshot;
|
||||
lock (_connections)
|
||||
{
|
||||
snapshot = _connections.ToArray();
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var connection in snapshot)
|
||||
{
|
||||
if (!connection.IsOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - connection.LastActivityUtc > IdleTimeout)
|
||||
{
|
||||
Log.Info($"WebSocketServer: connection #{connection.Id} timed out (idle)");
|
||||
connection.Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
connection.SendPing();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Handshake(TcpClient client)
|
||||
{
|
||||
try
|
||||
@@ -148,21 +215,30 @@ public sealed class WebSocketServer : IDisposable
|
||||
public int Id { get; }
|
||||
public bool IsOpen => !_closed;
|
||||
|
||||
/// <summary>UTC of the last frame received from the peer — drives idle-timeout detection.</summary>
|
||||
public DateTime LastActivityUtc =>
|
||||
new(Volatile.Read(ref _lastActivityTicks), DateTimeKind.Utc);
|
||||
|
||||
private readonly TcpClient _client;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly ConcurrentQueue<byte[]> _inbox = new();
|
||||
private readonly object _sendLock = new();
|
||||
private volatile bool _closed;
|
||||
private long _lastActivityTicks;
|
||||
|
||||
internal ServerConnection(int id, TcpClient client)
|
||||
{
|
||||
Id = id;
|
||||
_client = client;
|
||||
_stream = client.GetStream();
|
||||
_lastActivityTicks = DateTime.UtcNow.Ticks;
|
||||
}
|
||||
|
||||
internal void StartReceiveLoop() => Task.Run(ReceiveLoop);
|
||||
|
||||
/// <summary>Sends a heartbeat ping; a live peer answers with a pong, refreshing activity.</summary>
|
||||
internal void SendPing() => SendControl(WebSocketOpcode.Ping, []);
|
||||
|
||||
public void Send(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (_closed)
|
||||
@@ -184,6 +260,27 @@ public sealed class WebSocketServer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void SendControl(WebSocketOpcode opcode, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var frame = WebSocketProtocol.EncodeFrame(payload, opcode);
|
||||
try
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
_stream.Write(frame, 0, frame.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
|
||||
|
||||
public void Close()
|
||||
@@ -224,31 +321,18 @@ public sealed class WebSocketServer : IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
// Любой кадр (включая pong) — признак жизни: сбрасываем счётчик простоя.
|
||||
Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
|
||||
|
||||
switch (opcode)
|
||||
{
|
||||
case WebSocketOpcode.Ping:
|
||||
lock (_sendLock)
|
||||
{
|
||||
var pong = WebSocketProtocol.EncodeFrame(
|
||||
payload,
|
||||
WebSocketOpcode.Pong
|
||||
);
|
||||
_stream.Write(pong, 0, pong.Length);
|
||||
}
|
||||
|
||||
SendControl(WebSocketOpcode.Pong, payload);
|
||||
continue;
|
||||
case WebSocketOpcode.Pong:
|
||||
continue;
|
||||
case WebSocketOpcode.Close:
|
||||
lock (_sendLock)
|
||||
{
|
||||
var close = WebSocketProtocol.EncodeFrame(
|
||||
[],
|
||||
WebSocketOpcode.Close
|
||||
);
|
||||
_stream.Write(close, 0, close.Length);
|
||||
}
|
||||
|
||||
SendControl(WebSocketOpcode.Close, []);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,6 +342,15 @@ public sealed class WebSocketServer : IDisposable
|
||||
pending.Clear();
|
||||
}
|
||||
|
||||
if (pending.Count + payload.Length > MaxMessageBytes)
|
||||
{
|
||||
Log.Warning(
|
||||
$"WebSocketServer: connection #{Id} exceeded {MaxMessageBytes}-byte "
|
||||
+ "message cap — closing"
|
||||
);
|
||||
return; // finally закроет соединение
|
||||
}
|
||||
|
||||
pending.AddRange(payload);
|
||||
if (fin && pendingOpcode == WebSocketOpcode.Binary)
|
||||
{
|
||||
|
||||
@@ -54,6 +54,20 @@ public class CalendarTests
|
||||
Assert.Equal(14 * 60 + 30, calendar.MinuteOfDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartDay_OffsetsTheTimeOfDay()
|
||||
{
|
||||
var clock = new GameClock();
|
||||
var calendar = new Calendar(clock, secondsPerDay: 10f, startDay: 7.0 / 24); // begin at 07:00
|
||||
|
||||
Assert.Equal(1, calendar.Day);
|
||||
Assert.Equal(7, calendar.Hour);
|
||||
Assert.Equal(0, calendar.Minute);
|
||||
|
||||
clock.Advance(5f); // half a day later → 19:00
|
||||
Assert.Equal(19, calendar.Hour);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pause_DoesNotAdvanceTheCalendar()
|
||||
{
|
||||
|
||||
@@ -81,4 +81,15 @@ public class ClimateTests
|
||||
Assert.Equal(2, climate.Year);
|
||||
Assert.Equal(0, climate.DayOfYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartDayOfYear_ShiftsTheSeasonalPhase()
|
||||
{
|
||||
// Begin the world already at the warmest day (15): the curve peaks at clock 0.
|
||||
var (_, climate) = Make(Seasonal with { StartDayOfYear = 15 });
|
||||
|
||||
Assert.Equal(30f, climate.Temperature, 2); // mean 10 + amplitude 20, at the peak
|
||||
Assert.Equal(15, climate.DayOfYear); // day-of-year reflects the offset
|
||||
Assert.Equal(Season.Summer, climate.Season); // day 15 of a 60-day year = start of summer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,29 @@ public class CameraMathTests
|
||||
AssertVector(new Vector2(0f, 200f), state.ScreenToWorld(Vector2.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldCenter_EqualsUnclampedCameraPosition()
|
||||
{
|
||||
var camera = new Camera(new Vector2(640f, 360f), zoom: 2f);
|
||||
|
||||
var state = CameraMath.Compute(camera, 1280, 720, ViewportMapping.Identity);
|
||||
|
||||
AssertVector(camera.Position, state.WorldCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition()
|
||||
{
|
||||
var bounds = new RectF(0f, 0f, 2000f, 1000f);
|
||||
var camera = new Camera(new Vector2(-500f, 500f), bounds: bounds);
|
||||
|
||||
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
|
||||
|
||||
// Raw position is (-500, 500); only X clamps (to half-width 400 from the left world edge),
|
||||
// Y (500) is already inside [300, 700]. The effective centre the view is built around is (400, 500).
|
||||
AssertVector(new Vector2(400f, 500f), state.WorldCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mapping_CentersVirtualResolutionInWiderWindow()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Lighting;
|
||||
using Xunit;
|
||||
@@ -39,6 +40,25 @@ public class DayNightTests
|
||||
Assert.True(dawn < mid && mid < noon, "daylight should rise from dawn to noon");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunShadow_ZeroAtNight_PointsWestInMorning_EastInAfternoon_ShortAtNoon()
|
||||
{
|
||||
var (clock, dayNight) = Make(); // 1s = 1 in-game hour
|
||||
|
||||
Assert.Equal(Vector2.Zero, dayNight.SunShadow(8f)); // 00:00 — night, no sun
|
||||
|
||||
clock.Advance(7f); // 07:00 — low morning sun in the east
|
||||
var morning = dayNight.SunShadow(8f);
|
||||
Assert.True(morning.X < 0f, "morning shadow points west");
|
||||
Assert.True(morning.Length() > 2f, "low sun casts a long shadow");
|
||||
|
||||
clock.Advance(5f); // 12:00 — sun overhead
|
||||
Assert.True(dayNight.SunShadow(8f).Length() < 1f, "noon sun casts almost no shadow");
|
||||
|
||||
clock.Advance(5f); // 17:00 — afternoon sun in the west
|
||||
Assert.True(dayNight.SunShadow(8f).X > 0f, "afternoon shadow points east");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ambient_DarkerAtNightThanAtNoon()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Lighting;
|
||||
using Xunit;
|
||||
|
||||
@@ -54,6 +55,29 @@ public class LightmapBuilderTests
|
||||
Assert.Equal(0f, light[5], 5); // за препятствием — тень
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunShadow_DarkensCellsInTheShadowDirection_FadingFromTheCaster()
|
||||
{
|
||||
var occ = new bool[7];
|
||||
occ[3] = true; // occluder in the middle
|
||||
var light = new float[7];
|
||||
LightmapBuilder.Build(
|
||||
light,
|
||||
7,
|
||||
1,
|
||||
1f,
|
||||
occ,
|
||||
[],
|
||||
sunShadow: new Vector2(3f, 0f),
|
||||
sunShadowStrength: 0.6f
|
||||
);
|
||||
|
||||
Assert.Equal(1f, light[2], 5); // toward the sun (opposite the shadow) — unshadowed
|
||||
Assert.Equal(LightmapBuilder.OccluderShade, light[3], 5); // occluder cell stays self-shaded
|
||||
Assert.True(light[4] < 1f); // in shadow
|
||||
Assert.True(light[4] < light[6]); // darker near the caster, fading along the shadow
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_StayWithinUnitRange()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using MrGameEng.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace MrGameEng.Net.Tests;
|
||||
|
||||
public class HeartbeatTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HealthyClient_SurvivesPastIdleTimeout()
|
||||
{
|
||||
var port = FreePort();
|
||||
using var server = new WebSocketServer(
|
||||
port,
|
||||
heartbeatInterval: TimeSpan.FromMilliseconds(100),
|
||||
idleTimeout: TimeSpan.FromMilliseconds(400)
|
||||
);
|
||||
server.Start();
|
||||
|
||||
using var client = await WebSocketClient.ConnectAsync(
|
||||
new Uri($"ws://localhost:{port}/"),
|
||||
new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token
|
||||
);
|
||||
var connection = await WaitFor(
|
||||
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||
"server accept"
|
||||
);
|
||||
|
||||
// Дольше idleTimeout: живой клиент авто-отвечает pong на server-ping и остаётся открыт.
|
||||
await Task.Delay(900, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(connection.IsOpen);
|
||||
Assert.True(client.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SilentPeer_IsDroppedAfterIdleTimeout()
|
||||
{
|
||||
var port = FreePort();
|
||||
using var server = new WebSocketServer(
|
||||
port,
|
||||
heartbeatInterval: TimeSpan.FromMilliseconds(100),
|
||||
idleTimeout: TimeSpan.FromMilliseconds(400)
|
||||
);
|
||||
server.Start();
|
||||
|
||||
// Сырой peer: проходит рукопожатие, но дальше молчит и не отвечает на ping.
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken);
|
||||
var request =
|
||||
"GET / HTTP/1.1\r\n"
|
||||
+ "Host: localhost\r\n"
|
||||
+ "Upgrade: websocket\r\n"
|
||||
+ "Connection: Upgrade\r\n"
|
||||
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
+ "Sec-WebSocket-Version: 13\r\n"
|
||||
+ "\r\n";
|
||||
var bytes = Encoding.ASCII.GetBytes(request);
|
||||
await tcp.GetStream().WriteAsync(bytes, TestContext.Current.CancellationToken);
|
||||
|
||||
var connection = await WaitFor(
|
||||
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||
"server accept"
|
||||
);
|
||||
Assert.True(connection.IsOpen);
|
||||
|
||||
// Peer не отвечает pong'ом → активность не обновляется → сервер закрывает по простою.
|
||||
await WaitFor(() => connection.IsOpen ? null : "closed", "idle drop");
|
||||
Assert.False(connection.IsOpen);
|
||||
}
|
||||
|
||||
private static async Task<T> WaitFor<T>(Func<T?> poll, string what)
|
||||
where T : class
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
if (poll() is { } result)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Timed out waiting for {what}.");
|
||||
}
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,8 @@ public class ReplicationTests
|
||||
server.Send([connection]);
|
||||
|
||||
Assert.True(connection.Sent.TryDequeue(out var delta));
|
||||
// Запись: type(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
|
||||
Assert.Equal(22, delta!.Length);
|
||||
// Запись: type(1) + version(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
|
||||
Assert.Equal(23, delta!.Length);
|
||||
|
||||
client.Apply(delta);
|
||||
var replicated = FindByNetId(clientStore, 1);
|
||||
@@ -185,6 +185,81 @@ public class ReplicationTests
|
||||
Assert.Equal(1, spawns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_DeletesEveryReplicatedEntity()
|
||||
{
|
||||
var serverStore = new EntityStore();
|
||||
var clientStore = new EntityStore();
|
||||
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||
var connection = new FakeConnection();
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 1f, Y = 2f }
|
||||
);
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 3f, Y = 4f }
|
||||
);
|
||||
server.Send([connection]);
|
||||
client.Pump(connection);
|
||||
Assert.Equal(2, client.EntityCount);
|
||||
|
||||
client.Clear();
|
||||
|
||||
Assert.Equal(0, client.EntityCount);
|
||||
foreach (var entity in clientStore.Entities)
|
||||
{
|
||||
Assert.False(entity.HasComponent<NetId>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MismatchedProtocolVersion_IsDropped()
|
||||
{
|
||||
var serverStore = new EntityStore();
|
||||
var clientStore = new EntityStore();
|
||||
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||
var connection = new FakeConnection();
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 1f, Y = 2f }
|
||||
);
|
||||
server.Send([connection]);
|
||||
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
||||
|
||||
// Портим байт версии (индекс 1: type=0, version=1) — клиент обязан отбросить снапшот целиком.
|
||||
snapshot![1] = 0xFF;
|
||||
client.Apply(snapshot);
|
||||
|
||||
Assert.Equal(0, client.EntityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncatedSnapshot_IsIgnoredWithoutThrowing()
|
||||
{
|
||||
var serverStore = new EntityStore();
|
||||
var clientStore = new EntityStore();
|
||||
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||
var connection = new FakeConnection();
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 5f, Y = 6f },
|
||||
new TestHealth { Value = 9 }
|
||||
);
|
||||
server.Send([connection]);
|
||||
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
||||
|
||||
// Режем хвост: заголовок и счётчик целы, но данные компонентов оборваны.
|
||||
var truncated = snapshot!.AsSpan(0, snapshot.Length - 6).ToArray();
|
||||
client.Apply(truncated); // не должно бросить
|
||||
|
||||
// Записи могли частично примениться, но клиент остался живым и консистентным.
|
||||
Assert.True(client.EntityCount <= 1);
|
||||
}
|
||||
|
||||
private static Entity FindByNetId(EntityStore store, int netId)
|
||||
{
|
||||
foreach (var entity in store.Entities)
|
||||
|
||||
Reference in New Issue
Block a user