Multiplayer: networked dedicated server + desktop client over MrGameEng.Net

Engine bump brings MrGameEng.Net (WebSocket transport, RFC 6455 server,
delta component replication) — this commit is its showcase.

LittleSim.Server --listen runs the world online: 24 pawns wander, tire
and rest on the engine's fixed-tick HeadlessHost (the same Wander/
Needs/Decision systems the windowed world uses), a WebSocketServer
accepts clients and a ReplicationServer ships Transform2D + PawnNeeds
deltas at 10 snapshots/s per the shared NetSchema. --probe is the CLI
check: it connects, listens for a second and prints replicated pawns
with positions sampled twice to show the world is alive.

The game gains MultiplayerScene (dotnet run --project src/LittleSim --
--connect [ws://host:port]): simulation stays on the server, the client
applies snapshots into its scene store, decorates spawned entities with
sprites and reuses PawnAppearanceSystem so replicated fatigue darkens
pawns locally. HUD strings go through ru/en localization; the `net`
console command reports connection state and entity count. Esc returns
to the main menu.

Verified end to end: probe sees 24 pawns moving between samples; the
windowed client connects and runs against a live local server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 23:45:35 +03:00
co-authored by Claude Fable 5
parent ce8f8ba2ed
commit e8273f9ffa
17 changed files with 451 additions and 68 deletions
+3
View File
@@ -31,6 +31,9 @@ git submodule update --init # after fresh clone
dotnet build LittleSim.sln dotnet build LittleSim.sln
dotnet run --project src/LittleSim -c Release # measure perf in Release only 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 -- --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 dotnet test LittleSim.sln # runs the engine test suites
``` ```
+30
View File
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "eng
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{BEFB468B-6784-468E-9B60-EB44AB078D15}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{BEFB468B-6784-468E-9B60-EB44AB078D15}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -298,5 +326,7 @@ Global
{D4222C26-40D8-4465-9B26-CD6BD0722EC0} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519} {D4222C26-40D8-4465-9B26-CD6BD0722EC0} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
{41F0249B-106E-4D56-B022-73D2C2D74807} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5} {41F0249B-106E-4D56-B022-73D2C2D74807} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{BEFB468B-6784-468E-9B60-EB44AB078D15} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {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 EndGlobalSection
EndGlobal EndGlobal
+6 -12
View File
@@ -8,7 +8,6 @@
"season.winter": "winter", "season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector", "hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
"hud.paused": "PAUSED", "hud.paused": "PAUSED",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation", "menu.subtitle": "a god-game: minimal graphics, deep simulation",
"menu.newworld": "New World", "menu.newworld": "New World",
@@ -16,7 +15,6 @@
"menu.settings": "Settings", "menu.settings": "Settings",
"menu.credits": "Credits", "menu.credits": "Credits",
"menu.quit": "Quit", "menu.quit": "Quit",
"newworld.title": "New World", "newworld.title": "New World",
"newworld.name": "Name", "newworld.name": "Name",
"newworld.defaultname": "New World", "newworld.defaultname": "New World",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Terrain smoothing", "newworld.smoothing": "Terrain smoothing",
"newworld.create": "Create", "newworld.create": "Create",
"newworld.back": "Back", "newworld.back": "Back",
"load.title": "Load", "load.title": "Load",
"load.empty": "No saves yet", "load.empty": "No saves yet",
"load.load": "Load", "load.load": "Load",
"load.delete": "Delete", "load.delete": "Delete",
"load.back": "Back", "load.back": "Back",
"settings.title": "Settings", "settings.title": "Settings",
"settings.language": "Language", "settings.language": "Language",
"settings.fullscreen": "Fullscreen", "settings.fullscreen": "Fullscreen",
@@ -41,7 +37,6 @@
"settings.volume": "Volume", "settings.volume": "Volume",
"settings.apply": "Apply", "settings.apply": "Apply",
"settings.back": "Back", "settings.back": "Back",
"pause.title": "Paused", "pause.title": "Paused",
"pause.resume": "Resume", "pause.resume": "Resume",
"pause.settings": "Settings", "pause.settings": "Settings",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Main menu", "pause.mainmenu": "Main menu",
"pause.quit": "Quit", "pause.quit": "Quit",
"pause.saved": "Saved: {0}", "pause.saved": "Saved: {0}",
"speed.pause": "⏸", "speed.pause": "⏸",
"credits.title": "Credits", "credits.title": "Credits",
"credits.author": "mrleo1nid", "credits.author": "mrleo1nid",
"credits.role": "Concept, code, mrgameeng engine", "credits.role": "Concept, code, mrgameeng engine",
"credits.back": "Back", "credits.back": "Back",
"preset.small": "Small", "preset.small": "Small",
"preset.medium": "Medium", "preset.medium": "Medium",
"preset.large": "Large", "preset.large": "Large",
"terrain.deepwater": "deep water", "terrain.deepwater": "deep water",
"terrain.water": "water", "terrain.water": "water",
"terrain.sand": "sand", "terrain.sand": "sand",
"terrain.grass": "grass", "terrain.grass": "grass",
"terrain.forest": "forest", "terrain.forest": "forest",
"terrain.mountain": "mountains", "terrain.mountain": "mountains",
"plant.oak": "oak", "plant.oak": "oak",
"plant.birch": "birch", "plant.birch": "birch",
"plant.pine": "pine", "plant.pine": "pine",
@@ -77,7 +67,6 @@
"plant.stage.sprout": "sprout", "plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling", "plant.stage.sapling": "sapling",
"plant.stage.mature": "mature", "plant.stage.mature": "mature",
"pawn.being": "being", "pawn.being": "being",
"pawn.bear": "bear", "pawn.bear": "bear",
"pawn.deer": "deer", "pawn.deer": "deer",
@@ -86,5 +75,10 @@
"pawn.boar": "boar", "pawn.boar": "boar",
"pawn.wolf": "wolf", "pawn.wolf": "wolf",
"pawn.muffalo": "muffalo", "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"
} }
+6 -12
View File
@@ -8,7 +8,6 @@
"season.winter": "зима", "season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор", "hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции", "menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
"menu.newworld": "Новый мир", "menu.newworld": "Новый мир",
@@ -16,7 +15,6 @@
"menu.settings": "Настройки", "menu.settings": "Настройки",
"menu.credits": "Авторы", "menu.credits": "Авторы",
"menu.quit": "Выход", "menu.quit": "Выход",
"newworld.title": "Новый мир", "newworld.title": "Новый мир",
"newworld.name": "Название", "newworld.name": "Название",
"newworld.defaultname": "Новый мир", "newworld.defaultname": "Новый мир",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Сглаживание рельефа", "newworld.smoothing": "Сглаживание рельефа",
"newworld.create": "Создать", "newworld.create": "Создать",
"newworld.back": "Назад", "newworld.back": "Назад",
"load.title": "Загрузка", "load.title": "Загрузка",
"load.empty": "Сохранений пока нет", "load.empty": "Сохранений пока нет",
"load.load": "Загрузить", "load.load": "Загрузить",
"load.delete": "Удалить", "load.delete": "Удалить",
"load.back": "Назад", "load.back": "Назад",
"settings.title": "Настройки", "settings.title": "Настройки",
"settings.language": "Язык", "settings.language": "Язык",
"settings.fullscreen": "Полный экран", "settings.fullscreen": "Полный экран",
@@ -41,7 +37,6 @@
"settings.volume": "Громкость", "settings.volume": "Громкость",
"settings.apply": "Применить", "settings.apply": "Применить",
"settings.back": "Назад", "settings.back": "Назад",
"pause.title": "Пауза", "pause.title": "Пауза",
"pause.resume": "Продолжить", "pause.resume": "Продолжить",
"pause.settings": "Настройки", "pause.settings": "Настройки",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Главное меню", "pause.mainmenu": "Главное меню",
"pause.quit": "Выход", "pause.quit": "Выход",
"pause.saved": "Сохранено: {0}", "pause.saved": "Сохранено: {0}",
"speed.pause": "⏸", "speed.pause": "⏸",
"credits.title": "Авторы", "credits.title": "Авторы",
"credits.author": "mrleo1nid", "credits.author": "mrleo1nid",
"credits.role": "Идея, код, движок mrgameeng", "credits.role": "Идея, код, движок mrgameeng",
"credits.back": "Назад", "credits.back": "Назад",
"preset.small": "Маленький", "preset.small": "Маленький",
"preset.medium": "Средний", "preset.medium": "Средний",
"preset.large": "Большой", "preset.large": "Большой",
"terrain.deepwater": "глубокая вода", "terrain.deepwater": "глубокая вода",
"terrain.water": "вода", "terrain.water": "вода",
"terrain.sand": "песок", "terrain.sand": "песок",
"terrain.grass": "трава", "terrain.grass": "трава",
"terrain.forest": "лес", "terrain.forest": "лес",
"terrain.mountain": "горы", "terrain.mountain": "горы",
"plant.oak": "дуб", "plant.oak": "дуб",
"plant.birch": "берёза", "plant.birch": "берёза",
"plant.pine": "сосна", "plant.pine": "сосна",
@@ -77,7 +67,6 @@
"plant.stage.sprout": "всходы", "plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец", "plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое", "plant.stage.mature": "взрослое",
"pawn.being": "житель", "pawn.being": "житель",
"pawn.bear": "медведь", "pawn.bear": "медведь",
"pawn.deer": "олень", "pawn.deer": "олень",
@@ -86,5 +75,10 @@
"pawn.boar": "кабан", "pawn.boar": "кабан",
"pawn.wolf": "волк", "pawn.wolf": "волк",
"pawn.muffalo": "муффало", "pawn.muffalo": "муффало",
"pawn.squirrel": "белка" "pawn.squirrel": "белка",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…",
"net.connected": "подключено",
"net.failed": "не удалось подключиться",
"net.lost": "соединение потеряно"
} }
+1 -1
Submodule engine updated: 2ac074004a...3438ed77f6
+1 -4
View File
@@ -1,4 +1 @@
<Project> <Project></Project>
</Project>
+7 -7
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup> <PropertyGroup>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems> <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
@@ -16,11 +15,9 @@
<!--<InvariantGlobalization>true</InvariantGlobalization>--> <!--<InvariantGlobalization>true</InvariantGlobalization>-->
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "></PropertyGroup>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "></PropertyGroup>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Pages\Index.razor.cs" /> <Compile Include="Pages\Index.razor.cs" />
@@ -51,11 +48,14 @@
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' "> <ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.17" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.17" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.17" PrivateAssets="all" /> <PackageReference
Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer"
Version="8.0.17"
PrivateAssets="all"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<KniContentReference Include="Content\KniWebSpikeContent.mgcb" /> <KniContentReference Include="Content\KniWebSpikeContent.mgcb" />
</ItemGroup> </ItemGroup>
</Project> </Project>
-1
View File
@@ -31,6 +31,5 @@ namespace KniWebSpike.Pages
// run gameloop // run gameloop
_game.Tick(); _game.Tick();
} }
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@ namespace KniWebSpike
builder.RootComponents.Add<HeadOutlet>("head::after"); builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient() builder.Services.AddScoped(sp => new HttpClient()
{ {
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
}); });
await builder.Build().RunAsync(); await builder.Build().RunAsync();
} }
+7 -6
View File
@@ -39,7 +39,12 @@ namespace KniWebSpike
private readonly float _width; private readonly float _width;
private readonly float _height; private readonly float _height;
public SpikeScene(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel, float width, float height) public SpikeScene(
Func<SpriteBatch> spriteBatch,
Func<Texture2D> pixel,
float width,
float height
)
{ {
_spriteBatch = spriteBatch; _spriteBatch = spriteBatch;
_pixel = pixel; _pixel = pixel;
@@ -60,11 +65,7 @@ namespace KniWebSpike
X = (float)random.NextDouble() * _width, X = (float)random.NextDouble() * _width,
Y = (float)random.NextDouble() * _height, Y = (float)random.NextDouble() * _height,
}, },
new DotVelocity new DotVelocity { X = MathF.Cos(angle) * speed, Y = MathF.Sin(angle) * speed },
{
X = MathF.Cos(angle) * speed,
Y = MathF.Sin(angle) * speed,
},
new DotTint new DotTint
{ {
R = (byte)random.Next(64, 256), R = (byte)random.Next(64, 256),
+85 -5
View File
@@ -1,27 +1,107 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems; using Friflo.Engine.ECS.Systems;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes; using LittleSim.Scenes;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Server; namespace LittleSim.Server;
/// <summary> /// <summary>
/// Серверная сцена-зародыш: календарь и климат мира идут на фиксированном тике /// Серверный мир: жители бродят, устают и отдыхают (те же системы, что в WorldScene) на
/// <see cref="HeadlessHost"/>, без окна, GPU и текстур. Демонстрирует headless-режим /// фиксированном тике <see cref="HeadlessHost"/> без окна, GPU и спрайтов. С
/// движка; дальше сюда переедет полная симуляция мира (растения, жители) и сетевой слой. /// <see cref="WebSocketServer"/> мир ещё и реплицируется подключённым клиентам по схеме
/// <see cref="NetSchema"/> (10 снапшотов в секунду, дельты). Демонстрация MrGameEng.Net.
/// </summary> /// </summary>
internal sealed class HeadlessWorldScene : Scene internal sealed class HeadlessWorldScene : Scene
{ {
private readonly GameContent _content; /// <summary>Жителей в серверном мире.</summary>
public const int PawnCount = 24;
public HeadlessWorldScene(GameContent content) => _content = content; /// <summary>Сид мира — рельефа пока нет, но блуждание детерминировано им.</summary>
public const int Seed = 424242;
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly GameContent _content;
private readonly WebSocketServer? _server;
/// <summary>Мир без сети (fast-forward) или, с <paramref name="server"/>, онлайн-мир.</summary>
public HeadlessWorldScene(GameContent content, WebSocketServer? server = null)
{
_content = content;
_server = server;
}
protected override void OnLoad() protected override void OnLoad()
{ {
Context.Services.Add(_content); Context.Services.Add(_content);
var calendar = Context.UseCalendar(WorldScene.SecondsPerDay); var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default); 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)); UpdateSystems.Add(new DayReportSystem(calendar, climate));
if (_server is not null && replication is not null)
{
UpdateSystems.Add(new NetworkSystem(_server, replication));
}
}
/// <summary>Принимает новые соединения и шлёт дельта-снапшоты с сетевой частотой.</summary>
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);
}
}
} }
/// <summary>Пишет строку состояния мира в лог на рассвете каждого игрового дня.</summary> /// <summary>Пишет строку состояния мира в лог на рассвете каждого игрового дня.</summary>
+113 -6
View File
@@ -1,19 +1,31 @@
using System.Diagnostics; using System.Diagnostics;
using Friflo.Engine.ECS;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes; using LittleSim.Scenes;
using LittleSim.Server; using LittleSim.Server;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
// Дедикейтед-сервер LittleSim (прототип): мир без окна и GPU на HeadlessHost движка. // Дедикейтед-сервер 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 [-- --days N] [--tps N] // 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 days = ReadOption("--days", 10);
var ticksPerSecond = ReadOption("--tps", 60); var ticksPerSecond = ReadOption("--tps", 60);
var port = ReadOption("--port", NetSchema.DefaultPort);
Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}"); Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
if (Array.IndexOf(args, "--probe") >= 0)
{
return await Probe(port);
}
var content = GameContent.Load(buildAtlases: false); var content = GameContent.Load(buildAtlases: false);
Log.Info( Log.Info(
$"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | " $"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
@@ -22,11 +34,37 @@ Log.Info(
+ $"{content.Defs.All<PawnDef>().Count} жителей" + $"{content.Defs.All<PawnDef>().Count} жителей"
); );
if (Array.IndexOf(args, "--listen") >= 0)
{
using var socketServer = new WebSocketServer(port);
socketServer.Start();
using var host = new HeadlessHost( 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;
}
using (
var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false }, new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
new HeadlessWorldScene(content) new HeadlessWorldScene(content)
); )
)
{
var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond); var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
host.RunTicks(ticks); host.RunTicks(ticks);
@@ -34,6 +72,75 @@ Log.Info(
$"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени " $"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
+ $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)" + $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
); );
}
return 0;
// Подключается к работающему серверу, секунду слушает снапшоты и показывает жителей —
// консольная проверка репликации без игрового клиента.
async Task<int> 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<NetId>() && entity.HasComponent<Transform2D>())
{
var position = entity.GetComponent<Transform2D>().Position;
result.Add((entity.GetComponent<NetId>().Value, position.X, position.Y));
}
}
return result;
}
int ReadOption(string name, int fallback) int ReadOption(string name, int fallback)
{ {
+1
View File
@@ -11,6 +11,7 @@
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference <ProjectReference
Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
+20
View File
@@ -0,0 +1,20 @@
using LittleSim.Sim;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Net;
/// <summary>
/// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов.
/// Сервер (LittleSim.Server) и клиент (<see cref="Scenes.MultiplayerScene"/>) обязаны
/// строить схему одинаково — порядок регистрации определяет wire-id компонентов.
/// </summary>
public static class NetSchema
{
/// <summary>Порт сервера по умолчанию.</summary>
public const int DefaultPort = 9050;
/// <summary>Схема репликации: позиция/масштаб жителя и его потребности.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<Transform2D>().Register<PawnNeeds>();
}
+14 -1
View File
@@ -1,7 +1,20 @@
using LittleSim.Net;
using LittleSim.Scenes; using LittleSim.Scenes;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Host; 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), в фоне. // Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost( using var host = new GameHost(
new GameHostOptions new GameHostOptions
@@ -11,7 +24,7 @@ using var host = new GameHost(
Height = 720, Height = 720,
ClearColor = new Color(12, 16, 24), ClearColor = new Color(12, 16, 24),
}, },
new BootScene() new BootScene(connect)
); );
host.Run(); host.Run();
+12 -1
View File
@@ -19,10 +19,18 @@ namespace LittleSim.Scenes;
public sealed class BootScene : Scene public sealed class BootScene : Scene
{ {
private readonly string[] _steps = { "Загрузка", "Loading" }; private readonly string[] _steps = { "Загрузка", "Loading" };
private readonly string? _connectTo;
private Task<GameContent>? _load; private Task<GameContent>? _load;
private GameSettings _settings = new(); private GameSettings _settings = new();
private Label _label = null!; private Label _label = null!;
/// <summary>Обычный запуск — в главное меню.</summary>
public BootScene()
: this(null) { }
/// <summary>С <paramref name="connectTo"/> (ws://host:port) грузится сразу в сетевую сцену.</summary>
public BootScene(string? connectTo) => _connectTo = connectTo;
protected override void OnLoad() protected override void OnLoad()
{ {
Context.UseAudio(); Context.UseAudio();
@@ -74,6 +82,9 @@ public sealed class BootScene : Scene
Context.Services.Add(content); Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory)); Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language); 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));
} }
} }
+133
View File
@@ -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;
/// <summary>
/// Сетевой клиент: подключается к дедикейтед-серверу (LittleSim.Server --listen) и рендерит
/// реплицированных жителей. Симуляция целиком на сервере; сюда приезжают только компоненты
/// из <see cref="NetSchema"/>, а спрайты вешаются локально при спавне — граница
/// sim/presentation проходит теперь через сеть. Esc — назад в меню, команда консоли `net` —
/// состояние соединения.
/// </summary>
public sealed class MultiplayerScene : Scene
{
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly Uri _server;
private Task<WebSocketClient>? _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";
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
public MultiplayerScene(Uri server) => _server = server;
protected override void OnLoad()
{
_content = Context.Services.Get<GameContent>();
var assets = Context.Services.GetOrDefault<AssetManager>() ?? 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));
}
}
}