diff --git a/CLAUDE.md b/CLAUDE.md
index d3dffcb..16c545d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,12 +14,14 @@ Game design docs live in `docs/` and are written in **Russian**. Engine rules li
## Layout
```
-engine/ mrgameeng git submodule (own repo, own CLAUDE.md)
-src/LittleSim the game (net8.0); references engine projects directly
-Mods/Core the game's own content as a mod: About, Defs, Languages, Textures
-Cache/ runtime-built atlases (gitignored)
-docs/ концепт, симуляция, моды, roadmap (Russian)
-LittleSim.sln game + engine sources + engine tests — one window for everything
+engine/ mrgameeng git submodule (own repo, own CLAUDE.md)
+src/LittleSim the game (net8.0); references engine projects directly
+src/LittleSim.Server dedicated-server prototype: the world headless (engine HeadlessHost),
+ loads mods/defs without atlases; the future network server grows here
+Mods/Core the game's own content as a mod: About, Defs, Languages, Textures
+Cache/ runtime-built atlases (gitignored)
+docs/ концепт, симуляция, моды, roadmap (Russian)
+LittleSim.sln game + engine sources + engine tests — one window for everything
```
## Commands
@@ -28,6 +30,7 @@ LittleSim.sln game + engine sources + engine tests — one window for everythin
git submodule update --init # after fresh clone
dotnet build LittleSim.sln
dotnet run --project src/LittleSim -c Release # measure perf in Release only
+dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 # headless fast-forward
dotnet test LittleSim.sln # runs the engine test suites
```
diff --git a/LittleSim.sln b/LittleSim.sln
index 90a0b1e..ec7a4a8 100644
--- a/LittleSim.sln
+++ b/LittleSim.sln
@@ -41,6 +41,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "engin
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "engine\tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{E0E37D87-4F62-41E9-9CD1-3E7432301508}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "engine\src\MrGameEng.Host\MrGameEng.Host.csproj", "{D4222C26-40D8-4465-9B26-CD6BD0722EC0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "engine\tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{41F0249B-106E-4D56-B022-73D2C2D74807}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleSim.Server", "src\LittleSim.Server\LittleSim.Server.csproj", "{BEFB468B-6784-468E-9B60-EB44AB078D15}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -231,6 +237,42 @@ Global
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x64.Build.0 = Release|Any CPU
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x86.ActiveCfg = Release|Any CPU
{E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x86.Build.0 = Release|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x64.Build.0 = Debug|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Debug|x86.Build.0 = Debug|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x64.ActiveCfg = Release|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x64.Build.0 = Release|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x86.ActiveCfg = Release|Any CPU
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0}.Release|x86.Build.0 = Release|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x64.Build.0 = Debug|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Debug|x86.Build.0 = Debug|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|Any CPU.Build.0 = Release|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x64.ActiveCfg = Release|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x64.Build.0 = Release|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x86.ActiveCfg = Release|Any CPU
+ {41F0249B-106E-4D56-B022-73D2C2D74807}.Release|x86.Build.0 = Release|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x64.Build.0 = Debug|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Debug|x86.Build.0 = Debug|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.ActiveCfg = Release|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x64.Build.0 = Release|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.ActiveCfg = Release|Any CPU
+ {BEFB468B-6784-468E-9B60-EB44AB078D15}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -253,5 +295,8 @@ Global
{86ECA4A0-46A2-45F4-8CF4-8FB8BB5DC050} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
{E0E37D87-4F62-41E9-9CD1-3E7432301508} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
+ {D4222C26-40D8-4465-9B26-CD6BD0722EC0} = {F4A69A46-AF86-AB4E-7D23-4CCCB7B9A519}
+ {41F0249B-106E-4D56-B022-73D2C2D74807} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
+ {BEFB468B-6784-468E-9B60-EB44AB078D15} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/engine b/engine
index d0104df..2ac0740 160000
--- a/engine
+++ b/engine
@@ -1 +1 @@
-Subproject commit d0104df3044961f74bda68d3e6cc97dd0d34b326
+Subproject commit 2ac074004a18d9814c565440b3d0ecc87337458e
diff --git a/src/LittleSim.Server/HeadlessWorldScene.cs b/src/LittleSim.Server/HeadlessWorldScene.cs
new file mode 100644
index 0000000..de0c57b
--- /dev/null
+++ b/src/LittleSim.Server/HeadlessWorldScene.cs
@@ -0,0 +1,46 @@
+using Friflo.Engine.ECS.Systems;
+using LittleSim.Content;
+using LittleSim.Scenes;
+using MrGameEng.Core;
+
+namespace LittleSim.Server;
+
+///
+/// Серверная сцена-зародыш: календарь и климат мира идут на фиксированном тике
+/// , без окна, GPU и текстур. Демонстрирует headless-режим
+/// движка; дальше сюда переедет полная симуляция мира (растения, жители) и сетевой слой.
+///
+internal sealed class HeadlessWorldScene : Scene
+{
+ private readonly GameContent _content;
+
+ public HeadlessWorldScene(GameContent content) => _content = content;
+
+ protected override void OnLoad()
+ {
+ Context.Services.Add(_content);
+ var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
+ var climate = Context.UseClimate(ClimateSettings.Default);
+ UpdateSystems.Add(new DayReportSystem(calendar, climate));
+ }
+
+ /// Пишет строку состояния мира в лог на рассвете каждого игрового дня.
+ 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"
+ );
+ }
+ }
+}
diff --git a/src/LittleSim.Server/LittleSim.Server.csproj b/src/LittleSim.Server/LittleSim.Server.csproj
new file mode 100644
index 0000000..4f05ed4
--- /dev/null
+++ b/src/LittleSim.Server/LittleSim.Server.csproj
@@ -0,0 +1,10 @@
+
+
+ Exe
+ net8.0
+
+
+
+
+
+
diff --git a/src/LittleSim.Server/Program.cs b/src/LittleSim.Server/Program.cs
new file mode 100644
index 0000000..4074ce0
--- /dev/null
+++ b/src/LittleSim.Server/Program.cs
@@ -0,0 +1,44 @@
+using System.Diagnostics;
+using LittleSim.Content;
+using LittleSim.Scenes;
+using LittleSim.Server;
+using MrGameEng.Core;
+
+// Дедикейтед-сервер LittleSim (прототип): мир без окна и GPU на HeadlessHost движка.
+// Пока умеет загрузить моды/дефы (без атласов) и прогнать календарь и климат на
+// фиксированном тике. Использование:
+// dotnet run --project src/LittleSim.Server [-- --days N] [--tps N]
+
+var days = ReadOption("--days", 10);
+var ticksPerSecond = ReadOption("--tps", 60);
+
+Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
+
+var content = GameContent.Load(buildAtlases: false);
+Log.Info(
+ $"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
+ + $"дефов: {content.Defs.All().Count} террейна, "
+ + $"{content.Defs.All().Count} растений, "
+ + $"{content.Defs.All().Count} жителей"
+);
+
+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} тиков/с)"
+);
+
+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;
+}
diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs
index ed41727..abb0694 100644
--- a/src/LittleSim/Content/GameContent.cs
+++ b/src/LittleSim/Content/GameContent.cs
@@ -44,8 +44,10 @@ public sealed class GameContent
///
/// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и
/// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов.
+ /// С = false атласы не собираются — headless-режим
+ /// (дедикейтед-сервер) текстур не рисует.
///
- public static GameContent Load()
+ public static GameContent Load(bool buildAtlases = true)
{
var modsRoot =
ModLoader.FindModsRoot(AppContext.BaseDirectory)
@@ -71,19 +73,26 @@ public sealed class GameContent
"Cache",
"Atlases"
);
- var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
- var result = AtlasBuilder.Build(
- new AtlasBuildOptions
- {
- OutputDirectory = atlasCacheDirectory,
- GroupDepth = ModAtlases.GroupDepth,
- },
- textures.Files.Select(f => (f.FullPath, f.RelativePath))
- );
- var built = result.Groups.Count(g => !g.Skipped);
- Log.Info(
- $"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
- );
+ if (buildAtlases)
+ {
+ var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
+ var result = AtlasBuilder.Build(
+ new AtlasBuildOptions
+ {
+ OutputDirectory = atlasCacheDirectory,
+ GroupDepth = ModAtlases.GroupDepth,
+ },
+ textures.Files.Select(f => (f.FullPath, f.RelativePath))
+ );
+ var built = result.Groups.Count(g => !g.Skipped);
+ Log.Info(
+ $"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
+ );
+ }
+ else
+ {
+ Log.Info("Atlases: skipped (headless)");
+ }
return new GameContent(
mods,
diff --git a/src/LittleSim/LittleSim.csproj b/src/LittleSim/LittleSim.csproj
index c969813..0ad0e36 100644
--- a/src/LittleSim/LittleSim.csproj
+++ b/src/LittleSim/LittleSim.csproj
@@ -6,6 +6,7 @@
+
diff --git a/src/LittleSim/Program.cs b/src/LittleSim/Program.cs
index 80779f4..5de48b0 100644
--- a/src/LittleSim/Program.cs
+++ b/src/LittleSim/Program.cs
@@ -1,6 +1,6 @@
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
-using MrGameEng.Core;
+using MrGameEng.Host;
// Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost(
diff --git a/src/LittleSim/Scenes/BootScene.cs b/src/LittleSim/Scenes/BootScene.cs
index b0597d7..6431e3e 100644
--- a/src/LittleSim/Scenes/BootScene.cs
+++ b/src/LittleSim/Scenes/BootScene.cs
@@ -1,78 +1,79 @@
-using System.Threading.Tasks;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using MrGameEng.Audio;
-using MrGameEng.Core;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
-/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
-/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
-///
-public sealed class BootScene : Scene
-{
- private readonly string[] _steps = { "Загрузка", "Loading" };
- private Task? _load;
- private GameSettings _settings = new();
- private Label _label = null!;
-
- protected override void OnLoad()
- {
- Context.UseAudio();
- Context.UseGameSpeed(1f, 3f, 6f);
-
- _settings = GameSettingsStore.Load();
- var host = (GameHost)Context.Services.Get();
- host.Graphics.IsFullScreen = _settings.Fullscreen;
- host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
- host.Graphics.PreferredBackBufferWidth = _settings.Width;
- host.Graphics.PreferredBackBufferHeight = _settings.Height;
- host.Graphics.ApplyChanges();
- Context.Services.Get().MasterVolume = _settings.Volume;
-
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
- _label = new Label
- {
- TextColor = Ui.Accent,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- };
- desktop.Root = Ui.Screen(_label);
-
- // Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
- _load = Task.Run(GameContent.Load);
- UpdateSystems.Add(new CallbackSystem(Tick));
- }
-
- private void Tick()
- {
- var word = _settings.Language == "en" ? _steps[1] : _steps[0];
- var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
- _label.Text = word + dots;
-
- if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
- {
- return;
- }
-
- if (_load.IsFaulted)
- {
- _label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
- Log.Error($"Content load failed: {_label.Text}");
- return;
- }
-
- var content = _load.Result;
- _load = null;
- Context.Services.Add(content);
- Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
- content.Languages.SetLanguage(_settings.Language);
- Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.6f));
- }
-}
+using System.Threading.Tasks;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using MrGameEng.Audio;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
+/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
+/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
+///
+public sealed class BootScene : Scene
+{
+ private readonly string[] _steps = { "Загрузка", "Loading" };
+ private Task? _load;
+ private GameSettings _settings = new();
+ private Label _label = null!;
+
+ protected override void OnLoad()
+ {
+ Context.UseAudio();
+ Context.UseGameSpeed(1f, 3f, 6f);
+
+ _settings = GameSettingsStore.Load();
+ var host = (GameHost)Context.Services.Get();
+ host.Graphics.IsFullScreen = _settings.Fullscreen;
+ host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
+ host.Graphics.PreferredBackBufferWidth = _settings.Width;
+ host.Graphics.PreferredBackBufferHeight = _settings.Height;
+ host.Graphics.ApplyChanges();
+ Context.Services.Get().MasterVolume = _settings.Volume;
+
+ var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+ _label = new Label
+ {
+ TextColor = Ui.Accent,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ desktop.Root = Ui.Screen(_label);
+
+ // Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
+ _load = Task.Run(() => GameContent.Load());
+ UpdateSystems.Add(new CallbackSystem(Tick));
+ }
+
+ private void Tick()
+ {
+ var word = _settings.Language == "en" ? _steps[1] : _steps[0];
+ var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
+ _label.Text = word + dots;
+
+ if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
+ {
+ return;
+ }
+
+ if (_load.IsFaulted)
+ {
+ _label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
+ Log.Error($"Content load failed: {_label.Text}");
+ return;
+ }
+
+ var content = _load.Result;
+ _load = null;
+ Context.Services.Add(content);
+ Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
+ content.Languages.SetLanguage(_settings.Language);
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.6f));
+ }
+}
diff --git a/src/LittleSim/Scenes/CreditsScene.cs b/src/LittleSim/Scenes/CreditsScene.cs
index d6a6848..e09dfe0 100644
--- a/src/LittleSim/Scenes/CreditsScene.cs
+++ b/src/LittleSim/Scenes/CreditsScene.cs
@@ -1,46 +1,47 @@
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Core;
-using MrGameEng.Input;
-using MrGameEng.UI;
-
-namespace LittleSim.Scenes;
-
-/// Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.
-public sealed class CreditsScene : Scene
-{
- protected override void OnLoad()
- {
- var lang = Context.Services.Get().Languages;
- var input = this.UseInput();
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
-
- var column = Ui.Column(12);
- column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
- column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
- column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
- column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
- column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
- column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
-
- desktop.Root = Ui.Screen(column);
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
- }
- }
-}
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+
+namespace LittleSim.Scenes;
+
+/// Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.
+public sealed class CreditsScene : Scene
+{
+ protected override void OnLoad()
+ {
+ var lang = Context.Services.Get().Languages;
+ var input = this.UseInput();
+ var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+
+ var column = Ui.Column(12);
+ column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
+ column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
+ column.Widgets.Add(Ui.Subtitle(lang.Get("credits.author")));
+ column.Widgets.Add(Ui.Subtitle(lang.Get("credits.role")));
+ column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
+ column.Widgets.Add(Ui.Button(lang.Get("credits.back"), Back));
+
+ desktop.Root = Ui.Screen(column);
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/LoadGameScene.cs b/src/LittleSim/Scenes/LoadGameScene.cs
index 9e502fd..2a49ae5 100644
--- a/src/LittleSim/Scenes/LoadGameScene.cs
+++ b/src/LittleSim/Scenes/LoadGameScene.cs
@@ -1,110 +1,111 @@
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Core;
-using MrGameEng.Input;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Экран загрузки: список сохранений из (имя, размер, дата) с
-/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
-///
-public sealed class LoadGameScene : Scene
-{
- private readonly SaveStore _store = new();
- private GameContent _content = null!;
- private Desktop _desktop = null!;
-
- protected override void OnLoad()
- {
- _content = Context.Services.Get();
- var input = this.UseInput();
- _desktop = this.UseUI();
- Rebuild();
-
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private void Rebuild()
- {
- var lang = _content.Languages;
- var column = Ui.Column(8);
- column.Widgets.Add(Ui.Title(lang.Get("load.title")));
- column.Widgets.Add(new Label { Height = 8 });
-
- var saves = _store.List();
- if (saves.Count == 0)
- {
- column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty")));
- }
-
- foreach (var entry in saves)
- {
- var save = entry.Save;
- var row = Ui.Row(8);
- row.Widgets.Add(
- new Label
- {
- TextColor = Ui.Accent,
- Text =
- $"{save.Name} — {save.Width}×{save.Height} — "
- + save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
- }
- );
- var file = entry.FileName;
- row.Widgets.Add(
- Ui.Button(
- lang.Get("load.load"),
- () =>
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(
- new WorldScene(save.ToConfig(), save),
- Transition.Fade(0.6f)
- );
- }
- },
- width: 130
- )
- );
- row.Widgets.Add(
- Ui.Button(
- lang.Get("load.delete"),
- () =>
- {
- _store.Delete(file);
- Rebuild();
- },
- width: 130
- )
- );
- column.Widgets.Add(row);
- }
-
- column.Widgets.Add(new Label { Height = 8 });
- column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
-
- _desktop.Root = Ui.Screen(column);
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
- }
- }
-}
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Экран загрузки: список сохранений из (имя, размер, дата) с
+/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
+///
+public sealed class LoadGameScene : Scene
+{
+ private readonly SaveStore _store = new();
+ private GameContent _content = null!;
+ private Desktop _desktop = null!;
+
+ protected override void OnLoad()
+ {
+ _content = Context.Services.Get();
+ var input = this.UseInput();
+ _desktop = this.UseUI();
+ Rebuild();
+
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private void Rebuild()
+ {
+ var lang = _content.Languages;
+ var column = Ui.Column(8);
+ column.Widgets.Add(Ui.Title(lang.Get("load.title")));
+ column.Widgets.Add(new Label { Height = 8 });
+
+ var saves = _store.List();
+ if (saves.Count == 0)
+ {
+ column.Widgets.Add(Ui.Subtitle(lang.Get("load.empty")));
+ }
+
+ foreach (var entry in saves)
+ {
+ var save = entry.Save;
+ var row = Ui.Row(8);
+ row.Widgets.Add(
+ new Label
+ {
+ TextColor = Ui.Accent,
+ Text =
+ $"{save.Name} — {save.Width}×{save.Height} — "
+ + save.SavedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
+ }
+ );
+ var file = entry.FileName;
+ row.Widgets.Add(
+ Ui.Button(
+ lang.Get("load.load"),
+ () =>
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(
+ new WorldScene(save.ToConfig(), save),
+ Transitions.Fade(0.6f)
+ );
+ }
+ },
+ width: 130
+ )
+ );
+ row.Widgets.Add(
+ Ui.Button(
+ lang.Get("load.delete"),
+ () =>
+ {
+ _store.Delete(file);
+ Rebuild();
+ },
+ width: 130
+ )
+ );
+ column.Widgets.Add(row);
+ }
+
+ column.Widgets.Add(new Label { Height = 8 });
+ column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
+
+ _desktop.Root = Ui.Screen(column);
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/MainMenuScene.cs b/src/LittleSim/Scenes/MainMenuScene.cs
index 5800983..429873e 100644
--- a/src/LittleSim/Scenes/MainMenuScene.cs
+++ b/src/LittleSim/Scenes/MainMenuScene.cs
@@ -1,43 +1,44 @@
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using MrGameEng.Core;
-using MrGameEng.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
-/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
-///
-public sealed class MainMenuScene : Scene
-{
- protected override void OnLoad()
- {
- var content = Context.Services.Get();
- var lang = content.Languages;
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
-
- var column = Ui.Column(12);
- column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
- column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
- column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
- column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
- column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
- column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
- column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
- column.Widgets.Add(
- Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get().Exit())
- );
-
- desktop.Root = Ui.Screen(column);
- }
-
- private void Go(Scene scene)
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(scene, Transition.Fade(0.4f));
- }
- }
-}
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
+/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
+///
+public sealed class MainMenuScene : Scene
+{
+ protected override void OnLoad()
+ {
+ var content = Context.Services.Get();
+ var lang = content.Languages;
+ var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+
+ var column = Ui.Column(12);
+ column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
+ column.Widgets.Add(Ui.Subtitle(lang.Get("menu.subtitle")));
+ column.Widgets.Add(new Myra.Graphics2D.UI.Label { Height = 16 });
+ column.Widgets.Add(Ui.Button(lang.Get("menu.newworld"), () => Go(new NewWorldScene())));
+ column.Widgets.Add(Ui.Button(lang.Get("menu.load"), () => Go(new LoadGameScene())));
+ column.Widgets.Add(Ui.Button(lang.Get("menu.settings"), () => Go(new SettingsScene())));
+ column.Widgets.Add(Ui.Button(lang.Get("menu.credits"), () => Go(new CreditsScene())));
+ column.Widgets.Add(
+ Ui.Button(lang.Get("menu.quit"), () => Context.Services.Get().Exit())
+ );
+
+ desktop.Root = Ui.Screen(column);
+ }
+
+ private void Go(Scene scene)
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(scene, Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/NewWorldScene.cs b/src/LittleSim/Scenes/NewWorldScene.cs
index 70d53d8..eafdb76 100644
--- a/src/LittleSim/Scenes/NewWorldScene.cs
+++ b/src/LittleSim/Scenes/NewWorldScene.cs
@@ -1,166 +1,167 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Core;
-using MrGameEng.Input;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Окно настройки нового мира: имя, размер (пресеты из Core-мода),
-/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает .
-///
-public sealed class NewWorldScene : Scene
-{
- private const int MinSmoothing = 1;
- private const int MaxSmoothing = 8;
-
- private WorldPresetDef _preset = null!;
- private int _smoothing = 4;
- private TextBox _name = null!;
- private TextBox _seed = null!;
- private readonly List _refreshers = [];
-
- protected override void OnLoad()
- {
- var content = Context.Services.Get();
- var lang = content.Languages;
- var input = this.UseInput();
- var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
-
- var presets = content.Defs.All().OrderBy(p => p.Order).ToList();
- _preset = presets[0];
-
- var column = Ui.Column(10);
- column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
- column.Widgets.Add(new Label { Height = 8 });
-
- _name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
- column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
-
- // Размер — сегменты пресетов.
- var sizeRow = Ui.Row(6);
- sizeRow.Widgets.Add(new Label { Text = lang.Get("newworld.size"), TextColor = Ui.Muted });
- foreach (var preset in presets)
- {
- var value = preset;
- var button = new TextButton { Text = lang.Get(preset.Label) };
- button.Click += (_, _) =>
- {
- _preset = value;
- Refresh();
- };
- sizeRow.Widgets.Add(button);
- _refreshers.Add(() =>
- button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted
- );
- }
-
- column.Widgets.Add(sizeRow);
-
- // Сид + случайно.
- _seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 };
- var seedRow = Ui.Row(6);
- seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted });
- seedRow.Widgets.Add(_seed);
- seedRow.Widgets.Add(
- Ui.Button(
- lang.Get("newworld.random"),
- () => _seed.Text = Random.Shared.Next().ToString(),
- width: 120
- )
- );
- column.Widgets.Add(seedRow);
-
- // Сглаживание −/+.
- var smoothRow = Ui.Row(6);
- var smoothLabel = new Label { TextColor = Ui.Muted };
- _refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}");
- var minus = new TextButton { Text = "−" };
- minus.Click += (_, _) =>
- {
- _smoothing = Math.Max(MinSmoothing, _smoothing - 1);
- Refresh();
- };
- var plus = new TextButton { Text = "+" };
- plus.Click += (_, _) =>
- {
- _smoothing = Math.Min(MaxSmoothing, _smoothing + 1);
- Refresh();
- };
- smoothRow.Widgets.Add(smoothLabel);
- smoothRow.Widgets.Add(minus);
- smoothRow.Widgets.Add(plus);
- column.Widgets.Add(smoothRow);
-
- column.Widgets.Add(new Label { Height = 8 });
- var buttons = Ui.Row(10);
- buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150));
- buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
- column.Widgets.Add(buttons);
-
- desktop.Root = Ui.Screen(column);
- Refresh();
-
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private static Widget LabeledRow(string label, Widget control)
- {
- var row = Ui.Row(6);
- row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
- row.Widgets.Add(control);
- return row;
- }
-
- private void Refresh()
- {
- foreach (var refresh in _refreshers)
- {
- refresh();
- }
- }
-
- private void Create()
- {
- if (Context.Scenes.IsTransitioning)
- {
- return;
- }
-
- var seed = int.TryParse(_seed.Text, out var parsed) ? parsed : Random.Shared.Next();
- var name = string.IsNullOrWhiteSpace(_name.Text) ? "World" : _name.Text.Trim();
- var config = new WorldConfig
- {
- Name = name,
- Width = _preset.Width,
- Height = _preset.Height,
- Population = _preset.Population,
- Seed = seed,
- SmoothPasses = _smoothing,
- };
- Context.Scenes.Switch(new WorldScene(config), Transition.Fade(0.6f));
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Окно настройки нового мира: имя, размер (пресеты из Core-мода),
+/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает .
+///
+public sealed class NewWorldScene : Scene
+{
+ private const int MinSmoothing = 1;
+ private const int MaxSmoothing = 8;
+
+ private WorldPresetDef _preset = null!;
+ private int _smoothing = 4;
+ private TextBox _name = null!;
+ private TextBox _seed = null!;
+ private readonly List _refreshers = [];
+
+ protected override void OnLoad()
+ {
+ var content = Context.Services.Get();
+ var lang = content.Languages;
+ var input = this.UseInput();
+ var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
+
+ var presets = content.Defs.All().OrderBy(p => p.Order).ToList();
+ _preset = presets[0];
+
+ var column = Ui.Column(10);
+ column.Widgets.Add(Ui.Title(lang.Get("newworld.title")));
+ column.Widgets.Add(new Label { Height = 8 });
+
+ _name = new TextBox { Text = lang.Get("newworld.defaultname"), Width = 280 };
+ column.Widgets.Add(LabeledRow(lang.Get("newworld.name"), _name));
+
+ // Размер — сегменты пресетов.
+ var sizeRow = Ui.Row(6);
+ sizeRow.Widgets.Add(new Label { Text = lang.Get("newworld.size"), TextColor = Ui.Muted });
+ foreach (var preset in presets)
+ {
+ var value = preset;
+ var button = new TextButton { Text = lang.Get(preset.Label) };
+ button.Click += (_, _) =>
+ {
+ _preset = value;
+ Refresh();
+ };
+ sizeRow.Widgets.Add(button);
+ _refreshers.Add(() =>
+ button.TextColor = ReferenceEquals(_preset, value) ? Ui.Accent : Ui.Muted
+ );
+ }
+
+ column.Widgets.Add(sizeRow);
+
+ // Сид + случайно.
+ _seed = new TextBox { Text = Random.Shared.Next().ToString(), Width = 200 };
+ var seedRow = Ui.Row(6);
+ seedRow.Widgets.Add(new Label { Text = lang.Get("newworld.seed"), TextColor = Ui.Muted });
+ seedRow.Widgets.Add(_seed);
+ seedRow.Widgets.Add(
+ Ui.Button(
+ lang.Get("newworld.random"),
+ () => _seed.Text = Random.Shared.Next().ToString(),
+ width: 120
+ )
+ );
+ column.Widgets.Add(seedRow);
+
+ // Сглаживание −/+.
+ var smoothRow = Ui.Row(6);
+ var smoothLabel = new Label { TextColor = Ui.Muted };
+ _refreshers.Add(() => smoothLabel.Text = $"{lang.Get("newworld.smoothing")}: {_smoothing}");
+ var minus = new TextButton { Text = "−" };
+ minus.Click += (_, _) =>
+ {
+ _smoothing = Math.Max(MinSmoothing, _smoothing - 1);
+ Refresh();
+ };
+ var plus = new TextButton { Text = "+" };
+ plus.Click += (_, _) =>
+ {
+ _smoothing = Math.Min(MaxSmoothing, _smoothing + 1);
+ Refresh();
+ };
+ smoothRow.Widgets.Add(smoothLabel);
+ smoothRow.Widgets.Add(minus);
+ smoothRow.Widgets.Add(plus);
+ column.Widgets.Add(smoothRow);
+
+ column.Widgets.Add(new Label { Height = 8 });
+ var buttons = Ui.Row(10);
+ buttons.Widgets.Add(Ui.Button(lang.Get("newworld.create"), Create, width: 150));
+ buttons.Widgets.Add(Ui.Button(lang.Get("newworld.back"), Back, width: 150));
+ column.Widgets.Add(buttons);
+
+ desktop.Root = Ui.Screen(column);
+ Refresh();
+
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private static Widget LabeledRow(string label, Widget control)
+ {
+ var row = Ui.Row(6);
+ row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
+ row.Widgets.Add(control);
+ return row;
+ }
+
+ private void Refresh()
+ {
+ foreach (var refresh in _refreshers)
+ {
+ refresh();
+ }
+ }
+
+ private void Create()
+ {
+ if (Context.Scenes.IsTransitioning)
+ {
+ return;
+ }
+
+ var seed = int.TryParse(_seed.Text, out var parsed) ? parsed : Random.Shared.Next();
+ var name = string.IsNullOrWhiteSpace(_name.Text) ? "World" : _name.Text.Trim();
+ var config = new WorldConfig
+ {
+ Name = name,
+ Width = _preset.Width,
+ Height = _preset.Height,
+ Population = _preset.Population,
+ Seed = seed,
+ SmoothPasses = _smoothing,
+ };
+ Context.Scenes.Switch(new WorldScene(config), Transitions.Fade(0.6f));
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/PauseMenu.cs b/src/LittleSim/Scenes/PauseMenu.cs
index 0395c9d..a3aced3 100644
--- a/src/LittleSim/Scenes/PauseMenu.cs
+++ b/src/LittleSim/Scenes/PauseMenu.cs
@@ -1,167 +1,168 @@
-using System;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using MrGameEng.Audio;
-using MrGameEng.Core;
-using Myra.Graphics2D.Brushes;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
-/// Открытие ставит игру на паузу через , закрытие — снимает.
-/// Кнопки: Продолжить / Настройки (общая ) / Сохранить /
-/// Главное меню / Выход. добавляется в корневую панель сцены.
-///
-internal sealed class PauseMenu
-{
- private readonly EngineContext _context;
- private readonly GameContent _content;
- private readonly GameSpeed _speed;
- private readonly GameHost _host;
- private readonly AudioManager _audio;
- private readonly Func _onSave;
- private readonly Action _onMainMenu;
- private readonly Action _onQuit;
- private readonly Panel _overlay;
- private GameSettings _settings = GameSettingsStore.Load();
- private bool _inSettings;
- private string? _savedToast;
-
- public PauseMenu(
- EngineContext context,
- GameContent content,
- GameSpeed speed,
- Func onSave,
- Action onMainMenu,
- Action onQuit
- )
- {
- _context = context;
- _content = content;
- _speed = speed;
- _host = (GameHost)context.Services.Get();
- _audio = context.Services.Get();
- _onSave = onSave;
- _onMainMenu = onMainMenu;
- _onQuit = onQuit;
-
- _overlay = new Panel
- {
- Visible = false,
- Background = new SolidBrush(new Color(0, 0, 0, 200)),
- HorizontalAlignment = HorizontalAlignment.Stretch,
- VerticalAlignment = VerticalAlignment.Stretch,
- };
- }
-
- /// Виджет оверлея для добавления в корень сцены.
- public Widget Root => _overlay;
-
- /// Открыт ли оверлей паузы.
- public bool IsOpen { get; private set; }
-
- ///
- /// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
- /// меню паузы.
- ///
- public void Toggle()
- {
- if (!IsOpen)
- {
- Open();
- }
- else if (_inSettings)
- {
- _inSettings = false;
- ShowButtons();
- }
- else
- {
- Close();
- }
- }
-
- private void Open()
- {
- IsOpen = true;
- _inSettings = false;
- _savedToast = null;
- _speed.Pause();
- ShowButtons();
- _overlay.Visible = true;
- }
-
- private void Close()
- {
- IsOpen = false;
- _inSettings = false;
- _overlay.Visible = false;
- _speed.Resume();
- }
-
- private void ShowButtons()
- {
- var lang = _content.Languages;
- var column = Ui.Column(10);
- column.Widgets.Add(Ui.Title(lang.Get("pause.title")));
- column.Widgets.Add(new Label { Height = 8 });
- column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close));
- column.Widgets.Add(
- Ui.Button(
- lang.Get("pause.settings"),
- () =>
- {
- _inSettings = true;
- ShowSettings();
- }
- )
- );
- column.Widgets.Add(Ui.Button(lang.Get("pause.save"), Save));
- column.Widgets.Add(Ui.Button(lang.Get("pause.mainmenu"), _onMainMenu));
- column.Widgets.Add(Ui.Button(lang.Get("pause.quit"), _onQuit));
-
- if (_savedToast is not null)
- {
- column.Widgets.Add(
- new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted }
- );
- }
-
- SetContent(column);
- }
-
- private void ShowSettings() =>
- SetContent(
- SettingsPanel.Build(
- _settings,
- _content.Languages,
- onApply: () =>
- {
- GameSettingsStore.Save(_settings);
- GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
- ShowSettings(); // язык мог смениться
- },
- onBack: () =>
- {
- _inSettings = false;
- ShowButtons();
- }
- )
- );
-
- private void Save()
- {
- _savedToast = _onSave();
- ShowButtons();
- }
-
- private void SetContent(Widget content)
- {
- _overlay.Widgets.Clear();
- _overlay.Widgets.Add(content);
- }
-}
+using System;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using MrGameEng.Audio;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using Myra.Graphics2D.Brushes;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
+/// Открытие ставит игру на паузу через , закрытие — снимает.
+/// Кнопки: Продолжить / Настройки (общая ) / Сохранить /
+/// Главное меню / Выход. добавляется в корневую панель сцены.
+///
+internal sealed class PauseMenu
+{
+ private readonly EngineContext _context;
+ private readonly GameContent _content;
+ private readonly GameSpeed _speed;
+ private readonly GameHost _host;
+ private readonly AudioManager _audio;
+ private readonly Func _onSave;
+ private readonly Action _onMainMenu;
+ private readonly Action _onQuit;
+ private readonly Panel _overlay;
+ private GameSettings _settings = GameSettingsStore.Load();
+ private bool _inSettings;
+ private string? _savedToast;
+
+ public PauseMenu(
+ EngineContext context,
+ GameContent content,
+ GameSpeed speed,
+ Func onSave,
+ Action onMainMenu,
+ Action onQuit
+ )
+ {
+ _context = context;
+ _content = content;
+ _speed = speed;
+ _host = (GameHost)context.Services.Get();
+ _audio = context.Services.Get();
+ _onSave = onSave;
+ _onMainMenu = onMainMenu;
+ _onQuit = onQuit;
+
+ _overlay = new Panel
+ {
+ Visible = false,
+ Background = new SolidBrush(new Color(0, 0, 0, 200)),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ };
+ }
+
+ /// Виджет оверлея для добавления в корень сцены.
+ public Widget Root => _overlay;
+
+ /// Открыт ли оверлей паузы.
+ public bool IsOpen { get; private set; }
+
+ ///
+ /// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
+ /// меню паузы.
+ ///
+ public void Toggle()
+ {
+ if (!IsOpen)
+ {
+ Open();
+ }
+ else if (_inSettings)
+ {
+ _inSettings = false;
+ ShowButtons();
+ }
+ else
+ {
+ Close();
+ }
+ }
+
+ private void Open()
+ {
+ IsOpen = true;
+ _inSettings = false;
+ _savedToast = null;
+ _speed.Pause();
+ ShowButtons();
+ _overlay.Visible = true;
+ }
+
+ private void Close()
+ {
+ IsOpen = false;
+ _inSettings = false;
+ _overlay.Visible = false;
+ _speed.Resume();
+ }
+
+ private void ShowButtons()
+ {
+ var lang = _content.Languages;
+ var column = Ui.Column(10);
+ column.Widgets.Add(Ui.Title(lang.Get("pause.title")));
+ column.Widgets.Add(new Label { Height = 8 });
+ column.Widgets.Add(Ui.Button(lang.Get("pause.resume"), Close));
+ column.Widgets.Add(
+ Ui.Button(
+ lang.Get("pause.settings"),
+ () =>
+ {
+ _inSettings = true;
+ ShowSettings();
+ }
+ )
+ );
+ column.Widgets.Add(Ui.Button(lang.Get("pause.save"), Save));
+ column.Widgets.Add(Ui.Button(lang.Get("pause.mainmenu"), _onMainMenu));
+ column.Widgets.Add(Ui.Button(lang.Get("pause.quit"), _onQuit));
+
+ if (_savedToast is not null)
+ {
+ column.Widgets.Add(
+ new Label { Text = lang.Format("pause.saved", _savedToast), TextColor = Ui.Muted }
+ );
+ }
+
+ SetContent(column);
+ }
+
+ private void ShowSettings() =>
+ SetContent(
+ SettingsPanel.Build(
+ _settings,
+ _content.Languages,
+ onApply: () =>
+ {
+ GameSettingsStore.Save(_settings);
+ GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
+ ShowSettings(); // язык мог смениться
+ },
+ onBack: () =>
+ {
+ _inSettings = false;
+ ShowButtons();
+ }
+ )
+ );
+
+ private void Save()
+ {
+ _savedToast = _onSave();
+ ShowButtons();
+ }
+
+ private void SetContent(Widget content)
+ {
+ _overlay.Widgets.Clear();
+ _overlay.Widgets.Add(content);
+ }
+}
diff --git a/src/LittleSim/Scenes/SettingsScene.cs b/src/LittleSim/Scenes/SettingsScene.cs
index d03ad04..59583cb 100644
--- a/src/LittleSim/Scenes/SettingsScene.cs
+++ b/src/LittleSim/Scenes/SettingsScene.cs
@@ -1,65 +1,66 @@
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Audio;
-using MrGameEng.Core;
-using MrGameEng.Input;
-using MrGameEng.UI;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
-/// . «Применить» сохраняет в settings.json и применяет к движку
-/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
-///
-public sealed class SettingsScene : Scene
-{
- private GameContent _content = null!;
- private AudioManager _audio = null!;
- private GameHost _host = null!;
- private Desktop _desktop = null!;
- private GameSettings _settings = GameSettingsStore.Load();
-
- protected override void OnLoad()
- {
- _content = Context.Services.Get();
- _audio = Context.Services.Get();
- _host = (GameHost)Context.Services.Get();
- var input = this.UseInput();
-
- _desktop = this.UseUI();
- Rebuild();
-
- UpdateSystems.Add(
- new CallbackSystem(() =>
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- Back();
- }
- })
- );
- }
-
- private void Rebuild() =>
- _desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
-
- private void Apply()
- {
- GameSettingsStore.Save(_settings);
- GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
- Rebuild(); // язык мог смениться — перестраиваем подписи
- }
-
- private void Back()
- {
- if (!Context.Scenes.IsTransitioning)
- {
- Context.Scenes.Switch(new MainMenuScene(), Transition.Fade(0.4f));
- }
- }
-}
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Audio;
+using MrGameEng.Core;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.UI;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
+/// . «Применить» сохраняет в settings.json и применяет к движку
+/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
+///
+public sealed class SettingsScene : Scene
+{
+ private GameContent _content = null!;
+ private AudioManager _audio = null!;
+ private GameHost _host = null!;
+ private Desktop _desktop = null!;
+ private GameSettings _settings = GameSettingsStore.Load();
+
+ protected override void OnLoad()
+ {
+ _content = Context.Services.Get();
+ _audio = Context.Services.Get();
+ _host = (GameHost)Context.Services.Get();
+ var input = this.UseInput();
+
+ _desktop = this.UseUI();
+ Rebuild();
+
+ UpdateSystems.Add(
+ new CallbackSystem(() =>
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ Back();
+ }
+ })
+ );
+ }
+
+ private void Rebuild() =>
+ _desktop.Root = Ui.Screen(SettingsPanel.Build(_settings, _content.Languages, Apply, Back));
+
+ private void Apply()
+ {
+ GameSettingsStore.Save(_settings);
+ GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
+ Rebuild(); // язык мог смениться — перестраиваем подписи
+ }
+
+ private void Back()
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs
index 1afd90f..f8f7684 100644
--- a/src/LittleSim/Scenes/WorldScene.cs
+++ b/src/LittleSim/Scenes/WorldScene.cs
@@ -1,311 +1,312 @@
-using System;
-using System.Collections.Generic;
-using Friflo.Engine.ECS;
-using LittleSim.App;
-using LittleSim.Content;
-using LittleSim.Sim;
-using LittleSim.UI;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Input;
-using MrGameEng.Assets;
-using MrGameEng.Core;
-using MrGameEng.DevConsole;
-using MrGameEng.Graphics;
-using MrGameEng.Input;
-using MrGameEng.Inspector;
-using MrGameEng.Lighting;
-using MrGameEng.Tilemaps;
-using MrGameEng.UI;
-using Myra.Graphics2D;
-using Myra.Graphics2D.UI;
-
-namespace LittleSim.Scenes;
-
-///
-/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
-/// (размер/сид/масштаб деталей) процедурной генерацией движка
-/// (). Каждый тип клетки рисуется текстурой-поверхностью из атласа
-/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
-/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
-///
-public sealed class WorldScene : Scene
-{
- public const int CellSize = 16;
-
- ///
- /// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
- /// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
- ///
- public const float SecondsPerDay = 480f;
-
- private readonly WorldConfig _config;
- private readonly WorldSave? _save;
- private readonly RectF _bounds;
-
- private GameSpeed _speed = null!;
- private PauseMenu _pause = null!;
- private readonly List _speedRefreshers = [];
-
- /// Новый мир из конфига.
- public WorldScene(WorldConfig config)
- : this(config, null) { }
-
- /// Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).
- public WorldScene(WorldConfig config, WorldSave? save)
- {
- _config = config;
- _save = save;
- _bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
- }
-
- protected override void OnLoad()
- {
- var assets = Context.Services.GetOrDefault() ?? Context.UseAssets();
- var content = Context.Services.Get();
- var atlases = Context.Services.Get();
- var device = Context.GraphicsDevice;
- var input = this.UseInput();
- _speed = Context.Services.Get();
- _speed.SetStep(0); // новый мир/загрузка стартуют на x1
-
- var renderer = this.UseRenderer2D(
- new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
- );
- GameLayers.EnsureRegistered(renderer);
- this.UseTilemaps();
-
- var calendar = Context.UseCalendar(SecondsPerDay);
- var climate = Context.UseClimate(ClimateSettings.Default);
- var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
-
- // Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
- var plants = new PlantSet(content, atlases, device);
- var random = new Random(_config.Seed);
- BuildTerrain(content, atlases, device, assets, plants, random);
-
- var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
-
- // HUD, полоса скорости и меню-пауза в одной корневой панели.
- var desktop = this.UseUI();
- var hudLabel = new Label { Left = 10, Top = 8 };
- var speedBar = BuildSpeedBar(content);
- _pause = new PauseMenu(
- Context,
- content,
- _speed,
- onSave: SaveWorld,
- onMainMenu: () => Switch(new MainMenuScene()),
- onQuit: () => Context.Services.Get().Exit()
- );
- desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
-
- this.UseInspector(renderer);
- var console = this.UseDevConsole();
- RegisterCommands(console, content, atlases);
-
- UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
- UpdateSystems.Add(
- new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
- );
- UpdateSystems.Add(
- new HudSystem(
- Context,
- content.Languages,
- hudLabel,
- "hud.world",
- _config.Seed,
- calendar,
- climate
- )
- );
- UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
-
- Log.Info(
- $"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
- + (_save is null ? " (new)" : " (loaded)")
- );
- }
-
- ///
- /// Строит одну сущность-: тайлсет из дефов рельефа (поверхность из
- /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
- /// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
- ///
- private void BuildTerrain(
- GameContent content,
- ModAtlases atlases,
- Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
- AssetManager assets,
- PlantSet plants,
- Random random
- )
- {
- var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
-
- // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
- var tiles = new TileSet();
- var tileByDef = new Dictionary();
- foreach (var terrain in content.Terrains.All)
- {
- tileByDef[terrain] = terrain.Surface is { } surface
- ? tiles.Add(atlases.GetRegion(device, surface))
- : tiles.Add(white, terrain.Tint);
- }
-
- // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
- var heights = WorldGenerator.Generate(
- _config.Width,
- _config.Height,
- _config.Seed,
- _config.SmoothPasses
- );
- var grid = new TileGrid(_config.Width, _config.Height);
- for (var x = 0; x < _config.Width; x++)
- {
- for (var y = 0; y < _config.Height; y++)
- {
- var terrain = content.Terrains.Classify(heights[x, y]);
- grid[x, y] = tileByDef[terrain];
- ScatterSpawner.Spawn(
- this,
- content,
- plants,
- terrain,
- new Point(x, y),
- CellSize,
- random
- );
- }
- }
-
- Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
- }
-
- private HorizontalStackPanel BuildSpeedBar(GameContent content)
- {
- var bar = Ui.Row(6);
- bar.HorizontalAlignment = HorizontalAlignment.Center;
- bar.VerticalAlignment = VerticalAlignment.Bottom;
- bar.Margin = new Thickness(0, 0, 0, 12);
-
- var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
- pause.Click += (_, _) => _speed.Pause();
- bar.Widgets.Add(pause);
- _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
-
- for (var i = 0; i < _speed.Steps.Count; i++)
- {
- var index = i;
- var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
- button.Click += (_, _) => _speed.SetStep(index);
- bar.Widgets.Add(button);
- _speedRefreshers.Add(() =>
- button.TextColor =
- !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
- );
- }
-
- void Refresh()
- {
- foreach (var refresh in _speedRefreshers)
- {
- refresh();
- }
- }
-
- _speed.Changed += Refresh;
- RegisterUnload(() => _speed.Changed -= Refresh);
- Refresh();
- return bar;
- }
-
- private void Hotkeys(InputManager input)
- {
- if (input.IsKeyPressed(Keys.Escape))
- {
- _pause.Toggle();
- }
-
- if (_pause.IsOpen)
- {
- return;
- }
-
- if (input.IsKeyPressed(Keys.Space))
- {
- _speed.TogglePause();
- }
-
- if (input.IsKeyPressed(Keys.D1))
- {
- _speed.SetStep(0);
- }
-
- if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
- {
- _speed.SetStep(1);
- }
-
- if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
- {
- _speed.SetStep(2);
- }
- }
-
- private string SaveWorld()
- {
- // Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
- var save = new WorldSave
- {
- Name = _config.Name,
- Width = _config.Width,
- Height = _config.Height,
- Seed = _config.Seed,
- SmoothPasses = _config.SmoothPasses,
- Population = _config.Population,
- SavedUtc = DateTime.UtcNow,
- ElapsedSeconds = Context.Clock.TotalTime,
- };
-
- new SaveStore().Write(save);
- Log.Info($"World '{_config.Name}' saved");
- return _config.Name;
- }
-
- private void Switch(Scene scene)
- {
- if (!Context.Scenes.IsTransitioning)
- {
- _speed.Resume();
- Context.Scenes.Switch(scene, Transition.Fade(0.5f));
- }
- }
-
- private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
- {
- ContentCommands.Register(console, content, atlases);
- console.Register(
- "regen",
- "regen [seed] — regenerate the world with a new seed",
- (c, args) =>
- {
- if (Context.Scenes.IsTransitioning)
- {
- return;
- }
-
- var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
- c.WriteLine($"regenerating world, seed {seed}");
- Context.Scenes.Switch(
- new WorldScene(_config with { Seed = seed }),
- Transition.Fade(0.6f)
- );
- }
- );
- console.Register(
- "menu",
- "menu — return to the main menu",
- (_, _) => Switch(new MainMenuScene())
- );
- }
-}
+using System;
+using System.Collections.Generic;
+using Friflo.Engine.ECS;
+using LittleSim.App;
+using LittleSim.Content;
+using LittleSim.Sim;
+using LittleSim.UI;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+using MrGameEng.Assets;
+using MrGameEng.Core;
+using MrGameEng.DevConsole;
+using MrGameEng.Graphics;
+using MrGameEng.Host;
+using MrGameEng.Input;
+using MrGameEng.Inspector;
+using MrGameEng.Lighting;
+using MrGameEng.Tilemaps;
+using MrGameEng.UI;
+using Myra.Graphics2D;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.Scenes;
+
+///
+/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
+/// (размер/сид/масштаб деталей) процедурной генерацией движка
+/// (). Каждый тип клетки рисуется текстурой-поверхностью из атласа
+/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
+/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
+///
+public sealed class WorldScene : Scene
+{
+ public const int CellSize = 16;
+
+ ///
+ /// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
+ /// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
+ ///
+ public const float SecondsPerDay = 480f;
+
+ private readonly WorldConfig _config;
+ private readonly WorldSave? _save;
+ private readonly RectF _bounds;
+
+ private GameSpeed _speed = null!;
+ private PauseMenu _pause = null!;
+ private readonly List _speedRefreshers = [];
+
+ /// Новый мир из конфига.
+ public WorldScene(WorldConfig config)
+ : this(config, null) { }
+
+ /// Мир, восстановленный из сохранения (рельеф детерминирован сидом конфига).
+ public WorldScene(WorldConfig config, WorldSave? save)
+ {
+ _config = config;
+ _save = save;
+ _bounds = new RectF(0f, 0f, config.Width * CellSize, config.Height * CellSize);
+ }
+
+ protected override void OnLoad()
+ {
+ var assets = Context.Services.GetOrDefault() ?? Context.UseAssets();
+ var content = Context.Services.Get();
+ var atlases = Context.Services.Get();
+ var device = Context.GetGraphicsDevice();
+ var input = this.UseInput();
+ _speed = Context.Services.Get();
+ _speed.SetStep(0); // новый мир/загрузка стартуют на x1
+
+ var renderer = this.UseRenderer2D(
+ new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
+ );
+ GameLayers.EnsureRegistered(renderer);
+ this.UseTilemaps();
+
+ var calendar = Context.UseCalendar(SecondsPerDay);
+ var climate = Context.UseClimate(ClimateSettings.Default);
+ var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
+
+ // Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
+ var plants = new PlantSet(content, atlases, device);
+ var random = new Random(_config.Seed);
+ BuildTerrain(content, atlases, device, assets, plants, random);
+
+ var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
+
+ // HUD, полоса скорости и меню-пауза в одной корневой панели.
+ var desktop = this.UseUI();
+ var hudLabel = new Label { Left = 10, Top = 8 };
+ var speedBar = BuildSpeedBar(content);
+ _pause = new PauseMenu(
+ Context,
+ content,
+ _speed,
+ onSave: SaveWorld,
+ onMainMenu: () => Switch(new MainMenuScene()),
+ onQuit: () => Context.Services.Get().Exit()
+ );
+ desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
+
+ this.UseInspector(renderer);
+ var console = this.UseDevConsole();
+ RegisterCommands(console, content, atlases);
+
+ UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
+ UpdateSystems.Add(
+ new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
+ );
+ UpdateSystems.Add(
+ new HudSystem(
+ Context,
+ content.Languages,
+ hudLabel,
+ "hud.world",
+ _config.Seed,
+ calendar,
+ climate
+ )
+ );
+ UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
+
+ Log.Info(
+ $"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
+ + (_save is null ? " (new)" : " (loaded)")
+ );
+ }
+
+ ///
+ /// Строит одну сущность-: тайлсет из дефов рельефа (поверхность из
+ /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
+ /// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
+ ///
+ private void BuildTerrain(
+ GameContent content,
+ ModAtlases atlases,
+ Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
+ AssetManager assets,
+ PlantSet plants,
+ Random random
+ )
+ {
+ var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
+
+ // Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
+ var tiles = new TileSet();
+ var tileByDef = new Dictionary();
+ foreach (var terrain in content.Terrains.All)
+ {
+ tileByDef[terrain] = terrain.Surface is { } surface
+ ? tiles.Add(atlases.GetRegion(device, surface))
+ : tiles.Add(white, terrain.Tint);
+ }
+
+ // Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
+ var heights = WorldGenerator.Generate(
+ _config.Width,
+ _config.Height,
+ _config.Seed,
+ _config.SmoothPasses
+ );
+ var grid = new TileGrid(_config.Width, _config.Height);
+ for (var x = 0; x < _config.Width; x++)
+ {
+ for (var y = 0; y < _config.Height; y++)
+ {
+ var terrain = content.Terrains.Classify(heights[x, y]);
+ grid[x, y] = tileByDef[terrain];
+ ScatterSpawner.Spawn(
+ this,
+ content,
+ plants,
+ terrain,
+ new Point(x, y),
+ CellSize,
+ random
+ );
+ }
+ }
+
+ Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
+ }
+
+ private HorizontalStackPanel BuildSpeedBar(GameContent content)
+ {
+ var bar = Ui.Row(6);
+ bar.HorizontalAlignment = HorizontalAlignment.Center;
+ bar.VerticalAlignment = VerticalAlignment.Bottom;
+ bar.Margin = new Thickness(0, 0, 0, 12);
+
+ var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
+ pause.Click += (_, _) => _speed.Pause();
+ bar.Widgets.Add(pause);
+ _speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
+
+ for (var i = 0; i < _speed.Steps.Count; i++)
+ {
+ var index = i;
+ var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
+ button.Click += (_, _) => _speed.SetStep(index);
+ bar.Widgets.Add(button);
+ _speedRefreshers.Add(() =>
+ button.TextColor =
+ !_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
+ );
+ }
+
+ void Refresh()
+ {
+ foreach (var refresh in _speedRefreshers)
+ {
+ refresh();
+ }
+ }
+
+ _speed.Changed += Refresh;
+ RegisterUnload(() => _speed.Changed -= Refresh);
+ Refresh();
+ return bar;
+ }
+
+ private void Hotkeys(InputManager input)
+ {
+ if (input.IsKeyPressed(Keys.Escape))
+ {
+ _pause.Toggle();
+ }
+
+ if (_pause.IsOpen)
+ {
+ return;
+ }
+
+ if (input.IsKeyPressed(Keys.Space))
+ {
+ _speed.TogglePause();
+ }
+
+ if (input.IsKeyPressed(Keys.D1))
+ {
+ _speed.SetStep(0);
+ }
+
+ if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
+ {
+ _speed.SetStep(1);
+ }
+
+ if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
+ {
+ _speed.SetStep(2);
+ }
+ }
+
+ private string SaveWorld()
+ {
+ // Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
+ var save = new WorldSave
+ {
+ Name = _config.Name,
+ Width = _config.Width,
+ Height = _config.Height,
+ Seed = _config.Seed,
+ SmoothPasses = _config.SmoothPasses,
+ Population = _config.Population,
+ SavedUtc = DateTime.UtcNow,
+ ElapsedSeconds = Context.Clock.TotalTime,
+ };
+
+ new SaveStore().Write(save);
+ Log.Info($"World '{_config.Name}' saved");
+ return _config.Name;
+ }
+
+ private void Switch(Scene scene)
+ {
+ if (!Context.Scenes.IsTransitioning)
+ {
+ _speed.Resume();
+ Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
+ }
+ }
+
+ private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
+ {
+ ContentCommands.Register(console, content, atlases);
+ console.Register(
+ "regen",
+ "regen [seed] — regenerate the world with a new seed",
+ (c, args) =>
+ {
+ if (Context.Scenes.IsTransitioning)
+ {
+ return;
+ }
+
+ var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
+ c.WriteLine($"regenerating world, seed {seed}");
+ Context.Scenes.Switch(
+ new WorldScene(_config with { Seed = seed }),
+ Transitions.Fade(0.6f)
+ );
+ }
+ );
+ console.Register(
+ "menu",
+ "menu — return to the main menu",
+ (_, _) => Switch(new MainMenuScene())
+ );
+ }
+}