Compare commits

..
4 Commits
Author SHA1 Message Date
Leonid PershinandClaude Fable 5 580cb6ccc9 Browser multiplayer client: promote the KNI spike to src/LittleSim.Web
LittleSim.Web (Blazor WASM + KNI/WebGL) is a real network client now: it
connects to the dedicated server over WebSocket (ClientWebSocket maps to
the browser socket), applies MrGameEng.Net delta snapshots into its
EntityStore and draws pawns through SpriteBatch — fatigue dims them just
like on desktop. The server address comes from ?server=ws://host:port
in the page URL, defaulting to the page's host on port 9050. The net
contract is mirrored in NetContract.cs (KNI and DesktopGL assemblies
can't mix until the graphics libraries build per platform) with loud
keep-in-sync comments on both sides.

Both clients now smooth replicated positions between 10 Hz snapshots:
NetLerp + NetSmoothingSystem lerp the visual position toward the latest
server position every frame (exponential, ~0.25 s to converge).

Verified against a live LittleSim.Server --listen: the browser client
connects (server log), draws ~3.3k lit pixels of pawns whose layout
changes between samples, and survives 400+ ticks without errors. Found
along the way: requestAnimationFrame freezes in hidden windows — the
game loop only runs while the tab is visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:04:11 +03:00
Leonid PershinandClaude Fable 5 e8273f9ffa Multiplayer: networked dedicated server + desktop client over MrGameEng.Net
Engine bump brings MrGameEng.Net (WebSocket transport, RFC 6455 server,
delta component replication) — this commit is its showcase.

LittleSim.Server --listen runs the world online: 24 pawns wander, tire
and rest on the engine's fixed-tick HeadlessHost (the same Wander/
Needs/Decision systems the windowed world uses), a WebSocketServer
accepts clients and a ReplicationServer ships Transform2D + PawnNeeds
deltas at 10 snapshots/s per the shared NetSchema. --probe is the CLI
check: it connects, listens for a second and prints replicated pawns
with positions sampled twice to show the world is alive.

The game gains MultiplayerScene (dotnet run --project src/LittleSim --
--connect [ws://host:port]): simulation stays on the server, the client
applies snapshots into its scene store, decorates spawned entities with
sprites and reuses PawnAppearanceSystem so replicated fatigue darkens
pawns locally. HUD strings go through ru/en localization; the `net`
console command reports connection state and entity count. Esc returns
to the main menu.

Verified end to end: probe sees 24 pawns moving between samples; the
windowed client connects and runs against a live local server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:45:35 +03:00
Leonid PershinandClaude Fable 5 ce8f8ba2ed KNI spike: engine core + Friflo render via WebGL in the browser
spikes/KniWeb (outside LittleSim.sln): a kni-blazor-gl template project
(KNI 4.2.9001, net8.0) referencing MrGameEng.Core directly. A mini-host
in the GameHost mold drives EngineContext/GameClock/Scene phases over
KNI's Game; the scene moves 300 Friflo entities in the update phase and
draws them with SpriteBatch (WebGL). Verified in a real browser: sprites
render and animate, browser console is clean.

Decision (docs/web-client.md): path A — KNI — is the primary route for
the web client; the core runs in Blazor WASM unchanged thanks to the
Core/Host split. Known follow-ups: per-platform compilation of the
graphics libraries against nkast.* packages, shader compatibility for
Renderer2D, HTTP-served content instead of the filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:27:40 +03:00
Leonid PershinandClaude Fable 5 8a7e2cce52 Adopt the engine Core/Host split; add LittleSim.Server headless prototype
Engine bump: MrGameEng.Core is now platform-free (Friflo only), the
windowed MonoGame host lives in the new MrGameEng.Host library, and the
core gains HeadlessHost — a fixed-timestep loop without a window or GPU.

Game side: scenes switch to Transitions.Fade from the Host library,
WorldScene reads the graphics device via Context.GetGraphicsDevice(),
GameContent.Load(buildAtlases: false) skips atlas building for headless
runs.

LittleSim.Server is the dedicated-server seed and the showcase for
HeadlessHost: it loads mods/defs without textures and fast-forwards the
world calendar and climate on a fixed tick (~3.6M ticks/s in Debug):

  dotnet run --project src/LittleSim.Server -- --days 10 --tps 60

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:13:40 +03:00
48 changed files with 4962 additions and 1033 deletions
+17 -6
View File
@@ -14,12 +14,18 @@ Game design docs live in `docs/` and are written in **Russian**. Engine rules li
## Layout ## Layout
``` ```
engine/ mrgameeng git submodule (own repo, own CLAUDE.md) engine/ mrgameeng git submodule (own repo, own CLAUDE.md)
src/LittleSim the game (net8.0); references engine projects directly src/LittleSim the game (net8.0); references engine projects directly
Mods/Core the game's own content as a mod: About, Defs, Languages, Textures src/LittleSim.Server dedicated server: the world headless (engine HeadlessHost) +
Cache/ runtime-built atlases (gitignored) WebSocket replication (MrGameEng.Net); also fast-forward and probe modes
docs/ концепт, симуляция, моды, roadmap (Russian) src/LittleSim.Web browser client (Blazor WASM + KNI/WebGL): connects to the dedicated
LittleSim.sln game + engine sources + engine tests — one window for everything 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 ## Commands
@@ -28,6 +34,11 @@ LittleSim.sln game + engine sources + engine tests — one window for everythin
git submodule update --init # after fresh clone git submodule update --init # after fresh clone
dotnet build LittleSim.sln dotnet build LittleSim.sln
dotnet run --project src/LittleSim -c Release # measure perf in Release only dotnet run --project src/LittleSim -c Release # measure perf in Release only
dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 # headless fast-forward
dotnet run --project src/LittleSim.Server -- --listen # multiplayer world (WebSocket)
dotnet run --project src/LittleSim -- --connect # client → ws://localhost:9050
dotnet run --project src/LittleSim.Server -- --probe # CLI check of a running server
dotnet run --project src/LittleSim.Web # browser client (?server=ws://…)
dotnet test LittleSim.sln # runs the engine test suites dotnet test LittleSim.sln # runs the engine test suites
``` ```
+90
View File
@@ -41,6 +41,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "engin
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "engine\tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{E0E37D87-4F62-41E9-9CD1-3E7432301508}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "engine\tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{E0E37D87-4F62-41E9-9CD1-3E7432301508}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -253,5 +337,11 @@ Global
{86ECA4A0-46A2-45F4-8CF4-8FB8BB5DC050} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5} {86ECA4A0-46A2-45F4-8CF4-8FB8BB5DC050} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD} = {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} {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 EndGlobalSection
EndGlobal EndGlobal
+6 -12
View File
@@ -8,7 +8,6 @@
"season.winter": "winter", "season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector", "hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector",
"hud.paused": "PAUSED", "hud.paused": "PAUSED",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation", "menu.subtitle": "a god-game: minimal graphics, deep simulation",
"menu.newworld": "New World", "menu.newworld": "New World",
@@ -16,7 +15,6 @@
"menu.settings": "Settings", "menu.settings": "Settings",
"menu.credits": "Credits", "menu.credits": "Credits",
"menu.quit": "Quit", "menu.quit": "Quit",
"newworld.title": "New World", "newworld.title": "New World",
"newworld.name": "Name", "newworld.name": "Name",
"newworld.defaultname": "New World", "newworld.defaultname": "New World",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Terrain smoothing", "newworld.smoothing": "Terrain smoothing",
"newworld.create": "Create", "newworld.create": "Create",
"newworld.back": "Back", "newworld.back": "Back",
"load.title": "Load", "load.title": "Load",
"load.empty": "No saves yet", "load.empty": "No saves yet",
"load.load": "Load", "load.load": "Load",
"load.delete": "Delete", "load.delete": "Delete",
"load.back": "Back", "load.back": "Back",
"settings.title": "Settings", "settings.title": "Settings",
"settings.language": "Language", "settings.language": "Language",
"settings.fullscreen": "Fullscreen", "settings.fullscreen": "Fullscreen",
@@ -41,7 +37,6 @@
"settings.volume": "Volume", "settings.volume": "Volume",
"settings.apply": "Apply", "settings.apply": "Apply",
"settings.back": "Back", "settings.back": "Back",
"pause.title": "Paused", "pause.title": "Paused",
"pause.resume": "Resume", "pause.resume": "Resume",
"pause.settings": "Settings", "pause.settings": "Settings",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Main menu", "pause.mainmenu": "Main menu",
"pause.quit": "Quit", "pause.quit": "Quit",
"pause.saved": "Saved: {0}", "pause.saved": "Saved: {0}",
"speed.pause": "⏸", "speed.pause": "⏸",
"credits.title": "Credits", "credits.title": "Credits",
"credits.author": "mrleo1nid", "credits.author": "mrleo1nid",
"credits.role": "Concept, code, mrgameeng engine", "credits.role": "Concept, code, mrgameeng engine",
"credits.back": "Back", "credits.back": "Back",
"preset.small": "Small", "preset.small": "Small",
"preset.medium": "Medium", "preset.medium": "Medium",
"preset.large": "Large", "preset.large": "Large",
"terrain.deepwater": "deep water", "terrain.deepwater": "deep water",
"terrain.water": "water", "terrain.water": "water",
"terrain.sand": "sand", "terrain.sand": "sand",
"terrain.grass": "grass", "terrain.grass": "grass",
"terrain.forest": "forest", "terrain.forest": "forest",
"terrain.mountain": "mountains", "terrain.mountain": "mountains",
"plant.oak": "oak", "plant.oak": "oak",
"plant.birch": "birch", "plant.birch": "birch",
"plant.pine": "pine", "plant.pine": "pine",
@@ -77,7 +67,6 @@
"plant.stage.sprout": "sprout", "plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling", "plant.stage.sapling": "sapling",
"plant.stage.mature": "mature", "plant.stage.mature": "mature",
"pawn.being": "being", "pawn.being": "being",
"pawn.bear": "bear", "pawn.bear": "bear",
"pawn.deer": "deer", "pawn.deer": "deer",
@@ -86,5 +75,10 @@
"pawn.boar": "boar", "pawn.boar": "boar",
"pawn.wolf": "wolf", "pawn.wolf": "wolf",
"pawn.muffalo": "muffalo", "pawn.muffalo": "muffalo",
"pawn.squirrel": "squirrel" "pawn.squirrel": "squirrel",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…",
"net.connected": "connected",
"net.failed": "connection failed",
"net.lost": "connection lost"
} }
+6 -12
View File
@@ -8,7 +8,6 @@
"season.winter": "зима", "season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор", "hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции", "menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
"menu.newworld": "Новый мир", "menu.newworld": "Новый мир",
@@ -16,7 +15,6 @@
"menu.settings": "Настройки", "menu.settings": "Настройки",
"menu.credits": "Авторы", "menu.credits": "Авторы",
"menu.quit": "Выход", "menu.quit": "Выход",
"newworld.title": "Новый мир", "newworld.title": "Новый мир",
"newworld.name": "Название", "newworld.name": "Название",
"newworld.defaultname": "Новый мир", "newworld.defaultname": "Новый мир",
@@ -26,13 +24,11 @@
"newworld.smoothing": "Сглаживание рельефа", "newworld.smoothing": "Сглаживание рельефа",
"newworld.create": "Создать", "newworld.create": "Создать",
"newworld.back": "Назад", "newworld.back": "Назад",
"load.title": "Загрузка", "load.title": "Загрузка",
"load.empty": "Сохранений пока нет", "load.empty": "Сохранений пока нет",
"load.load": "Загрузить", "load.load": "Загрузить",
"load.delete": "Удалить", "load.delete": "Удалить",
"load.back": "Назад", "load.back": "Назад",
"settings.title": "Настройки", "settings.title": "Настройки",
"settings.language": "Язык", "settings.language": "Язык",
"settings.fullscreen": "Полный экран", "settings.fullscreen": "Полный экран",
@@ -41,7 +37,6 @@
"settings.volume": "Громкость", "settings.volume": "Громкость",
"settings.apply": "Применить", "settings.apply": "Применить",
"settings.back": "Назад", "settings.back": "Назад",
"pause.title": "Пауза", "pause.title": "Пауза",
"pause.resume": "Продолжить", "pause.resume": "Продолжить",
"pause.settings": "Настройки", "pause.settings": "Настройки",
@@ -49,25 +44,20 @@
"pause.mainmenu": "Главное меню", "pause.mainmenu": "Главное меню",
"pause.quit": "Выход", "pause.quit": "Выход",
"pause.saved": "Сохранено: {0}", "pause.saved": "Сохранено: {0}",
"speed.pause": "⏸", "speed.pause": "⏸",
"credits.title": "Авторы", "credits.title": "Авторы",
"credits.author": "mrleo1nid", "credits.author": "mrleo1nid",
"credits.role": "Идея, код, движок mrgameeng", "credits.role": "Идея, код, движок mrgameeng",
"credits.back": "Назад", "credits.back": "Назад",
"preset.small": "Маленький", "preset.small": "Маленький",
"preset.medium": "Средний", "preset.medium": "Средний",
"preset.large": "Большой", "preset.large": "Большой",
"terrain.deepwater": "глубокая вода", "terrain.deepwater": "глубокая вода",
"terrain.water": "вода", "terrain.water": "вода",
"terrain.sand": "песок", "terrain.sand": "песок",
"terrain.grass": "трава", "terrain.grass": "трава",
"terrain.forest": "лес", "terrain.forest": "лес",
"terrain.mountain": "горы", "terrain.mountain": "горы",
"plant.oak": "дуб", "plant.oak": "дуб",
"plant.birch": "берёза", "plant.birch": "берёза",
"plant.pine": "сосна", "plant.pine": "сосна",
@@ -77,7 +67,6 @@
"plant.stage.sprout": "всходы", "plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец", "plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое", "plant.stage.mature": "взрослое",
"pawn.being": "житель", "pawn.being": "житель",
"pawn.bear": "медведь", "pawn.bear": "медведь",
"pawn.deer": "олень", "pawn.deer": "олень",
@@ -86,5 +75,10 @@
"pawn.boar": "кабан", "pawn.boar": "кабан",
"pawn.wolf": "волк", "pawn.wolf": "волк",
"pawn.muffalo": "муффало", "pawn.muffalo": "муффало",
"pawn.squirrel": "белка" "pawn.squirrel": "белка",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…",
"net.connected": "подключено",
"net.failed": "не удалось подключиться",
"net.lost": "соединение потеряно"
} }
+68
View File
@@ -0,0 +1,68 @@
# Веб-клиент: spike KNI и решение A/B
Дата: 2026-06-12. Спайк жил в `spikes/KniWeb`; после успеха повышен до
**`src/LittleSim.Web`** — рабочего браузерного клиента мультиплеера (Blazor WASM + KNI):
он подключается к `LittleSim.Server --listen` по WebSocket, применяет дельта-снапшоты
`MrGameEng.Net` и рисует жителей через WebGL со сглаживанием позиций. Адрес сервера —
`?server=ws://host:port` в URL страницы (по умолчанию — хост страницы, порт 9050).
Сетевой контракт там продублирован бинарным зеркалом (`NetContract.cs`) — KNI- и
DesktopGL-сборки нельзя смешивать, пока графика движка не собирается пер-платформенно.
## Вопрос
Путь A — «MonoGame в браузере» через [KNI](https://github.com/kniEngine/kni)
(форк MonoGame с платформой Blazor WebAssembly/WebGL, те же неймспейсы
`Microsoft.Xna.Framework.*`). Путь B — тонкий веб-клиент без MonoGame
(TypeScript/PixiJS поверх сетевой репликации). Спайк проверял минимальную
жизнеспособность пути A: **ядро движка + Friflo + WebGL-спрайт в браузере**.
## Что сделано
`dotnet new kni-blazor-gl` (пакет шаблонов `nkast.Kni.Templates`, KNI 4.2.9001,
net8.0) + ProjectReference на `engine/src/MrGameEng.Core` + мини-хост в духе
`GameHost` поверх KNI `Game`. Сцена: 300 сущностей в Friflo `EntityStore`,
`QuerySystem` двигает их в Update-фазе (отскок от краёв, seed 42), Draw-фаза
рисует через KNI `SpriteBatch` (WebGL).
## Результат — путь A жизнеспособен
- **`MrGameEng.Core` работает в Blazor WASM без изменений**: `EngineContext`,
`GameClock`, `Scene`/`SceneManager`, тайминг переходов — всё ядро завелось
как есть (заслуга расслоения Core/Host: в ядре нет ни MonoGame, ни платформы).
- **Friflo.Engine.ECS 3.6 работает в wasm**: создание сущностей, архетипы,
`QuerySystem`, `ForEachEntity` — без ошибок в консоли браузера.
- **KNI 4.2.9001 рендерит через WebGL** с XNA-API: `Game`,
`GraphicsDeviceManager`, `SpriteBatch`, `Texture2D.SetData` — совпадает с
кодом, который пишется под десктопный MonoGame.
- Сборка тривиальна: обычный `Microsoft.NET.Sdk.BlazorWebAssembly` проект,
никаких wasm-workload-плясок не понадобилось.
## Известные ограничения пути A (работа на этапе «веб-клиент»)
1. **Пер-платформенная компиляция библиотек движка.** `MrGameEng.Graphics`,
`Content`, `Audio`, `UI` ссылаются на `MonoGame.Framework.DesktopGL`; для
веба их надо собирать против пакетов `nkast.*` (типы те же по API, но другие
сборки). Решение — msbuild-условие (`KniPlatform=BlazorGL` → nkast-пакеты),
без изменения исходников.
2. **Шейдеры.** `Renderer2D` использует прекомпилированные `dotnet-mgfxc`
эффекты — KNI имеет собственный компилятор эффектов; совместимость надо
проверять отдельным спайком, прежде чем тащить батчер в веб.
3. **Нет файловой системы.** Моды/дефы/атласы в браузер приезжают по HTTP;
текущая схема «собрать атласы при старте из PNG» в вебе не работает —
атласы пре-билдятся и кладутся в `wwwroot` (или приезжают с сервера).
4. **Потоки.** `Task.Run`-загрузка контента и `Thread.Sleep`-пейсинг не для
браузера (клиенту `HeadlessHost.Run` и не нужен — цикл гонит
`requestAnimationFrame` через KNI).
5. **Производительность не мерялась** (300 спрайтов — гладко); бюджет
сущностей в wasm-интерпретаторе будет заметно ниже десктопного, замерять
на реальной сцене с включённым AOT.
## Решение
**Путь A (KNI)** — основной для веб-клиента: переиспользуем ядро, сцены и
в перспективе графику движка; код игры один на все платформы. Путь B остаётся
запасным, если упрёмся в шейдеры (п. 2) или производительность (п. 5).
Порядок работ не меняется: сначала `MrGameEng.Net` (WebSocket-транспорт +
репликация — нужен любому пути) и сетевой мультиплеер на десктопе, затем
`MrGameEng.Host.Web` поверх KNI по образцу спайка.
+1 -1
Submodule engine updated: d0104df304...3438ed77f6
+126
View File
@@ -0,0 +1,126 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Server;
/// <summary>
/// Серверный мир: жители бродят, устают и отдыхают (те же системы, что в WorldScene) на
/// фиксированном тике <see cref="HeadlessHost"/> — без окна, GPU и спрайтов. С
/// <see cref="WebSocketServer"/> мир ещё и реплицируется подключённым клиентам по схеме
/// <see cref="NetSchema"/> (10 снапшотов в секунду, дельты). Демонстрация MrGameEng.Net.
/// </summary>
internal sealed class HeadlessWorldScene : Scene
{
/// <summary>Жителей в серверном мире.</summary>
public const int PawnCount = 24;
/// <summary>Сид мира — рельефа пока нет, но блуждание детерминировано им.</summary>
public const int Seed = 424242;
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly GameContent _content;
private readonly WebSocketServer? _server;
/// <summary>Мир без сети (fast-forward) или, с <paramref name="server"/>, онлайн-мир.</summary>
public HeadlessWorldScene(GameContent content, WebSocketServer? server = null)
{
_content = content;
_server = server;
}
protected override void OnLoad()
{
Context.Services.Add(_content);
var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
var replication = _server is null ? null : new ReplicationServer(NetSchema.Create(), Store);
var random = new Random(Seed);
for (var i = 0; i < PawnCount; i++)
{
var position = new Vector2(
random.NextSingle() * Bounds.Width,
random.NextSingle() * Bounds.Height
);
var transform = new Transform2D(position, scale: new Vector2(12f));
var needs = new PawnNeeds { Energy = 0.4f + random.NextSingle() * 0.6f };
if (replication is null)
{
Store.CreateEntity(transform, new Wander(), new PawnBrain(), needs);
}
else
{
Store.CreateEntity(
transform,
new Wander(),
new PawnBrain(),
needs,
new NetId { Value = replication.NextNetId() }
);
}
}
UpdateSystems.Add(new PawnDecisionSystem());
UpdateSystems.Add(new PawnNeedsSystem());
UpdateSystems.Add(new WanderSystem(Seed, Bounds));
UpdateSystems.Add(new DayReportSystem(calendar, climate));
if (_server is not null && replication is not null)
{
UpdateSystems.Add(new NetworkSystem(_server, replication));
}
}
/// <summary>Принимает новые соединения и шлёт дельта-снапшоты с сетевой частотой.</summary>
private sealed class NetworkSystem(WebSocketServer server, ReplicationServer replication)
: BaseSystem
{
// 60 тиков симуляции / 6 = 10 снапшотов в секунду.
private const int SendEveryTicks = 6;
private int _ticks;
protected override void OnUpdateGroup()
{
while (server.TryAcceptConnection(out var connection))
{
Log.Info(
$"Клиент #{connection.Id} подключился ({server.Connections.Count} онлайн)"
);
}
if (++_ticks % SendEveryTicks == 0)
{
replication.Send(server.Connections);
}
}
}
/// <summary>Пишет строку состояния мира в лог на рассвете каждого игрового дня.</summary>
private sealed class DayReportSystem(Calendar calendar, Climate climate) : BaseSystem
{
private int _lastDay;
protected override void OnUpdateGroup()
{
if (calendar.Day == _lastDay)
{
return;
}
_lastDay = calendar.Day;
Log.Info(
$"День {calendar.Day} | год {climate.Year}, день года {climate.DayOfYear + 1}, "
+ $"{climate.Season} | {climate.Temperature:+0.0;-0.0;0.0} °C"
);
}
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LittleSim\LittleSim.csproj" />
</ItemGroup>
</Project>
+151
View File
@@ -0,0 +1,151 @@
using System.Diagnostics;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Scenes;
using LittleSim.Server;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Net;
// Дедикейтед-сервер LittleSim: мир без окна и GPU на HeadlessHost движка.
// dotnet run --project src/LittleSim.Server — fast-forward N дней
// dotnet run --project src/LittleSim.Server -- --days 30 — то же, явное число дней
// dotnet run --project src/LittleSim.Server -- --listen [--port N] — онлайн-мир (WebSocket)
// dotnet run --project src/LittleSim.Server -- --probe [--port N] — проверка: подключиться
// к серверу и показать реплицированных жителей
var days = ReadOption("--days", 10);
var ticksPerSecond = ReadOption("--tps", 60);
var port = ReadOption("--port", NetSchema.DefaultPort);
Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
if (Array.IndexOf(args, "--probe") >= 0)
{
return await Probe(port);
}
var content = GameContent.Load(buildAtlases: false);
Log.Info(
$"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
+ $"дефов: {content.Defs.All<TerrainDef>().Count} террейна, "
+ $"{content.Defs.All<PlantDef>().Count} растений, "
+ $"{content.Defs.All<PawnDef>().Count} жителей"
);
if (Array.IndexOf(args, "--listen") >= 0)
{
using var socketServer = new WebSocketServer(port);
socketServer.Start();
using var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = true },
new HeadlessWorldScene(content, socketServer)
);
using var cancel = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancel.Cancel();
};
Log.Info(
$"Мир онлайн: ws://localhost:{port}, {HeadlessWorldScene.PawnCount} жителей, "
+ $"{ticksPerSecond} тиков/с. Клиент: dotnet run --project src/LittleSim -- --connect. "
+ "Ctrl+C — остановка."
);
host.Run(cancel.Token);
Log.Info($"Сервер остановлен на тике {host.TickCount}.");
return 0;
}
using (
var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
new HeadlessWorldScene(content)
)
)
{
var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
var stopwatch = Stopwatch.StartNew();
host.RunTicks(ticks);
Log.Info(
$"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
+ $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
);
}
return 0;
// Подключается к работающему серверу, секунду слушает снапшоты и показывает жителей —
// консольная проверка репликации без игрового клиента.
async Task<int> Probe(int probePort)
{
var store = new EntityStore();
var replication = new ReplicationClient(NetSchema.Create(), store);
var uri = new Uri($"ws://localhost:{probePort}/");
Log.Info($"Подключение к {uri}…");
WebSocketClient connection;
try
{
connection = await WebSocketClient.ConnectAsync(
uri,
new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token
);
}
catch (Exception exception)
{
Log.Error($"Не подключилось: {exception.GetBaseException().Message}");
return 1;
}
using (connection)
{
await Task.Delay(1000);
replication.Pump(connection);
var first = SamplePositions(store);
Log.Info($"Снапшот получен: {replication.EntityCount} жителей");
await Task.Delay(2000);
replication.Pump(connection);
var second = SamplePositions(store);
for (var i = 0; i < first.Count; i++)
{
Log.Info(
$" житель {first[i].NetId}: ({first[i].X:F0}, {first[i].Y:F0}) → "
+ $"({second[i].X:F0}, {second[i].Y:F0})"
);
}
var moved = first.Where((p, i) => p.X != second[i].X || p.Y != second[i].Y).Count();
Log.Info($"Двигались {moved} из {first.Count} показанных — мир жив.");
return replication.EntityCount > 0 && moved > 0 ? 0 : 1;
}
}
static List<(int NetId, float X, float Y)> SamplePositions(EntityStore store)
{
var result = new List<(int, float, float)>();
foreach (var entity in store.Entities)
{
if (result.Count == 5)
{
break;
}
if (entity.HasComponent<NetId>() && entity.HasComponent<Transform2D>())
{
var position = entity.GetComponent<Transform2D>().Position;
result.Add((entity.GetComponent<NetId>().Value, position.X, position.Y));
}
}
return result;
}
int ReadOption(string name, int fallback)
{
var index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value)
? value
: fallback;
}
+12
View File
@@ -0,0 +1,12 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,15 @@
#----------------------------- Global Properties ----------------------------#
/outputDir:bin/$(Platform)
/intermediateDir:obj/$(Platform)
/platform:BlazorGL
/config:
/profile:Reach
/compress:True
#-------------------------------- References --------------------------------#
#---------------------------------- Content ---------------------------------#
+1
View File
@@ -0,0 +1 @@
<Project></Project>
+63
View File
@@ -0,0 +1,63 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>LittleSim.Web</RootNamespace>
<AssemblyName>LittleSim.Web</AssemblyName>
<DefineConstants>$(DefineConstants);BLAZORGL</DefineConstants>
<KniPlatform>BlazorGL</KniPlatform>
</PropertyGroup>
<PropertyGroup>
<BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
</PropertyGroup>
<ItemGroup>
<Compile Include="Pages\Index.razor.cs" />
<Compile Include="Program.cs" />
<Compile Include="LittleSimWebGame.cs" />
<Compile Include="NetContract.cs" />
<Compile Include="WorldViewScene.cs" />
</ItemGroup>
<ItemGroup>
<!--
Веб-клиент собирается против KNI (форк MonoGame с платформой Blazor/WebGL), поэтому
ссылается только на платформо-независимые библиотеки движка: Core и Net.
Графические библиотеки движка (DesktopGL) сюда подключать нельзя — у KNI свои
сборки с теми же неймспейсами.
-->
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="nkast.Xna.Framework" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Content" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Graphics" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Audio" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Media" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Input" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Game" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Devices" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.Storage" Version="4.2.9001" />
<PackageReference Include="nkast.Xna.Framework.XR" Version="4.2.9001" />
<PackageReference Include="nkast.Kni.Platform.Blazor.GL" Version="4.2.9001.2" />
<PackageReference Include="nkast.Xna.Framework.Content.Pipeline.Builder" Version="4.2.9001" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.17" />
<PackageReference
Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer"
Version="8.0.17"
PrivateAssets="all"
/>
</ItemGroup>
<ItemGroup>
<KniContentReference Include="Content\LittleSimWebContent.mgcb" />
</ItemGroup>
</Project>
+62
View File
@@ -0,0 +1,62 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace LittleSim.Web;
/// <summary>
/// Веб-хост LittleSim поверх KNI (BlazorGL/WebGL) — браузерный аналог
/// MrGameEng.Host.GameHost: владеет EngineContext ядра, гонит GameClock и фазы сцены.
/// Цикл тикается из requestAnimationFrame (см. Pages/Index.razor.cs). Когда графика
/// движка научится собираться под KNI, этот класс переедет в MrGameEng.Host.Web.
/// </summary>
public class LittleSimWebGame : Game
{
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
public EngineContext Context { get; } = new EngineContext();
private readonly Uri _server;
private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch = null!;
private Texture2D _pixel = null!;
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary>
public LittleSimWebGame(Uri server)
{
_server = server;
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
}
/// <inheritdoc />
protected override void Initialize()
{
base.Initialize();
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel));
}
/// <inheritdoc />
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
_pixel = new Texture2D(GraphicsDevice, 1, 1);
_pixel.SetData(new[] { Color.White });
}
/// <inheritdoc />
protected override void Update(GameTime gameTime)
{
Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds);
Context.Scenes.Update(Context.Clock);
base.Update(gameTime);
}
/// <inheritdoc />
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(new Color(12, 16, 24));
Context.Scenes.Draw(Context.Clock);
base.Draw(gameTime);
}
}
+7
View File
@@ -0,0 +1,7 @@
@inherits LayoutComponentBase
<div class="page">
<main>
@Body
</main>
</div>
+98
View File
@@ -0,0 +1,98 @@
.page
{
position: relative;
display: flex;
flex-direction: column;
}
main
{
flex: 1;
}
.sidebar
{
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row
{
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link
{
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover
{
text-decoration: underline;
}
.top-row ::deep a:first-child
{
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px)
{
.top-row:not(.auth)
{
display: none;
}
.top-row.auth
{
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link
{
margin-left: 0;
}
}
@media (min-width: 641px)
{
.page
{
flex-direction: row;
}
.sidebar
{
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row
{
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child
{
flex: 1;
text-align: right;
width: 0;
}
.top-row, article
{
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
+42
View File
@@ -0,0 +1,42 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Net;
namespace LittleSim.Web;
// ВНИМАНИЕ: бинарное зеркало сетевого контракта десктопа (src/LittleSim/Net/NetSchema.cs).
// Веб-клиент не может ссылаться на LittleSim/MrGameEng.Graphics (они собраны против
// MonoGame DesktopGL, а тут KNI), поэтому реплицируемые компоненты продублированы
// со СТРОГО тем же лейаутом и порядком регистрации. Меняешь схему там — меняй здесь.
// Уйдёт после пер-платформенной сборки графических библиотек (см. docs/web-client.md).
/// <summary>Зеркало MrGameEng.Graphics.Transform2D: Position(8) + Rotation(4) + Scale(8).</summary>
public struct NetTransform : IComponent
{
/// <summary>Позиция в мировых координатах.</summary>
public Vector2 Position;
/// <summary>Поворот в радианах.</summary>
public float Rotation;
/// <summary>Масштаб (у жителей — размер квада в пикселях).</summary>
public Vector2 Scale;
}
/// <summary>Зеркало LittleSim.Sim.PawnNeeds: Energy(4).</summary>
public struct NetPawnNeeds : IComponent
{
/// <summary>Запас сил жителя в [0, 1] — затемняет спрайт.</summary>
public float Energy;
}
/// <summary>Схема репликации веб-клиента — порядок тот же, что в NetSchema десктопа.</summary>
public static class WebNetSchema
{
/// <summary>Порт сервера по умолчанию (NetSchema.DefaultPort).</summary>
public const int DefaultPort = 9050;
/// <summary>Transform2D → NetTransform, PawnNeeds → NetPawnNeeds.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<NetTransform>().Register<NetPawnNeeds>();
}
+20
View File
@@ -0,0 +1,20 @@
@page "/"
@page "/index.html"
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
margin:0%;
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width:100vw;
height:100vh;
">
<canvas id="theCanvas" style="touch-action:none;"></canvas>
</div>
+60
View File
@@ -0,0 +1,60 @@
using System;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.Xna.Framework;
namespace LittleSim.Web.Pages
{
public partial class Index
{
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private Game? _game;
protected override void OnAfterRender(bool firstRender)
{
base.OnAfterRender(firstRender);
if (firstRender)
{
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
}
}
[JSInvokable]
public void TickDotNet()
{
if (_game == null)
{
_game = new LittleSimWebGame(ResolveServerUri());
_game.Run();
}
_game.Tick();
}
// Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию —
// хост самой страницы на порту LittleSim.Server.
private Uri ResolveServerUri()
{
var page = new Uri(Navigation.Uri);
var query = page.Query.TrimStart('?');
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separator = pair.IndexOf('=');
if (separator > 0 && pair[..separator] == "server")
{
return new Uri(Uri.UnescapeDataString(pair[(separator + 1)..]));
}
}
return new UriBuilder
{
Scheme = "ws",
Host = page.Host,
Port = WebNetSchema.DefaultPort,
}.Uri;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace LittleSim.Web
{
internal class Program
{
private static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient()
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
});
await builder.Build().RunAsync();
}
}
}
@@ -0,0 +1,30 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:56897",
"sslPort": 0
}
},
"profiles": {
"KniWebSpike": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5259",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+153
View File
@@ -0,0 +1,153 @@
using System;
using System.Threading.Tasks;
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
using MrGameEng.Net;
namespace LittleSim.Web;
/// <summary>
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты
/// кадра, усталость затемняет квадратик жителя.
/// </summary>
public sealed class WorldViewScene : Scene
{
private readonly Uri _server;
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
private ReplicationClient _replication = null!;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary>
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
{
_server = server;
_spriteBatch = spriteBatch;
_pixel = pixel;
}
/// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary>
private struct NetLerp : IComponent
{
public Vector2 Visual;
public Vector2 Target;
public bool Initialized;
}
protected override void OnLoad()
{
_replication = new ReplicationClient(WebNetSchema.Create(), Store);
_replication.EntitySpawned += entity => entity.AddComponent(new NetLerp());
UpdateSystems.Add(new CallbackSystem(Pump));
UpdateSystems.Add(new NetSmoothingSystem());
DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel));
Log.Info($"LittleSim.Web: connecting to {_server}…");
_connecting = WebSocketClient.ConnectAsync(_server);
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
if (_connecting is { IsCompleted: true } finished)
{
_connecting = null;
if (finished.IsFaulted)
{
Log.Error(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
}
else
{
_connection = finished.Result;
Log.Info($"Connected to {_server}");
}
}
if (_connection is not null)
{
_replication.Pump(_connection);
}
}
/// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary>
private sealed class CallbackSystem(Action update) : BaseSystem
{
protected override void OnUpdateGroup() => update();
}
/// <summary>Та же экспонента, что в NetSmoothingSystem десктопа (LittleSim/Net/NetSmoothing.cs).</summary>
private sealed class NetSmoothingSystem : QuerySystem<NetTransform, NetLerp>
{
private const float Rate = 12f;
protected override void OnUpdate()
{
var blend = 1f - MathF.Exp(-Rate * Tick.deltaTime);
Query.ForEachEntity(
(ref NetTransform transform, ref NetLerp lerp, Entity _) =>
{
if (!lerp.Initialized)
{
lerp.Visual = lerp.Target = transform.Position;
lerp.Initialized = true;
return;
}
if (transform.Position != lerp.Visual)
{
lerp.Target = transform.Position;
}
lerp.Visual = Vector2.Lerp(lerp.Visual, lerp.Target, blend);
transform.Position = lerp.Visual;
}
);
}
}
/// <summary>Житель — квадратик размером Scale, затемняющийся с усталостью (как на десктопе).</summary>
private sealed class PawnDrawSystem(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
: QuerySystem<NetTransform, NetPawnNeeds>
{
private const float MinBrightness = 0.45f;
protected override void OnUpdate()
{
var batch = spriteBatch();
var white = pixel();
batch.Begin();
Query.ForEachEntity(
(ref NetTransform transform, ref NetPawnNeeds needs, Entity _) =>
{
var size = transform.Scale;
var brightness =
MinBrightness + (1f - MinBrightness) * Math.Clamp(needs.Energy, 0f, 1f);
batch.Draw(
white,
new Rectangle(
(int)(transform.Position.X - size.X / 2f),
(int)(transform.Position.Y - size.Y / 2f),
(int)size.X,
(int)size.Y
),
Color.White * brightness
);
}
);
batch.End();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using nkast.Wasm.Canvas
@using LittleSim.Web
+97
View File
@@ -0,0 +1,97 @@
html, body
{
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus
{
outline: none;
}
a, .btn-link
{
color: #0077cc;
}
.btn-primary
{
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.content
{
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox])
{
outline: 1px solid #26b050;
}
.invalid
{
outline: 1px solid red;
}
.validation-message
{
color: red;
}
#blazor-error-ui
{
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss
{
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary
{
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after
{
content: "An error has occurred."
}
#theCanvas
{
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
/* Disable text highlighting and magnifying glass on iPhone/webkit */
-webkit-user-select: none;
}
#canvas
{
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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

After

Width:  |  Height:  |  Size: 423 B

+23 -14
View File
@@ -44,8 +44,10 @@ public sealed class GameContent
/// <summary> /// <summary>
/// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и /// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и
/// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов. /// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов.
/// С <paramref name="buildAtlases"/> = false атласы не собираются — headless-режим
/// (дедикейтед-сервер) текстур не рисует.
/// </summary> /// </summary>
public static GameContent Load() public static GameContent Load(bool buildAtlases = true)
{ {
var modsRoot = var modsRoot =
ModLoader.FindModsRoot(AppContext.BaseDirectory) ModLoader.FindModsRoot(AppContext.BaseDirectory)
@@ -71,19 +73,26 @@ public sealed class GameContent
"Cache", "Cache",
"Atlases" "Atlases"
); );
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp"); if (buildAtlases)
var result = AtlasBuilder.Build( {
new AtlasBuildOptions var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
{ var result = AtlasBuilder.Build(
OutputDirectory = atlasCacheDirectory, new AtlasBuildOptions
GroupDepth = ModAtlases.GroupDepth, {
}, OutputDirectory = atlasCacheDirectory,
textures.Files.Select(f => (f.FullPath, f.RelativePath)) GroupDepth = ModAtlases.GroupDepth,
); },
var built = result.Groups.Count(g => !g.Skipped); textures.Files.Select(f => (f.FullPath, f.RelativePath))
Log.Info( );
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)" 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( return new GameContent(
mods, mods,
+2
View File
@@ -6,10 +6,12 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" /> <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.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.Simulation\MrGameEng.Simulation.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Net\MrGameEng.Net.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" /> <ProjectReference Include="..\..\engine\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference <ProjectReference
Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" Include="..\..\engine\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
+23
View File
@@ -0,0 +1,23 @@
using LittleSim.Sim;
using MrGameEng.Graphics;
using MrGameEng.Net;
namespace LittleSim.Net;
/// <summary>
/// Сетевой контракт LittleSim: какие компоненты реплицируются с сервера на клиентов.
/// Сервер (LittleSim.Server) и клиент (<see cref="Scenes.MultiplayerScene"/>) обязаны
/// строить схему одинаково — порядок регистрации определяет wire-id компонентов.
/// ВНИМАНИЕ: у веб-клиента бинарное зеркало этой схемы
/// (src/LittleSim.Web/NetContract.cs — он собран против KNI и не может ссылаться сюда);
/// меняешь состав или порядок — меняй и там.
/// </summary>
public static class NetSchema
{
/// <summary>Порт сервера по умолчанию.</summary>
public const int DefaultPort = 9050;
/// <summary>Схема репликации: позиция/масштаб жителя и его потребности.</summary>
public static ReplicationSchema Create() =>
new ReplicationSchema().Register<Transform2D>().Register<PawnNeeds>();
}
+65
View File
@@ -0,0 +1,65 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace LittleSim.Net;
/// <summary>
/// Сглаживание сетевой позиции между снапшотами. Сервер шлёт ~10 снапшотов в секунду,
/// а рендер идёт на частоте кадра — без сглаживания жители телепортируются рывками.
/// Снапшот пишет в <see cref="Transform2D.Position"/>; система ловит это (позиция
/// разошлась с нарисованной), запоминает цель и каждый кадр экспоненциально подтягивает
/// видимую позицию к цели, записывая её обратно в трансформ для рендера.
/// </summary>
public struct NetLerp : IComponent
{
/// <summary>Нарисованная (сглаженная) позиция прошлого кадра.</summary>
public Vector2 Visual;
/// <summary>Последняя серверная позиция — цель сглаживания.</summary>
public Vector2 Target;
/// <summary>Ложь до первого кадра: стартуем точно с серверной позиции, без подлёта.</summary>
public bool Initialized;
}
/// <summary>
/// Двигает <see cref="NetLerp.Visual"/> к <see cref="NetLerp.Target"/> и пишет результат в
/// <see cref="Transform2D.Position"/>. Ставится после прокачки сети и до рендера.
/// </summary>
public sealed class NetSmoothingSystem : QuerySystem<Transform2D, NetLerp>
{
// Скорость экспоненциального сглаживания: за ~0.25 с визуал почти догоняет цель.
private const float Rate = 12f;
protected override void OnUpdate()
{
var blend = 1f - MathF.Exp(-Rate * Tick.deltaTime);
foreach (var (transforms, lerps, _) in Query.Chunks)
{
var t = transforms.Span;
var l = lerps.Span;
for (var i = 0; i < t.Length; i++)
{
ref var lerp = ref l[i];
ref var position = ref t[i].Position;
if (!lerp.Initialized)
{
lerp.Visual = lerp.Target = position;
lerp.Initialized = true;
continue;
}
// Транформ трогает только снапшот: разошёлся с нарисованным — новая цель.
if (position != lerp.Visual)
{
lerp.Target = position;
}
lerp.Visual = Vector2.Lerp(lerp.Visual, lerp.Target, blend);
position = lerp.Visual;
}
}
}
}
+15 -2
View File
@@ -1,6 +1,19 @@
using LittleSim.Net;
using LittleSim.Scenes; using LittleSim.Scenes;
using Microsoft.Xna.Framework; 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), в фоне. // Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost( using var host = new GameHost(
@@ -11,7 +24,7 @@ using var host = new GameHost(
Height = 720, Height = 720,
ClearColor = new Color(12, 16, 24), ClearColor = new Color(12, 16, 24),
}, },
new BootScene() new BootScene(connect)
); );
host.Run(); host.Run();
+90 -78
View File
@@ -1,78 +1,90 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Audio; using MrGameEng.Audio;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.UI; using MrGameEng.Host;
using Myra.Graphics2D.UI; using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет /// <summary>
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с /// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню. /// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
/// </summary> /// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
public sealed class BootScene : Scene /// </summary>
{ public sealed class BootScene : Scene
private readonly string[] _steps = { "Загрузка", "Loading" }; {
private Task<GameContent>? _load; private readonly string[] _steps = { "Загрузка", "Loading" };
private GameSettings _settings = new(); private readonly string? _connectTo;
private Label _label = null!; private Task<GameContent>? _load;
private GameSettings _settings = new();
protected override void OnLoad() private Label _label = null!;
{
Context.UseAudio(); /// <summary>Обычный запуск — в главное меню.</summary>
Context.UseGameSpeed(1f, 3f, 6f); public BootScene()
: this(null) { }
_settings = GameSettingsStore.Load();
var host = (GameHost)Context.Services.Get<Game>(); /// <summary>С <paramref name="connectTo"/> (ws://host:port) грузится сразу в сетевую сцену.</summary>
host.Graphics.IsFullScreen = _settings.Fullscreen; public BootScene(string? connectTo) => _connectTo = connectTo;
host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
host.Graphics.PreferredBackBufferWidth = _settings.Width; protected override void OnLoad()
host.Graphics.PreferredBackBufferHeight = _settings.Height; {
host.Graphics.ApplyChanges(); Context.UseAudio();
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume; Context.UseGameSpeed(1f, 3f, 6f);
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов _settings = GameSettingsStore.Load();
_label = new Label var host = (GameHost)Context.Services.Get<Game>();
{ host.Graphics.IsFullScreen = _settings.Fullscreen;
TextColor = Ui.Accent, host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
HorizontalAlignment = HorizontalAlignment.Center, host.Graphics.PreferredBackBufferWidth = _settings.Width;
VerticalAlignment = VerticalAlignment.Center, host.Graphics.PreferredBackBufferHeight = _settings.Height;
}; host.Graphics.ApplyChanges();
desktop.Root = Ui.Screen(_label); Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока. var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
_load = Task.Run(GameContent.Load); _label = new Label
UpdateSystems.Add(new CallbackSystem(Tick)); {
} TextColor = Ui.Accent,
HorizontalAlignment = HorizontalAlignment.Center,
private void Tick() VerticalAlignment = VerticalAlignment.Center,
{ };
var word = _settings.Language == "en" ? _steps[1] : _steps[0]; desktop.Root = Ui.Screen(_label);
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = word + dots; // Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(() => GameContent.Load());
if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning) UpdateSystems.Add(new CallbackSystem(Tick));
{ }
return;
} private void Tick()
{
if (_load.IsFaulted) var word = _settings.Language == "en" ? _steps[1] : _steps[0];
{ var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = _load.Exception?.GetBaseException().Message ?? "load failed"; _label.Text = word + dots;
Log.Error($"Content load failed: {_label.Text}");
return; if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
} {
return;
var content = _load.Result; }
_load = null;
Context.Services.Add(content); if (_load.IsFaulted)
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory)); {
content.Languages.SetLanguage(_settings.Language); _label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.6f)); 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));
}
}
+47 -46
View File
@@ -1,46 +1,47 @@
using LittleSim.Content; using LittleSim.Content;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Input;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Input; using MrGameEng.Host;
using MrGameEng.UI; using MrGameEng.Input;
using MrGameEng.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.</summary>
public sealed class CreditsScene : Scene /// <summary>Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.</summary>
{ public sealed class CreditsScene : Scene
protected override void OnLoad() {
{ protected override void OnLoad()
var lang = Context.Services.Get<GameContent>().Languages; {
var input = this.UseInput(); var lang = Context.Services.Get<GameContent>().Languages;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("credits.title"))); var column = Ui.Column(12);
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 }); column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author"))); column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role"))); column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 }); column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back)); 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( desktop.Root = Ui.Screen(column);
new CallbackSystem(() => UpdateSystems.Add(
{ new CallbackSystem(() =>
if (input.IsKeyPressed(Keys.Escape)) {
{ if (input.IsKeyPressed(Keys.Escape))
Back(); {
} Back();
}) }
); })
} );
}
private void Back()
{ private void Back()
if (!Context.Scenes.IsTransitioning) {
{ if (!Context.Scenes.IsTransitioning)
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f)); {
} Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
} }
} }
}
+111 -110
View File
@@ -1,110 +1,111 @@
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Input;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Input; using MrGameEng.Host;
using MrGameEng.UI; using MrGameEng.Input;
using Myra.Graphics2D.UI; using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>
/// Экран загрузки: список сохранений из <see cref="SaveStore"/> (имя, размер, дата) с /// <summary>
/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции. /// Экран загрузки: список сохранений из <see cref="SaveStore"/> (имя, размер, дата) с
/// </summary> /// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
public sealed class LoadGameScene : Scene /// </summary>
{ public sealed class LoadGameScene : Scene
private readonly SaveStore _store = new(); {
private GameContent _content = null!; private readonly SaveStore _store = new();
private Desktop _desktop = null!; private GameContent _content = null!;
private Desktop _desktop = null!;
protected override void OnLoad()
{ protected override void OnLoad()
_content = Context.Services.Get<GameContent>(); {
var input = this.UseInput(); _content = Context.Services.Get<GameContent>();
_desktop = this.UseUI(); var input = this.UseInput();
Rebuild(); _desktop = this.UseUI();
Rebuild();
UpdateSystems.Add(
new CallbackSystem(() => UpdateSystems.Add(
{ new CallbackSystem(() =>
if (input.IsKeyPressed(Keys.Escape)) {
{ if (input.IsKeyPressed(Keys.Escape))
Back(); {
} Back();
}) }
); })
} );
}
private void Rebuild()
{ private void Rebuild()
var lang = _content.Languages; {
var column = Ui.Column(8); var lang = _content.Languages;
column.Widgets.Add(Ui.Title(lang.Get("load.title"))); var column = Ui.Column(8);
column.Widgets.Add(new Label { Height = 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) var saves = _store.List();
{ if (saves.Count == 0)
column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty"))); {
} column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty")));
}
foreach (var entry in saves)
{ foreach (var entry in saves)
var save = entry.Save; {
var row = Ui.Row(8); var save = entry.Save;
row.Widgets.Add( var row = Ui.Row(8);
new Label row.Widgets.Add(
{ new Label
TextColor = Ui.Accent, {
Text = TextColor = Ui.Accent,
$"{save.Name} — {save.Width}×{save.Height} — " Text =
+ save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"), $"{save.Name} — {save.Width}×{save.Height} — "
} + save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
); }
var file = entry.FileName; );
row.Widgets.Add( var file = entry.FileName;
Ui.Button( row.Widgets.Add(
lang.Get("load.load"), Ui.Button(
() => lang.Get("load.load"),
{ () =>
if (!Context.Scenes.IsTransitioning) {
{ if (!Context.Scenes.IsTransitioning)
Context.Scenes.Switch( {
new WorldScene(save.ToConfig(), save), Context.Scenes.Switch(
Transition.Fade(0.6f) new WorldScene(save.ToConfig(), save),
); Transitions.Fade(0.6f)
} );
}, }
width: 130 },
) width: 130
); )
row.Widgets.Add( );
Ui.Button( row.Widgets.Add(
lang.Get("load.delete"), Ui.Button(
() => lang.Get("load.delete"),
{ () =>
_store.Delete(file); {
Rebuild(); _store.Delete(file);
}, Rebuild();
width: 130 },
) width: 130
); )
column.Widgets.Add(row); );
} column.Widgets.Add(row);
}
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back)); column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
_desktop.Root = Ui.Screen(column);
} _desktop.Root = Ui.Screen(column);
}
private void Back()
{ private void Back()
if (!Context.Scenes.IsTransitioning) {
{ if (!Context.Scenes.IsTransitioning)
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f)); {
} Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
} }
} }
}
+44 -43
View File
@@ -1,43 +1,44 @@
using LittleSim.Content; using LittleSim.Content;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.UI; using MrGameEng.Host;
using MrGameEng.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>
/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход. /// <summary>
/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера. /// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
/// </summary> /// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
public sealed class MainMenuScene : Scene /// </summary>
{ public sealed class MainMenuScene : Scene
protected override void OnLoad() {
{ protected override void OnLoad()
var content = Context.Services.Get<GameContent>(); {
var lang = content.Languages; var content = Context.Services.Get<GameContent>();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var lang = content.Languages;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("menu.title"))); var column = Ui.Column(12);
column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle"))); column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 }); column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene()))); column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene()))); column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene()))); column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene()))); column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
column.Widgets.Add( column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get<Game>().Exit()) column.Widgets.Add(
); Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get<Game>().Exit())
);
desktop.Root = Ui.Screen(column);
} desktop.Root = Ui.Screen(column);
}
private void Go(Scene scene)
{ private void Go(Scene scene)
if (!Context.Scenes.IsTransitioning) {
{ if (!Context.Scenes.IsTransitioning)
Context.Scenes.Switch(scene, Transition.Fade(0.4f)); {
} Context.Scenes.Switch(scene, Transitions.Fade(0.4f));
} }
} }
}
+136
View File
@@ -0,0 +1,136 @@
using System;
using System.Threading.Tasks;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Net;
using LittleSim.Sim;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.Net;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Сетевой клиент: подключается к дедикейтед-серверу (LittleSim.Server --listen) и рендерит
/// реплицированных жителей. Симуляция целиком на сервере; сюда приезжают только компоненты
/// из <see cref="NetSchema"/>, а спрайты вешаются локально при спавне — граница
/// sim/presentation проходит теперь через сеть. Esc — назад в меню, команда консоли `net` —
/// состояние соединения.
/// </summary>
public sealed class MultiplayerScene : Scene
{
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
private readonly Uri _server;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
private ReplicationClient _replication = null!;
private InputManager _input = null!;
private GameContent _content = null!;
private Label _hud = null!;
private string _statusKey = "net.connecting";
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
public MultiplayerScene(Uri server) => _server = server;
protected override void OnLoad()
{
_content = Context.Services.Get<GameContent>();
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
_input = this.UseInput();
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
Store.CreateEntity(new Camera(Bounds.Center, zoom: 1f, bounds: Bounds));
// Реплика пишет в ECS сцены; презентацию (спрайт) вешаем сами при спавне.
_replication = new ReplicationClient(NetSchema.Create(), Store);
_replication.EntitySpawned += entity =>
{
var sprite = new Sprite(white, GameLayers.Beings);
sprite.CenterOrigin();
entity.AddComponent(sprite);
entity.AddComponent(new NetLerp());
};
var desktop = this.UseUI();
_hud = new Label { Left = 10, Top = 8 };
desktop.Root = Ui.Screen(_hud);
UpdateSystems.Add(new CallbackSystem(Pump));
// Снапшоты приходят ~10 раз в секунду — сглаживаем позиции до частоты кадра.
UpdateSystems.Add(new NetSmoothingSystem());
// Усталость жителей видна и по сети: PawnNeeds реплицируется, спрайт темнеет локально.
UpdateSystems.Add(new PawnAppearanceSystem());
var console = this.UseDevConsole();
console.Register(
"net",
"net — connection status and replicated entity count",
(c, _) =>
{
c.WriteLine($"server: {_server}");
c.WriteLine($"state: {(_connection?.IsOpen == true ? "connected" : "offline")}");
c.WriteLine($"entities: {_replication.EntityCount}");
}
);
_connecting = WebSocketClient.ConnectAsync(_server);
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
if (_connecting is { IsCompleted: true } finished)
{
_connecting = null;
if (finished.IsFaulted)
{
_statusKey = "net.failed";
Log.Error(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
}
else
{
_connection = finished.Result;
_statusKey = "net.connected";
Log.Info($"Connected to {_server}");
}
}
if (_connection is not null)
{
_replication.Pump(_connection);
if (!_connection.IsOpen)
{
_statusKey = "net.lost";
}
}
_hud.Text = _content.Languages.Format(
"net.hud",
_content.Languages.Get(_statusKey),
_replication.EntityCount
);
if (_input.IsKeyPressed(Keys.Escape))
{
_connection?.Close();
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+167 -166
View File
@@ -1,166 +1,167 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Input;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Input; using MrGameEng.Host;
using MrGameEng.UI; using MrGameEng.Input;
using Myra.Graphics2D.UI; using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>
/// Окно настройки нового мира: имя, размер (пресеты из <see cref="WorldPresetDef"/> Core-мода), /// <summary>
/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает <see cref="WorldScene"/>. /// Окно настройки нового мира: имя, размер (пресеты из <see cref="WorldPresetDef"/> Core-мода),
/// </summary> /// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает <see cref="WorldScene"/>.
public sealed class NewWorldScene : Scene /// </summary>
{ public sealed class NewWorldScene : Scene
private const int MinSmoothing = 1; {
private const int MaxSmoothing = 8; private const int MinSmoothing = 1;
private const int MaxSmoothing = 8;
private WorldPresetDef _preset = null!;
private int _smoothing = 4; private WorldPresetDef _preset = null!;
private TextBox _name = null!; private int _smoothing = 4;
private TextBox _seed = null!; private TextBox _name = null!;
private readonly List<Action> _refreshers = []; private TextBox _seed = null!;
private readonly List<Action> _refreshers = [];
protected override void OnLoad()
{ protected override void OnLoad()
var content = Context.Services.Get<GameContent>(); {
var lang = content.Languages; var content = Context.Services.Get<GameContent>();
var input = this.UseInput(); var lang = content.Languages;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов 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 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"))); var column = Ui.Column(10);
column.Widgets.Add(new Label { Height = 8 }); 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)); _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 }); var sizeRow = Ui.Row(6);
foreach (var preset in presets) 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) }; var value = preset;
button.Click += (_, _) => var button = new TextButton { Text = lang.Get(preset.Label) };
{ button.Click += (_, _) =>
_preset = value; {
Refresh(); _preset = value;
}; Refresh();
sizeRow.Widgets.Add(button); };
_refreshers.Add(() => sizeRow.Widgets.Add(button);
button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted _refreshers.Add(() =>
); button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted
} );
}
column.Widgets.Add(sizeRow);
column.Widgets.Add(sizeRow);
// Сид + случайно.
_seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 }; // Сид + случайно.
var seedRow = Ui.Row(6); _seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 };
seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted }); var seedRow = Ui.Row(6);
seedRow.Widgets.Add(_seed); seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted });
seedRow.Widgets.Add( seedRow.Widgets.Add(_seed);
Ui.Button( seedRow.Widgets.Add(
lang.Get("newworld.random"), Ui.Button(
() => _seed.Text = Random.Shared.Next().ToString(), lang.Get("newworld.random"),
width: 120 () => _seed.Text = Random.Shared.Next().ToString(),
) width: 120
); )
column.Widgets.Add(seedRow); );
column.Widgets.Add(seedRow);
// Сглаживание /+.
var smoothRow = Ui.Row(6); // Сглаживание /+.
var smoothLabel = new Label { TextColor = Ui.Muted }; var smoothRow = Ui.Row(6);
_refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}"); var smoothLabel = new Label { TextColor = Ui.Muted };
var minus = new TextButton { Text = "" }; _refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}");
minus.Click += (_, _) => var minus = new TextButton { Text = "" };
{ minus.Click += (_, _) =>
_smoothing = Math.Max(MinSmoothing, _smoothing - 1); {
Refresh(); _smoothing = Math.Max(MinSmoothing, _smoothing - 1);
}; Refresh();
var plus = new TextButton { Text = "+" }; };
plus.Click += (_, _) => var plus = new TextButton { Text = "+" };
{ plus.Click += (_, _) =>
_smoothing = Math.Min(MaxSmoothing, _smoothing + 1); {
Refresh(); _smoothing = Math.Min(MaxSmoothing, _smoothing + 1);
}; Refresh();
smoothRow.Widgets.Add(smoothLabel); };
smoothRow.Widgets.Add(minus); smoothRow.Widgets.Add(smoothLabel);
smoothRow.Widgets.Add(plus); smoothRow.Widgets.Add(minus);
column.Widgets.Add(smoothRow); smoothRow.Widgets.Add(plus);
column.Widgets.Add(smoothRow);
column.Widgets.Add(new Label { Height = 8 });
var buttons = Ui.Row(10); column.Widgets.Add(new Label { Height = 8 });
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150)); var buttons = Ui.Row(10);
buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150)); buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150));
column.Widgets.Add(buttons); buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
column.Widgets.Add(buttons);
desktop.Root = Ui.Screen(column);
Refresh(); desktop.Root = Ui.Screen(column);
Refresh();
UpdateSystems.Add(
new CallbackSystem(() => UpdateSystems.Add(
{ new CallbackSystem(() =>
if (input.IsKeyPressed(Keys.Escape)) {
{ if (input.IsKeyPressed(Keys.Escape))
Back(); {
} Back();
}) }
); })
} );
}
private static Widget LabeledRow(string label, Widget control)
{ private static Widget LabeledRow(string label, Widget control)
var row = Ui.Row(6); {
row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted }); var row = Ui.Row(6);
row.Widgets.Add(control); row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
return row; row.Widgets.Add(control);
} return row;
}
private void Refresh()
{ private void Refresh()
foreach (var refresh in _refreshers) {
{ foreach (var refresh in _refreshers)
refresh(); {
} refresh();
} }
}
private void Create()
{ private void Create()
if (Context.Scenes.IsTransitioning) {
{ if (Context.Scenes.IsTransitioning)
return; {
} 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 seed = int.TryParse(_seed.Text, out var parsed) ? parsed : Random.Shared.Next();
var config = new WorldConfig var name = string.IsNullOrWhiteSpace(_name.Text) ? "World" : _name.Text.Trim();
{ var config = new WorldConfig
Name = name, {
Width = _preset.Width, Name = name,
Height = _preset.Height, Width = _preset.Width,
Population = _preset.Population, Height = _preset.Height,
Seed = seed, Population = _preset.Population,
SmoothPasses = _smoothing, Seed = seed,
}; SmoothPasses = _smoothing,
Context.Scenes.Switch(new WorldScene(config), Transition.Fade(0.6f)); };
} Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f));
}
private void Back()
{ private void Back()
if (!Context.Scenes.IsTransitioning) {
{ if (!Context.Scenes.IsTransitioning)
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f)); {
} Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
} }
} }
}
+168 -167
View File
@@ -1,167 +1,168 @@
using System; using System;
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Audio; using MrGameEng.Audio;
using MrGameEng.Core; using MrGameEng.Core;
using Myra.Graphics2D.Brushes; using MrGameEng.Host;
using Myra.Graphics2D.UI; using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>
/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным). /// <summary>
/// Открытие ставит игру на паузу через <see cref="GameSpeed"/>, закрытие — снимает. /// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
/// Кнопки: Продолжить / Настройки (общая <see cref="SettingsPanel"/>) / Сохранить / /// Открытие ставит игру на паузу через <see cref="GameSpeed"/>, закрытие — снимает.
/// Главное меню / Выход. <see cref="Root"/> добавляется в корневую панель сцены. /// Кнопки: Продолжить / Настройки (общая <see cref="SettingsPanel"/>) / Сохранить /
/// </summary> /// Главное меню / Выход. <see cref="Root"/> добавляется в корневую панель сцены.
internal sealed class PauseMenu /// </summary>
{ internal sealed class PauseMenu
private readonly EngineContext _context; {
private readonly GameContent _content; private readonly EngineContext _context;
private readonly GameSpeed _speed; private readonly GameContent _content;
private readonly GameHost _host; private readonly GameSpeed _speed;
private readonly AudioManager _audio; private readonly GameHost _host;
private readonly Func<string> _onSave; private readonly AudioManager _audio;
private readonly Action _onMainMenu; private readonly Func<string> _onSave;
private readonly Action _onQuit; private readonly Action _onMainMenu;
private readonly Panel _overlay; private readonly Action _onQuit;
private GameSettings _settings = GameSettingsStore.Load(); private readonly Panel _overlay;
private bool _inSettings; private GameSettings _settings = GameSettingsStore.Load();
private string? _savedToast; private bool _inSettings;
private string? _savedToast;
public PauseMenu(
EngineContext context, public PauseMenu(
GameContent content, EngineContext context,
GameSpeed speed, GameContent content,
Func<string> onSave, GameSpeed speed,
Action onMainMenu, Func<string> onSave,
Action onQuit Action onMainMenu,
) Action onQuit
{ )
_context = context; {
_content = content; _context = context;
_speed = speed; _content = content;
_host = (GameHost)context.Services.Get<Game>(); _speed = speed;
_audio = context.Services.Get<AudioManager>(); _host = (GameHost)context.Services.Get<Game>();
_onSave = onSave; _audio = context.Services.Get<AudioManager>();
_onMainMenu = onMainMenu; _onSave = onSave;
_onQuit = onQuit; _onMainMenu = onMainMenu;
_onQuit = onQuit;
_overlay = new Panel
{ _overlay = new Panel
Visible = false, {
Background = new SolidBrush(new Color(0, 0, 0, 200)), Visible = false,
HorizontalAlignment = HorizontalAlignment.Stretch, Background = new SolidBrush(new Color(0, 0, 0, 200)),
VerticalAlignment = VerticalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Stretch,
}; VerticalAlignment = VerticalAlignment.Stretch,
} };
}
/// <summary>Виджет оверлея для добавления в корень сцены.</summary>
public Widget Root => _overlay; /// <summary>Виджет оверлея для добавления в корень сцены.</summary>
public Widget Root => _overlay;
/// <summary>Открыт ли оверлей паузы.</summary>
public bool IsOpen { get; private set; } /// <summary>Открыт ли оверлей паузы.</summary>
public bool IsOpen { get; private set; }
/// <summary>
/// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть /// <summary>
/// меню паузы. /// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
/// </summary> /// меню паузы.
public void Toggle() /// </summary>
{ public void Toggle()
if (!IsOpen) {
{ if (!IsOpen)
Open(); {
} Open();
else if (_inSettings) }
{ else if (_inSettings)
_inSettings = false; {
ShowButtons(); _inSettings = false;
} ShowButtons();
else }
{ else
Close(); {
} Close();
} }
}
private void Open()
{ private void Open()
IsOpen = true; {
_inSettings = false; IsOpen = true;
_savedToast = null; _inSettings = false;
_speed.Pause(); _savedToast = null;
ShowButtons(); _speed.Pause();
_overlay.Visible = true; ShowButtons();
} _overlay.Visible = true;
}
private void Close()
{ private void Close()
IsOpen = false; {
_inSettings = false; IsOpen = false;
_overlay.Visible = false; _inSettings = false;
_speed.Resume(); _overlay.Visible = false;
} _speed.Resume();
}
private void ShowButtons()
{ private void ShowButtons()
var lang = _content.Languages; {
var column = Ui.Column(10); var lang = _content.Languages;
column.Widgets.Add(Ui.Title(lang.Get("pause.title"))); var column = Ui.Column(10);
column.Widgets.Add(new Label { Height = 8 }); column.Widgets.Add(Ui.Title(lang.Get("pause.title")));
column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close)); column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add( column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close));
Ui.Button( column.Widgets.Add(
lang.Get("pause.settings"), Ui.Button(
() => lang.Get("pause.settings"),
{ () =>
_inSettings = true; {
ShowSettings(); _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.save"), Save));
column.Widgets.Add(Ui.Button(lang.Get("pause.quit"), _onQuit)); 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)
{ if (_savedToast is not null)
column.Widgets.Add( {
new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted } column.Widgets.Add(
); new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted }
} );
}
SetContent(column);
} SetContent(column);
}
private void ShowSettings() =>
SetContent( private void ShowSettings() =>
SettingsPanel.Build( SetContent(
_settings, SettingsPanel.Build(
_content.Languages, _settings,
onApply: () => _content.Languages,
{ onApply: () =>
GameSettingsStore.Save(_settings); {
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio); GameSettingsStore.Save(_settings);
ShowSettings(); // язык мог смениться GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
}, ShowSettings(); // язык мог смениться
onBack: () => },
{ onBack: () =>
_inSettings = false; {
ShowButtons(); _inSettings = false;
} ShowButtons();
) }
); )
);
private void Save()
{ private void Save()
_savedToast = _onSave(); {
ShowButtons(); _savedToast = _onSave();
} ShowButtons();
}
private void SetContent(Widget content)
{ private void SetContent(Widget content)
_overlay.Widgets.Clear(); {
_overlay.Widgets.Add(content); _overlay.Widgets.Clear();
} _overlay.Widgets.Add(content);
} }
}
+66 -65
View File
@@ -1,65 +1,66 @@
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Input;
using MrGameEng.Audio; using MrGameEng.Audio;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Input; using MrGameEng.Host;
using MrGameEng.UI; using MrGameEng.Input;
using Myra.Graphics2D.UI; using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>
/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий /// <summary>
/// <see cref="SettingsPanel"/>. «Применить» сохраняет в settings.json и применяет к движку /// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню. /// <see cref="SettingsPanel"/>. «Применить» сохраняет в settings.json и применяет к движку
/// </summary> /// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
public sealed class SettingsScene : Scene /// </summary>
{ public sealed class SettingsScene : Scene
private GameContent _content = null!; {
private AudioManager _audio = null!; private GameContent _content = null!;
private GameHost _host = null!; private AudioManager _audio = null!;
private Desktop _desktop = null!; private GameHost _host = null!;
private GameSettings _settings = GameSettingsStore.Load(); private Desktop _desktop = null!;
private GameSettings _settings = GameSettingsStore.Load();
protected override void OnLoad()
{ protected override void OnLoad()
_content = Context.Services.Get<GameContent>(); {
_audio = Context.Services.Get<AudioManager>(); _content = Context.Services.Get<GameContent>();
_host = (GameHost)Context.Services.Get<Game>(); _audio = Context.Services.Get<AudioManager>();
var input = this.UseInput(); _host = (GameHost)Context.Services.Get<Game>();
var input = this.UseInput();
_desktop = this.UseUI();
Rebuild(); _desktop = this.UseUI();
Rebuild();
UpdateSystems.Add(
new CallbackSystem(() => UpdateSystems.Add(
{ new CallbackSystem(() =>
if (input.IsKeyPressed(Keys.Escape)) {
{ if (input.IsKeyPressed(Keys.Escape))
Back(); {
} Back();
}) }
); })
} );
}
private void Rebuild() =>
_desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back)); private void Rebuild() =>
_desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
private void Apply()
{ private void Apply()
GameSettingsStore.Save(_settings); {
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio); GameSettingsStore.Save(_settings);
Rebuild(); // язык мог смениться — перестраиваем подписи GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
} Rebuild(); // язык мог смениться — перестраиваем подписи
}
private void Back()
{ private void Back()
if (!Context.Scenes.IsTransitioning) {
{ if (!Context.Scenes.IsTransitioning)
Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f)); {
} Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
} }
} }
}
+312 -311
View File
@@ -1,311 +1,312 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
using LittleSim.App; using LittleSim.App;
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Sim; using LittleSim.Sim;
using LittleSim.UI; using LittleSim.UI;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Input;
using MrGameEng.Assets; using MrGameEng.Assets;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.DevConsole; using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Input; using MrGameEng.Host;
using MrGameEng.Inspector; using MrGameEng.Input;
using MrGameEng.Lighting; using MrGameEng.Inspector;
using MrGameEng.Tilemaps; using MrGameEng.Lighting;
using MrGameEng.UI; using MrGameEng.Tilemaps;
using Myra.Graphics2D; using MrGameEng.UI;
using Myra.Graphics2D.UI; using Myra.Graphics2D;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
namespace LittleSim.Scenes;
/// <summary>
/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из /// <summary>
/// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка /// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
/// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа /// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие /// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн. /// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
/// </summary> /// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
public sealed class WorldScene : Scene /// </summary>
{ public sealed class WorldScene : Scene
public const int CellSize = 16; {
public const int CellSize = 16;
/// <summary>
/// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые /// <summary>
/// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с. /// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
/// </summary> /// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
public const float SecondsPerDay = 480f; /// </summary>
public const float SecondsPerDay = 480f;
private readonly WorldConfig _config;
private readonly WorldSave? _save; private readonly WorldConfig _config;
private readonly RectF _bounds; private readonly WorldSave? _save;
private readonly RectF _bounds;
private GameSpeed _speed = null!;
private PauseMenu _pause = null!; private GameSpeed _speed = null!;
private readonly List<Action> _speedRefreshers = []; private PauseMenu _pause = null!;
private readonly List<Action> _speedRefreshers = [];
/// <summary>Новый мир из конфига.</summary>
public WorldScene(WorldConfig config) /// <summary>Новый мир из конфига.</summary>
: this(config, null) { } public WorldScene(WorldConfig config)
: this(config, null) { }
/// <summary>Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).</summary>
public WorldScene(WorldConfig config, WorldSave? save) /// <summary>Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).</summary>
{ public WorldScene(WorldConfig config, WorldSave? save)
_config = config; {
_save = save; _config = config;
_bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize); _save = save;
} _bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
}
protected override void OnLoad()
{ protected override void OnLoad()
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets(); {
var content = Context.Services.Get<GameContent>(); var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
var atlases = Context.Services.Get<ModAtlases>(); var content = Context.Services.Get<GameContent>();
var device = Context.GraphicsDevice; var atlases = Context.Services.Get<ModAtlases>();
var input = this.UseInput(); var device = Context.GetGraphicsDevice();
_speed = Context.Services.Get<GameSpeed>(); var input = this.UseInput();
_speed.SetStep(0); // новый мир/загрузка стартуют на x1 _speed = Context.Services.Get<GameSpeed>();
_speed.SetStep(0); // новый мир/загрузка стартуют на x1
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) } var renderer = this.UseRenderer2D(
); new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
GameLayers.EnsureRegistered(renderer); );
this.UseTilemaps(); GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
var calendar = Context.UseCalendar(SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default); var calendar = Context.UseCalendar(SecondsPerDay);
var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер var climate = Context.UseClimate(ClimateSettings.Default);
var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
// Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
var plants = new PlantSet(content, atlases, device); // Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
var random = new Random(_config.Seed); var plants = new PlantSet(content, atlases, device);
BuildTerrain(content, atlases, device, assets, plants, random); 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));
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели.
var desktop = this.UseUI(); // HUD, полоса скорости и меню-пауза в одной корневой панели.
var hudLabel = new Label { Left = 10, Top = 8 }; var desktop = this.UseUI();
var speedBar = BuildSpeedBar(content); var hudLabel = new Label { Left = 10, Top = 8 };
_pause = new PauseMenu( var speedBar = BuildSpeedBar(content);
Context, _pause = new PauseMenu(
content, Context,
_speed, content,
onSave: SaveWorld, _speed,
onMainMenu: () => Switch(new MainMenuScene()), onSave: SaveWorld,
onQuit: () => Context.Services.Get<Game>().Exit() onMainMenu: () => Switch(new MainMenuScene()),
); onQuit: () => Context.Services.Get<Game>().Exit()
desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root); );
desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
this.UseInspector(renderer);
var console = this.UseDevConsole(); this.UseInspector(renderer);
RegisterCommands(console, content, atlases); var console = this.UseDevConsole();
RegisterCommands(console, content, atlases);
UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
UpdateSystems.Add( UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) UpdateSystems.Add(
); new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
UpdateSystems.Add( );
new HudSystem( UpdateSystems.Add(
Context, new HudSystem(
content.Languages, Context,
hudLabel, content.Languages,
"hud.world", hudLabel,
_config.Seed, "hud.world",
calendar, _config.Seed,
climate calendar,
) climate
); )
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input))); );
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
Log.Info(
$"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}" Log.Info(
+ (_save is null ? " (new)" : " (loaded)") $"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
); + (_save is null ? " (new)" : " (loaded)")
} );
}
/// <summary>
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из /// <summary>
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а /// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости). /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
/// </summary> /// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
private void BuildTerrain( /// </summary>
GameContent content, private void BuildTerrain(
ModAtlases atlases, GameContent content,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device, ModAtlases atlases,
AssetManager assets, Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
PlantSet plants, AssetManager assets,
Random random PlantSet plants,
) Random random
{ )
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); {
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
// Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
var tiles = new TileSet(); // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
var tileByDef = new Dictionary<TerrainDef, ushort>(); var tiles = new TileSet();
foreach (var terrain in content.Terrains.All) 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)) tileByDef[terrain] = terrain.Surface is { } surface
: tiles.Add(white, terrain.Tint); ? tiles.Add(atlases.GetRegion(device, surface))
} : tiles.Add(white, terrain.Tint);
}
// Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
var heights = WorldGenerator.Generate( // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
_config.Width, var heights = WorldGenerator.Generate(
_config.Height, _config.Width,
_config.Seed, _config.Height,
_config.SmoothPasses _config.Seed,
); _config.SmoothPasses
var grid = new TileGrid(_config.Width, _config.Height); );
for (var x = 0; x < _config.Width; x++) var grid = new TileGrid(_config.Width, _config.Height);
{ for (var x = 0; x < _config.Width; x++)
for (var y = 0; y < _config.Height; y++) {
{ for (var y = 0; y < _config.Height; y++)
var terrain = content.Terrains.Classify(heights[x, y]); {
grid[x, y] = tileByDef[terrain]; var terrain = content.Terrains.Classify(heights[x, y]);
ScatterSpawner.Spawn( grid[x, y] = tileByDef[terrain];
this, ScatterSpawner.Spawn(
content, this,
plants, content,
terrain, plants,
new Point(x, y), terrain,
CellSize, new Point(x, y),
random CellSize,
); random
} );
} }
}
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
} Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
}
private HorizontalStackPanel BuildSpeedBar(GameContent content)
{ private HorizontalStackPanel BuildSpeedBar(GameContent content)
var bar = Ui.Row(6); {
bar.HorizontalAlignment = HorizontalAlignment.Center; var bar = Ui.Row(6);
bar.VerticalAlignment = VerticalAlignment.Bottom; bar.HorizontalAlignment = HorizontalAlignment.Center;
bar.Margin = new Thickness(0, 0, 0, 12); 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(); var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
bar.Widgets.Add(pause); pause.Click += (_, _) => _speed.Pause();
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted); bar.Widgets.Add(pause);
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
for (var i = 0; i < _speed.Steps.Count; i++)
{ for (var i = 0; i < _speed.Steps.Count; i++)
var index = i; {
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" }; var index = i;
button.Click += (_, _) => _speed.SetStep(index); var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
bar.Widgets.Add(button); button.Click += (_, _) => _speed.SetStep(index);
_speedRefreshers.Add(() => bar.Widgets.Add(button);
button.TextColor = _speedRefreshers.Add(() =>
!_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted button.TextColor =
); !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
} );
}
void Refresh()
{ void Refresh()
foreach (var refresh in _speedRefreshers) {
{ foreach (var refresh in _speedRefreshers)
refresh(); {
} refresh();
} }
}
_speed.Changed += Refresh;
RegisterUnload(() => _speed.Changed -= Refresh); _speed.Changed += Refresh;
Refresh(); RegisterUnload(() => _speed.Changed -= Refresh);
return bar; Refresh();
} return bar;
}
private void Hotkeys(InputManager input)
{ private void Hotkeys(InputManager input)
if (input.IsKeyPressed(Keys.Escape)) {
{ if (input.IsKeyPressed(Keys.Escape))
_pause.Toggle(); {
} _pause.Toggle();
}
if (_pause.IsOpen)
{ if (_pause.IsOpen)
return; {
} return;
}
if (input.IsKeyPressed(Keys.Space))
{ if (input.IsKeyPressed(Keys.Space))
_speed.TogglePause(); {
} _speed.TogglePause();
}
if (input.IsKeyPressed(Keys.D1))
{ if (input.IsKeyPressed(Keys.D1))
_speed.SetStep(0); {
} _speed.SetStep(0);
}
if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
{ if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
_speed.SetStep(1); {
} _speed.SetStep(1);
}
if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
{ if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
_speed.SetStep(2); {
} _speed.SetStep(2);
} }
}
private string SaveWorld()
{ private string SaveWorld()
// Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида. {
var save = new WorldSave // Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
{ var save = new WorldSave
Name = _config.Name, {
Width = _config.Width, Name = _config.Name,
Height = _config.Height, Width = _config.Width,
Seed = _config.Seed, Height = _config.Height,
SmoothPasses = _config.SmoothPasses, Seed = _config.Seed,
Population = _config.Population, SmoothPasses = _config.SmoothPasses,
SavedUtc = DateTime.UtcNow, Population = _config.Population,
ElapsedSeconds = Context.Clock.TotalTime, SavedUtc = DateTime.UtcNow,
}; ElapsedSeconds = Context.Clock.TotalTime,
};
new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved"); new SaveStore().Write(save);
return _config.Name; Log.Info($"World '{_config.Name}' saved");
} return _config.Name;
}
private void Switch(Scene scene)
{ private void Switch(Scene scene)
if (!Context.Scenes.IsTransitioning) {
{ if (!Context.Scenes.IsTransitioning)
_speed.Resume(); {
Context.Scenes.Switch(scene, Transition.Fade(0.5f)); _speed.Resume();
} Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
} }
}
private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
{ private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
ContentCommands.Register(console, content, atlases); {
console.Register( ContentCommands.Register(console, content, atlases);
"regen", console.Register(
"regen [seed] — regenerate the world with a new seed", "regen",
(c, args) => "regen [seed] — regenerate the world with a new seed",
{ (c, args) =>
if (Context.Scenes.IsTransitioning) {
{ if (Context.Scenes.IsTransitioning)
return; {
} return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating world, seed {seed}"); var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
Context.Scenes.Switch( c.WriteLine($"regenerating world, seed {seed}");
new WorldScene(_config with { Seed = seed }), Context.Scenes.Switch(
Transition.Fade(0.6f) new WorldScene(_config with { Seed = seed }),
); Transitions.Fade(0.6f)
} );
); }
console.Register( );
"menu", console.Register(
"menu — return to the main menu", "menu",
(_, _) => Switch(new MainMenuScene()) "menu — return to the main menu",
); (_, _) => Switch(new MainMenuScene())
} );
} }
}