Multiplayer: headless dedicated server, MrGameEng.Net, desktop and browser clients #1
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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-сборки нельзя смешивать, пока графика движка не собирается пер-платформенно.
|
||||
|
||||
## Вопрос
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
{
|
||||
@@ -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 |
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user