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());