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)); + } + } +}