From 580cb6ccc9a1406dd4053b69fa694d71fb1a1037 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 13 Jun 2026 00:04:11 +0300 Subject: [PATCH] Browser multiplayer client: promote the KNI spike to src/LittleSim.Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LittleSim.Web (Blazor WASM + KNI/WebGL) is a real network client now: it connects to the dedicated server over WebSocket (ClientWebSocket maps to the browser socket), applies MrGameEng.Net delta snapshots into its EntityStore and draws pawns through SpriteBatch — fatigue dims them just like on desktop. The server address comes from ?server=ws://host:port in the page URL, defaulting to the page's host on port 9050. The net contract is mirrored in NetContract.cs (KNI and DesktopGL assemblies can't mix until the graphics libraries build per platform) with loud keep-in-sync comments on both sides. Both clients now smooth replicated positions between 10 Hz snapshots: NetLerp + NetSmoothingSystem lerp the visual position toward the latest server position every frame (exponential, ~0.25 s to converge). Verified against a live LittleSim.Server --listen: the browser client connects (server log), draws ~3.3k lit pixels of pawns whose layout changes between samples, and survives 400+ ticks without errors. Found along the way: requestAnimationFrame freezes in hidden windows — the game loop only runs while the tab is visible. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 9 +- LittleSim.sln | 15 ++ docs/web-client.md | 8 +- spikes/KniWeb/KniWebSpike.sln | 25 --- spikes/KniWeb/KniWebSpikeGame.cs | 58 ------- spikes/KniWeb/Pages/Index.razor.cs | 35 ---- spikes/KniWeb/SpikeScene.cs | 149 ----------------- .../KniWeb => src/LittleSim.Web}/App.razor | 0 .../Content/LittleSimWebContent.mgcb | 0 .../LittleSim.Web}/Directory.Build.props | 0 .../LittleSim.Web/LittleSim.Web.csproj | 124 +++++++------- src/LittleSim.Web/LittleSimWebGame.cs | 62 +++++++ .../LittleSim.Web}/MainLayout.razor | 0 .../LittleSim.Web}/MainLayout.razor.css | 0 src/LittleSim.Web/NetContract.cs | 42 +++++ .../LittleSim.Web}/Pages/Index.razor | 2 +- src/LittleSim.Web/Pages/Index.razor.cs | 60 +++++++ .../KniWeb => src/LittleSim.Web}/Program.cs | 2 +- .../Properties/launchSettings.json | 0 src/LittleSim.Web/WorldViewScene.cs | 153 ++++++++++++++++++ .../LittleSim.Web}/_Imports.razor | 2 +- .../LittleSim.Web}/wwwroot/Content/.gitignore | 0 .../LittleSim.Web}/wwwroot/css/app.css | 0 .../wwwroot/css/bootstrap/bootstrap.min.css | 0 .../css/bootstrap/bootstrap.min.css.map | 0 .../LittleSim.Web}/wwwroot/favicon.ico | Bin .../LittleSim.Web}/wwwroot/index.html | 4 +- .../LittleSim.Web}/wwwroot/js/decode.js | 0 .../LittleSim.Web}/wwwroot/js/decode.min.js | 0 .../LittleSim.Web}/wwwroot/js/micProcessor.js | 0 .../wwwroot/js/streamProcessor.js | 0 .../LittleSim.Web}/wwwroot/kni.png | Bin src/LittleSim/Net/NetSchema.cs | 3 + src/LittleSim/Net/NetSmoothing.cs | 65 ++++++++ src/LittleSim/Scenes/MultiplayerScene.cs | 3 + 35 files changed, 485 insertions(+), 336 deletions(-) delete mode 100644 spikes/KniWeb/KniWebSpike.sln delete mode 100644 spikes/KniWeb/KniWebSpikeGame.cs delete mode 100644 spikes/KniWeb/Pages/Index.razor.cs delete mode 100644 spikes/KniWeb/SpikeScene.cs rename {spikes/KniWeb => src/LittleSim.Web}/App.razor (100%) rename spikes/KniWeb/Content/KniWebSpikeContent.mgcb => src/LittleSim.Web/Content/LittleSimWebContent.mgcb (100%) rename {spikes/KniWeb => src/LittleSim.Web}/Directory.Build.props (100%) rename spikes/KniWeb/KniWebSpike.csproj => src/LittleSim.Web/LittleSim.Web.csproj (69%) create mode 100644 src/LittleSim.Web/LittleSimWebGame.cs rename {spikes/KniWeb => src/LittleSim.Web}/MainLayout.razor (100%) rename {spikes/KniWeb => src/LittleSim.Web}/MainLayout.razor.css (100%) create mode 100644 src/LittleSim.Web/NetContract.cs rename {spikes/KniWeb => src/LittleSim.Web}/Pages/Index.razor (86%) create mode 100644 src/LittleSim.Web/Pages/Index.razor.cs rename {spikes/KniWeb => src/LittleSim.Web}/Program.cs (93%) rename {spikes/KniWeb => src/LittleSim.Web}/Properties/launchSettings.json (100%) create mode 100644 src/LittleSim.Web/WorldViewScene.cs rename {spikes/KniWeb => src/LittleSim.Web}/_Imports.razor (91%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/Content/.gitignore (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/css/app.css (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/css/bootstrap/bootstrap.min.css (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/css/bootstrap/bootstrap.min.css.map (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/favicon.ico (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/index.html (96%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/js/decode.js (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/js/decode.min.js (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/js/micProcessor.js (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/js/streamProcessor.js (100%) rename {spikes/KniWeb => src/LittleSim.Web}/wwwroot/kni.png (100%) create mode 100644 src/LittleSim/Net/NetSmoothing.cs 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());