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
42 changed files with 4026 additions and 145 deletions
+11
View File
@@ -16,6 +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) engine/ mrgameeng git submodule (own repo, own CLAUDE.md)
src/LittleSim the game (net8.0); references engine projects directly src/LittleSim the game (net8.0); references engine projects directly
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 Mods/Core the game's own content as a mod: About, Defs, Languages, Textures
Cache/ runtime-built atlases (gitignored) Cache/ runtime-built atlases (gitignored)
docs/ концепт, симуляция, моды, roadmap (Russian) docs/ концепт, симуляция, моды, roadmap (Russian)
@@ -28,6 +34,11 @@ LittleSim.sln game + engine sources + engine tests — one window for everythin
git submodule update --init # after fresh clone git submodule update --init # after fresh clone
dotnet build LittleSim.sln dotnet build LittleSim.sln
dotnet run --project src/LittleSim -c Release # measure perf in Release only dotnet run --project src/LittleSim -c Release # measure perf in Release only
dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 # headless fast-forward
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 dotnet test LittleSim.sln # runs the engine test suites
``` ```
+60
View File
@@ -45,6 +45,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "engine\sr
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "engine\tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "engine\tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "engine\src\MrGameEng.Net\MrGameEng.Net.csproj", "{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "engine\tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{95630217-333B-4263-84AE-5EE5961FC307}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Web", "src\LittleSim.Web\LittleSim.Web.csproj", "{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -259,6 +267,54 @@ Global
{BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x64.Build.0 = Release|Any CPU {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x64.Build.0 = Release|Any CPU
{BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x86.ActiveCfg = Release|Any CPU {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x86.ActiveCfg = Release|Any CPU
{BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x86.Build.0 = Release|Any CPU {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A}.Release|x86.Build.0 = Release|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x64.ActiveCfg = Debug|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x64.Build.0 = Debug|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x86.ActiveCfg = Debug|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Debug|x86.Build.0 = Debug|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|Any CPU.Build.0 = Release|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x64.ActiveCfg = Release|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x64.Build.0 = Release|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x86.ActiveCfg = Release|Any CPU
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7}.Release|x86.Build.0 = Release|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x64.ActiveCfg = Debug|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x64.Build.0 = Debug|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x86.ActiveCfg = Debug|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Debug|x86.Build.0 = Debug|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|Any CPU.Build.0 = Release|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x64.ActiveCfg = Release|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x64.Build.0 = Release|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x86.ActiveCfg = Release|Any CPU
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA}.Release|x86.Build.0 = Release|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Debug|Any CPU.Build.0 = Debug|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Debug|x64.ActiveCfg = Debug|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Debug|x64.Build.0 = Debug|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Debug|x86.ActiveCfg = Debug|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Debug|x86.Build.0 = Debug|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Release|Any CPU.ActiveCfg = Release|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Release|Any CPU.Build.0 = Release|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Release|x64.ActiveCfg = Release|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Release|x64.Build.0 = Release|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Release|x86.ActiveCfg = Release|Any CPU
{95630217-333B-4263-84AE-5EE5961FC307}.Release|x86.Build.0 = Release|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x64.ActiveCfg = Debug|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x64.Build.0 = Debug|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x86.ActiveCfg = Debug|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Debug|x86.Build.0 = Debug|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|Any CPU.Build.0 = Release|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x64.ActiveCfg = Release|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x64.Build.0 = Release|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x86.ActiveCfg = Release|Any CPU
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -283,5 +339,9 @@ Global
{E0E37D87-4F62-41E9-9CD1-3E7432301508} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5} {E0E37D87-4F62-41E9-9CD1-3E7432301508} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{6EFAFBB4-F11A-42B9-A8AB-B98E735431F6} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519} {6EFAFBB4-F11A-42B9-A8AB-B98E735431F6} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
{BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5} {BE18D2E0-B8AD-4632-AA0F-F9C80BE5D83A} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{841EAE51-E86B-4BFB-A70C-A67E4D492DD7} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
{D66A7D37-A1F1-473D-A5FA-9F121CCCC0CA} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{95630217-333B-4263-84AE-5EE5961FC307} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{1FF0DF78-22E4-4169-A9DC-233FFAEA5B14} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+6 -13
View File
@@ -8,7 +8,6 @@
"season.winter": "winter", "season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector", "hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
"hud.paused": "PAUSED", "hud.paused": "PAUSED",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation", "menu.subtitle": "a god-game: minimal graphics, deep simulation",
"menu.newworld": "New World", "menu.newworld": "New World",
@@ -16,7 +15,6 @@
"menu.settings": "Settings", "menu.settings": "Settings",
"menu.credits": "Credits", "menu.credits": "Credits",
"menu.quit": "Quit", "menu.quit": "Quit",
"newworld.title": "New World", "newworld.title": "New World",
"newworld.name": "Name", "newworld.name": "Name",
"newworld.defaultname": "New World", "newworld.defaultname": "New World",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Terrain smoothing", "newworld.smoothing": "Terrain smoothing",
"newworld.create": "Create", "newworld.create": "Create",
"newworld.back": "Back", "newworld.back": "Back",
"load.title": "Load", "load.title": "Load",
"load.empty": "No saves yet", "load.empty": "No saves yet",
"load.load": "Load", "load.load": "Load",
"load.delete": "Delete", "load.delete": "Delete",
"load.back": "Back", "load.back": "Back",
"settings.title": "Settings", "settings.title": "Settings",
"settings.language": "Language", "settings.language": "Language",
"settings.fullscreen": "Fullscreen", "settings.fullscreen": "Fullscreen",
@@ -41,7 +37,6 @@
"settings.volume": "Volume", "settings.volume": "Volume",
"settings.apply": "Apply", "settings.apply": "Apply",
"settings.back": "Back", "settings.back": "Back",
"pause.title": "Paused", "pause.title": "Paused",
"pause.resume": "Resume", "pause.resume": "Resume",
"pause.settings": "Settings", "pause.settings": "Settings",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Main menu", "pause.mainmenu": "Main menu",
"pause.quit": "Quit", "pause.quit": "Quit",
"pause.saved": "Saved: {0}", "pause.saved": "Saved: {0}",
"speed.pause": "⏸", "speed.pause": "⏸",
"credits.title": "Credits", "credits.title": "Credits",
"credits.author": "mrleo1nid", "credits.author": "mrleo1nid",
"credits.role": "Concept, code, mrgameeng engine", "credits.role": "Concept, code, mrgameeng engine",
"credits.back": "Back", "credits.back": "Back",
"preset.small": "Small", "preset.small": "Small",
"preset.medium": "Medium", "preset.medium": "Medium",
"preset.large": "Large", "preset.large": "Large",
"terrain.deepwater": "deep water", "terrain.deepwater": "deep water",
"terrain.water": "water", "terrain.water": "water",
"terrain.sand": "sand", "terrain.sand": "sand",
"terrain.grass": "grass", "terrain.grass": "grass",
"terrain.forest": "forest", "terrain.forest": "forest",
"terrain.mountain": "mountains", "terrain.mountain": "mountains",
"plant.oak": "oak", "plant.oak": "oak",
"plant.birch": "birch", "plant.birch": "birch",
"plant.pine": "pine", "plant.pine": "pine",
@@ -77,12 +67,10 @@
"plant.stage.sprout": "sprout", "plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling", "plant.stage.sapling": "sapling",
"plant.stage.mature": "mature", "plant.stage.mature": "mature",
"product.wood": "wood", "product.wood": "wood",
"product.grass": "grass", "product.grass": "grass",
"product.berry": "berries", "product.berry": "berries",
"product.acorn": "acorn", "product.acorn": "acorn",
"pawn.being": "being", "pawn.being": "being",
"pawn.bear": "bear", "pawn.bear": "bear",
"pawn.deer": "deer", "pawn.deer": "deer",
@@ -91,5 +79,10 @@
"pawn.boar": "boar", "pawn.boar": "boar",
"pawn.wolf": "wolf", "pawn.wolf": "wolf",
"pawn.muffalo": "muffalo", "pawn.muffalo": "muffalo",
"pawn.squirrel": "squirrel" "pawn.squirrel": "squirrel",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…",
"net.connected": "connected",
"net.failed": "connection failed",
"net.lost": "connection lost"
} }
+6 -13
View File
@@ -8,7 +8,6 @@
"season.winter": "зима", "season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор", "hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции", "menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
"menu.newworld": "Новый мир", "menu.newworld": "Новый мир",
@@ -16,7 +15,6 @@
"menu.settings": "Настройки", "menu.settings": "Настройки",
"menu.credits": "Авторы", "menu.credits": "Авторы",
"menu.quit": "Выход", "menu.quit": "Выход",
"newworld.title": "Новый мир", "newworld.title": "Новый мир",
"newworld.name": "Название", "newworld.name": "Название",
"newworld.defaultname": "Новый мир", "newworld.defaultname": "Новый мир",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Сглаживание рельефа", "newworld.smoothing": "Сглаживание рельефа",
"newworld.create": "Создать", "newworld.create": "Создать",
"newworld.back": "Назад", "newworld.back": "Назад",
"load.title": "Загрузка", "load.title": "Загрузка",
"load.empty": "Сохранений пока нет", "load.empty": "Сохранений пока нет",
"load.load": "Загрузить", "load.load": "Загрузить",
"load.delete": "Удалить", "load.delete": "Удалить",
"load.back": "Назад", "load.back": "Назад",
"settings.title": "Настройки", "settings.title": "Настройки",
"settings.language": "Язык", "settings.language": "Язык",
"settings.fullscreen": "Полный экран", "settings.fullscreen": "Полный экран",
@@ -41,7 +37,6 @@
"settings.volume": "Громкость", "settings.volume": "Громкость",
"settings.apply": "Применить", "settings.apply": "Применить",
"settings.back": "Назад", "settings.back": "Назад",
"pause.title": "Пауза", "pause.title": "Пауза",
"pause.resume": "Продолжить", "pause.resume": "Продолжить",
"pause.settings": "Настройки", "pause.settings": "Настройки",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Главное меню", "pause.mainmenu": "Главное меню",
"pause.quit": "Выход", "pause.quit": "Выход",
"pause.saved": "Сохранено: {0}", "pause.saved": "Сохранено: {0}",
"speed.pause": "⏸", "speed.pause": "⏸",
"credits.title": "Авторы", "credits.title": "Авторы",
"credits.author": "mrleo1nid", "credits.author": "mrleo1nid",
"credits.role": "Идея, код, движок mrgameeng", "credits.role": "Идея, код, движок mrgameeng",
"credits.back": "Назад", "credits.back": "Назад",
"preset.small": "Маленький", "preset.small": "Маленький",
"preset.medium": "Средний", "preset.medium": "Средний",
"preset.large": "Большой", "preset.large": "Большой",
"terrain.deepwater": "глубокая вода", "terrain.deepwater": "глубокая вода",
"terrain.water": "вода", "terrain.water": "вода",
"terrain.sand": "песок", "terrain.sand": "песок",
"terrain.grass": "трава", "terrain.grass": "трава",
"terrain.forest": "лес", "terrain.forest": "лес",
"terrain.mountain": "горы", "terrain.mountain": "горы",
"plant.oak": "дуб", "plant.oak": "дуб",
"plant.birch": "берёза", "plant.birch": "берёза",
"plant.pine": "сосна", "plant.pine": "сосна",
@@ -77,12 +67,10 @@
"plant.stage.sprout": "всходы", "plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец", "plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое", "plant.stage.mature": "взрослое",
"product.wood": "древесина", "product.wood": "древесина",
"product.grass": "трава", "product.grass": "трава",
"product.berry": "ягоды", "product.berry": "ягоды",
"product.acorn": "жёлудь", "product.acorn": "жёлудь",
"pawn.being": "житель", "pawn.being": "житель",
"pawn.bear": "медведь", "pawn.bear": "медведь",
"pawn.deer": "олень", "pawn.deer": "олень",
@@ -91,5 +79,10 @@
"pawn.boar": "кабан", "pawn.boar": "кабан",
"pawn.wolf": "волк", "pawn.wolf": "волк",
"pawn.muffalo": "муффало", "pawn.muffalo": "муффало",
"pawn.squirrel": "белка" "pawn.squirrel": "белка",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…",
"net.connected": "подключено",
"net.failed": "не удалось подключиться",
"net.lost": "соединение потеряно"
} }
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<SkinSet> <SkinSet>
<lifeStage> <lifeStage>
<appliesToAll>true</appliesToAll> <appliesToAll>true</appliesToAll>
<variants> <variants>
@@ -24,9 +23,6 @@
<commonality>0.4</commonality> <commonality>0.4</commonality>
<texName>GuineaPig3</texName> <texName>GuineaPig3</texName>
</skin> </skin>
</variants> </variants>
</lifeStage> </lifeStage>
</SkinSet> </SkinSet>
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<SkinSet> <SkinSet>
<lifeStage> <lifeStage>
<appliesToAll>true</appliesToAll> <appliesToAll>true</appliesToAll>
<variants> <variants>
@@ -39,8 +38,6 @@
<commonality>0.25</commonality> <commonality>0.25</commonality>
<texName>Horse6</texName> <texName>Horse6</texName>
</skin> </skin>
</variants> </variants>
</lifeStage> </lifeStage>
</SkinSet> </SkinSet>
+68
View File
@@ -0,0 +1,68 @@
# Веб-клиент: spike KNI и решение A/B
Дата: 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-сборки нельзя смешивать, пока графика движка не собирается пер-платформенно.
## Вопрос
Путь A — «MonoGame в браузере» через [KNI](https://github.com/kniEngine/kni)
(форк MonoGame с платформой Blazor WebAssembly/WebGL, те же неймспейсы
`Microsoft.Xna.Framework.*`). Путь B — тонкий веб-клиент без MonoGame
(TypeScript/PixiJS поверх сетевой репликации). Спайк проверял минимальную
жизнеспособность пути A: **ядро движка + Friflo + WebGL-спрайт в браузере**.
## Что сделано
`dotnet new kni-blazor-gl` (пакет шаблонов `nkast.Kni.Templates`, KNI 4.2.9001,
net8.0) + ProjectReference на `engine/src/MrGameEng.Core` + мини-хост в духе
`GameHost` поверх KNI `Game`. Сцена: 300 сущностей в Friflo `EntityStore`,
`QuerySystem` двигает их в Update-фазе (отскок от краёв, seed 42), Draw-фаза
рисует через KNI `SpriteBatch` (WebGL).
## Результат — путь A жизнеспособен
- **`MrGameEng.Core` работает в Blazor WASM без изменений**: `EngineContext`,
`GameClock`, `Scene`/`SceneManager`, тайминг переходов — всё ядро завелось
как есть (заслуга расслоения Core/Host: в ядре нет ни MonoGame, ни платформы).
- **Friflo.Engine.ECS 3.6 работает в wasm**: создание сущностей, архетипы,
`QuerySystem`, `ForEachEntity` — без ошибок в консоли браузера.
- **KNI 4.2.9001 рендерит через WebGL** с XNA-API: `Game`,
`GraphicsDeviceManager`, `SpriteBatch`, `Texture2D.SetData` — совпадает с
кодом, который пишется под десктопный MonoGame.
- Сборка тривиальна: обычный `Microsoft.NET.Sdk.BlazorWebAssembly` проект,
никаких wasm-workload-плясок не понадобилось.
## Известные ограничения пути A (работа на этапе «веб-клиент»)
1. **Пер-платформенная компиляция библиотек движка.** `MrGameEng.Graphics`,
`Content`, `Audio`, `UI` ссылаются на `MonoGame.Framework.DesktopGL`; для
веба их надо собирать против пакетов `nkast.*` (типы те же по API, но другие
сборки). Решение — msbuild-условие (`KniPlatform=BlazorGL` → nkast-пакеты),
без изменения исходников.
2. **Шейдеры.** `Renderer2D` использует прекомпилированные `dotnet-mgfxc`
эффекты — KNI имеет собственный компилятор эффектов; совместимость надо
проверять отдельным спайком, прежде чем тащить батчер в веб.
3. **Нет файловой системы.** Моды/дефы/атласы в браузер приезжают по HTTP;
текущая схема «собрать атласы при старте из PNG» в вебе не работает —
атласы пре-билдятся и кладутся в `wwwroot` (или приезжают с сервера).
4. **Потоки.** `Task.Run`-загрузка контента и `Thread.Sleep`-пейсинг не для
браузера (клиенту `HeadlessHost.Run` и не нужен — цикл гонит
`requestAnimationFrame` через KNI).
5. **Производительность не мерялась** (300 спрайтов — гладко); бюджет
сущностей в wasm-интерпретаторе будет заметно ниже десктопного, замерять
на реальной сцене с включённым AOT.
## Решение
**Путь A (KNI)** — основной для веб-клиента: переиспользуем ядро, сцены и
в перспективе графику движка; код игры один на все платформы. Путь B остаётся
запасным, если упрёмся в шейдеры (п. 2) или производительность (п. 5).
Порядок работ не меняется: сначала `MrGameEng.Net` (WebSocket-транспорт +
репликация — нужен любому пути) и сетевой мультиплеер на десктопе, затем
`MrGameEng.Host.Web` поверх KNI по образцу спайка.
+126
View File
@@ -0,0 +1,126 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Server;
/// <summary>
/// Серверный мир: жители бродят, устают и отдыхают (те же системы, что в WorldScene) на
/// фиксированном тике <see cref="HeadlessHost"/> — без окна, GPU и спрайтов. С
/// <see cref="WebSocketServer"/> мир ещё и реплицируется подключённым клиентам по схеме
/// <see cref="NetSchema"/> (10 снапшотов в секунду, дельты). Демонстрация MrGameEng.Net.
/// </summary>
internal sealed class HeadlessWorldScene : Scene
{
/// <summary>Жителей в серверном мире.</summary>
public const int PawnCount = 24;
/// <summary>Сид мира — рельефа пока нет, но блуждание детерминировано им.</summary>
public const int Seed = 424242;
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly GameContent _content;
private readonly WebSocketServer? _server;
/// <summary>Мир без сети (fast-forward) или, с <paramref name="server"/>, онлайн-мир.</summary>
public HeadlessWorldScene(GameContent content, WebSocketServer? server = null)
{
_content = content;
_server = server;
}
protected override void OnLoad()
{
Context.Services.Add(_content);
var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
var replication = _server is null ? null : new ReplicationServer(NetSchema.Create(), Store);
var random = new Random(Seed);
for (var i = 0; i < PawnCount; i++)
{
var position = new Vector2(
random.NextSingle() * Bounds.Width,
random.NextSingle() * Bounds.Height
);
var transform = new Transform2D(position, scale: new Vector2(12f));
var needs = new PawnNeeds { Energy = 0.4f + random.NextSingle() * 0.6f };
if (replication is null)
{
Store.CreateEntity(transform, new Wander(), new PawnBrain(), needs);
}
else
{
Store.CreateEntity(
transform,
new Wander(),
new PawnBrain(),
needs,
new NetId { Value = replication.NextNetId() }
);
}
}
UpdateSystems.Add(new PawnDecisionSystem());
UpdateSystems.Add(new PawnNeedsSystem());
UpdateSystems.Add(new WanderSystem(Seed, Bounds));
UpdateSystems.Add(new DayReportSystem(calendar, climate));
if (_server is not null && replication is not null)
{
UpdateSystems.Add(new NetworkSystem(_server, replication));
}
}
/// <summary>Принимает новые соединения и шлёт дельта-снапшоты с сетевой частотой.</summary>
private sealed class NetworkSystem(WebSocketServer server, ReplicationServer replication)
: BaseSystem
{
// 60 тиков симуляции / 6 = 10 снапшотов в секунду.
private const int SendEveryTicks = 6;
private int _ticks;
protected override void OnUpdateGroup()
{
while (server.TryAcceptConnection(out var connection))
{
Log.Info(
$"Клиент #{connection.Id} подключился ({server.Connections.Count} онлайн)"
);
}
if (++_ticks % SendEveryTicks == 0)
{
replication.Send(server.Connections);
}
}
}
/// <summary>Пишет строку состояния мира в лог на рассвете каждого игрового дня.</summary>
private sealed class DayReportSystem(Calendar calendar, Climate climate) : BaseSystem
{
private int _lastDay;
protected override void OnUpdateGroup()
{
if (calendar.Day == _lastDay)
{
return;
}
_lastDay = calendar.Day;
Log.Info(
$"День {calendar.Day} | год {climate.Year}, день года {climate.DayOfYear + 1}, "
+ $"{climate.Season} | {climate.Temperature:+0.0;-0.0;0.0} °C"
);
}
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LittleSim\LittleSim.csproj" />
</ItemGroup>
</Project>
+151
View File
@@ -0,0 +1,151 @@
using System.Diagnostics;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Server;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
// Дедикейтед-сервер LittleSim: мир без окна и GPU на HeadlessHost движка.
// dotnet run --project src/LittleSim.Server — fast-forward N дней
// dotnet run --project src/LittleSim.Server -- --days 30 — то же, явное число дней
// dotnet run --project src/LittleSim.Server -- --listen [--port N] — онлайн-мир (WebSocket)
// dotnet run --project src/LittleSim.Server -- --probe [--port N] — проверка: подключиться
// к серверу и показать реплицированных жителей
var days = ReadOption("--days", 10);
var ticksPerSecond = ReadOption("--tps", 60);
var port = ReadOption("--port", NetSchema.DefaultPort);
Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
if (Array.IndexOf(args, "--probe") >= 0)
{
return await Probe(port);
}
var content = GameContent.Load(buildAtlases: false);
Log.Info(
$"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
+ $"дефов: {content.Defs.All<TerrainDef>().Count} террейна, "
+ $"{content.Defs.All<PlantDef>().Count} растений, "
+ $"{content.Defs.All<PawnDef>().Count} жителей"
);
if (Array.IndexOf(args, "--listen") >= 0)
{
using var socketServer = new WebSocketServer(port);
socketServer.Start();
using var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = true },
new HeadlessWorldScene(content, socketServer)
);
using var cancel = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancel.Cancel();
};
Log.Info(
$"Мир онлайн: ws://localhost:{port}, {HeadlessWorldScene.PawnCount} жителей, "
+ $"{ticksPerSecond} тиков/с. Клиент: dotnet run --project src/LittleSim -- --connect. "
+ "Ctrl+C — остановка."
);
host.Run(cancel.Token);
Log.Info($"Сервер остановлен на тике {host.TickCount}.");
return 0;
}
using (
var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
new HeadlessWorldScene(content)
)
)
{
var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
var stopwatch = Stopwatch.StartNew();
host.RunTicks(ticks);
Log.Info(
$"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
+ $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
);
}
return 0;
// Подключается к работающему серверу, секунду слушает снапшоты и показывает жителей —
// консольная проверка репликации без игрового клиента.
async Task<int> Probe(int probePort)
{
var store = new EntityStore();
var replication = new ReplicationClient(NetSchema.Create(), store);
var uri = new Uri($"ws://localhost:{probePort}/");
Log.Info($"Подключение к {uri}…");
WebSocketClient connection;
try
{
connection = await WebSocketClient.ConnectAsync(
uri,
new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token
);
}
catch (Exception exception)
{
Log.Error($"Не подключилось: {exception.GetBaseException().Message}");
return 1;
}
using (connection)
{
await Task.Delay(1000);
replication.Pump(connection);
var first = SamplePositions(store);
Log.Info($"Снапшот получен: {replication.EntityCount} жителей");
await Task.Delay(2000);
replication.Pump(connection);
var second = SamplePositions(store);
for (var i = 0; i < first.Count; i++)
{
Log.Info(
$" житель {first[i].NetId}: ({first[i].X:F0}, {first[i].Y:F0}) → "
+ $"({second[i].X:F0}, {second[i].Y:F0})"
);
}
var moved = first.Where((p, i) => p.X != second[i].X || p.Y != second[i].Y).Count();
Log.Info($"Двигались {moved} из {first.Count} показанных — мир жив.");
return replication.EntityCount > 0 && moved > 0 ? 0 : 1;
}
}
static List<(int NetId, float X, float Y)> SamplePositions(EntityStore store)
{
var result = new List<(int, float, float)>();
foreach (var entity in store.Entities)
{
if (result.Count == 5)
{
break;
}
if (entity.HasComponent<NetId>() && entity.HasComponent<Transform2D>())
{
var position = entity.GetComponent<Transform2D>().Position;
result.Add((entity.GetComponent<NetId>().Value, position.X, position.Y));
}
}
return result;
}
int ReadOption(string name, int fallback)
{
var index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value)
? value
: fallback;
}
+12
View File
@@ -0,0 +1,12 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,15 @@
#----------------------------- Global Properties ----------------------------#
/outputDir:bin/$(Platform)
/intermediateDir:obj/$(Platform)
/platform:BlazorGL
/config:
/profile:Reach
/compress:True
#-------------------------------- References --------------------------------#
#---------------------------------- Content ---------------------------------#
+1
View File
@@ -0,0 +1 @@
<Project></Project>
+63
View File
@@ -0,0 +1,63 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>LittleSim.Web</RootNamespace>
<AssemblyName>LittleSim.Web</AssemblyName>
<DefineConstants>$(DefineConstants);BLAZORGL</DefineConstants>
<KniPlatform>BlazorGL</KniPlatform>
</PropertyGroup>
<PropertyGroup>
<BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
</PropertyGroup>
<ItemGroup>
<Compile Include="Pages\Index.razor.cs" />
<Compile Include="Program.cs" />
<Compile Include="LittleSimWebGame.cs" />
<Compile Include="NetContract.cs" />
<Compile Include="WorldViewScene.cs" />
</ItemGroup>
<ItemGroup>
<!--
Веб-клиент собирается против 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>
<PackageReference Include="nkast.Xna.Framework" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Content" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Graphics" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Audio" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Media" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Input" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Game" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Devices" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Storage" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.XR" Version="4.2.9001" />
<PackageReference Include="nkast.Kni.Platform.Blazor.GL" Version="4.2.9001.2" />
<PackageReference Include="nkast.Xna.Framework.Content.Pipeline.Builder" Version="4.2.9001" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.17" />
<PackageReference
Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer"
Version="8.0.17"
PrivateAssets="all"
/>
</ItemGroup>
<ItemGroup>
<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);
}
}
+7
View File
@@ -0,0 +1,7 @@
@inherits LayoutComponentBase
<div class="page">
<main>
@Body
</main>
</div>
+98
View File
@@ -0,0 +1,98 @@
.page
{
position: relative;
display: flex;
flex-direction: column;
}
main
{
flex: 1;
}
.sidebar
{
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row
{
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link
{
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover
{
text-decoration: underline;
}
.top-row ::deep a:first-child
{
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px)
{
.top-row:not(.auth)
{
display: none;
}
.top-row.auth
{
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link
{
margin-left: 0;
}
}
@media (min-width: 641px)
{
.page
{
flex-direction: row;
}
.sidebar
{
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row
{
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child
{
flex: 1;
text-align: right;
width: 0;
}
.top-row, article
{
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
+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>();
}
+20
View File
@@ -0,0 +1,20 @@
@page "/"
@page "/index.html"
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
margin:0%;
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width:100vw;
height:100vh;
">
<canvas id="theCanvas" style="touch-action:none;"></canvas>
</div>
+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;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace LittleSim.Web
{
internal class Program
{
private static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient()
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
});
await builder.Build().RunAsync();
}
}
}
@@ -0,0 +1,30 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:56897",
"sslPort": 0
}
},
"profiles": {
"KniWebSpike": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5259",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+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();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using nkast.Wasm.Canvas
@using LittleSim.Web
+97
View File
@@ -0,0 +1,97 @@
html, body
{
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus
{
outline: none;
}
a, .btn-link
{
color: #0077cc;
}
.btn-primary
{
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.content
{
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox])
{
outline: 1px solid #26b050;
}
.invalid
{
outline: 1px solid red;
}
.validation-message
{
color: red;
}
#blazor-error-ui
{
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss
{
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary
{
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after
{
content: "An error has occurred."
}
#theCanvas
{
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
/* Disable text highlighting and magnifying glass on iPhone/webkit */
-webkit-user-select: none;
}
#canvas
{
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>LittleSim</title>
<base href="./" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link href="LittleSim.Web.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<div id="loading" style="display: table-cell; margin: auto; width:100vw; height:100vh; vertical-align: middle; background: #ffcc10;">
<div style="display: block; margin: auto; width: 9em; color: white;font-family: 'Segoe UI', sans-serif;">
<div style="text-align: center; font-size: 0.85em;">Made with<br/><a href="https://github.com/kniEngine/kni"><img src="kni.png" border="0" alt="Kni"></a></div>
<div style="text-align: center; font-size: 1.8em;">loading&nbsp;<marquee style="width:0.9em; vertical-align: bottom;">.&nbsp;.&nbsp;.&nbsp;&nbsp;&nbsp;</marquee></div>
</div>
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">x</a>
</div>
<script src="_framework/blazor.webassembly.js" autostart="false"></script>
<script type="module">
import { BrotliDecode } from './js/decode.min.js';
window.BrotliDecode = BrotliDecode;
// Set this to enable Brotli (.br) decompression on static webServers
// that don't support content compression and http://.
var enableBrotliDecompression = false;
Blazor.start({
loadBootResource: function (type, name, defaultUri, integrity)
{
if (enableBrotliDecompression === true && type !== 'dotnetjs' && location.hostname !== 'localhost')
{
return (async function()
{
const response = await fetch(defaultUri + '.br', { cache: 'no-cache' });
if (!response.ok)
throw new Error(response.statusText);
const originalResponseBuffer = await response.arrayBuffer();
const originalResponseArray = new Int8Array(originalResponseBuffer);
const contentType = (type === 'dotnetwasm')
? 'application/wasm'
: 'application/octet-stream';
const decompressedResponseArray = BrotliDecode(originalResponseArray);
return new Response(decompressedResponseArray,
{ headers: { 'content-type': contentType }
});
})();
}
}
});
</script>
<script src="_content/nkast.Wasm.JSInterop/js/JSObject.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Window.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Document.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Navigator.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Gamepad.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Dom/js/Media.8.0.11.js"></script>
<script src="_content/nkast.Wasm.XHR/js/XHR.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Canvas/js/Canvas.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Canvas/js/CanvasGLContext.8.0.11.js"></script>
<script src="_content/nkast.Wasm.Audio/js/Audio.8.0.11.js"></script>
<script src="_content/nkast.Wasm.XR/js/XR.8.0.11.js"></script>
<script>
function tickJS()
{
window.theInstance.invokeMethod('TickDotNet');
window.requestAnimationFrame(tickJS);
}
window.initRenderJS = (instance) =>
{
window.theInstance = instance;
// set initial canvas size
var canvas = document.getElementById('theCanvas');
var holder = document.getElementById('canvasHolder');
canvas.width = holder.clientWidth;
canvas.height = holder.clientHeight;
// disable context menu on right click
canvas.addEventListener("contextmenu", e => e.preventDefault());
// begin game loop
window.requestAnimationFrame(tickJS);
};
window.addEventListener("keydown", function(event)
{
// Prevent Arrows Keys and Spacebar scrolling the outer page
// when running inside an iframe. e.g: itch.io embedding.
if ([32, 37, 38, 39, 40].indexOf(event.keyCode) > -1)
event.preventDefault();
});
window.addEventListener("wheel", function(event)
{
// Prevent Mousewheel scrolling the outer page
// when running inside an iframe. e.g: itch.io embedding.
event.preventDefault();
}, { passive: false });
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
// micProcessor.js
class MicProcessor extends AudioWorkletProcessor
{
constructor()
{
super();
// global variables for testing
var sampleRate = globalThis.sampleRate;
var currentFrame = globalThis.currentFrame;
var currentTime = globalThis.currentTime;
var currentRenderQuantum = globalThis.currentRenderQuantum;
this.SampleRate = sampleRate;
this.TargetSamples = Math.floor(this.SampleRate * 0.1); // 100ms
this.Buffer = new Float32Array(this.TargetSamples);
this.BufferIndex = 0;
this.port.onmessage = (event) =>
{
var data = event.data;
if (typeof data === 'number')
{
//this.port.postMessage(data); // echo back test
}
if (data instanceof Uint8Array)
{
}
};
}
process(inputs, outputs, parameters)
{
var inChannel0 = inputs[0][0];
if (!inChannel0) return true;
let srcIndex = 0;
var srcLen = inChannel0.length;
while (srcIndex < srcLen)
{
var remaining = this.TargetSamples - this.BufferIndex;
var copyCount = Math.min(remaining, srcLen - srcIndex);
this.Buffer.set(
inChannel0.subarray(srcIndex, srcIndex + copyCount),
this.BufferIndex);
this.BufferIndex += copyCount;
srcIndex += copyCount;
if (this.BufferIndex >= this.TargetSamples)
{
this.SendBuffer();
this.BufferIndex = 0;
}
}
return true;
}
SendBuffer()
{
// convert to 16-6bit PCM
var int16 = new Int16Array(this.TargetSamples);
for (var i = 0; i < this.TargetSamples; i++)
{
int16[i] = this.Buffer[i] * 32767;
}
var byteArray = new Uint8Array(int16.buffer);
this.port.postMessage(byteArray, [byteArray.buffer]);
}
}
registerProcessor('mic-processor', MicProcessor);
@@ -0,0 +1,87 @@
// streamProcessor.js
class StreamProcessor extends AudioWorkletProcessor
{
constructor()
{
super();
this.queue = [];
this.port.onmessage = (event) =>
{
var data = event.data;
if (typeof data === 'number')
{
if (data === 2)
{
this.queue = [];
}
}
if (data instanceof Uint8Array)
{
const buffer = new Int16Array(data.buffer, data.byteOffset, data.length / 2);
buffer.offset = 0;
this.queue.push(buffer);
}
};
}
process(inputs, outputs, parameters)
{
const output = outputs[0];
const channelCount = output.length;
const sampleCount = output[0].length;
let written = 0;
while (written < sampleCount && this.queue.length > 0)
{
const buffer = this.queue[0];
const offset = buffer.offset;
const available = buffer.length - offset;
const needed = sampleCount - written;
const copyCount = Math.min(available, needed);
for (let i = 0; i < copyCount; i++)
{
for (let c = 0; c < channelCount; c++)
{
const channel = output[c];
let value = (buffer[offset+i] / 32767);
channel[written+i] = value;
}
}
written += copyCount;
buffer.offset += copyCount;
if (buffer.offset >= buffer.length)
{
this.queue.shift();
this.port.postMessage(1);
}
}
// Fill remaining samples with silence
if (written < sampleCount)
{
for (let c = 0; c < channelCount; c++)
{
const channel = output[c];
for (let i = written; i < sampleCount; i++)
{
let value = 0;
channel[i] = value;
}
}
}
return true;
}
}
registerProcessor("stream-processor", StreamProcessor);
Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

+10 -1
View File
@@ -45,8 +45,10 @@ public sealed class GameContent
/// <summary> /// <summary>
/// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и /// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и
/// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов. /// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов.
/// С <paramref name="buildAtlases"/> = false атласы не собираются — headless-режим
/// (дедикейтед-сервер) текстур не рисует.
/// </summary> /// </summary>
public static GameContent Load() public static GameContent Load(bool buildAtlases = true)
{ {
var modsRoot = var modsRoot =
ModLoader.FindModsRoot(AppContext.BaseDirectory) ModLoader.FindModsRoot(AppContext.BaseDirectory)
@@ -82,6 +84,8 @@ public sealed class GameContent
"Cache", "Cache",
"Atlases" "Atlases"
); );
if (buildAtlases)
{
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp"); var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
var result = AtlasBuilder.Build( var result = AtlasBuilder.Build(
new AtlasBuildOptions new AtlasBuildOptions
@@ -95,6 +99,11 @@ public sealed class GameContent
Log.Info( Log.Info(
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)" $"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
); );
}
else
{
Log.Info("Atlases: skipped (headless)");
}
return new GameContent( return new GameContent(
mods, mods,
+1
View File
@@ -11,6 +11,7 @@
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference <ProjectReference
Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
+23
View File
@@ -0,0 +1,23 @@
using LittleSim.Sim;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Net;
/// <summary>
/// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов.
/// Сервер (LittleSim.Server) и клиент (<see cref="Scenes.MultiplayerScene"/>) обязаны
/// строить схему одинаково — порядок регистрации определяет wire-id компонентов.
/// ВНИМАНИЕ: у веб-клиента бинарное зеркало этой схемы
/// (src/LittleSim.Web/NetContract.cs — он собран против KNI и не может ссылаться сюда);
/// меняешь состав или порядок — меняй и там.
/// </summary>
public static class NetSchema
{
/// <summary>Порт сервера по умолчанию.</summary>
public const int DefaultPort = 9050;
/// <summary>Схема репликации: позиция/масштаб жителя и его потребности.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<Transform2D>().Register<PawnNeeds>();
}
+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;
}
}
}
}
+14 -2
View File
@@ -1,7 +1,7 @@
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes; using LittleSim.Scenes;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Genetics; using MrGameEng.Genetics;
using MrGameEng.Host; using MrGameEng.Host;
@@ -30,6 +30,18 @@ if (args.Contains("--check-content"))
} }
} }
// --connect [ws://host:port] — после загрузки контента сразу в сетевую сцену
// (см. MultiplayerScene); без аргументов — обычный запуск в главное меню.
string? connect = null;
var connectIndex = Array.IndexOf(args, "--connect");
if (connectIndex >= 0)
{
connect =
connectIndex + 1 < args.Length && !args[connectIndex + 1].StartsWith("--")
? args[connectIndex + 1]
: $"ws://localhost:{NetSchema.DefaultPort}/";
}
// Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне. // Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost( using var host = new GameHost(
new GameHostOptions new GameHostOptions
@@ -39,7 +51,7 @@ using var host = new GameHost(
Height = 720, Height = 720,
ClearColor = new Color(12, 16, 24), ClearColor = new Color(12, 16, 24),
}, },
new BootScene() new BootScene(connect)
); );
host.Run(); host.Run();
+14 -2
View File
@@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
@@ -19,10 +20,18 @@ namespace LittleSim.Scenes;
public sealed class BootScene : Scene public sealed class BootScene : Scene
{ {
private readonly string[] _steps = { "Загрузка", "Loading" }; private readonly string[] _steps = { "Загрузка", "Loading" };
private readonly string? _connectTo;
private Task<GameContent>? _load; private Task<GameContent>? _load;
private GameSettings _settings = new(); private GameSettings _settings = new();
private Label _label = null!; private Label _label = null!;
/// <summary>Обычный запуск — в главное меню.</summary>
public BootScene()
: this(null) { }
/// <summary>С <paramref name="connectTo"/> (ws://host:port) грузится сразу в сетевую сцену.</summary>
public BootScene(string? connectTo) => _connectTo = connectTo;
protected override void OnLoad() protected override void OnLoad()
{ {
Context.UseAudio(); Context.UseAudio();
@@ -47,7 +56,7 @@ public sealed class BootScene : Scene
desktop.Root = Ui.Screen(_label); desktop.Root = Ui.Screen(_label);
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока. // Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(GameContent.Load); _load = Task.Run(() => GameContent.Load());
UpdateSystems.Add(new CallbackSystem(Tick)); UpdateSystems.Add(new CallbackSystem(Tick));
} }
@@ -74,6 +83,9 @@ public sealed class BootScene : Scene
Context.Services.Add(content); Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory)); Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language); content.Languages.SetLanguage(_settings.Language);
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.6f)); Scene next = _connectTo is null
? new MainMenuScene()
: new MultiplayerScene(new Uri(_connectTo));
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
} }
} }
+136
View File
@@ -0,0 +1,136 @@
using System;
using System.Threading.Tasks;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Sim;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.Net;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Сетевой клиент: подключается к дедикейтед-серверу (LittleSim.Server --listen) и рендерит
/// реплицированных жителей. Симуляция целиком на сервере; сюда приезжают только компоненты
/// из <see cref="NetSchema"/>, а спрайты вешаются локально при спавне — граница
/// sim/presentation проходит теперь через сеть. Esc — назад в меню, команда консоли `net` —
/// состояние соединения.
/// </summary>
public sealed class MultiplayerScene : Scene
{
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly Uri _server;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
private ReplicationClient _replication = null!;
private InputManager _input = null!;
private GameContent _content = null!;
private Label _hud = null!;
private string _statusKey = "net.connecting";
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
public MultiplayerScene(Uri server) => _server = server;
protected override void OnLoad()
{
_content = Context.Services.Get<GameContent>();
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
_input = this.UseInput();
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
Store.CreateEntity(new Camera(Bounds.Center, zoom: 1f, bounds: Bounds));
// Реплика пишет в ECS сцены; презентацию (спрайт) вешаем сами при спавне.
_replication = new ReplicationClient(NetSchema.Create(), Store);
_replication.EntitySpawned += entity =>
{
var sprite = new Sprite(white, GameLayers.Beings);
sprite.CenterOrigin();
entity.AddComponent(sprite);
entity.AddComponent(new NetLerp());
};
var desktop = this.UseUI();
_hud = new Label { Left = 10, Top = 8 };
desktop.Root = Ui.Screen(_hud);
UpdateSystems.Add(new CallbackSystem(Pump));
// Снапшоты приходят ~10 раз в секунду — сглаживаем позиции до частоты кадра.
UpdateSystems.Add(new NetSmoothingSystem());
// Усталость жителей видна и по сети: PawnNeeds реплицируется, спрайт темнеет локально.
UpdateSystems.Add(new PawnAppearanceSystem());
var console = this.UseDevConsole();
console.Register(
"net",
"net — connection status and replicated entity count",
(c, _) =>
{
c.WriteLine($"server: {_server}");
c.WriteLine($"state: {(_connection?.IsOpen == true ? "connected" : "offline")}");
c.WriteLine($"entities: {_replication.EntityCount}");
}
);
_connecting = WebSocketClient.ConnectAsync(_server);
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
if (_connecting is { IsCompleted: true } finished)
{
_connecting = null;
if (finished.IsFaulted)
{
_statusKey = "net.failed";
Log.Error(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
}
else
{
_connection = finished.Result;
_statusKey = "net.connected";
Log.Info($"Connected to {_server}");
}
}
if (_connection is not null)
{
_replication.Pump(_connection);
if (!_connection.IsOpen)
{
_statusKey = "net.lost";
}
}
_hud.Text = _content.Languages.Format(
"net.hud",
_content.Languages.Get(_statusKey),
_replication.EntityCount
);
if (_input.IsKeyPressed(Keys.Escape))
{
_connection?.Close();
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}