From e8273f9ffa2629a204ec1f98edf1463f8d965c75 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 23:45:35 +0300 Subject: [PATCH] Multiplayer: networked dedicated server + desktop client over MrGameEng.Net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 3 + LittleSim.sln | 30 +++++ Mods/Core/Languages/en/ui.json | 18 +-- Mods/Core/Languages/ru/ui.json | 18 +-- engine | 2 +- spikes/KniWeb/Directory.Build.props | 5 +- spikes/KniWeb/KniWebSpike.csproj | 18 +-- spikes/KniWeb/Pages/Index.razor.cs | 1 - spikes/KniWeb/Program.cs | 2 +- spikes/KniWeb/SpikeScene.cs | 13 +- src/LittleSim.Server/HeadlessWorldScene.cs | 90 +++++++++++++- src/LittleSim.Server/Program.cs | 137 ++++++++++++++++++--- src/LittleSim/LittleSim.csproj | 1 + src/LittleSim/Net/NetSchema.cs | 20 +++ src/LittleSim/Program.cs | 15 ++- src/LittleSim/Scenes/BootScene.cs | 13 +- src/LittleSim/Scenes/MultiplayerScene.cs | 133 ++++++++++++++++++++ 17 files changed, 451 insertions(+), 68 deletions(-) create mode 100644 src/LittleSim/Net/NetSchema.cs create mode 100644 src/LittleSim/Scenes/MultiplayerScene.cs 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)); + } + } +}