diff --git a/CLAUDE.md b/CLAUDE.md
index 16c545d..63dfac2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,6 +31,9 @@ git submodule update --init # after fresh clone
dotnet build LittleSim.sln
dotnet run --project src/LittleSim -c Release # measure perf in Release only
dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 # headless fast-forward
+dotnet run --project src/LittleSim.Server -- --listen # multiplayer world (WebSocket)
+dotnet run --project src/LittleSim -- --connect # client → ws://localhost:9050
+dotnet run --project src/LittleSim.Server -- --probe # CLI check of a running server
dotnet test LittleSim.sln # runs the engine test suites
```
diff --git a/LittleSim.sln b/LittleSim.sln
index ec7a4a8..53b63be 100644
--- a/LittleSim.sln
+++ b/LittleSim.sln
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "eng
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{BEFB468B-6784-468E-9B60-EB44AB078D15}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "engine\src\MrGameEng.Net\MrGameEng.Net.csproj", "{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "engine\tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -273,6 +277,30 @@ Global
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.Build.0 = Release|Any CPU
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.ActiveCfg = Release|Any CPU
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.Build.0 = Release|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.Build.0 = Debug|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.Build.0 = Debug|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.ActiveCfg = Release|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.Build.0 = Release|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.ActiveCfg = Release|Any CPU
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.Build.0 = Release|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.Build.0 = Debug|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.Build.0 = Debug|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x64.ActiveCfg = Release|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x64.Build.0 = Release|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x86.ActiveCfg = Release|Any CPU
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -298,5 +326,7 @@ Global
{D4222C26-40D8-4465-9B26-CD6BD0722EC0} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
{41F0249B-106E-4D56-B022-73D2C2D74807} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{BEFB468B-6784-468E-9B60-EB44AB078D15} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {61C84FE7-1E0A-4CF2-A63F-39B23F936A7E} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
+ {03EC6C39-E1DE-4C66-835F-4B05B5C40DB0} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
EndGlobalSection
EndGlobal
diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json
index db6ac32..a0f9cc7 100644
--- a/Mods/Core/Languages/en/ui.json
+++ b/Mods/Core/Languages/en/ui.json
@@ -8,7 +8,6 @@
"season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
"hud.paused": "PAUSED",
-
"menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
"menu.newworld": "New World",
@@ -16,7 +15,6 @@
"menu.settings": "Settings",
"menu.credits": "Credits",
"menu.quit": "Quit",
-
"newworld.title": "New World",
"newworld.name": "Name",
"newworld.defaultname": "New World",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Terrain smoothing",
"newworld.create": "Create",
"newworld.back": "Back",
-
"load.title": "Load",
"load.empty": "No saves yet",
"load.load": "Load",
"load.delete": "Delete",
"load.back": "Back",
-
"settings.title": "Settings",
"settings.language": "Language",
"settings.fullscreen": "Fullscreen",
@@ -41,7 +37,6 @@
"settings.volume": "Volume",
"settings.apply": "Apply",
"settings.back": "Back",
-
"pause.title": "Paused",
"pause.resume": "Resume",
"pause.settings": "Settings",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Main menu",
"pause.quit": "Quit",
"pause.saved": "Saved: {0}",
-
"speed.pause": "⏸",
-
"credits.title": "Credits",
"credits.author": "mrleo1nid",
"credits.role": "Concept, code, mrgameeng engine",
"credits.back": "Back",
-
"preset.small": "Small",
"preset.medium": "Medium",
"preset.large": "Large",
-
"terrain.deepwater": "deep water",
"terrain.water": "water",
"terrain.sand": "sand",
"terrain.grass": "grass",
"terrain.forest": "forest",
"terrain.mountain": "mountains",
-
"plant.oak": "oak",
"plant.birch": "birch",
"plant.pine": "pine",
@@ -77,7 +67,6 @@
"plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling",
"plant.stage.mature": "mature",
-
"pawn.being": "being",
"pawn.bear": "bear",
"pawn.deer": "deer",
@@ -86,5 +75,10 @@
"pawn.boar": "boar",
"pawn.wolf": "wolf",
"pawn.muffalo": "muffalo",
- "pawn.squirrel": "squirrel"
+ "pawn.squirrel": "squirrel",
+ "net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
+ "net.connecting": "connecting…",
+ "net.connected": "connected",
+ "net.failed": "connection failed",
+ "net.lost": "connection lost"
}
diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json
index 138d77e..80d0929 100644
--- a/Mods/Core/Languages/ru/ui.json
+++ b/Mods/Core/Languages/ru/ui.json
@@ -8,7 +8,6 @@
"season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
"hud.paused": "ПАУЗА",
-
"menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
"menu.newworld": "Новый мир",
@@ -16,7 +15,6 @@
"menu.settings": "Настройки",
"menu.credits": "Авторы",
"menu.quit": "Выход",
-
"newworld.title": "Новый мир",
"newworld.name": "Название",
"newworld.defaultname": "Новый мир",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Сглаживание рельефа",
"newworld.create": "Создать",
"newworld.back": "Назад",
-
"load.title": "Загрузка",
"load.empty": "Сохранений пока нет",
"load.load": "Загрузить",
"load.delete": "Удалить",
"load.back": "Назад",
-
"settings.title": "Настройки",
"settings.language": "Язык",
"settings.fullscreen": "Полный экран",
@@ -41,7 +37,6 @@
"settings.volume": "Громкость",
"settings.apply": "Применить",
"settings.back": "Назад",
-
"pause.title": "Пауза",
"pause.resume": "Продолжить",
"pause.settings": "Настройки",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Главное меню",
"pause.quit": "Выход",
"pause.saved": "Сохранено: {0}",
-
"speed.pause": "⏸",
-
"credits.title": "Авторы",
"credits.author": "mrleo1nid",
"credits.role": "Идея, код, движок mrgameeng",
"credits.back": "Назад",
-
"preset.small": "Маленький",
"preset.medium": "Средний",
"preset.large": "Большой",
-
"terrain.deepwater": "глубокая вода",
"terrain.water": "вода",
"terrain.sand": "песок",
"terrain.grass": "трава",
"terrain.forest": "лес",
"terrain.mountain": "горы",
-
"plant.oak": "дуб",
"plant.birch": "берёза",
"plant.pine": "сосна",
@@ -77,7 +67,6 @@
"plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое",
-
"pawn.being": "житель",
"pawn.bear": "медведь",
"pawn.deer": "олень",
@@ -86,5 +75,10 @@
"pawn.boar": "кабан",
"pawn.wolf": "волк",
"pawn.muffalo": "муффало",
- "pawn.squirrel": "белка"
+ "pawn.squirrel": "белка",
+ "net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
+ "net.connecting": "подключение…",
+ "net.connected": "подключено",
+ "net.failed": "не удалось подключиться",
+ "net.lost": "соединение потеряно"
}
diff --git a/engine b/engine
index 2ac0740..3438ed7 160000
--- a/engine
+++ b/engine
@@ -1 +1 @@
-Subproject commit 2ac074004a18d9814c565440b3d0ecc87337458e
+Subproject commit 3438ed77f66d0a88d5dda640bfbd7a60b4a2de2f
diff --git a/spikes/KniWeb/Directory.Build.props b/spikes/KniWeb/Directory.Build.props
index 424c669..1bb35f0 100644
--- a/spikes/KniWeb/Directory.Build.props
+++ b/spikes/KniWeb/Directory.Build.props
@@ -1,4 +1 @@
-
-
-
-
+
diff --git a/spikes/KniWeb/KniWebSpike.csproj b/spikes/KniWeb/KniWebSpike.csproj
index 01a5e84..5653f3d 100644
--- a/spikes/KniWeb/KniWebSpike.csproj
+++ b/spikes/KniWeb/KniWebSpike.csproj
@@ -1,5 +1,4 @@
-
false
net8.0
@@ -8,19 +7,17 @@
KniWebSpike
KniWebSpike
$(DefineConstants);BLAZORGL
- BlazorGL
+ BlazorGL
-
+
false
-
-
+
-
-
+
@@ -51,11 +48,14 @@
-
+
-
diff --git a/spikes/KniWeb/Pages/Index.razor.cs b/spikes/KniWeb/Pages/Index.razor.cs
index ab8508c..058cc90 100644
--- a/spikes/KniWeb/Pages/Index.razor.cs
+++ b/spikes/KniWeb/Pages/Index.razor.cs
@@ -31,6 +31,5 @@ namespace KniWebSpike.Pages
// run gameloop
_game.Tick();
}
-
}
}
diff --git a/spikes/KniWeb/Program.cs b/spikes/KniWeb/Program.cs
index 91addfc..946f926 100644
--- a/spikes/KniWeb/Program.cs
+++ b/spikes/KniWeb/Program.cs
@@ -16,7 +16,7 @@ namespace KniWebSpike
builder.RootComponents.Add("head::after");
builder.Services.AddScoped(sp => new HttpClient()
{
- BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
+ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
});
await builder.Build().RunAsync();
}
diff --git a/spikes/KniWeb/SpikeScene.cs b/spikes/KniWeb/SpikeScene.cs
index 9945ced..4f5caac 100644
--- a/spikes/KniWeb/SpikeScene.cs
+++ b/spikes/KniWeb/SpikeScene.cs
@@ -39,7 +39,12 @@ namespace KniWebSpike
private readonly float _width;
private readonly float _height;
- public SpikeScene(Func spriteBatch, Func pixel, float width, float height)
+ public SpikeScene(
+ Func spriteBatch,
+ Func pixel,
+ float width,
+ float height
+ )
{
_spriteBatch = spriteBatch;
_pixel = pixel;
@@ -60,11 +65,7 @@ namespace KniWebSpike
X = (float)random.NextDouble() * _width,
Y = (float)random.NextDouble() * _height,
},
- new DotVelocity
- {
- X = MathF.Cos(angle) * speed,
- Y = MathF.Sin(angle) * speed,
- },
+ new DotVelocity { X = MathF.Cos(angle) * speed, Y = MathF.Sin(angle) * speed },
new DotTint
{
R = (byte)random.Next(64, 256),
diff --git a/src/LittleSim.Server/HeadlessWorldScene.cs b/src/LittleSim.Server/HeadlessWorldScene.cs
index de0c57b..62be34f 100644
--- a/src/LittleSim.Server/HeadlessWorldScene.cs
+++ b/src/LittleSim.Server/HeadlessWorldScene.cs
@@ -1,27 +1,107 @@
+using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
+using LittleSim.Net;
using LittleSim.Scenes;
+using LittleSim.Sim;
+using Microsoft.Xna.Framework;
using MrGameEng.Core;
+using MrGameEng.Graphics;
+using MrGameEng.Net;
namespace LittleSim.Server;
///
-/// Серверная сцена-зародыш: календарь и климат мира идут на фиксированном тике
-/// , без окна, GPU и текстур. Демонстрирует headless-режим
-/// движка; дальше сюда переедет полная симуляция мира (растения, жители) и сетевой слой.
+/// Серверный мир: жители бродят, устают и отдыхают (те же системы, что в WorldScene) на
+/// фиксированном тике — без окна, GPU и спрайтов. С
+/// мир ещё и реплицируется подключённым клиентам по схеме
+/// (10 снапшотов в секунду, дельты). Демонстрация MrGameEng.Net.
///
internal sealed class HeadlessWorldScene : Scene
{
- private readonly GameContent _content;
+ /// Жителей в серверном мире.
+ public const int PawnCount = 24;
- public HeadlessWorldScene(GameContent content) => _content = content;
+ /// Сид мира — рельефа пока нет, но блуждание детерминировано им.
+ public const int Seed = 424242;
+
+ private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
+
+ private readonly GameContent _content;
+ private readonly WebSocketServer? _server;
+
+ /// Мир без сети (fast-forward) или, с , онлайн-мир.
+ public HeadlessWorldScene(GameContent content, WebSocketServer? server = null)
+ {
+ _content = content;
+ _server = server;
+ }
protected override void OnLoad()
{
Context.Services.Add(_content);
var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
+
+ var replication = _server is null ? null : new ReplicationServer(NetSchema.Create(), Store);
+ var random = new Random(Seed);
+ for (var i = 0; i < PawnCount; i++)
+ {
+ var position = new Vector2(
+ random.NextSingle() * Bounds.Width,
+ random.NextSingle() * Bounds.Height
+ );
+ var transform = new Transform2D(position, scale: new Vector2(12f));
+ var needs = new PawnNeeds { Energy = 0.4f + random.NextSingle() * 0.6f };
+ if (replication is null)
+ {
+ Store.CreateEntity(transform, new Wander(), new PawnBrain(), needs);
+ }
+ else
+ {
+ Store.CreateEntity(
+ transform,
+ new Wander(),
+ new PawnBrain(),
+ needs,
+ new NetId { Value = replication.NextNetId() }
+ );
+ }
+ }
+
+ UpdateSystems.Add(new PawnDecisionSystem());
+ UpdateSystems.Add(new PawnNeedsSystem());
+ UpdateSystems.Add(new WanderSystem(Seed, Bounds));
UpdateSystems.Add(new DayReportSystem(calendar, climate));
+ if (_server is not null && replication is not null)
+ {
+ UpdateSystems.Add(new NetworkSystem(_server, replication));
+ }
+ }
+
+ /// Принимает новые соединения и шлёт дельта-снапшоты с сетевой частотой.
+ private sealed class NetworkSystem(WebSocketServer server, ReplicationServer replication)
+ : BaseSystem
+ {
+ // 60 тиков симуляции / 6 = 10 снапшотов в секунду.
+ private const int SendEveryTicks = 6;
+
+ private int _ticks;
+
+ protected override void OnUpdateGroup()
+ {
+ while (server.TryAcceptConnection(out var connection))
+ {
+ Log.Info(
+ $"Клиент #{connection.Id} подключился ({server.Connections.Count} онлайн)"
+ );
+ }
+
+ if (++_ticks % SendEveryTicks == 0)
+ {
+ replication.Send(server.Connections);
+ }
+ }
}
/// Пишет строку состояния мира в лог на рассвете каждого игрового дня.
diff --git a/src/LittleSim.Server/Program.cs b/src/LittleSim.Server/Program.cs
index 4074ce0..aebd7ee 100644
--- a/src/LittleSim.Server/Program.cs
+++ b/src/LittleSim.Server/Program.cs
@@ -1,19 +1,31 @@
using System.Diagnostics;
+using Friflo.Engine.ECS;
using LittleSim.Content;
+using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Server;
using MrGameEng.Core;
+using MrGameEng.Graphics;
+using MrGameEng.Net;
-// Дедикейтед-сервер LittleSim (прототип): мир без окна и GPU на HeadlessHost движка.
-// Пока умеет загрузить моды/дефы (без атласов) и прогнать календарь и климат на
-// фиксированном тике. Использование:
-// dotnet run --project src/LittleSim.Server [-- --days N] [--tps N]
+// Дедикейтед-сервер LittleSim: мир без окна и GPU на HeadlessHost движка.
+// dotnet run --project src/LittleSim.Server — fast-forward N дней
+// dotnet run --project src/LittleSim.Server -- --days 30 — то же, явное число дней
+// dotnet run --project src/LittleSim.Server -- --listen [--port N] — онлайн-мир (WebSocket)
+// dotnet run --project src/LittleSim.Server -- --probe [--port N] — проверка: подключиться
+// к серверу и показать реплицированных жителей
var days = ReadOption("--days", 10);
var ticksPerSecond = ReadOption("--tps", 60);
+var port = ReadOption("--port", NetSchema.DefaultPort);
Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
+if (Array.IndexOf(args, "--probe") >= 0)
+{
+ return await Probe(port);
+}
+
var content = GameContent.Load(buildAtlases: false);
Log.Info(
$"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
@@ -22,18 +34,113 @@ Log.Info(
+ $"{content.Defs.All().Count} жителей"
);
-using var host = new HeadlessHost(
- new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
- new HeadlessWorldScene(content)
-);
+if (Array.IndexOf(args, "--listen") >= 0)
+{
+ using var socketServer = new WebSocketServer(port);
+ socketServer.Start();
+ using var host = new HeadlessHost(
+ new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = true },
+ new HeadlessWorldScene(content, socketServer)
+ );
+ using var cancel = new CancellationTokenSource();
+ Console.CancelKeyPress += (_, eventArgs) =>
+ {
+ eventArgs.Cancel = true;
+ cancel.Cancel();
+ };
+ Log.Info(
+ $"Мир онлайн: ws://localhost:{port}, {HeadlessWorldScene.PawnCount} жителей, "
+ + $"{ticksPerSecond} тиков/с. Клиент: dotnet run --project src/LittleSim -- --connect. "
+ + "Ctrl+C — остановка."
+ );
+ host.Run(cancel.Token);
+ Log.Info($"Сервер остановлен на тике {host.TickCount}.");
+ return 0;
+}
-var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
-var stopwatch = Stopwatch.StartNew();
-host.RunTicks(ticks);
-Log.Info(
- $"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
- + $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
-);
+using (
+ var host = new HeadlessHost(
+ new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
+ new HeadlessWorldScene(content)
+ )
+)
+{
+ var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
+ var stopwatch = Stopwatch.StartNew();
+ host.RunTicks(ticks);
+ Log.Info(
+ $"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
+ + $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
+ );
+}
+
+return 0;
+
+// Подключается к работающему серверу, секунду слушает снапшоты и показывает жителей —
+// консольная проверка репликации без игрового клиента.
+async Task Probe(int probePort)
+{
+ var store = new EntityStore();
+ var replication = new ReplicationClient(NetSchema.Create(), store);
+ var uri = new Uri($"ws://localhost:{probePort}/");
+ Log.Info($"Подключение к {uri}…");
+ WebSocketClient connection;
+ try
+ {
+ connection = await WebSocketClient.ConnectAsync(
+ uri,
+ new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token
+ );
+ }
+ catch (Exception exception)
+ {
+ Log.Error($"Не подключилось: {exception.GetBaseException().Message}");
+ return 1;
+ }
+
+ using (connection)
+ {
+ await Task.Delay(1000);
+ replication.Pump(connection);
+ var first = SamplePositions(store);
+ Log.Info($"Снапшот получен: {replication.EntityCount} жителей");
+
+ await Task.Delay(2000);
+ replication.Pump(connection);
+ var second = SamplePositions(store);
+ for (var i = 0; i < first.Count; i++)
+ {
+ Log.Info(
+ $" житель {first[i].NetId}: ({first[i].X:F0}, {first[i].Y:F0}) → "
+ + $"({second[i].X:F0}, {second[i].Y:F0})"
+ );
+ }
+
+ var moved = first.Where((p, i) => p.X != second[i].X || p.Y != second[i].Y).Count();
+ Log.Info($"Двигались {moved} из {first.Count} показанных — мир жив.");
+ return replication.EntityCount > 0 && moved > 0 ? 0 : 1;
+ }
+}
+
+static List<(int NetId, float X, float Y)> SamplePositions(EntityStore store)
+{
+ var result = new List<(int, float, float)>();
+ foreach (var entity in store.Entities)
+ {
+ if (result.Count == 5)
+ {
+ break;
+ }
+
+ if (entity.HasComponent() && entity.HasComponent())
+ {
+ var position = entity.GetComponent().Position;
+ result.Add((entity.GetComponent().Value, position.X, position.Y));
+ }
+ }
+
+ return result;
+}
int ReadOption(string name, int fallback)
{
diff --git a/src/LittleSim/LittleSim.csproj b/src/LittleSim/LittleSim.csproj
index 0ad0e36..fa52a67 100644
--- a/src/LittleSim/LittleSim.csproj
+++ b/src/LittleSim/LittleSim.csproj
@@ -11,6 +11,7 @@
+
+/// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов.
+/// Сервер (LittleSim.Server) и клиент () обязаны
+/// строить схему одинаково — порядок регистрации определяет wire-id компонентов.
+///
+public static class NetSchema
+{
+ /// Порт сервера по умолчанию.
+ public const int DefaultPort = 9050;
+
+ /// Схема репликации: позиция/масштаб жителя и его потребности.
+ public static ReplicationSchema Create() =>
+ new ReplicationSchema().Register().Register();
+}
diff --git a/src/LittleSim/Program.cs b/src/LittleSim/Program.cs
index 5de48b0..f6b7ae6 100644
--- a/src/LittleSim/Program.cs
+++ b/src/LittleSim/Program.cs
@@ -1,7 +1,20 @@
+using LittleSim.Net;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Host;
+// --connect [ws://host:port] — после загрузки контента сразу в сетевую сцену
+// (см. MultiplayerScene); без аргументов — обычный запуск в главное меню.
+string? connect = null;
+var connectIndex = Array.IndexOf(args, "--connect");
+if (connectIndex >= 0)
+{
+ connect =
+ connectIndex + 1 < args.Length && !args[connectIndex + 1].StartsWith("--")
+ ? args[connectIndex + 1]
+ : $"ws://localhost:{NetSchema.DefaultPort}/";
+}
+
// Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost(
new GameHostOptions
@@ -11,7 +24,7 @@ using var host = new GameHost(
Height = 720,
ClearColor = new Color(12, 16, 24),
},
- new BootScene()
+ new BootScene(connect)
);
host.Run();
diff --git a/src/LittleSim/Scenes/BootScene.cs b/src/LittleSim/Scenes/BootScene.cs
index 6431e3e..5a37af5 100644
--- a/src/LittleSim/Scenes/BootScene.cs
+++ b/src/LittleSim/Scenes/BootScene.cs
@@ -19,10 +19,18 @@ namespace LittleSim.Scenes;
public sealed class BootScene : Scene
{
private readonly string[] _steps = { "Загрузка", "Loading" };
+ private readonly string? _connectTo;
private Task? _load;
private GameSettings _settings = new();
private Label _label = null!;
+ /// Обычный запуск — в главное меню.
+ public BootScene()
+ : this(null) { }
+
+ /// С (ws://host:port) грузится сразу в сетевую сцену.
+ public BootScene(string? connectTo) => _connectTo = connectTo;
+
protected override void OnLoad()
{
Context.UseAudio();
@@ -74,6 +82,9 @@ public sealed class BootScene : Scene
Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language);
- Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.6f));
+ Scene next = _connectTo is null
+ ? new MainMenuScene()
+ : new MultiplayerScene(new Uri(_connectTo));
+ Context.Scenes.Switch(next, Transitions.Fade(0.6f));
}
}
diff --git a/src/LittleSim/Scenes/MultiplayerScene.cs b/src/LittleSim/Scenes/MultiplayerScene.cs
new file mode 100644
index 0000000..b7a11f6
--- /dev/null
+++ b/src/LittleSim/Scenes/MultiplayerScene.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Threading.Tasks;
+using Friflo.Engine.ECS;
+using LittleSim.Content;
+using LittleSim.Net;
+using LittleSim.Sim;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Assets;
+using MrGameEng.Core;
+using MrGameEng.DevConsole;
+using MrGameEng.Graphics;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.Net;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Сетевой клиент: подключается к дедикейтед-серверу (LittleSim.Server --listen) и рендерит
+/// реплицированных жителей. Симуляция целиком на сервере; сюда приезжают только компоненты
+/// из , а спрайты вешаются локально при спавне — граница
+/// sim/presentation проходит теперь через сеть. Esc — назад в меню, команда консоли `net` —
+/// состояние соединения.
+///
+public sealed class MultiplayerScene : Scene
+{
+ private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
+
+ private readonly Uri _server;
+ private Task? _connecting;
+ private WebSocketClient? _connection;
+ private ReplicationClient _replication = null!;
+ private InputManager _input = null!;
+ private GameContent _content = null!;
+ private Label _hud = null!;
+ private string _statusKey = "net.connecting";
+
+ /// Сцена, подключающаяся к серверу (ws:// или wss://).
+ public MultiplayerScene(Uri server) => _server = server;
+
+ protected override void OnLoad()
+ {
+ _content = Context.Services.Get();
+ var assets = Context.Services.GetOrDefault() ?? Context.UseAssets();
+ _input = this.UseInput();
+ var renderer = this.UseRenderer2D(
+ new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
+ );
+ GameLayers.EnsureRegistered(renderer);
+ var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
+ Store.CreateEntity(new Camera(Bounds.Center, zoom: 1f, bounds: Bounds));
+
+ // Реплика пишет в ECS сцены; презентацию (спрайт) вешаем сами при спавне.
+ _replication = new ReplicationClient(NetSchema.Create(), Store);
+ _replication.EntitySpawned += entity =>
+ {
+ var sprite = new Sprite(white, GameLayers.Beings);
+ sprite.CenterOrigin();
+ entity.AddComponent(sprite);
+ };
+
+ var desktop = this.UseUI();
+ _hud = new Label { Left = 10, Top = 8 };
+ desktop.Root = Ui.Screen(_hud);
+
+ UpdateSystems.Add(new CallbackSystem(Pump));
+ // Усталость жителей видна и по сети: PawnNeeds реплицируется, спрайт темнеет локально.
+ UpdateSystems.Add(new PawnAppearanceSystem());
+
+ var console = this.UseDevConsole();
+ console.Register(
+ "net",
+ "net — connection status and replicated entity count",
+ (c, _) =>
+ {
+ c.WriteLine($"server: {_server}");
+ c.WriteLine($"state: {(_connection?.IsOpen == true ? "connected" : "offline")}");
+ c.WriteLine($"entities: {_replication.EntityCount}");
+ }
+ );
+
+ _connecting = WebSocketClient.ConnectAsync(_server);
+ }
+
+ protected override void OnUnload() => _connection?.Close();
+
+ private void Pump()
+ {
+ if (_connecting is { IsCompleted: true } finished)
+ {
+ _connecting = null;
+ if (finished.IsFaulted)
+ {
+ _statusKey = "net.failed";
+ Log.Error(
+ $"Connect to {_server} failed: "
+ + finished.Exception?.GetBaseException().Message
+ );
+ }
+ else
+ {
+ _connection = finished.Result;
+ _statusKey = "net.connected";
+ Log.Info($"Connected to {_server}");
+ }
+ }
+
+ if (_connection is not null)
+ {
+ _replication.Pump(_connection);
+ if (!_connection.IsOpen)
+ {
+ _statusKey = "net.lost";
+ }
+ }
+
+ _hud.Text = _content.Languages.Format(
+ "net.hud",
+ _content.Languages.Get(_statusKey),
+ _replication.EntityCount
+ );
+
+ if (_input.IsKeyPressed(Keys.Escape))
+ {
+ _connection?.Close();
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}