Multiplayer: headless dedicated server, MrGameEng.Net, desktop and browser clients #1

Merged
mrleo1nid merged 5 commits from claude/fervent-shannon-8d884e into main 2026-06-13 01:25:07 +00:00
35 changed files with 485 additions and 336 deletions
Showing only changes of commit 580cb6ccc9 - Show all commits
+7 -2
View File
@@ -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
```
+15
View File
@@ -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
+7 -1
View File
@@ -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-сборки нельзя смешивать, пока графика движка не собирается пер-платформенно.
## Вопрос
-25
View File
@@ -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
-58
View File
@@ -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
{
/// <summary>
/// Мини-хост в духе MrGameEng.Host.GameHost, но поверх KNI (BlazorGL/WebGL):
/// владеет EngineContext ядра, гонит GameClock и фазы сцены. Если этот класс
/// работает в браузере — будущий MrGameEng.Host.Web реализуем.
/// </summary>
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);
}
}
}
-35
View File
@@ -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<object>("initRenderJS", DotNetObjectReference.Create(this));
}
}
[JSInvokable]
public void TickDotNet()
{
// init game
if (_game == null)
{
_game = new KniWebSpikeGame();
_game.Run();
}
// run gameloop
_game.Tick();
}
}
}
-149
View File
@@ -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;
}
/// <summary>
/// Сцена спайка: 300 «спрайтов» в Friflo EntityStore, движение в Update-фазе
/// (детерминированный seed, отскок от краёв), отрисовка в Draw-фазе через
/// KNI SpriteBatch (WebGL). Сцена и системные корни — из MrGameEng.Core.
/// </summary>
public sealed class SpikeScene : Scene
{
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
private readonly float _width;
private readonly float _height;
public SpikeScene(
Func<SpriteBatch> spriteBatch,
Func<Texture2D> 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<DotPosition, DotVelocity>
{
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<DotPosition, DotTint>
{
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
public DotDrawSystem(Func<SpriteBatch> spriteBatch, Func<Texture2D> 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();
}
}
}
}
@@ -2,33 +2,35 @@
<PropertyGroup>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>KniWebSpike</RootNamespace>
<AssemblyName>KniWebSpike</AssemblyName>
<RootNamespace>LittleSim.Web</RootNamespace>
<AssemblyName>LittleSim.Web</AssemblyName>
<DefineConstants>$(DefineConstants);BLAZORGL</DefineConstants>
<KniPlatform>BlazorGL</KniPlatform>
</PropertyGroup>
<PropertyGroup>
<BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
<!--<InvariantGlobalization>true</InvariantGlobalization>-->
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "></PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "></PropertyGroup>
<ItemGroup>
<Compile Include="Pages\Index.razor.cs" />
<Compile Include="Program.cs" />
<Compile Include="KniWebSpikeGame.cs" />
<Compile Include="SpikeScene.cs" />
<Compile Include="LittleSimWebGame.cs" />
<Compile Include="NetContract.cs" />
<Compile Include="WorldViewScene.cs" />
</ItemGroup>
<ItemGroup>
<!-- Суть спайка: платформо-независимое ядро движка + Friflo внутри Blazor WASM. -->
<!--
Веб-клиент собирается против KNI (форк MonoGame с платформой Blazor/WebGL), поэтому
ссылается только на платформо-независимые библиотеки движка: Core и Net.
Графические библиотеки движка (DesktopGL) сюда подключать нельзя — у KNI свои
сборки с теми же неймспейсами.
-->
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
</ItemGroup>
<ItemGroup>
@@ -56,6 +58,6 @@
</ItemGroup>
<ItemGroup>
<KniContentReference Include="Content\KniWebSpikeContent.mgcb" />
<KniContentReference Include="Content\LittleSimWebContent.mgcb" />
</ItemGroup>
</Project>
+62
View File
@@ -0,0 +1,62 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace LittleSim.Web;
/// <summary>
/// Веб-хост LittleSim поверх KNI (BlazorGL/WebGL) — браузерный аналог
/// MrGameEng.Host.GameHost: владеет EngineContext ядра, гонит GameClock и фазы сцены.
/// Цикл тикается из requestAnimationFrame (см. Pages/Index.razor.cs). Когда графика
/// движка научится собираться под KNI, этот класс переедет в MrGameEng.Host.Web.
/// </summary>
public class LittleSimWebGame : Game
{
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
public EngineContext Context { get; } = new EngineContext();
private readonly Uri _server;
private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch = null!;
private Texture2D _pixel = null!;
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary>
public LittleSimWebGame(Uri server)
{
_server = server;
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
}
/// <inheritdoc />
protected override void Initialize()
{
base.Initialize();
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel));
}
/// <inheritdoc />
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
_pixel = new Texture2D(GraphicsDevice, 1, 1);
_pixel.SetData(new[] { Color.White });
}
/// <inheritdoc />
protected override void Update(GameTime gameTime)
{
Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds);
Context.Scenes.Update(Context.Clock);
base.Update(gameTime);
}
/// <inheritdoc />
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(new Color(12, 16, 24));
Context.Scenes.Draw(Context.Clock);
base.Draw(gameTime);
}
}
+42
View File
@@ -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).
/// <summary>Зеркало MrGameEng.Graphics.Transform2D: Position(8) + Rotation(4) + Scale(8).</summary>
public struct NetTransform : IComponent
{
/// <summary>Позиция в мировых координатах.</summary>
public Vector2 Position;
/// <summary>Поворот в радианах.</summary>
public float Rotation;
/// <summary>Масштаб (у жителей — размер квада в пикселях).</summary>
public Vector2 Scale;
}
/// <summary>Зеркало LittleSim.Sim.PawnNeeds: Energy(4).</summary>
public struct NetPawnNeeds : IComponent
{
/// <summary>Запас сил жителя в [0, 1] — затемняет спрайт.</summary>
public float Energy;
}
/// <summary>Схема репликации веб-клиента — порядок тот же, что в NetSchema десктопа.</summary>
public static class WebNetSchema
{
/// <summary>Порт сервера по умолчанию (NetSchema.DefaultPort).</summary>
public const int DefaultPort = 9050;
/// <summary>Transform2D → NetTransform, PawnNeeds → NetPawnNeeds.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<NetTransform>().Register<NetPawnNeeds>();
}
@@ -3,7 +3,7 @@
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>KniWebSpike</PageTitle>
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
+60
View File
@@ -0,0 +1,60 @@
using System;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.Xna.Framework;
namespace LittleSim.Web.Pages
{
public partial class Index
{
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private Game? _game;
protected override void OnAfterRender(bool firstRender)
{
base.OnAfterRender(firstRender);
if (firstRender)
{
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
}
}
[JSInvokable]
public void TickDotNet()
{
if (_game == null)
{
_game = new LittleSimWebGame(ResolveServerUri());
_game.Run();
}
_game.Tick();
}
// Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию —
// хост самой страницы на порту LittleSim.Server.
private Uri ResolveServerUri()
{
var page = new Uri(Navigation.Uri);
var query = page.Query.TrimStart('?');
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separator = pair.IndexOf('=');
if (separator > 0 && pair[..separator] == "server")
{
return new Uri(Uri.UnescapeDataString(pair[(separator + 1)..]));
}
}
return new UriBuilder
{
Scheme = "ws",
Host = page.Host,
Port = WebNetSchema.DefaultPort,
}.Uri;
}
}
}
@@ -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
{
+153
View File
@@ -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;
/// <summary>
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты
/// кадра, усталость затемняет квадратик жителя.
/// </summary>
public sealed class WorldViewScene : Scene
{
private readonly Uri _server;
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
private ReplicationClient _replication = null!;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary>
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
{
_server = server;
_spriteBatch = spriteBatch;
_pixel = pixel;
}
/// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary>
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);
}
}
/// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary>
private sealed class CallbackSystem(Action update) : BaseSystem
{
protected override void OnUpdateGroup() => update();
}
/// <summary>Та же экспонента, что в NetSmoothingSystem десктопа (LittleSim/Net/NetSmoothing.cs).</summary>
private sealed class NetSmoothingSystem : QuerySystem<NetTransform, NetLerp>
{
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;
}
);
}
}
/// <summary>Житель — квадратик размером Scale, затемняющийся с усталостью (как на десктопе).</summary>
private sealed class PawnDrawSystem(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
: QuerySystem<NetTransform, NetPawnNeeds>
{
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();
}
}
}
@@ -7,4 +7,4 @@
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using nkast.Wasm.Canvas
@using KniWebSpike
@using LittleSim.Web

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -4,11 +4,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>KniWebSpike</title>
<title>LittleSim</title>
<base href="./" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link href="KniWebSpike.styles.css" rel="stylesheet" />
<link href="LittleSim.Web.styles.css" rel="stylesheet" />
</head>
<body>

Before

Width:  |  Height:  |  Size: 423 B

After

Width:  |  Height:  |  Size: 423 B

+3
View File
@@ -8,6 +8,9 @@ namespace LittleSim.Net;
/// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов.
/// Сервер (LittleSim.Server) и клиент (<see cref="Scenes.MultiplayerScene"/>) обязаны
/// строить схему одинаково — порядок регистрации определяет wire-id компонентов.
/// ВНИМАНИЕ: у веб-клиента бинарное зеркало этой схемы
/// (src/LittleSim.Web/NetContract.cs — он собран против KNI и не может ссылаться сюда);
/// меняешь состав или порядок — меняй и там.
/// </summary>
public static class NetSchema
{
+65
View File
@@ -0,0 +1,65 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace LittleSim.Net;
/// <summary>
/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду,
/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками.
/// Снапшот пишет в <see cref="Transform2D.Position"/>; система ловит это (позиция
/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает
/// видимую позицию к цели, записывая её обратно в трансформ для рендера.
/// </summary>
public struct NetLerp : IComponent
{
/// <summary>Нарисованная (сглаженная) позиция прошлого кадра.</summary>
public Vector2 Visual;
/// <summary>Последняя серверная позиция — цель сглаживания.</summary>
public Vector2 Target;
/// <summary>Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта.</summary>
public bool Initialized;
}
/// <summary>
/// Двигает <see cref="NetLerp.Visual"/> к <see cref="NetLerp.Target"/> и пишет результат в
/// <see cref="Transform2D.Position"/>. Ставится после прокачки сети и до рендера.
/// </summary>
public sealed class NetSmoothingSystem : QuerySystem<Transform2D, NetLerp>
{
// Скорость экспоненциального сглаживания: за ~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;
}
}
}
}
+3
View File
@@ -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());