Compare commits
4
Commits
bfe1b139f0
...
580cb6ccc9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
580cb6ccc9 | ||
|
|
e8273f9ffa | ||
|
|
ce8f8ba2ed | ||
|
|
8a7e2cce52 |
@@ -14,12 +14,18 @@ Game design docs live in `docs/` and are written in **Russian**. Engine rules li
|
||||
## Layout
|
||||
|
||||
```
|
||||
engine/ mrgameeng git submodule (own repo, own CLAUDE.md)
|
||||
src/LittleSim the game (net8.0); references engine projects directly
|
||||
Mods/Core the game's own content as a mod: About, Defs, Languages, Textures
|
||||
Cache/ runtime-built atlases (gitignored)
|
||||
docs/ концепт, симуляция, моды, roadmap (Russian)
|
||||
LittleSim.sln game + engine sources + engine tests — one window for everything
|
||||
engine/ mrgameeng git submodule (own repo, own CLAUDE.md)
|
||||
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
|
||||
Cache/ runtime-built atlases (gitignored)
|
||||
docs/ концепт, симуляция, моды, roadmap (Russian)
|
||||
LittleSim.sln game + engine sources + engine tests — one window for everything
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -28,6 +34,11 @@ LittleSim.sln game + engine sources + engine tests — one window for everythin
|
||||
git submodule update --init # after fresh clone
|
||||
dotnet build LittleSim.sln
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -41,6 +41,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "engin
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "engine\tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{E0E37D87-4F62-41E9-9CD1-3E7432301508}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "engine\src\MrGameEng.Host\MrGameEng.Host.csproj", "{D4222C26-40D8-4465-9B26-CD6BD0722EC0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "engine\tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{41F0249B-106E-4D56-B022-73D2C2D74807}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{BEFB468B-6784-468E-9B60-EB44AB078D15}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "engine\src\MrGameEng.Net\MrGameEng.Net.csproj", "{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}"
|
||||
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
|
||||
@@ -231,6 +243,78 @@ Global
|
||||
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x64.Build.0 = Release|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.Build.0 = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{61C84FE7-1E0A-4CF2-A63F-39B23F936A7E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{03EC6C39-E1DE-4C66-835F-4B05B5C40DB0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{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
|
||||
@@ -253,5 +337,11 @@ Global
|
||||
{86ECA4A0-46A2-45F4-8CF4-8FB8BB5DC050} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
{E0E37D87-4F62-41E9-9CD1-3E7432301508} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
{D4222C26-40D8-4465-9B26-CD6BD0722EC0} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
|
||||
{41F0249B-106E-4D56-B022-73D2C2D74807} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
|
||||
{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
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"season.winter": "winter",
|
||||
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
|
||||
"hud.paused": "PAUSED",
|
||||
|
||||
"menu.title": "LittleSim",
|
||||
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
|
||||
"menu.newworld": "New World",
|
||||
@@ -16,7 +15,6 @@
|
||||
"menu.settings": "Settings",
|
||||
"menu.credits": "Credits",
|
||||
"menu.quit": "Quit",
|
||||
|
||||
"newworld.title": "New World",
|
||||
"newworld.name": "Name",
|
||||
"newworld.defaultname": "New World",
|
||||
@@ -26,13 +24,11 @@
|
||||
"newworld.smoothing": "Terrain smoothing",
|
||||
"newworld.create": "Create",
|
||||
"newworld.back": "Back",
|
||||
|
||||
"load.title": "Load",
|
||||
"load.empty": "No saves yet",
|
||||
"load.load": "Load",
|
||||
"load.delete": "Delete",
|
||||
"load.back": "Back",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.fullscreen": "Fullscreen",
|
||||
@@ -41,7 +37,6 @@
|
||||
"settings.volume": "Volume",
|
||||
"settings.apply": "Apply",
|
||||
"settings.back": "Back",
|
||||
|
||||
"pause.title": "Paused",
|
||||
"pause.resume": "Resume",
|
||||
"pause.settings": "Settings",
|
||||
@@ -49,25 +44,20 @@
|
||||
"pause.mainmenu": "Main menu",
|
||||
"pause.quit": "Quit",
|
||||
"pause.saved": "Saved: {0}",
|
||||
|
||||
"speed.pause": "⏸",
|
||||
|
||||
"credits.title": "Credits",
|
||||
"credits.author": "mrleo1nid",
|
||||
"credits.role": "Concept, code, mrgameeng engine",
|
||||
"credits.back": "Back",
|
||||
|
||||
"preset.small": "Small",
|
||||
"preset.medium": "Medium",
|
||||
"preset.large": "Large",
|
||||
|
||||
"terrain.deepwater": "deep water",
|
||||
"terrain.water": "water",
|
||||
"terrain.sand": "sand",
|
||||
"terrain.grass": "grass",
|
||||
"terrain.forest": "forest",
|
||||
"terrain.mountain": "mountains",
|
||||
|
||||
"plant.oak": "oak",
|
||||
"plant.birch": "birch",
|
||||
"plant.pine": "pine",
|
||||
@@ -77,7 +67,6 @@
|
||||
"plant.stage.sprout": "sprout",
|
||||
"plant.stage.sapling": "sapling",
|
||||
"plant.stage.mature": "mature",
|
||||
|
||||
"pawn.being": "being",
|
||||
"pawn.bear": "bear",
|
||||
"pawn.deer": "deer",
|
||||
@@ -86,5 +75,10 @@
|
||||
"pawn.boar": "boar",
|
||||
"pawn.wolf": "wolf",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"season.winter": "зима",
|
||||
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
|
||||
"hud.paused": "ПАУЗА",
|
||||
|
||||
"menu.title": "LittleSim",
|
||||
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
|
||||
"menu.newworld": "Новый мир",
|
||||
@@ -16,7 +15,6 @@
|
||||
"menu.settings": "Настройки",
|
||||
"menu.credits": "Авторы",
|
||||
"menu.quit": "Выход",
|
||||
|
||||
"newworld.title": "Новый мир",
|
||||
"newworld.name": "Название",
|
||||
"newworld.defaultname": "Новый мир",
|
||||
@@ -26,13 +24,11 @@
|
||||
"newworld.smoothing": "Сглаживание рельефа",
|
||||
"newworld.create": "Создать",
|
||||
"newworld.back": "Назад",
|
||||
|
||||
"load.title": "Загрузка",
|
||||
"load.empty": "Сохранений пока нет",
|
||||
"load.load": "Загрузить",
|
||||
"load.delete": "Удалить",
|
||||
"load.back": "Назад",
|
||||
|
||||
"settings.title": "Настройки",
|
||||
"settings.language": "Язык",
|
||||
"settings.fullscreen": "Полный экран",
|
||||
@@ -41,7 +37,6 @@
|
||||
"settings.volume": "Громкость",
|
||||
"settings.apply": "Применить",
|
||||
"settings.back": "Назад",
|
||||
|
||||
"pause.title": "Пауза",
|
||||
"pause.resume": "Продолжить",
|
||||
"pause.settings": "Настройки",
|
||||
@@ -49,25 +44,20 @@
|
||||
"pause.mainmenu": "Главное меню",
|
||||
"pause.quit": "Выход",
|
||||
"pause.saved": "Сохранено: {0}",
|
||||
|
||||
"speed.pause": "⏸",
|
||||
|
||||
"credits.title": "Авторы",
|
||||
"credits.author": "mrleo1nid",
|
||||
"credits.role": "Идея, код, движок mrgameeng",
|
||||
"credits.back": "Назад",
|
||||
|
||||
"preset.small": "Маленький",
|
||||
"preset.medium": "Средний",
|
||||
"preset.large": "Большой",
|
||||
|
||||
"terrain.deepwater": "глубокая вода",
|
||||
"terrain.water": "вода",
|
||||
"terrain.sand": "песок",
|
||||
"terrain.grass": "трава",
|
||||
"terrain.forest": "лес",
|
||||
"terrain.mountain": "горы",
|
||||
|
||||
"plant.oak": "дуб",
|
||||
"plant.birch": "берёза",
|
||||
"plant.pine": "сосна",
|
||||
@@ -77,7 +67,6 @@
|
||||
"plant.stage.sprout": "всходы",
|
||||
"plant.stage.sapling": "саженец",
|
||||
"plant.stage.mature": "взрослое",
|
||||
|
||||
"pawn.being": "житель",
|
||||
"pawn.bear": "медведь",
|
||||
"pawn.deer": "олень",
|
||||
@@ -86,5 +75,10 @@
|
||||
"pawn.boar": "кабан",
|
||||
"pawn.wolf": "волк",
|
||||
"pawn.muffalo": "муффало",
|
||||
"pawn.squirrel": "белка"
|
||||
"pawn.squirrel": "белка",
|
||||
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
|
||||
"net.connecting": "подключение…",
|
||||
"net.connected": "подключено",
|
||||
"net.failed": "не удалось подключиться",
|
||||
"net.lost": "соединение потеряно"
|
||||
}
|
||||
|
||||
@@ -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 по образцу спайка.
|
||||
+1
-1
Submodule engine updated: d0104df304...3438ed77f6
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ---------------------------------#
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<Project></Project>
|
||||
@@ -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>
|
||||
@@ -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,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<main>
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 <marquee style="width:0.9em; vertical-align: bottom;">. . . </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
+1
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 |
@@ -44,8 +44,10 @@ public sealed class GameContent
|
||||
/// <summary>
|
||||
/// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и
|
||||
/// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов.
|
||||
/// С <paramref name="buildAtlases"/> = false атласы не собираются — headless-режим
|
||||
/// (дедикейтед-сервер) текстур не рисует.
|
||||
/// </summary>
|
||||
public static GameContent Load()
|
||||
public static GameContent Load(bool buildAtlases = true)
|
||||
{
|
||||
var modsRoot =
|
||||
ModLoader.FindModsRoot(AppContext.BaseDirectory)
|
||||
@@ -71,19 +73,26 @@ public sealed class GameContent
|
||||
"Cache",
|
||||
"Atlases"
|
||||
);
|
||||
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
|
||||
var result = AtlasBuilder.Build(
|
||||
new AtlasBuildOptions
|
||||
{
|
||||
OutputDirectory = atlasCacheDirectory,
|
||||
GroupDepth = ModAtlases.GroupDepth,
|
||||
},
|
||||
textures.Files.Select(f => (f.FullPath, f.RelativePath))
|
||||
);
|
||||
var built = result.Groups.Count(g => !g.Skipped);
|
||||
Log.Info(
|
||||
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
|
||||
);
|
||||
if (buildAtlases)
|
||||
{
|
||||
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
|
||||
var result = AtlasBuilder.Build(
|
||||
new AtlasBuildOptions
|
||||
{
|
||||
OutputDirectory = atlasCacheDirectory,
|
||||
GroupDepth = ModAtlases.GroupDepth,
|
||||
},
|
||||
textures.Files.Select(f => (f.FullPath, f.RelativePath))
|
||||
);
|
||||
var built = result.Groups.Count(g => !g.Skipped);
|
||||
Log.Info(
|
||||
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Info("Atlases: skipped (headless)");
|
||||
}
|
||||
|
||||
return new GameContent(
|
||||
mods,
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Host\MrGameEng.Host.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
|
||||
<ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.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.Assets.Generator\MrGameEng.Assets.Generator.csproj"
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
using LittleSim.Net;
|
||||
using LittleSim.Scenes;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
|
||||
// --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), в фоне.
|
||||
using var host = new GameHost(
|
||||
@@ -11,7 +24,7 @@ using var host = new GameHost(
|
||||
Height = 720,
|
||||
ClearColor = new Color(12, 16, 24),
|
||||
},
|
||||
new BootScene()
|
||||
new BootScene(connect)
|
||||
);
|
||||
|
||||
host.Run();
|
||||
|
||||
@@ -1,78 +1,90 @@
|
||||
using System.Threading.Tasks;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
|
||||
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
|
||||
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
|
||||
/// </summary>
|
||||
public sealed class BootScene : Scene
|
||||
{
|
||||
private readonly string[] _steps = { "Загрузка", "Loading" };
|
||||
private Task<GameContent>? _load;
|
||||
private GameSettings _settings = new();
|
||||
private Label _label = null!;
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
Context.UseAudio();
|
||||
Context.UseGameSpeed(1f, 3f, 6f);
|
||||
|
||||
_settings = GameSettingsStore.Load();
|
||||
var host = (GameHost)Context.Services.Get<Game>();
|
||||
host.Graphics.IsFullScreen = _settings.Fullscreen;
|
||||
host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
|
||||
host.Graphics.PreferredBackBufferWidth = _settings.Width;
|
||||
host.Graphics.PreferredBackBufferHeight = _settings.Height;
|
||||
host.Graphics.ApplyChanges();
|
||||
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
|
||||
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
_label = new Label
|
||||
{
|
||||
TextColor = Ui.Accent,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
desktop.Root = Ui.Screen(_label);
|
||||
|
||||
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
|
||||
_load = Task.Run(GameContent.Load);
|
||||
UpdateSystems.Add(new CallbackSystem(Tick));
|
||||
}
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
var word = _settings.Language == "en" ? _steps[1] : _steps[0];
|
||||
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
|
||||
_label.Text = word + dots;
|
||||
|
||||
if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_load.IsFaulted)
|
||||
{
|
||||
_label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
|
||||
Log.Error($"Content load failed: {_label.Text}");
|
||||
return;
|
||||
}
|
||||
|
||||
var content = _load.Result;
|
||||
_load = null;
|
||||
Context.Services.Add(content);
|
||||
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
|
||||
content.Languages.SetLanguage(_settings.Language);
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.6f));
|
||||
}
|
||||
}
|
||||
using System.Threading.Tasks;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
|
||||
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
|
||||
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
|
||||
/// </summary>
|
||||
public sealed class BootScene : Scene
|
||||
{
|
||||
private readonly string[] _steps = { "Загрузка", "Loading" };
|
||||
private readonly string? _connectTo;
|
||||
private Task<GameContent>? _load;
|
||||
private GameSettings _settings = new();
|
||||
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()
|
||||
{
|
||||
Context.UseAudio();
|
||||
Context.UseGameSpeed(1f, 3f, 6f);
|
||||
|
||||
_settings = GameSettingsStore.Load();
|
||||
var host = (GameHost)Context.Services.Get<Game>();
|
||||
host.Graphics.IsFullScreen = _settings.Fullscreen;
|
||||
host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
|
||||
host.Graphics.PreferredBackBufferWidth = _settings.Width;
|
||||
host.Graphics.PreferredBackBufferHeight = _settings.Height;
|
||||
host.Graphics.ApplyChanges();
|
||||
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
|
||||
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
_label = new Label
|
||||
{
|
||||
TextColor = Ui.Accent,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
desktop.Root = Ui.Screen(_label);
|
||||
|
||||
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
|
||||
_load = Task.Run(() => GameContent.Load());
|
||||
UpdateSystems.Add(new CallbackSystem(Tick));
|
||||
}
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
var word = _settings.Language == "en" ? _steps[1] : _steps[0];
|
||||
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
|
||||
_label.Text = word + dots;
|
||||
|
||||
if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_load.IsFaulted)
|
||||
{
|
||||
_label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
|
||||
Log.Error($"Content load failed: {_label.Text}");
|
||||
return;
|
||||
}
|
||||
|
||||
var content = _load.Result;
|
||||
_load = null;
|
||||
Context.Services.Add(content);
|
||||
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
|
||||
content.Languages.SetLanguage(_settings.Language);
|
||||
Scene next = _connectTo is null
|
||||
? new MainMenuScene()
|
||||
: new MultiplayerScene(new Uri(_connectTo));
|
||||
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.</summary>
|
||||
public sealed class CreditsScene : Scene
|
||||
{
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var lang = Context.Services.Get<GameContent>().Languages;
|
||||
var input = this.UseInput();
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
|
||||
var column = Ui.Column(12);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
|
||||
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
|
||||
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
|
||||
|
||||
desktop.Root = Ui.Screen(column);
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.</summary>
|
||||
public sealed class CreditsScene : Scene
|
||||
{
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var lang = Context.Services.Get<GameContent>().Languages;
|
||||
var input = this.UseInput();
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
|
||||
var column = Ui.Column(12);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
|
||||
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
|
||||
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
|
||||
|
||||
desktop.Root = Ui.Screen(column);
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +1,111 @@
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Экран загрузки: список сохранений из <see cref="SaveStore"/> (имя, размер, дата) с
|
||||
/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
|
||||
/// </summary>
|
||||
public sealed class LoadGameScene : Scene
|
||||
{
|
||||
private readonly SaveStore _store = new();
|
||||
private GameContent _content = null!;
|
||||
private Desktop _desktop = null!;
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
_content = Context.Services.Get<GameContent>();
|
||||
var input = this.UseInput();
|
||||
_desktop = this.UseUI();
|
||||
Rebuild();
|
||||
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
var lang = _content.Languages;
|
||||
var column = Ui.Column(8);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("load.title")));
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
|
||||
var saves = _store.List();
|
||||
if (saves.Count == 0)
|
||||
{
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty")));
|
||||
}
|
||||
|
||||
foreach (var entry in saves)
|
||||
{
|
||||
var save = entry.Save;
|
||||
var row = Ui.Row(8);
|
||||
row.Widgets.Add(
|
||||
new Label
|
||||
{
|
||||
TextColor = Ui.Accent,
|
||||
Text =
|
||||
$"{save.Name} — {save.Width}×{save.Height} — "
|
||||
+ save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
|
||||
}
|
||||
);
|
||||
var file = entry.FileName;
|
||||
row.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("load.load"),
|
||||
() =>
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(
|
||||
new WorldScene(save.ToConfig(), save),
|
||||
Transition.Fade(0.6f)
|
||||
);
|
||||
}
|
||||
},
|
||||
width: 130
|
||||
)
|
||||
);
|
||||
row.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("load.delete"),
|
||||
() =>
|
||||
{
|
||||
_store.Delete(file);
|
||||
Rebuild();
|
||||
},
|
||||
width: 130
|
||||
)
|
||||
);
|
||||
column.Widgets.Add(row);
|
||||
}
|
||||
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
|
||||
|
||||
_desktop.Root = Ui.Screen(column);
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Экран загрузки: список сохранений из <see cref="SaveStore"/> (имя, размер, дата) с
|
||||
/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
|
||||
/// </summary>
|
||||
public sealed class LoadGameScene : Scene
|
||||
{
|
||||
private readonly SaveStore _store = new();
|
||||
private GameContent _content = null!;
|
||||
private Desktop _desktop = null!;
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
_content = Context.Services.Get<GameContent>();
|
||||
var input = this.UseInput();
|
||||
_desktop = this.UseUI();
|
||||
Rebuild();
|
||||
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
var lang = _content.Languages;
|
||||
var column = Ui.Column(8);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("load.title")));
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
|
||||
var saves = _store.List();
|
||||
if (saves.Count == 0)
|
||||
{
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty")));
|
||||
}
|
||||
|
||||
foreach (var entry in saves)
|
||||
{
|
||||
var save = entry.Save;
|
||||
var row = Ui.Row(8);
|
||||
row.Widgets.Add(
|
||||
new Label
|
||||
{
|
||||
TextColor = Ui.Accent,
|
||||
Text =
|
||||
$"{save.Name} — {save.Width}×{save.Height} — "
|
||||
+ save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
|
||||
}
|
||||
);
|
||||
var file = entry.FileName;
|
||||
row.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("load.load"),
|
||||
() =>
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(
|
||||
new WorldScene(save.ToConfig(), save),
|
||||
Transitions.Fade(0.6f)
|
||||
);
|
||||
}
|
||||
},
|
||||
width: 130
|
||||
)
|
||||
);
|
||||
row.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("load.delete"),
|
||||
() =>
|
||||
{
|
||||
_store.Delete(file);
|
||||
Rebuild();
|
||||
},
|
||||
width: 130
|
||||
)
|
||||
);
|
||||
column.Widgets.Add(row);
|
||||
}
|
||||
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
|
||||
|
||||
_desktop.Root = Ui.Screen(column);
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
|
||||
/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
|
||||
/// </summary>
|
||||
public sealed class MainMenuScene : Scene
|
||||
{
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
var lang = content.Languages;
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
|
||||
var column = Ui.Column(12);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
|
||||
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
|
||||
column.Widgets.Add(
|
||||
Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get<Game>().Exit())
|
||||
);
|
||||
|
||||
desktop.Root = Ui.Screen(column);
|
||||
}
|
||||
|
||||
private void Go(Scene scene)
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(scene, Transition.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
using MrGameEng.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
|
||||
/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
|
||||
/// </summary>
|
||||
public sealed class MainMenuScene : Scene
|
||||
{
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
var lang = content.Languages;
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
|
||||
var column = Ui.Column(12);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
|
||||
column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
|
||||
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
|
||||
column.Widgets.Add(
|
||||
Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get<Game>().Exit())
|
||||
);
|
||||
|
||||
desktop.Root = Ui.Screen(column);
|
||||
}
|
||||
|
||||
private void Go(Scene scene)
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(scene, Transitions.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +1,167 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Окно настройки нового мира: имя, размер (пресеты из <see cref="WorldPresetDef"/> Core-мода),
|
||||
/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает <see cref="WorldScene"/>.
|
||||
/// </summary>
|
||||
public sealed class NewWorldScene : Scene
|
||||
{
|
||||
private const int MinSmoothing = 1;
|
||||
private const int MaxSmoothing = 8;
|
||||
|
||||
private WorldPresetDef _preset = null!;
|
||||
private int _smoothing = 4;
|
||||
private TextBox _name = null!;
|
||||
private TextBox _seed = null!;
|
||||
private readonly List<Action> _refreshers = [];
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
var lang = content.Languages;
|
||||
var input = this.UseInput();
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
|
||||
var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList();
|
||||
_preset = presets[0];
|
||||
|
||||
var column = Ui.Column(10);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
|
||||
_name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
|
||||
column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
|
||||
|
||||
// Размер — сегменты пресетов.
|
||||
var sizeRow = Ui.Row(6);
|
||||
sizeRow.Widgets.Add(new Label { Text = lang.Get("newworld.size"), TextColor = Ui.Muted });
|
||||
foreach (var preset in presets)
|
||||
{
|
||||
var value = preset;
|
||||
var button = new TextButton { Text = lang.Get(preset.Label) };
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
_preset = value;
|
||||
Refresh();
|
||||
};
|
||||
sizeRow.Widgets.Add(button);
|
||||
_refreshers.Add(() =>
|
||||
button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted
|
||||
);
|
||||
}
|
||||
|
||||
column.Widgets.Add(sizeRow);
|
||||
|
||||
// Сид + случайно.
|
||||
_seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 };
|
||||
var seedRow = Ui.Row(6);
|
||||
seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted });
|
||||
seedRow.Widgets.Add(_seed);
|
||||
seedRow.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("newworld.random"),
|
||||
() => _seed.Text = Random.Shared.Next().ToString(),
|
||||
width: 120
|
||||
)
|
||||
);
|
||||
column.Widgets.Add(seedRow);
|
||||
|
||||
// Сглаживание −/+.
|
||||
var smoothRow = Ui.Row(6);
|
||||
var smoothLabel = new Label { TextColor = Ui.Muted };
|
||||
_refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}");
|
||||
var minus = new TextButton { Text = "−" };
|
||||
minus.Click += (_, _) =>
|
||||
{
|
||||
_smoothing = Math.Max(MinSmoothing, _smoothing - 1);
|
||||
Refresh();
|
||||
};
|
||||
var plus = new TextButton { Text = "+" };
|
||||
plus.Click += (_, _) =>
|
||||
{
|
||||
_smoothing = Math.Min(MaxSmoothing, _smoothing + 1);
|
||||
Refresh();
|
||||
};
|
||||
smoothRow.Widgets.Add(smoothLabel);
|
||||
smoothRow.Widgets.Add(minus);
|
||||
smoothRow.Widgets.Add(plus);
|
||||
column.Widgets.Add(smoothRow);
|
||||
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
var buttons = Ui.Row(10);
|
||||
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150));
|
||||
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
|
||||
column.Widgets.Add(buttons);
|
||||
|
||||
desktop.Root = Ui.Screen(column);
|
||||
Refresh();
|
||||
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private static Widget LabeledRow(string label, Widget control)
|
||||
{
|
||||
var row = Ui.Row(6);
|
||||
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
|
||||
row.Widgets.Add(control);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
foreach (var refresh in _refreshers)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void Create()
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seed = int.TryParse(_seed.Text, out var parsed) ? parsed : Random.Shared.Next();
|
||||
var name = string.IsNullOrWhiteSpace(_name.Text) ? "World" : _name.Text.Trim();
|
||||
var config = new WorldConfig
|
||||
{
|
||||
Name = name,
|
||||
Width = _preset.Width,
|
||||
Height = _preset.Height,
|
||||
Population = _preset.Population,
|
||||
Seed = seed,
|
||||
SmoothPasses = _smoothing,
|
||||
};
|
||||
Context.Scenes.Switch(new WorldScene(config), Transition.Fade(0.6f));
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Окно настройки нового мира: имя, размер (пресеты из <see cref="WorldPresetDef"/> Core-мода),
|
||||
/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает <see cref="WorldScene"/>.
|
||||
/// </summary>
|
||||
public sealed class NewWorldScene : Scene
|
||||
{
|
||||
private const int MinSmoothing = 1;
|
||||
private const int MaxSmoothing = 8;
|
||||
|
||||
private WorldPresetDef _preset = null!;
|
||||
private int _smoothing = 4;
|
||||
private TextBox _name = null!;
|
||||
private TextBox _seed = null!;
|
||||
private readonly List<Action> _refreshers = [];
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
var lang = content.Languages;
|
||||
var input = this.UseInput();
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
|
||||
var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList();
|
||||
_preset = presets[0];
|
||||
|
||||
var column = Ui.Column(10);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
|
||||
_name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
|
||||
column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
|
||||
|
||||
// Размер — сегменты пресетов.
|
||||
var sizeRow = Ui.Row(6);
|
||||
sizeRow.Widgets.Add(new Label { Text = lang.Get("newworld.size"), TextColor = Ui.Muted });
|
||||
foreach (var preset in presets)
|
||||
{
|
||||
var value = preset;
|
||||
var button = new TextButton { Text = lang.Get(preset.Label) };
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
_preset = value;
|
||||
Refresh();
|
||||
};
|
||||
sizeRow.Widgets.Add(button);
|
||||
_refreshers.Add(() =>
|
||||
button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted
|
||||
);
|
||||
}
|
||||
|
||||
column.Widgets.Add(sizeRow);
|
||||
|
||||
// Сид + случайно.
|
||||
_seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 };
|
||||
var seedRow = Ui.Row(6);
|
||||
seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted });
|
||||
seedRow.Widgets.Add(_seed);
|
||||
seedRow.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("newworld.random"),
|
||||
() => _seed.Text = Random.Shared.Next().ToString(),
|
||||
width: 120
|
||||
)
|
||||
);
|
||||
column.Widgets.Add(seedRow);
|
||||
|
||||
// Сглаживание −/+.
|
||||
var smoothRow = Ui.Row(6);
|
||||
var smoothLabel = new Label { TextColor = Ui.Muted };
|
||||
_refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}");
|
||||
var minus = new TextButton { Text = "−" };
|
||||
minus.Click += (_, _) =>
|
||||
{
|
||||
_smoothing = Math.Max(MinSmoothing, _smoothing - 1);
|
||||
Refresh();
|
||||
};
|
||||
var plus = new TextButton { Text = "+" };
|
||||
plus.Click += (_, _) =>
|
||||
{
|
||||
_smoothing = Math.Min(MaxSmoothing, _smoothing + 1);
|
||||
Refresh();
|
||||
};
|
||||
smoothRow.Widgets.Add(smoothLabel);
|
||||
smoothRow.Widgets.Add(minus);
|
||||
smoothRow.Widgets.Add(plus);
|
||||
column.Widgets.Add(smoothRow);
|
||||
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
var buttons = Ui.Row(10);
|
||||
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150));
|
||||
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
|
||||
column.Widgets.Add(buttons);
|
||||
|
||||
desktop.Root = Ui.Screen(column);
|
||||
Refresh();
|
||||
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private static Widget LabeledRow(string label, Widget control)
|
||||
{
|
||||
var row = Ui.Row(6);
|
||||
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
|
||||
row.Widgets.Add(control);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
foreach (var refresh in _refreshers)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void Create()
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seed = int.TryParse(_seed.Text, out var parsed) ? parsed : Random.Shared.Next();
|
||||
var name = string.IsNullOrWhiteSpace(_name.Text) ? "World" : _name.Text.Trim();
|
||||
var config = new WorldConfig
|
||||
{
|
||||
Name = name,
|
||||
Width = _preset.Width,
|
||||
Height = _preset.Height,
|
||||
Population = _preset.Population,
|
||||
Seed = seed,
|
||||
SmoothPasses = _smoothing,
|
||||
};
|
||||
Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f));
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+168
-167
@@ -1,167 +1,168 @@
|
||||
using System;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
|
||||
/// Открытие ставит игру на паузу через <see cref="GameSpeed"/>, закрытие — снимает.
|
||||
/// Кнопки: Продолжить / Настройки (общая <see cref="SettingsPanel"/>) / Сохранить /
|
||||
/// Главное меню / Выход. <see cref="Root"/> добавляется в корневую панель сцены.
|
||||
/// </summary>
|
||||
internal sealed class PauseMenu
|
||||
{
|
||||
private readonly EngineContext _context;
|
||||
private readonly GameContent _content;
|
||||
private readonly GameSpeed _speed;
|
||||
private readonly GameHost _host;
|
||||
private readonly AudioManager _audio;
|
||||
private readonly Func<string> _onSave;
|
||||
private readonly Action _onMainMenu;
|
||||
private readonly Action _onQuit;
|
||||
private readonly Panel _overlay;
|
||||
private GameSettings _settings = GameSettingsStore.Load();
|
||||
private bool _inSettings;
|
||||
private string? _savedToast;
|
||||
|
||||
public PauseMenu(
|
||||
EngineContext context,
|
||||
GameContent content,
|
||||
GameSpeed speed,
|
||||
Func<string> onSave,
|
||||
Action onMainMenu,
|
||||
Action onQuit
|
||||
)
|
||||
{
|
||||
_context = context;
|
||||
_content = content;
|
||||
_speed = speed;
|
||||
_host = (GameHost)context.Services.Get<Game>();
|
||||
_audio = context.Services.Get<AudioManager>();
|
||||
_onSave = onSave;
|
||||
_onMainMenu = onMainMenu;
|
||||
_onQuit = onQuit;
|
||||
|
||||
_overlay = new Panel
|
||||
{
|
||||
Visible = false,
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 200)),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Виджет оверлея для добавления в корень сцены.</summary>
|
||||
public Widget Root => _overlay;
|
||||
|
||||
/// <summary>Открыт ли оверлей паузы.</summary>
|
||||
public bool IsOpen { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
|
||||
/// меню паузы.
|
||||
/// </summary>
|
||||
public void Toggle()
|
||||
{
|
||||
if (!IsOpen)
|
||||
{
|
||||
Open();
|
||||
}
|
||||
else if (_inSettings)
|
||||
{
|
||||
_inSettings = false;
|
||||
ShowButtons();
|
||||
}
|
||||
else
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void Open()
|
||||
{
|
||||
IsOpen = true;
|
||||
_inSettings = false;
|
||||
_savedToast = null;
|
||||
_speed.Pause();
|
||||
ShowButtons();
|
||||
_overlay.Visible = true;
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
IsOpen = false;
|
||||
_inSettings = false;
|
||||
_overlay.Visible = false;
|
||||
_speed.Resume();
|
||||
}
|
||||
|
||||
private void ShowButtons()
|
||||
{
|
||||
var lang = _content.Languages;
|
||||
var column = Ui.Column(10);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("pause.title")));
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close));
|
||||
column.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("pause.settings"),
|
||||
() =>
|
||||
{
|
||||
_inSettings = true;
|
||||
ShowSettings();
|
||||
}
|
||||
)
|
||||
);
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.save"), Save));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.mainmenu"), _onMainMenu));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.quit"), _onQuit));
|
||||
|
||||
if (_savedToast is not null)
|
||||
{
|
||||
column.Widgets.Add(
|
||||
new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted }
|
||||
);
|
||||
}
|
||||
|
||||
SetContent(column);
|
||||
}
|
||||
|
||||
private void ShowSettings() =>
|
||||
SetContent(
|
||||
SettingsPanel.Build(
|
||||
_settings,
|
||||
_content.Languages,
|
||||
onApply: () =>
|
||||
{
|
||||
GameSettingsStore.Save(_settings);
|
||||
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
|
||||
ShowSettings(); // язык мог смениться
|
||||
},
|
||||
onBack: () =>
|
||||
{
|
||||
_inSettings = false;
|
||||
ShowButtons();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
private void Save()
|
||||
{
|
||||
_savedToast = _onSave();
|
||||
ShowButtons();
|
||||
}
|
||||
|
||||
private void SetContent(Widget content)
|
||||
{
|
||||
_overlay.Widgets.Clear();
|
||||
_overlay.Widgets.Add(content);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
|
||||
/// Открытие ставит игру на паузу через <see cref="GameSpeed"/>, закрытие — снимает.
|
||||
/// Кнопки: Продолжить / Настройки (общая <see cref="SettingsPanel"/>) / Сохранить /
|
||||
/// Главное меню / Выход. <see cref="Root"/> добавляется в корневую панель сцены.
|
||||
/// </summary>
|
||||
internal sealed class PauseMenu
|
||||
{
|
||||
private readonly EngineContext _context;
|
||||
private readonly GameContent _content;
|
||||
private readonly GameSpeed _speed;
|
||||
private readonly GameHost _host;
|
||||
private readonly AudioManager _audio;
|
||||
private readonly Func<string> _onSave;
|
||||
private readonly Action _onMainMenu;
|
||||
private readonly Action _onQuit;
|
||||
private readonly Panel _overlay;
|
||||
private GameSettings _settings = GameSettingsStore.Load();
|
||||
private bool _inSettings;
|
||||
private string? _savedToast;
|
||||
|
||||
public PauseMenu(
|
||||
EngineContext context,
|
||||
GameContent content,
|
||||
GameSpeed speed,
|
||||
Func<string> onSave,
|
||||
Action onMainMenu,
|
||||
Action onQuit
|
||||
)
|
||||
{
|
||||
_context = context;
|
||||
_content = content;
|
||||
_speed = speed;
|
||||
_host = (GameHost)context.Services.Get<Game>();
|
||||
_audio = context.Services.Get<AudioManager>();
|
||||
_onSave = onSave;
|
||||
_onMainMenu = onMainMenu;
|
||||
_onQuit = onQuit;
|
||||
|
||||
_overlay = new Panel
|
||||
{
|
||||
Visible = false,
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 200)),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Виджет оверлея для добавления в корень сцены.</summary>
|
||||
public Widget Root => _overlay;
|
||||
|
||||
/// <summary>Открыт ли оверлей паузы.</summary>
|
||||
public bool IsOpen { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
|
||||
/// меню паузы.
|
||||
/// </summary>
|
||||
public void Toggle()
|
||||
{
|
||||
if (!IsOpen)
|
||||
{
|
||||
Open();
|
||||
}
|
||||
else if (_inSettings)
|
||||
{
|
||||
_inSettings = false;
|
||||
ShowButtons();
|
||||
}
|
||||
else
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void Open()
|
||||
{
|
||||
IsOpen = true;
|
||||
_inSettings = false;
|
||||
_savedToast = null;
|
||||
_speed.Pause();
|
||||
ShowButtons();
|
||||
_overlay.Visible = true;
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
IsOpen = false;
|
||||
_inSettings = false;
|
||||
_overlay.Visible = false;
|
||||
_speed.Resume();
|
||||
}
|
||||
|
||||
private void ShowButtons()
|
||||
{
|
||||
var lang = _content.Languages;
|
||||
var column = Ui.Column(10);
|
||||
column.Widgets.Add(Ui.Title(lang.Get("pause.title")));
|
||||
column.Widgets.Add(new Label { Height = 8 });
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close));
|
||||
column.Widgets.Add(
|
||||
Ui.Button(
|
||||
lang.Get("pause.settings"),
|
||||
() =>
|
||||
{
|
||||
_inSettings = true;
|
||||
ShowSettings();
|
||||
}
|
||||
)
|
||||
);
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.save"), Save));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.mainmenu"), _onMainMenu));
|
||||
column.Widgets.Add(Ui.Button(lang.Get("pause.quit"), _onQuit));
|
||||
|
||||
if (_savedToast is not null)
|
||||
{
|
||||
column.Widgets.Add(
|
||||
new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted }
|
||||
);
|
||||
}
|
||||
|
||||
SetContent(column);
|
||||
}
|
||||
|
||||
private void ShowSettings() =>
|
||||
SetContent(
|
||||
SettingsPanel.Build(
|
||||
_settings,
|
||||
_content.Languages,
|
||||
onApply: () =>
|
||||
{
|
||||
GameSettingsStore.Save(_settings);
|
||||
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
|
||||
ShowSettings(); // язык мог смениться
|
||||
},
|
||||
onBack: () =>
|
||||
{
|
||||
_inSettings = false;
|
||||
ShowButtons();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
private void Save()
|
||||
{
|
||||
_savedToast = _onSave();
|
||||
ShowButtons();
|
||||
}
|
||||
|
||||
private void SetContent(Widget content)
|
||||
{
|
||||
_overlay.Widgets.Clear();
|
||||
_overlay.Widgets.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,66 @@
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
|
||||
/// <see cref="SettingsPanel"/>. «Применить» сохраняет в settings.json и применяет к движку
|
||||
/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
|
||||
/// </summary>
|
||||
public sealed class SettingsScene : Scene
|
||||
{
|
||||
private GameContent _content = null!;
|
||||
private AudioManager _audio = null!;
|
||||
private GameHost _host = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private GameSettings _settings = GameSettingsStore.Load();
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
_content = Context.Services.Get<GameContent>();
|
||||
_audio = Context.Services.Get<AudioManager>();
|
||||
_host = (GameHost)Context.Services.Get<Game>();
|
||||
var input = this.UseInput();
|
||||
|
||||
_desktop = this.UseUI();
|
||||
Rebuild();
|
||||
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void Rebuild() =>
|
||||
_desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
GameSettingsStore.Save(_settings);
|
||||
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
|
||||
Rebuild(); // язык мог смениться — перестраиваем подписи
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Host;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
|
||||
/// <see cref="SettingsPanel"/>. «Применить» сохраняет в settings.json и применяет к движку
|
||||
/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
|
||||
/// </summary>
|
||||
public sealed class SettingsScene : Scene
|
||||
{
|
||||
private GameContent _content = null!;
|
||||
private AudioManager _audio = null!;
|
||||
private GameHost _host = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private GameSettings _settings = GameSettingsStore.Load();
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
_content = Context.Services.Get<GameContent>();
|
||||
_audio = Context.Services.Get<AudioManager>();
|
||||
_host = (GameHost)Context.Services.Get<Game>();
|
||||
var input = this.UseInput();
|
||||
|
||||
_desktop = this.UseUI();
|
||||
Rebuild();
|
||||
|
||||
UpdateSystems.Add(
|
||||
new CallbackSystem(() =>
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
Back();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void Rebuild() =>
|
||||
_desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
GameSettingsStore.Save(_settings);
|
||||
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
|
||||
Rebuild(); // язык мог смениться — перестраиваем подписи
|
||||
}
|
||||
|
||||
private void Back()
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+312
-311
@@ -1,311 +1,312 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Friflo.Engine.ECS;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
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.Input;
|
||||
using MrGameEng.Inspector;
|
||||
using MrGameEng.Lighting;
|
||||
using MrGameEng.Tilemaps;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
|
||||
/// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
|
||||
/// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
|
||||
/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
|
||||
/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
|
||||
/// </summary>
|
||||
public sealed class WorldScene : Scene
|
||||
{
|
||||
public const int CellSize = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
|
||||
/// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
|
||||
/// </summary>
|
||||
public const float SecondsPerDay = 480f;
|
||||
|
||||
private readonly WorldConfig _config;
|
||||
private readonly WorldSave? _save;
|
||||
private readonly RectF _bounds;
|
||||
|
||||
private GameSpeed _speed = null!;
|
||||
private PauseMenu _pause = null!;
|
||||
private readonly List<Action> _speedRefreshers = [];
|
||||
|
||||
/// <summary>Новый мир из конфига.</summary>
|
||||
public WorldScene(WorldConfig config)
|
||||
: this(config, null) { }
|
||||
|
||||
/// <summary>Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).</summary>
|
||||
public WorldScene(WorldConfig config, WorldSave? save)
|
||||
{
|
||||
_config = config;
|
||||
_save = save;
|
||||
_bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
|
||||
}
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
var atlases = Context.Services.Get<ModAtlases>();
|
||||
var device = Context.GraphicsDevice;
|
||||
var input = this.UseInput();
|
||||
_speed = Context.Services.Get<GameSpeed>();
|
||||
_speed.SetStep(0); // новый мир/загрузка стартуют на x1
|
||||
|
||||
var renderer = this.UseRenderer2D(
|
||||
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
|
||||
);
|
||||
GameLayers.EnsureRegistered(renderer);
|
||||
this.UseTilemaps();
|
||||
|
||||
var calendar = Context.UseCalendar(SecondsPerDay);
|
||||
var climate = Context.UseClimate(ClimateSettings.Default);
|
||||
var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
|
||||
|
||||
// Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
|
||||
var plants = new PlantSet(content, atlases, device);
|
||||
var random = new Random(_config.Seed);
|
||||
BuildTerrain(content, atlases, device, assets, plants, random);
|
||||
|
||||
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
|
||||
|
||||
// HUD, полоса скорости и меню-пауза в одной корневой панели.
|
||||
var desktop = this.UseUI();
|
||||
var hudLabel = new Label { Left = 10, Top = 8 };
|
||||
var speedBar = BuildSpeedBar(content);
|
||||
_pause = new PauseMenu(
|
||||
Context,
|
||||
content,
|
||||
_speed,
|
||||
onSave: SaveWorld,
|
||||
onMainMenu: () => Switch(new MainMenuScene()),
|
||||
onQuit: () => Context.Services.Get<Game>().Exit()
|
||||
);
|
||||
desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
|
||||
|
||||
this.UseInspector(renderer);
|
||||
var console = this.UseDevConsole();
|
||||
RegisterCommands(console, content, atlases);
|
||||
|
||||
UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
|
||||
UpdateSystems.Add(
|
||||
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
|
||||
);
|
||||
UpdateSystems.Add(
|
||||
new HudSystem(
|
||||
Context,
|
||||
content.Languages,
|
||||
hudLabel,
|
||||
"hud.world",
|
||||
_config.Seed,
|
||||
calendar,
|
||||
climate
|
||||
)
|
||||
);
|
||||
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
|
||||
|
||||
Log.Info(
|
||||
$"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
|
||||
+ (_save is null ? " (new)" : " (loaded)")
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
|
||||
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
|
||||
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
|
||||
/// </summary>
|
||||
private void BuildTerrain(
|
||||
GameContent content,
|
||||
ModAtlases atlases,
|
||||
Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
|
||||
AssetManager assets,
|
||||
PlantSet plants,
|
||||
Random random
|
||||
)
|
||||
{
|
||||
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
|
||||
|
||||
// Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
|
||||
var tiles = new TileSet();
|
||||
var tileByDef = new Dictionary<TerrainDef, ushort>();
|
||||
foreach (var terrain in content.Terrains.All)
|
||||
{
|
||||
tileByDef[terrain] = terrain.Surface is { } surface
|
||||
? tiles.Add(atlases.GetRegion(device, surface))
|
||||
: tiles.Add(white, terrain.Tint);
|
||||
}
|
||||
|
||||
// Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
|
||||
var heights = WorldGenerator.Generate(
|
||||
_config.Width,
|
||||
_config.Height,
|
||||
_config.Seed,
|
||||
_config.SmoothPasses
|
||||
);
|
||||
var grid = new TileGrid(_config.Width, _config.Height);
|
||||
for (var x = 0; x < _config.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < _config.Height; y++)
|
||||
{
|
||||
var terrain = content.Terrains.Classify(heights[x, y]);
|
||||
grid[x, y] = tileByDef[terrain];
|
||||
ScatterSpawner.Spawn(
|
||||
this,
|
||||
content,
|
||||
plants,
|
||||
terrain,
|
||||
new Point(x, y),
|
||||
CellSize,
|
||||
random
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
|
||||
}
|
||||
|
||||
private HorizontalStackPanel BuildSpeedBar(GameContent content)
|
||||
{
|
||||
var bar = Ui.Row(6);
|
||||
bar.HorizontalAlignment = HorizontalAlignment.Center;
|
||||
bar.VerticalAlignment = VerticalAlignment.Bottom;
|
||||
bar.Margin = new Thickness(0, 0, 0, 12);
|
||||
|
||||
var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
|
||||
pause.Click += (_, _) => _speed.Pause();
|
||||
bar.Widgets.Add(pause);
|
||||
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
|
||||
|
||||
for (var i = 0; i < _speed.Steps.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
|
||||
button.Click += (_, _) => _speed.SetStep(index);
|
||||
bar.Widgets.Add(button);
|
||||
_speedRefreshers.Add(() =>
|
||||
button.TextColor =
|
||||
!_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
|
||||
);
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
foreach (var refresh in _speedRefreshers)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
_speed.Changed += Refresh;
|
||||
RegisterUnload(() => _speed.Changed -= Refresh);
|
||||
Refresh();
|
||||
return bar;
|
||||
}
|
||||
|
||||
private void Hotkeys(InputManager input)
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
_pause.Toggle();
|
||||
}
|
||||
|
||||
if (_pause.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.Space))
|
||||
{
|
||||
_speed.TogglePause();
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D1))
|
||||
{
|
||||
_speed.SetStep(0);
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
|
||||
{
|
||||
_speed.SetStep(1);
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
|
||||
{
|
||||
_speed.SetStep(2);
|
||||
}
|
||||
}
|
||||
|
||||
private string SaveWorld()
|
||||
{
|
||||
// Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
|
||||
var save = new WorldSave
|
||||
{
|
||||
Name = _config.Name,
|
||||
Width = _config.Width,
|
||||
Height = _config.Height,
|
||||
Seed = _config.Seed,
|
||||
SmoothPasses = _config.SmoothPasses,
|
||||
Population = _config.Population,
|
||||
SavedUtc = DateTime.UtcNow,
|
||||
ElapsedSeconds = Context.Clock.TotalTime,
|
||||
};
|
||||
|
||||
new SaveStore().Write(save);
|
||||
Log.Info($"World '{_config.Name}' saved");
|
||||
return _config.Name;
|
||||
}
|
||||
|
||||
private void Switch(Scene scene)
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
_speed.Resume();
|
||||
Context.Scenes.Switch(scene, Transition.Fade(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
|
||||
{
|
||||
ContentCommands.Register(console, content, atlases);
|
||||
console.Register(
|
||||
"regen",
|
||||
"regen [seed] — regenerate the world with a new seed",
|
||||
(c, args) =>
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
|
||||
c.WriteLine($"regenerating world, seed {seed}");
|
||||
Context.Scenes.Switch(
|
||||
new WorldScene(_config with { Seed = seed }),
|
||||
Transition.Fade(0.6f)
|
||||
);
|
||||
}
|
||||
);
|
||||
console.Register(
|
||||
"menu",
|
||||
"menu — return to the main menu",
|
||||
(_, _) => Switch(new MainMenuScene())
|
||||
);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Friflo.Engine.ECS;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
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.Inspector;
|
||||
using MrGameEng.Lighting;
|
||||
using MrGameEng.Tilemaps;
|
||||
using MrGameEng.UI;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
|
||||
/// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
|
||||
/// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
|
||||
/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
|
||||
/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
|
||||
/// </summary>
|
||||
public sealed class WorldScene : Scene
|
||||
{
|
||||
public const int CellSize = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
|
||||
/// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
|
||||
/// </summary>
|
||||
public const float SecondsPerDay = 480f;
|
||||
|
||||
private readonly WorldConfig _config;
|
||||
private readonly WorldSave? _save;
|
||||
private readonly RectF _bounds;
|
||||
|
||||
private GameSpeed _speed = null!;
|
||||
private PauseMenu _pause = null!;
|
||||
private readonly List<Action> _speedRefreshers = [];
|
||||
|
||||
/// <summary>Новый мир из конфига.</summary>
|
||||
public WorldScene(WorldConfig config)
|
||||
: this(config, null) { }
|
||||
|
||||
/// <summary>Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).</summary>
|
||||
public WorldScene(WorldConfig config, WorldSave? save)
|
||||
{
|
||||
_config = config;
|
||||
_save = save;
|
||||
_bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
|
||||
}
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
var atlases = Context.Services.Get<ModAtlases>();
|
||||
var device = Context.GetGraphicsDevice();
|
||||
var input = this.UseInput();
|
||||
_speed = Context.Services.Get<GameSpeed>();
|
||||
_speed.SetStep(0); // новый мир/загрузка стартуют на x1
|
||||
|
||||
var renderer = this.UseRenderer2D(
|
||||
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
|
||||
);
|
||||
GameLayers.EnsureRegistered(renderer);
|
||||
this.UseTilemaps();
|
||||
|
||||
var calendar = Context.UseCalendar(SecondsPerDay);
|
||||
var climate = Context.UseClimate(ClimateSettings.Default);
|
||||
var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
|
||||
|
||||
// Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
|
||||
var plants = new PlantSet(content, atlases, device);
|
||||
var random = new Random(_config.Seed);
|
||||
BuildTerrain(content, atlases, device, assets, plants, random);
|
||||
|
||||
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
|
||||
|
||||
// HUD, полоса скорости и меню-пауза в одной корневой панели.
|
||||
var desktop = this.UseUI();
|
||||
var hudLabel = new Label { Left = 10, Top = 8 };
|
||||
var speedBar = BuildSpeedBar(content);
|
||||
_pause = new PauseMenu(
|
||||
Context,
|
||||
content,
|
||||
_speed,
|
||||
onSave: SaveWorld,
|
||||
onMainMenu: () => Switch(new MainMenuScene()),
|
||||
onQuit: () => Context.Services.Get<Game>().Exit()
|
||||
);
|
||||
desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
|
||||
|
||||
this.UseInspector(renderer);
|
||||
var console = this.UseDevConsole();
|
||||
RegisterCommands(console, content, atlases);
|
||||
|
||||
UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
|
||||
UpdateSystems.Add(
|
||||
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
|
||||
);
|
||||
UpdateSystems.Add(
|
||||
new HudSystem(
|
||||
Context,
|
||||
content.Languages,
|
||||
hudLabel,
|
||||
"hud.world",
|
||||
_config.Seed,
|
||||
calendar,
|
||||
climate
|
||||
)
|
||||
);
|
||||
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
|
||||
|
||||
Log.Info(
|
||||
$"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
|
||||
+ (_save is null ? " (new)" : " (loaded)")
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
|
||||
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
|
||||
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
|
||||
/// </summary>
|
||||
private void BuildTerrain(
|
||||
GameContent content,
|
||||
ModAtlases atlases,
|
||||
Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
|
||||
AssetManager assets,
|
||||
PlantSet plants,
|
||||
Random random
|
||||
)
|
||||
{
|
||||
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
|
||||
|
||||
// Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
|
||||
var tiles = new TileSet();
|
||||
var tileByDef = new Dictionary<TerrainDef, ushort>();
|
||||
foreach (var terrain in content.Terrains.All)
|
||||
{
|
||||
tileByDef[terrain] = terrain.Surface is { } surface
|
||||
? tiles.Add(atlases.GetRegion(device, surface))
|
||||
: tiles.Add(white, terrain.Tint);
|
||||
}
|
||||
|
||||
// Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
|
||||
var heights = WorldGenerator.Generate(
|
||||
_config.Width,
|
||||
_config.Height,
|
||||
_config.Seed,
|
||||
_config.SmoothPasses
|
||||
);
|
||||
var grid = new TileGrid(_config.Width, _config.Height);
|
||||
for (var x = 0; x < _config.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < _config.Height; y++)
|
||||
{
|
||||
var terrain = content.Terrains.Classify(heights[x, y]);
|
||||
grid[x, y] = tileByDef[terrain];
|
||||
ScatterSpawner.Spawn(
|
||||
this,
|
||||
content,
|
||||
plants,
|
||||
terrain,
|
||||
new Point(x, y),
|
||||
CellSize,
|
||||
random
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
|
||||
}
|
||||
|
||||
private HorizontalStackPanel BuildSpeedBar(GameContent content)
|
||||
{
|
||||
var bar = Ui.Row(6);
|
||||
bar.HorizontalAlignment = HorizontalAlignment.Center;
|
||||
bar.VerticalAlignment = VerticalAlignment.Bottom;
|
||||
bar.Margin = new Thickness(0, 0, 0, 12);
|
||||
|
||||
var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
|
||||
pause.Click += (_, _) => _speed.Pause();
|
||||
bar.Widgets.Add(pause);
|
||||
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
|
||||
|
||||
for (var i = 0; i < _speed.Steps.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
|
||||
button.Click += (_, _) => _speed.SetStep(index);
|
||||
bar.Widgets.Add(button);
|
||||
_speedRefreshers.Add(() =>
|
||||
button.TextColor =
|
||||
!_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
|
||||
);
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
foreach (var refresh in _speedRefreshers)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
_speed.Changed += Refresh;
|
||||
RegisterUnload(() => _speed.Changed -= Refresh);
|
||||
Refresh();
|
||||
return bar;
|
||||
}
|
||||
|
||||
private void Hotkeys(InputManager input)
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
_pause.Toggle();
|
||||
}
|
||||
|
||||
if (_pause.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.Space))
|
||||
{
|
||||
_speed.TogglePause();
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D1))
|
||||
{
|
||||
_speed.SetStep(0);
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
|
||||
{
|
||||
_speed.SetStep(1);
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
|
||||
{
|
||||
_speed.SetStep(2);
|
||||
}
|
||||
}
|
||||
|
||||
private string SaveWorld()
|
||||
{
|
||||
// Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
|
||||
var save = new WorldSave
|
||||
{
|
||||
Name = _config.Name,
|
||||
Width = _config.Width,
|
||||
Height = _config.Height,
|
||||
Seed = _config.Seed,
|
||||
SmoothPasses = _config.SmoothPasses,
|
||||
Population = _config.Population,
|
||||
SavedUtc = DateTime.UtcNow,
|
||||
ElapsedSeconds = Context.Clock.TotalTime,
|
||||
};
|
||||
|
||||
new SaveStore().Write(save);
|
||||
Log.Info($"World '{_config.Name}' saved");
|
||||
return _config.Name;
|
||||
}
|
||||
|
||||
private void Switch(Scene scene)
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
_speed.Resume();
|
||||
Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
|
||||
{
|
||||
ContentCommands.Register(console, content, atlases);
|
||||
console.Register(
|
||||
"regen",
|
||||
"regen [seed] — regenerate the world with a new seed",
|
||||
(c, args) =>
|
||||
{
|
||||
if (Context.Scenes.IsTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
|
||||
c.WriteLine($"regenerating world, seed {seed}");
|
||||
Context.Scenes.Switch(
|
||||
new WorldScene(_config with { Seed = seed }),
|
||||
Transitions.Fade(0.6f)
|
||||
);
|
||||
}
|
||||
);
|
||||
console.Register(
|
||||
"menu",
|
||||
"menu — return to the main menu",
|
||||
(_, _) => Switch(new MainMenuScene())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user