diff --git a/CLAUDE.md b/CLAUDE.md index 63dfac2..9760ce7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,12 @@ Game design docs live in `docs/` and are written in **Russian**. Engine rules li ``` engine/ mrgameeng git submodule (own repo, own CLAUDE.md) src/LittleSim the game (net8.0); references engine projects directly -src/LittleSim.Server dedicated-server prototype: the world headless (engine HeadlessHost), - loads mods/defs without atlases; the future network server grows here +src/LittleSim.Server dedicated server: the world headless (engine HeadlessHost) + + WebSocket replication (MrGameEng.Net); also fast-forward and probe modes +src/LittleSim.Web browser client (Blazor WASM + KNI/WebGL): connects to the dedicated + server, renders replicated pawns; mirrors the net contract (NetContract.cs) + because KNI and DesktopGL assemblies can't mix — keep in sync with + src/LittleSim/Net/NetSchema.cs. Outside LittleSim.sln's test flow. Mods/Core the game's own content as a mod: About, Defs, Languages, Textures Cache/ runtime-built atlases (gitignored) docs/ концепт, симуляция, моды, roadmap (Russian) @@ -34,6 +38,7 @@ dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 # headless fas 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 run --project src/LittleSim.Web # browser client (?server=ws://…) dotnet test LittleSim.sln # runs the engine test suites ``` diff --git a/LittleSim.sln b/LittleSim.sln index 53b63be..0e3fc5c 100644 --- a/LittleSim.sln +++ b/LittleSim.sln @@ -51,6 +51,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "engine\src 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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Web", "src\LittleSim.Web\LittleSim.Web.csproj", "{270A9E52-E0E3-4885-B662-79E35AF6F7B4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -301,6 +303,18 @@ Global {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 + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x64.Build.0 = Debug|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Debug|x86.Build.0 = Debug|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|Any CPU.Build.0 = Release|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x64.ActiveCfg = Release|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x64.Build.0 = Release|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x86.ActiveCfg = Release|Any CPU + {270A9E52-E0E3-4885-B662-79E35AF6F7B4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -328,5 +342,6 @@ Global {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} + {270A9E52-E0E3-4885-B662-79E35AF6F7B4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/docs/web-client.md b/docs/web-client.md index a066846..8254e3f 100644 --- a/docs/web-client.md +++ b/docs/web-client.md @@ -1,6 +1,12 @@ # Веб-клиент: spike KNI и решение A/B -Дата: 2026-06-12. Спайк живёт в `spikes/KniWeb` (вне `LittleSim.sln`). +Дата: 2026-06-12. Спайк жил в `spikes/KniWeb`; после успеха повышен до +**`src/LittleSim.Web`** — рабочего браузерного клиента мультиплеера (Blazor WASM + KNI): +он подключается к `LittleSim.Server --listen` по WebSocket, применяет дельта-снапшоты +`MrGameEng.Net` и рисует жителей через WebGL со сглаживанием позиций. Адрес сервера — +`?server=ws://host:port` в URL страницы (по умолчанию — хост страницы, порт 9050). +Сетевой контракт там продублирован бинарным зеркалом (`NetContract.cs`) — KNI- и +DesktopGL-сборки нельзя смешивать, пока графика движка не собирается пер-платформенно. ## Вопрос diff --git a/spikes/KniWeb/KniWebSpike.sln b/spikes/KniWeb/KniWebSpike.sln deleted file mode 100644 index 733fd03..0000000 --- a/spikes/KniWeb/KniWebSpike.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 d17.14 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KniWebSpike", "KniWebSpike.csproj", "{A902FD15-4463-413A-9D7E-9DB32E1DA469}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A902FD15-4463-413A-9D7E-9DB32E1DA469}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A902FD15-4463-413A-9D7E-9DB32E1DA469}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A902FD15-4463-413A-9D7E-9DB32E1DA469}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A902FD15-4463-413A-9D7E-9DB32E1DA469}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {56925F12-B776-4372-ACBD-785D2E46AE4E} - EndGlobalSection -EndGlobal diff --git a/spikes/KniWeb/KniWebSpikeGame.cs b/spikes/KniWeb/KniWebSpikeGame.cs deleted file mode 100644 index 6aacdb9..0000000 --- a/spikes/KniWeb/KniWebSpikeGame.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using Microsoft.Xna.Framework.Input; -using MrGameEng.Core; - -namespace KniWebSpike -{ - /// - /// Мини-хост в духе MrGameEng.Host.GameHost, но поверх KNI (BlazorGL/WebGL): - /// владеет EngineContext ядра, гонит GameClock и фазы сцены. Если этот класс - /// работает в браузере — будущий MrGameEng.Host.Web реализуем. - /// - public class KniWebSpikeGame : Game - { - public EngineContext Context { get; } = new EngineContext(); - - private GraphicsDeviceManager _graphics; - private SpriteBatch _spriteBatch; - private Texture2D _pixel; - - public KniWebSpikeGame() - { - _graphics = new GraphicsDeviceManager(this); - Content.RootDirectory = "Content"; - } - - protected override void Initialize() - { - base.Initialize(); - var viewport = GraphicsDevice.Viewport; - Context.Scenes.Switch( - new SpikeScene(() => _spriteBatch, () => _pixel, viewport.Width, viewport.Height) - ); - } - - protected override void LoadContent() - { - _spriteBatch = new SpriteBatch(GraphicsDevice); - _pixel = new Texture2D(GraphicsDevice, 1, 1); - _pixel.SetData(new[] { Color.White }); - } - - protected override void Update(GameTime gameTime) - { - Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds); - Context.Scenes.Update(Context.Clock); - base.Update(gameTime); - } - - protected override void Draw(GameTime gameTime) - { - GraphicsDevice.Clear(new Color(12, 16, 24)); - Context.Scenes.Draw(Context.Clock); - base.Draw(gameTime); - } - } -} diff --git a/spikes/KniWeb/Pages/Index.razor.cs b/spikes/KniWeb/Pages/Index.razor.cs deleted file mode 100644 index 058cc90..0000000 --- a/spikes/KniWeb/Pages/Index.razor.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using Microsoft.JSInterop; -using Microsoft.Xna.Framework; - -namespace KniWebSpike.Pages -{ - public partial class Index - { - Game _game; - - protected override void OnAfterRender(bool firstRender) - { - base.OnAfterRender(firstRender); - - if (firstRender) - { - JsRuntime.InvokeAsync("initRenderJS", DotNetObjectReference.Create(this)); - } - } - - [JSInvokable] - public void TickDotNet() - { - // init game - if (_game == null) - { - _game = new KniWebSpikeGame(); - _game.Run(); - } - - // run gameloop - _game.Tick(); - } - } -} diff --git a/spikes/KniWeb/SpikeScene.cs b/spikes/KniWeb/SpikeScene.cs deleted file mode 100644 index 4f5caac..0000000 --- a/spikes/KniWeb/SpikeScene.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using Friflo.Engine.ECS; -using Friflo.Engine.ECS.Systems; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using MrGameEng.Core; - -namespace KniWebSpike -{ - // Компоненты симуляции — платформо-независимые, как в LittleSim. - public struct DotPosition : IComponent - { - public float X; - public float Y; - } - - public struct DotVelocity : IComponent - { - public float X; - public float Y; - } - - public struct DotTint : IComponent - { - public byte R; - public byte G; - public byte B; - } - - /// - /// Сцена спайка: 300 «спрайтов» в Friflo EntityStore, движение в Update-фазе - /// (детерминированный seed, отскок от краёв), отрисовка в Draw-фазе через - /// KNI SpriteBatch (WebGL). Сцена и системные корни — из MrGameEng.Core. - /// - public sealed class SpikeScene : Scene - { - private readonly Func _spriteBatch; - private readonly Func _pixel; - private readonly float _width; - private readonly float _height; - - public SpikeScene( - Func spriteBatch, - Func pixel, - float width, - float height - ) - { - _spriteBatch = spriteBatch; - _pixel = pixel; - _width = width; - _height = height; - } - - protected override void OnLoad() - { - var random = new Random(42); - for (var i = 0; i < 300; i++) - { - var angle = (float)(random.NextDouble() * Math.Tau); - var speed = 40f + (float)random.NextDouble() * 160f; - Store.CreateEntity( - new DotPosition - { - X = (float)random.NextDouble() * _width, - Y = (float)random.NextDouble() * _height, - }, - new DotVelocity { X = MathF.Cos(angle) * speed, Y = MathF.Sin(angle) * speed }, - new DotTint - { - R = (byte)random.Next(64, 256), - G = (byte)random.Next(64, 256), - B = (byte)random.Next(64, 256), - } - ); - } - - UpdateSystems.Add(new BounceSystem(_width, _height)); - DrawSystems.Add(new DotDrawSystem(_spriteBatch, _pixel)); - } - - private sealed class BounceSystem : QuerySystem - { - private readonly float _width; - private readonly float _height; - - public BounceSystem(float width, float height) - { - _width = width; - _height = height; - } - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - var width = _width; - var height = _height; - Query.ForEachEntity( - (ref DotPosition pos, ref DotVelocity vel, Entity _) => - { - pos.X += vel.X * delta; - pos.Y += vel.Y * delta; - if (pos.X < 0f || pos.X > width) - { - vel.X = -vel.X; - pos.X = Math.Clamp(pos.X, 0f, width); - } - - if (pos.Y < 0f || pos.Y > height) - { - vel.Y = -vel.Y; - pos.Y = Math.Clamp(pos.Y, 0f, height); - } - } - ); - } - } - - private sealed class DotDrawSystem : QuerySystem - { - private readonly Func _spriteBatch; - private readonly Func _pixel; - - public DotDrawSystem(Func spriteBatch, Func pixel) - { - _spriteBatch = spriteBatch; - _pixel = pixel; - } - - protected override void OnUpdate() - { - var batch = _spriteBatch(); - var pixel = _pixel(); - batch.Begin(); - Query.ForEachEntity( - (ref DotPosition pos, ref DotTint tint, Entity _) => - { - batch.Draw( - pixel, - new Rectangle((int)pos.X, (int)pos.Y, 6, 6), - new Color(tint.R, tint.G, tint.B) - ); - } - ); - batch.End(); - } - } - } -} diff --git a/spikes/KniWeb/App.razor b/src/LittleSim.Web/App.razor similarity index 100% rename from spikes/KniWeb/App.razor rename to src/LittleSim.Web/App.razor diff --git a/spikes/KniWeb/Content/KniWebSpikeContent.mgcb b/src/LittleSim.Web/Content/LittleSimWebContent.mgcb similarity index 100% rename from spikes/KniWeb/Content/KniWebSpikeContent.mgcb rename to src/LittleSim.Web/Content/LittleSimWebContent.mgcb diff --git a/spikes/KniWeb/Directory.Build.props b/src/LittleSim.Web/Directory.Build.props similarity index 100% rename from spikes/KniWeb/Directory.Build.props rename to src/LittleSim.Web/Directory.Build.props diff --git a/spikes/KniWeb/KniWebSpike.csproj b/src/LittleSim.Web/LittleSim.Web.csproj similarity index 69% rename from spikes/KniWeb/KniWebSpike.csproj rename to src/LittleSim.Web/LittleSim.Web.csproj index 5653f3d..cd01e60 100644 --- a/spikes/KniWeb/KniWebSpike.csproj +++ b/src/LittleSim.Web/LittleSim.Web.csproj @@ -1,61 +1,63 @@ - - - false - net8.0 - disable - disable - KniWebSpike - KniWebSpike - $(DefineConstants);BLAZORGL - BlazorGL - - - - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + false + net8.0 + enable + disable + LittleSim.Web + LittleSim.Web + $(DefineConstants);BLAZORGL + BlazorGL + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LittleSim.Web/LittleSimWebGame.cs b/src/LittleSim.Web/LittleSimWebGame.cs new file mode 100644 index 0000000..6892636 --- /dev/null +++ b/src/LittleSim.Web/LittleSimWebGame.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Core; + +namespace LittleSim.Web; + +/// +/// Веб-хост LittleSim поверх KNI (BlazorGL/WebGL) — браузерный аналог +/// MrGameEng.Host.GameHost: владеет EngineContext ядра, гонит GameClock и фазы сцены. +/// Цикл тикается из requestAnimationFrame (см. Pages/Index.razor.cs). Когда графика +/// движка научится собираться под KNI, этот класс переедет в MrGameEng.Host.Web. +/// +public class LittleSimWebGame : Game +{ + /// Контекст ядра движка, общий со сценами и системами. + public EngineContext Context { get; } = new EngineContext(); + + private readonly Uri _server; + private GraphicsDeviceManager _graphics; + private SpriteBatch _spriteBatch = null!; + private Texture2D _pixel = null!; + + /// Игра, подключающаяся к серверу . + public LittleSimWebGame(Uri server) + { + _server = server; + _graphics = new GraphicsDeviceManager(this); + Content.RootDirectory = "Content"; + } + + /// + protected override void Initialize() + { + base.Initialize(); + Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel)); + } + + /// + protected override void LoadContent() + { + _spriteBatch = new SpriteBatch(GraphicsDevice); + _pixel = new Texture2D(GraphicsDevice, 1, 1); + _pixel.SetData(new[] { Color.White }); + } + + /// + protected override void Update(GameTime gameTime) + { + Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds); + Context.Scenes.Update(Context.Clock); + base.Update(gameTime); + } + + /// + protected override void Draw(GameTime gameTime) + { + GraphicsDevice.Clear(new Color(12, 16, 24)); + Context.Scenes.Draw(Context.Clock); + base.Draw(gameTime); + } +} diff --git a/spikes/KniWeb/MainLayout.razor b/src/LittleSim.Web/MainLayout.razor similarity index 100% rename from spikes/KniWeb/MainLayout.razor rename to src/LittleSim.Web/MainLayout.razor diff --git a/spikes/KniWeb/MainLayout.razor.css b/src/LittleSim.Web/MainLayout.razor.css similarity index 100% rename from spikes/KniWeb/MainLayout.razor.css rename to src/LittleSim.Web/MainLayout.razor.css diff --git a/src/LittleSim.Web/NetContract.cs b/src/LittleSim.Web/NetContract.cs new file mode 100644 index 0000000..5c80978 --- /dev/null +++ b/src/LittleSim.Web/NetContract.cs @@ -0,0 +1,42 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; +using MrGameEng.Net; + +namespace LittleSim.Web; + +// ВНИМАНИЕ: бинарное зеркало сетевого контракта десктопа (src/LittleSim/Net/NetSchema.cs). +// Веб-клиент не может ссылаться на LittleSim/MrGameEng.Graphics (они собраны против +// MonoGame DesktopGL, а тут KNI), поэтому реплицируемые компоненты продублированы +// со СТРОГО тем же лейаутом и порядком регистрации. Меняешь схему там — меняй здесь. +// Уйдёт после пер-платформенной сборки графических библиотек (см. docs/web-client.md). + +/// Зеркало MrGameEng.Graphics.Transform2D: Position(8) + Rotation(4) + Scale(8). +public struct NetTransform : IComponent +{ + /// Позиция в мировых координатах. + public Vector2 Position; + + /// Поворот в радианах. + public float Rotation; + + /// Масштаб (у жителей — размер квада в пикселях). + public Vector2 Scale; +} + +/// Зеркало LittleSim.Sim.PawnNeeds: Energy(4). +public struct NetPawnNeeds : IComponent +{ + /// Запас сил жителя в [0, 1] — затемняет спрайт. + public float Energy; +} + +/// Схема репликации веб-клиента — порядок тот же, что в NetSchema десктопа. +public static class WebNetSchema +{ + /// Порт сервера по умолчанию (NetSchema.DefaultPort). + public const int DefaultPort = 9050; + + /// Transform2D → NetTransform, PawnNeeds → NetPawnNeeds. + public static ReplicationSchema Create() => + new ReplicationSchema().Register().Register(); +} diff --git a/spikes/KniWeb/Pages/Index.razor b/src/LittleSim.Web/Pages/Index.razor similarity index 86% rename from spikes/KniWeb/Pages/Index.razor rename to src/LittleSim.Web/Pages/Index.razor index fa553d5..262c9da 100644 --- a/spikes/KniWeb/Pages/Index.razor +++ b/src/LittleSim.Web/Pages/Index.razor @@ -3,7 +3,7 @@ @inject IJSRuntime JsRuntime @using nkast.Wasm.Canvas -KniWebSpike +LittleSim
0 && pair[..separator] == "server") + { + return new Uri(Uri.UnescapeDataString(pair[(separator + 1)..])); + } + } + + return new UriBuilder + { + Scheme = "ws", + Host = page.Host, + Port = WebNetSchema.DefaultPort, + }.Uri; + } + } +} diff --git a/spikes/KniWeb/Program.cs b/src/LittleSim.Web/Program.cs similarity index 93% rename from spikes/KniWeb/Program.cs rename to src/LittleSim.Web/Program.cs index 946f926..dfb0379 100644 --- a/spikes/KniWeb/Program.cs +++ b/src/LittleSim.Web/Program.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.Extensions.DependencyInjection; -namespace KniWebSpike +namespace LittleSim.Web { internal class Program { diff --git a/spikes/KniWeb/Properties/launchSettings.json b/src/LittleSim.Web/Properties/launchSettings.json similarity index 100% rename from spikes/KniWeb/Properties/launchSettings.json rename to src/LittleSim.Web/Properties/launchSettings.json diff --git a/src/LittleSim.Web/WorldViewScene.cs b/src/LittleSim.Web/WorldViewScene.cs new file mode 100644 index 0000000..4d4756b --- /dev/null +++ b/src/LittleSim.Web/WorldViewScene.cs @@ -0,0 +1,153 @@ +using System; +using System.Threading.Tasks; +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Core; +using MrGameEng.Net; + +namespace LittleSim.Web; + +/// +/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу +/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует +/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают +/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты +/// кадра, усталость затемняет квадратик жителя. +/// +public sealed class WorldViewScene : Scene +{ + private readonly Uri _server; + private readonly Func _spriteBatch; + private readonly Func _pixel; + + private ReplicationClient _replication = null!; + private Task? _connecting; + private WebSocketClient? _connection; + + /// Сцена, подключающаяся к . + public WorldViewScene(Uri server, Func spriteBatch, Func pixel) + { + _server = server; + _spriteBatch = spriteBatch; + _pixel = pixel; + } + + /// Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово. + private struct NetLerp : IComponent + { + public Vector2 Visual; + public Vector2 Target; + public bool Initialized; + } + + protected override void OnLoad() + { + _replication = new ReplicationClient(WebNetSchema.Create(), Store); + _replication.EntitySpawned += entity => entity.AddComponent(new NetLerp()); + + UpdateSystems.Add(new CallbackSystem(Pump)); + UpdateSystems.Add(new NetSmoothingSystem()); + DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel)); + + Log.Info($"LittleSim.Web: connecting to {_server}…"); + _connecting = WebSocketClient.ConnectAsync(_server); + } + + protected override void OnUnload() => _connection?.Close(); + + private void Pump() + { + if (_connecting is { IsCompleted: true } finished) + { + _connecting = null; + if (finished.IsFaulted) + { + Log.Error( + $"Connect to {_server} failed: " + + finished.Exception?.GetBaseException().Message + ); + } + else + { + _connection = finished.Result; + Log.Info($"Connected to {_server}"); + } + } + + if (_connection is not null) + { + _replication.Pump(_connection); + } + } + + /// Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса. + private sealed class CallbackSystem(Action update) : BaseSystem + { + protected override void OnUpdateGroup() => update(); + } + + /// Та же экспонента, что в NetSmoothingSystem десктопа (LittleSim/Net/NetSmoothing.cs). + private sealed class NetSmoothingSystem : QuerySystem + { + private const float Rate = 12f; + + protected override void OnUpdate() + { + var blend = 1f - MathF.Exp(-Rate * Tick.deltaTime); + Query.ForEachEntity( + (ref NetTransform transform, ref NetLerp lerp, Entity _) => + { + if (!lerp.Initialized) + { + lerp.Visual = lerp.Target = transform.Position; + lerp.Initialized = true; + return; + } + + if (transform.Position != lerp.Visual) + { + lerp.Target = transform.Position; + } + + lerp.Visual = Vector2.Lerp(lerp.Visual, lerp.Target, blend); + transform.Position = lerp.Visual; + } + ); + } + } + + /// Житель — квадратик размером Scale, затемняющийся с усталостью (как на десктопе). + private sealed class PawnDrawSystem(Func spriteBatch, Func pixel) + : QuerySystem + { + private const float MinBrightness = 0.45f; + + protected override void OnUpdate() + { + var batch = spriteBatch(); + var white = pixel(); + batch.Begin(); + Query.ForEachEntity( + (ref NetTransform transform, ref NetPawnNeeds needs, Entity _) => + { + var size = transform.Scale; + var brightness = + MinBrightness + (1f - MinBrightness) * Math.Clamp(needs.Energy, 0f, 1f); + batch.Draw( + white, + new Rectangle( + (int)(transform.Position.X - size.X / 2f), + (int)(transform.Position.Y - size.Y / 2f), + (int)size.X, + (int)size.Y + ), + Color.White * brightness + ); + } + ); + batch.End(); + } + } +} diff --git a/spikes/KniWeb/_Imports.razor b/src/LittleSim.Web/_Imports.razor similarity index 91% rename from spikes/KniWeb/_Imports.razor rename to src/LittleSim.Web/_Imports.razor index 686df1a..39344fe 100644 --- a/spikes/KniWeb/_Imports.razor +++ b/src/LittleSim.Web/_Imports.razor @@ -7,4 +7,4 @@ @using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop @using nkast.Wasm.Canvas -@using KniWebSpike +@using LittleSim.Web diff --git a/spikes/KniWeb/wwwroot/Content/.gitignore b/src/LittleSim.Web/wwwroot/Content/.gitignore similarity index 100% rename from spikes/KniWeb/wwwroot/Content/.gitignore rename to src/LittleSim.Web/wwwroot/Content/.gitignore diff --git a/spikes/KniWeb/wwwroot/css/app.css b/src/LittleSim.Web/wwwroot/css/app.css similarity index 100% rename from spikes/KniWeb/wwwroot/css/app.css rename to src/LittleSim.Web/wwwroot/css/app.css diff --git a/spikes/KniWeb/wwwroot/css/bootstrap/bootstrap.min.css b/src/LittleSim.Web/wwwroot/css/bootstrap/bootstrap.min.css similarity index 100% rename from spikes/KniWeb/wwwroot/css/bootstrap/bootstrap.min.css rename to src/LittleSim.Web/wwwroot/css/bootstrap/bootstrap.min.css diff --git a/spikes/KniWeb/wwwroot/css/bootstrap/bootstrap.min.css.map b/src/LittleSim.Web/wwwroot/css/bootstrap/bootstrap.min.css.map similarity index 100% rename from spikes/KniWeb/wwwroot/css/bootstrap/bootstrap.min.css.map rename to src/LittleSim.Web/wwwroot/css/bootstrap/bootstrap.min.css.map diff --git a/spikes/KniWeb/wwwroot/favicon.ico b/src/LittleSim.Web/wwwroot/favicon.ico similarity index 100% rename from spikes/KniWeb/wwwroot/favicon.ico rename to src/LittleSim.Web/wwwroot/favicon.ico diff --git a/spikes/KniWeb/wwwroot/index.html b/src/LittleSim.Web/wwwroot/index.html similarity index 96% rename from spikes/KniWeb/wwwroot/index.html rename to src/LittleSim.Web/wwwroot/index.html index 27cf041..8d75d93 100644 --- a/spikes/KniWeb/wwwroot/index.html +++ b/src/LittleSim.Web/wwwroot/index.html @@ -4,11 +4,11 @@ - KniWebSpike + LittleSim - + diff --git a/spikes/KniWeb/wwwroot/js/decode.js b/src/LittleSim.Web/wwwroot/js/decode.js similarity index 100% rename from spikes/KniWeb/wwwroot/js/decode.js rename to src/LittleSim.Web/wwwroot/js/decode.js diff --git a/spikes/KniWeb/wwwroot/js/decode.min.js b/src/LittleSim.Web/wwwroot/js/decode.min.js similarity index 100% rename from spikes/KniWeb/wwwroot/js/decode.min.js rename to src/LittleSim.Web/wwwroot/js/decode.min.js diff --git a/spikes/KniWeb/wwwroot/js/micProcessor.js b/src/LittleSim.Web/wwwroot/js/micProcessor.js similarity index 100% rename from spikes/KniWeb/wwwroot/js/micProcessor.js rename to src/LittleSim.Web/wwwroot/js/micProcessor.js diff --git a/spikes/KniWeb/wwwroot/js/streamProcessor.js b/src/LittleSim.Web/wwwroot/js/streamProcessor.js similarity index 100% rename from spikes/KniWeb/wwwroot/js/streamProcessor.js rename to src/LittleSim.Web/wwwroot/js/streamProcessor.js diff --git a/spikes/KniWeb/wwwroot/kni.png b/src/LittleSim.Web/wwwroot/kni.png similarity index 100% rename from spikes/KniWeb/wwwroot/kni.png rename to src/LittleSim.Web/wwwroot/kni.png diff --git a/src/LittleSim/Net/NetSchema.cs b/src/LittleSim/Net/NetSchema.cs index 75f08a6..6db704d 100644 --- a/src/LittleSim/Net/NetSchema.cs +++ b/src/LittleSim/Net/NetSchema.cs @@ -8,6 +8,9 @@ namespace LittleSim.Net; /// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов. /// Сервер (LittleSim.Server) и клиент () обязаны /// строить схему одинаково — порядок регистрации определяет wire-id компонентов. +/// ВНИМАНИЕ: у веб-клиента бинарное зеркало этой схемы +/// (src/LittleSim.Web/NetContract.cs — он собран против KNI и не может ссылаться сюда); +/// меняешь состав или порядок — меняй и там. /// public static class NetSchema { diff --git a/src/LittleSim/Net/NetSmoothing.cs b/src/LittleSim/Net/NetSmoothing.cs new file mode 100644 index 0000000..281eba1 --- /dev/null +++ b/src/LittleSim/Net/NetSmoothing.cs @@ -0,0 +1,65 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace LittleSim.Net; + +/// +/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду, +/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками. +/// Снапшот пишет в ; система ловит это (позиция +/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает +/// видимую позицию к цели, записывая её обратно в трансформ для рендера. +/// +public struct NetLerp : IComponent +{ + /// Нарисованная (сглаженная) позиция прошлого кадра. + public Vector2 Visual; + + /// Последняя серверная позиция — цель сглаживания. + public Vector2 Target; + + /// Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта. + public bool Initialized; +} + +/// +/// Двигает к и пишет результат в +/// . Ставится после прокачки сети и до рендера. +/// +public sealed class NetSmoothingSystem : QuerySystem +{ + // Скорость экспоненциального сглаживания: за ~0.25 с визуал почти догоняет цель. + private const float Rate = 12f; + + protected override void OnUpdate() + { + var blend = 1f - MathF.Exp(-Rate * Tick.deltaTime); + foreach (var (transforms, lerps, _) in Query.Chunks) + { + var t = transforms.Span; + var l = lerps.Span; + for (var i = 0; i < t.Length; i++) + { + ref var lerp = ref l[i]; + ref var position = ref t[i].Position; + if (!lerp.Initialized) + { + lerp.Visual = lerp.Target = position; + lerp.Initialized = true; + continue; + } + + // Транформ трогает только снапшот: разошёлся с нарисованным — новая цель. + if (position != lerp.Visual) + { + lerp.Target = position; + } + + lerp.Visual = Vector2.Lerp(lerp.Visual, lerp.Target, blend); + position = lerp.Visual; + } + } + } +} diff --git a/src/LittleSim/Scenes/MultiplayerScene.cs b/src/LittleSim/Scenes/MultiplayerScene.cs index b7a11f6..accd9d2 100644 --- a/src/LittleSim/Scenes/MultiplayerScene.cs +++ b/src/LittleSim/Scenes/MultiplayerScene.cs @@ -61,6 +61,7 @@ public sealed class MultiplayerScene : Scene var sprite = new Sprite(white, GameLayers.Beings); sprite.CenterOrigin(); entity.AddComponent(sprite); + entity.AddComponent(new NetLerp()); }; var desktop = this.UseUI(); @@ -68,6 +69,8 @@ public sealed class MultiplayerScene : Scene desktop.Root = Ui.Screen(_hud); UpdateSystems.Add(new CallbackSystem(Pump)); + // Снапшоты приходят ~10 раз в секунду — сглаживаем позиции до частоты кадра. + UpdateSystems.Add(new NetSmoothingSystem()); // Усталость жителей видна и по сети: PawnNeeds реплицируется, спрайт темнеет локально. UpdateSystems.Add(new PawnAppearanceSystem());