diff --git a/LittleSim.sln b/LittleSim.sln
index de6276f..90a0b1e 100644
--- a/LittleSim.sln
+++ b/LittleSim.sln
@@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Simulation.Tests"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "engine\tests\MrGameEng.UI.Tests\MrGameEng.UI.Tests.csproj", "{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD}"
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -217,6 +219,18 @@ Global
{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD}.Release|x64.Build.0 = Release|Any CPU
{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD}.Release|x86.ActiveCfg = Release|Any CPU
{BC64F7AF-0FC3-4608-A4FF-1273AB8F2ABD}.Release|x86.Build.0 = Release|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Debug|x64.Build.0 = Debug|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Debug|x86.Build.0 = Debug|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508}.Release|x64.ActiveCfg = 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.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -238,5 +252,6 @@ Global
{A09593B2-82BD-468F-AC0C-52B8DEFE8E0F} = {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}
+ {E0E37D87-4F62-41E9-9CD1-3E7432301508} = {CD3B5CE5-C23F-38B4-04AA-A303F94731A5}
EndGlobalSection
EndGlobal
diff --git a/Mods/Core/Defs/worldpresets.json b/Mods/Core/Defs/worldpresets.json
new file mode 100644
index 0000000..23a14e3
--- /dev/null
+++ b/Mods/Core/Defs/worldpresets.json
@@ -0,0 +1,8 @@
+{
+ "type": "WorldPreset",
+ "defs": [
+ { "defName": "Small", "label": "preset.small", "order": 0, "width": 80, "height": 50, "population": 40 },
+ { "defName": "Medium", "label": "preset.medium", "order": 1, "width": 120, "height": 70, "population": 80 },
+ { "defName": "Large", "label": "preset.large", "order": 2, "width": 180, "height": 110, "population": 140 }
+ ]
+}
diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json
index 4b9b4b0..db2fe66 100644
--- a/Mods/Core/Languages/en/ui.json
+++ b/Mods/Core/Languages/en/ui.json
@@ -2,6 +2,59 @@
"hud.world": "LittleSim | seed {0} | population {1} | {2} FPS",
"hud.terrain": "LittleSim | terrain | seed {0} | animals: {1}",
"hud.controls": "WASD — camera, wheel — zoom, ` — console ('help' lists commands)",
+ "hud.paused": "PAUSED",
+
+ "menu.title": "LittleSim",
+ "menu.subtitle": "a god-game: minimal graphics, deep simulation",
+ "menu.newworld": "New World",
+ "menu.load": "Load",
+ "menu.settings": "Settings",
+ "menu.credits": "Credits",
+ "menu.quit": "Quit",
+
+ "newworld.title": "New World",
+ "newworld.name": "Name",
+ "newworld.defaultname": "New World",
+ "newworld.size": "Size",
+ "newworld.seed": "Seed",
+ "newworld.random": "Random",
+ "newworld.smoothing": "Terrain smoothing",
+ "newworld.create": "Create",
+ "newworld.back": "Back",
+
+ "load.title": "Load",
+ "load.empty": "No saves yet",
+ "load.load": "Load",
+ "load.delete": "Delete",
+ "load.back": "Back",
+
+ "settings.title": "Settings",
+ "settings.language": "Language",
+ "settings.fullscreen": "Fullscreen",
+ "settings.vsync": "Vertical sync",
+ "settings.resolution": "Resolution",
+ "settings.volume": "Volume",
+ "settings.apply": "Apply",
+ "settings.back": "Back",
+
+ "pause.title": "Paused",
+ "pause.resume": "Resume",
+ "pause.settings": "Settings",
+ "pause.save": "Save",
+ "pause.mainmenu": "Main menu",
+ "pause.quit": "Quit",
+ "pause.saved": "Saved: {0}",
+
+ "speed.pause": "⏸",
+
+ "credits.title": "Credits",
+ "credits.author": "mrleo1nid",
+ "credits.role": "Concept, code, mrgameeng engine",
+ "credits.back": "Back",
+
+ "preset.small": "Small",
+ "preset.medium": "Medium",
+ "preset.large": "Large",
"terrain.deepwater": "deep water",
"terrain.water": "water",
diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json
index e08fc85..4fe9056 100644
--- a/Mods/Core/Languages/ru/ui.json
+++ b/Mods/Core/Languages/ru/ui.json
@@ -2,6 +2,59 @@
"hud.world": "LittleSim | сид {0} | население {1} | {2} FPS",
"hud.terrain": "LittleSim | террейн | сид {0} | животных: {1}",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль ('help' — список команд)",
+ "hud.paused": "ПАУЗА",
+
+ "menu.title": "LittleSim",
+ "menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
+ "menu.newworld": "Новый мир",
+ "menu.load": "Загрузка",
+ "menu.settings": "Настройки",
+ "menu.credits": "Авторы",
+ "menu.quit": "Выход",
+
+ "newworld.title": "Новый мир",
+ "newworld.name": "Название",
+ "newworld.defaultname": "Новый мир",
+ "newworld.size": "Размер",
+ "newworld.seed": "Сид",
+ "newworld.random": "Случайно",
+ "newworld.smoothing": "Сглаживание рельефа",
+ "newworld.create": "Создать",
+ "newworld.back": "Назад",
+
+ "load.title": "Загрузка",
+ "load.empty": "Сохранений пока нет",
+ "load.load": "Загрузить",
+ "load.delete": "Удалить",
+ "load.back": "Назад",
+
+ "settings.title": "Настройки",
+ "settings.language": "Язык",
+ "settings.fullscreen": "Полный экран",
+ "settings.vsync": "Вертикальная синхронизация",
+ "settings.resolution": "Разрешение",
+ "settings.volume": "Громкость",
+ "settings.apply": "Применить",
+ "settings.back": "Назад",
+
+ "pause.title": "Пауза",
+ "pause.resume": "Продолжить",
+ "pause.settings": "Настройки",
+ "pause.save": "Сохранить",
+ "pause.mainmenu": "Главное меню",
+ "pause.quit": "Выход",
+ "pause.saved": "Сохранено: {0}",
+
+ "speed.pause": "⏸",
+
+ "credits.title": "Авторы",
+ "credits.author": "mrleo1nid",
+ "credits.role": "Идея, код, движок mrgameeng",
+ "credits.back": "Назад",
+
+ "preset.small": "Маленький",
+ "preset.medium": "Средний",
+ "preset.large": "Большой",
"terrain.deepwater": "глубокая вода",
"terrain.water": "вода",
diff --git a/src/LittleSim/App/GamePaths.cs b/src/LittleSim/App/GamePaths.cs
new file mode 100644
index 0000000..6b8ebb8
--- /dev/null
+++ b/src/LittleSim/App/GamePaths.cs
@@ -0,0 +1,26 @@
+using MrGameEng.Mods;
+
+namespace LittleSim.App;
+
+///
+/// Расположение пользовательских данных игры рядом с папкой Mods (где живёт и
+/// Cache/): файл настроек и каталог сохранений. Корень ищется один раз — поднятием
+/// вверх по дереву от исполняемого файла (как делает загрузка модов).
+///
+public static class GamePaths
+{
+ private static string? _root;
+
+ /// Корневая папка данных (родитель папки Mods).
+ public static string Root =>
+ _root ??= Path.GetDirectoryName(
+ ModLoader.FindModsRoot(AppContext.BaseDirectory)
+ ?? throw new DirectoryNotFoundException("Папка Mods не найдена.")
+ )!;
+
+ /// Путь к файлу настроек игры.
+ public static string SettingsFile => Path.Combine(Root, "settings.json");
+
+ /// Каталог файлов сохранений.
+ public static string SavesDirectory => Path.Combine(Root, "Saves");
+}
diff --git a/src/LittleSim/App/GameSettings.cs b/src/LittleSim/App/GameSettings.cs
new file mode 100644
index 0000000..50b3a46
--- /dev/null
+++ b/src/LittleSim/App/GameSettings.cs
@@ -0,0 +1,90 @@
+using System.Text.Json;
+using Microsoft.Xna.Framework;
+using MrGameEng.Audio;
+using MrGameEng.Mods;
+
+namespace LittleSim.App;
+
+///
+/// Пользовательские настройки игры, сохраняемые в settings.json: язык интерфейса,
+/// полноэкранный режим, вертикальная синхронизация, размер окна и общая громкость.
+///
+public sealed class GameSettings
+{
+ /// Код языка интерфейса (например, "ru" или "en").
+ public string Language { get; set; } = "ru";
+
+ /// Полноэкранный (безрамочный) режим.
+ public bool Fullscreen { get; set; }
+
+ /// Вертикальная синхронизация.
+ public bool VSync { get; set; } = true;
+
+ /// Ширина окна в пикселях.
+ public int Width { get; set; } = 1280;
+
+ /// Высота окна в пикселях.
+ public int Height { get; set; } = 720;
+
+ /// Общая громкость 0..1.
+ public float Volume { get; set; } = 1f;
+}
+
+/// Чтение/запись и применение их к движку.
+public static class GameSettingsStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ /// Загружает настройки из или возвращает значения по умолчанию.
+ public static GameSettings Load()
+ {
+ try
+ {
+ if (File.Exists(GamePaths.SettingsFile))
+ {
+ return JsonSerializer.Deserialize(
+ File.ReadAllText(GamePaths.SettingsFile),
+ JsonOptions
+ ) ?? new GameSettings();
+ }
+ }
+ catch (Exception exception) when (exception is IOException or JsonException)
+ {
+ // Битый файл настроек не должен ронять запуск — откатываемся к умолчаниям.
+ }
+
+ return new GameSettings();
+ }
+
+ /// Сохраняет настройки в .
+ public static void Save(GameSettings settings)
+ {
+ Directory.CreateDirectory(GamePaths.Root);
+ File.WriteAllText(GamePaths.SettingsFile, JsonSerializer.Serialize(settings, JsonOptions));
+ }
+
+ ///
+ /// Применяет настройки к движку: язык (), полноэкранный
+ /// режим/VSync/размер окна () и громкость ().
+ ///
+ public static void Apply(
+ GameSettings settings,
+ GraphicsDeviceManager graphics,
+ LanguageManager languages,
+ AudioManager audio
+ )
+ {
+ languages.SetLanguage(settings.Language);
+ audio.MasterVolume = settings.Volume;
+
+ graphics.IsFullScreen = settings.Fullscreen;
+ graphics.SynchronizeWithVerticalRetrace = settings.VSync;
+ graphics.PreferredBackBufferWidth = settings.Width;
+ graphics.PreferredBackBufferHeight = settings.Height;
+ graphics.ApplyChanges();
+ }
+}
diff --git a/src/LittleSim/App/WorldConfig.cs b/src/LittleSim/App/WorldConfig.cs
new file mode 100644
index 0000000..f755805
--- /dev/null
+++ b/src/LittleSim/App/WorldConfig.cs
@@ -0,0 +1,28 @@
+namespace LittleSim.App;
+
+///
+/// Параметры одного мира: имя, размер сетки, сид, число проходов сглаживания рельефа и
+/// население. Заменяет хардкод в ; строится из выбранного
+/// и пользовательского ввода в меню «Новый мир» либо
+/// восстанавливается из .
+///
+public sealed record WorldConfig
+{
+ /// Отображаемое имя мира (и имя файла сохранения).
+ public string Name { get; init; } = "World";
+
+ /// Ширина мира в клетках.
+ public int Width { get; init; } = 120;
+
+ /// Высота мира в клетках.
+ public int Height { get; init; } = 70;
+
+ /// Целочисленный сид — весь мир воспроизводится из него.
+ public int Seed { get; init; }
+
+ /// Число проходов сглаживания рельефа (больше — крупнее «континенты»).
+ public int SmoothPasses { get; init; } = 4;
+
+ /// Число жителей при генерации.
+ public int Population { get; init; } = 80;
+}
diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs
new file mode 100644
index 0000000..2b4fac9
--- /dev/null
+++ b/src/LittleSim/App/WorldSave.cs
@@ -0,0 +1,166 @@
+using System.Text.Json;
+
+namespace LittleSim.App;
+
+/// Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по ).
+public sealed class PawnSave
+{
+ /// Имя дефа жителя — по нему при загрузке берётся текстура из атласов.
+ public string DefName { get; set; } = "";
+
+ /// Позиция X в мировых координатах.
+ public float X { get; set; }
+
+ /// Позиция Y в мировых координатах.
+ public float Y { get; set; }
+
+ /// Масштаб трансформа (зависит от размера дефа и клетки).
+ public float Scale { get; set; } = 1f;
+
+ /// Запас энергии [0, 1].
+ public float Energy { get; set; }
+
+ /// Текущее действие (значение ).
+ public int Action { get; set; }
+
+ /// Секунды до следующего пересмотра решения.
+ public float DecideIn { get; set; }
+
+ /// Направление блуждания X.
+ public float DirX { get; set; }
+
+ /// Направление блуждания Y.
+ public float DirY { get; set; }
+
+ /// Секунды до смены направления.
+ public float ChangeIn { get; set; }
+}
+
+///
+/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
+/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
+/// детерминированно регенерируется из сида при загрузке.
+///
+public sealed class WorldSave
+{
+ /// Имя мира (и основа имени файла).
+ public string Name { get; set; } = "";
+
+ /// Ширина мира в клетках.
+ public int Width { get; set; }
+
+ /// Высота мира в клетках.
+ public int Height { get; set; }
+
+ /// Сид генерации.
+ public int Seed { get; set; }
+
+ /// Число проходов сглаживания рельефа.
+ public int SmoothPasses { get; set; } = 4;
+
+ /// Население на момент сохранения.
+ public int Population { get; set; }
+
+ /// Момент сохранения (UTC).
+ public DateTime SavedUtc { get; set; }
+
+ /// Прошедшее время симуляции в секундах.
+ public double ElapsedSeconds { get; set; }
+
+ /// Снимок жителей.
+ public List Pawns { get; set; } = [];
+
+ /// Конфиг мира для пересоздания сцены.
+ public WorldConfig ToConfig() =>
+ new()
+ {
+ Name = Name,
+ Width = Width,
+ Height = Height,
+ Seed = Seed,
+ SmoothPasses = SmoothPasses,
+ Population = Population,
+ };
+}
+
+/// Список/чтение/запись/удаление файлов сохранений Saves/*.json.
+public sealed class SaveStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly string _directory;
+
+ /// Создаёт хранилище поверх каталога (по умолчанию ).
+ public SaveStore(string? directory = null) =>
+ _directory = directory ?? GamePaths.SavesDirectory;
+
+ /// Запись сохранения: один файл на имя мира (повторное сохранение перезаписывает).
+ public readonly record struct Entry(string FileName, WorldSave Save);
+
+ /// Все сохранения, новые сверху. Битые файлы пропускаются.
+ public IReadOnlyList List()
+ {
+ if (!Directory.Exists(_directory))
+ {
+ return [];
+ }
+
+ var entries = new List();
+ foreach (var file in Directory.EnumerateFiles(_directory, "*.json"))
+ {
+ try
+ {
+ var save = JsonSerializer.Deserialize(
+ File.ReadAllText(file),
+ JsonOptions
+ );
+ if (save is not null)
+ {
+ entries.Add(new Entry(Path.GetFileName(file), save));
+ }
+ }
+ catch (Exception exception) when (exception is IOException or JsonException)
+ {
+ // Битый файл сохранения не должен ломать список — пропускаем.
+ }
+ }
+
+ entries.Sort((a, b) => b.Save.SavedUtc.CompareTo(a.Save.SavedUtc));
+ return entries;
+ }
+
+ /// Пишет сохранение в файл по имени мира и возвращает имя файла.
+ public string Write(WorldSave save)
+ {
+ Directory.CreateDirectory(_directory);
+ var fileName = Sanitize(save.Name) + ".json";
+ File.WriteAllText(
+ Path.Combine(_directory, fileName),
+ JsonSerializer.Serialize(save, JsonOptions)
+ );
+ return fileName;
+ }
+
+ /// Удаляет файл сохранения по имени.
+ public void Delete(string fileName)
+ {
+ var path = Path.Combine(_directory, fileName);
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static string Sanitize(string name)
+ {
+ var trimmed = name.Trim();
+ var safe = string.Concat(
+ trimmed.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)
+ );
+ return string.IsNullOrEmpty(safe) ? "world" : safe;
+ }
+}
diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs
index fcde94d..ed41727 100644
--- a/src/LittleSim/Content/GameContent.cs
+++ b/src/LittleSim/Content/GameContent.cs
@@ -58,6 +58,7 @@ public sealed class GameContent
defs.RegisterType("Terrain");
defs.RegisterType("Plant");
defs.RegisterType("Pawn");
+ defs.RegisterType("WorldPreset");
defs.Load(mods);
var languages = new LanguageManager(defaultLanguage: "ru");
diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs
index 78d0446..96c0af2 100644
--- a/src/LittleSim/Content/GameDefs.cs
+++ b/src/LittleSim/Content/GameDefs.cs
@@ -50,6 +50,26 @@ public sealed class PlantDef : Def
public float TrunkRadiusCells { get; init; }
}
+///
+/// Пресет размера мира (Defs/worldpresets.json): габариты сетки и население. Меню «Новый
+/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не
+/// хардкодом. — ключ локализации (preset.*).
+///
+public sealed class WorldPresetDef : Def
+{
+ /// Ширина мира в клетках.
+ public int Width { get; init; }
+
+ /// Высота мира в клетках.
+ public int Height { get; init; }
+
+ /// Число жителей при генерации.
+ public int Population { get; init; }
+
+ /// Порядок в списке UI (по возрастанию).
+ public int Order { get; init; }
+}
+
/// Существо (Defs/pawns.json): текстура, размер и вид (житель/животное).
public sealed class PawnDef : Def
{
diff --git a/src/LittleSim/Program.cs b/src/LittleSim/Program.cs
index c5714f6..80779f4 100644
--- a/src/LittleSim/Program.cs
+++ b/src/LittleSim/Program.cs
@@ -1,11 +1,8 @@
-using LittleSim.Content;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
-// Контент модов (дефы, локализация, атласы) загружается до создания окна — чистый CPU.
-var content = GameContent.Load();
-
+// Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost(
new GameHostOptions
{
@@ -14,10 +11,7 @@ using var host = new GameHost(
Height = 720,
ClearColor = new Color(12, 16, 24),
},
- new WorldScene(seed: 1337)
+ new BootScene()
);
-host.Context.Services.Add(content);
-host.Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
-
host.Run();
diff --git a/src/LittleSim/Scenes/BootScene.cs b/src/LittleSim/Scenes/BootScene.cs
new file mode 100644
index 0000000..b0597d7
--- /dev/null
+++ b/src/LittleSim/Scenes/BootScene.cs
@@ -0,0 +1,78 @@
+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));
+ }
+}
diff --git a/src/LittleSim/Scenes/CreditsScene.cs b/src/LittleSim/Scenes/CreditsScene.cs
new file mode 100644
index 0000000..d6a6848
--- /dev/null
+++ b/src/LittleSim/Scenes/CreditsScene.cs
@@ -0,0 +1,46 @@
+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));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/LoadGameScene.cs b/src/LittleSim/Scenes/LoadGameScene.cs
new file mode 100644
index 0000000..9e502fd
--- /dev/null
+++ b/src/LittleSim/Scenes/LoadGameScene.cs
@@ -0,0 +1,110 @@
+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));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/MainMenuScene.cs b/src/LittleSim/Scenes/MainMenuScene.cs
new file mode 100644
index 0000000..5800983
--- /dev/null
+++ b/src/LittleSim/Scenes/MainMenuScene.cs
@@ -0,0 +1,43 @@
+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));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/NewWorldScene.cs b/src/LittleSim/Scenes/NewWorldScene.cs
new file mode 100644
index 0000000..70d53d8
--- /dev/null
+++ b/src/LittleSim/Scenes/NewWorldScene.cs
@@ -0,0 +1,166 @@
+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));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/PauseMenu.cs b/src/LittleSim/Scenes/PauseMenu.cs
new file mode 100644
index 0000000..0395c9d
--- /dev/null
+++ b/src/LittleSim/Scenes/PauseMenu.cs
@@ -0,0 +1,167 @@
+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);
+ }
+}
diff --git a/src/LittleSim/Scenes/SettingsScene.cs b/src/LittleSim/Scenes/SettingsScene.cs
new file mode 100644
index 0000000..d03ad04
--- /dev/null
+++ b/src/LittleSim/Scenes/SettingsScene.cs
@@ -0,0 +1,65 @@
+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));
+ }
+ }
+}
diff --git a/src/LittleSim/Scenes/TerrainScene.cs b/src/LittleSim/Scenes/TerrainScene.cs
index 20c4c18..9dcfca9 100644
--- a/src/LittleSim/Scenes/TerrainScene.cs
+++ b/src/LittleSim/Scenes/TerrainScene.cs
@@ -158,7 +158,10 @@ public sealed class TerrainScene : Scene
}
var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
- Context.Scenes.Switch(new WorldScene(seed), Transition.Fade(0.6f));
+ Context.Scenes.Switch(
+ new WorldScene(new App.WorldConfig { Name = "World", Seed = seed }),
+ Transition.Fade(0.6f)
+ );
}
);
diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs
index 8248f9e..a879add 100644
--- a/src/LittleSim/Scenes/WorldScene.cs
+++ b/src/LittleSim/Scenes/WorldScene.cs
@@ -1,39 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
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.UI;
+using Myra.Graphics2D;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
///
-/// Мир LittleSim: рельеф, растения и жители целиком описаны дефами Core-мода
-/// (Mods/Core/Defs), текстуры — атласами из дерева текстур модов, строки HUD —
-/// локализацией. Камера бога, HUD и консоль ("regen", "terrain", "mods", "lang", …).
+/// Мир LittleSim: рельеф, растения и жители целиком описаны дефами Core-мода. Сцена строится
+/// из (размер/сид/население/сглаживание) и опционально восстанавливает
+/// полное состояние симуляции из . Поверх мира — HUD, полоса скорости
+/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc) и дев-консоль.
///
public sealed class WorldScene : Scene
{
- public const int WorldWidth = 120;
- public const int WorldHeight = 70;
public const int CellSize = 16;
- private const int Population = 80;
- private static readonly RectF WorldBounds = new(
- 0f,
- 0f,
- WorldWidth * CellSize,
- WorldHeight * CellSize
- );
+ private readonly WorldConfig _config;
+ private readonly WorldSave? _save;
+ private readonly RectF _bounds;
- private readonly int _seed;
+ private GameSpeed _speed = null!;
+ private PauseMenu _pause = null!;
+ private readonly List _speedRefreshers = [];
- public WorldScene(int seed) => _seed = seed;
+ /// Новый мир из конфига.
+ 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()
{
@@ -42,6 +56,9 @@ public sealed class WorldScene : Scene
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) }
);
@@ -49,76 +66,314 @@ public sealed class WorldScene : Scene
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
- // Рельеф: одна текстура на все клетки → один draw call на слой;
- // тип клетки и растительность приходят из TerrainDef'ов.
- var random = new Random(_seed);
- var heights = WorldGenerator.GenerateHeights(WorldWidth, WorldHeight, _seed);
- var landCells = new List();
- for (var x = 0; x < WorldWidth; x++)
+ // Рельеф детерминирован сидом и параметром сглаживания — и для нового мира, и для загрузки.
+ var random = new Random(_config.Seed);
+ var heights = WorldGenerator.GenerateHeights(
+ _config.Width,
+ _config.Height,
+ _config.Seed,
+ _config.SmoothPasses
+ );
+ for (var x = 0; x < _config.Width; x++)
{
- for (var y = 0; y < WorldHeight; y++)
+ for (var y = 0; y < _config.Height; y++)
{
var terrain = content.Terrains.Classify(heights[x, y]);
Store.CreateEntity(
Transform2D.At(new Vector2(x * CellSize, y * CellSize)),
new Sprite(white) { Color = terrain.Tint }
);
-
- var cell = new Point(x, y);
- if (terrain.IsLand)
- {
- landCells.Add(cell);
- }
-
ScatterSpawner.Spawn(
this,
content,
atlases,
device,
terrain,
- cell,
+ new Point(x, y),
CellSize,
random
);
}
}
- // Жители: дефы вида "being", появляются на суше, бродят (Y-sort: кто ниже — ближе).
+ if (_save is null)
+ {
+ SpawnPopulation(content, atlases, device, heights, random);
+ }
+ else
+ {
+ RestorePopulation(content, atlases, device);
+ }
+
+ 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);
+
+ var console = this.UseDevConsole();
+ RegisterCommands(console, content, atlases);
+
+ var decisions = new PawnDecisionSystem();
+ UpdateSystems.Add(decisions);
+ UpdateSystems.Add(new WanderSystem(_config.Seed, _bounds));
+ UpdateSystems.Add(new PawnNeedsSystem());
+ UpdateSystems.Add(new PawnAppearanceSystem());
+ UpdateSystems.Add(new GodCameraSystem(camera, input, console, () => _pause.IsOpen));
+ UpdateSystems.Add(
+ new HudSystem(
+ Context,
+ content.Languages,
+ hudLabel,
+ "hud.world",
+ _config.Seed,
+ _config.Population
+ )
+ );
+ 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 SpawnPopulation(
+ GameContent content,
+ ModAtlases atlases,
+ Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
+ float[,] heights,
+ Random random
+ )
+ {
+ var landCells = new List();
+ for (var x = 0; x < _config.Width; x++)
+ {
+ for (var y = 0; y < _config.Height; y++)
+ {
+ if (content.Terrains.Classify(heights[x, y]).IsLand)
+ {
+ landCells.Add(new Point(x, y));
+ }
+ }
+ }
+
var beings = content.Defs.All().Where(p => p.Kind == PawnDef.KindBeing).ToList();
- for (var i = 0; i < Population && landCells.Count > 0 && beings.Count > 0; i++)
+ for (var i = 0; i < _config.Population && landCells.Count > 0 && beings.Count > 0; i++)
{
var cell = landCells[random.Next(landCells.Count)];
var being = beings[random.Next(beings.Count)];
- var body = atlases.GetRegion(device, being.Texture);
- var sprite = new Sprite(body, GameLayers.Beings);
- sprite.CenterOrigin();
- Store.CreateEntity(
- new Transform2D(
- new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
- scale: new Vector2(CellSize * being.SizeCells / body.Width)
- ),
- sprite,
- new Wander(),
- // Начальная энергия и фаза решения разбросаны сидом — жители не отдыхают синхронно.
- new PawnNeeds { Energy = 0.5f + random.NextSingle() * 0.5f },
- new PawnBrain { Action = PawnAction.Wander, DecideIn = random.NextSingle() * 0.75f }
+ CreatePawn(
+ atlases,
+ device,
+ being,
+ new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
+ energy: 0.5f + random.NextSingle() * 0.5f,
+ action: PawnAction.Wander,
+ decideIn: random.NextSingle() * 0.75f,
+ direction: Vector2.Zero,
+ changeIn: 0f
+ );
+ }
+ }
+
+ private void RestorePopulation(
+ GameContent content,
+ ModAtlases atlases,
+ Microsoft.Xna.Framework.Graphics.GraphicsDevice device
+ )
+ {
+ foreach (var pawn in _save!.Pawns)
+ {
+ if (!content.Defs.TryGet(pawn.DefName, out var being))
+ {
+ continue; // деф пропал (мод убрали) — пропускаем жителя
+ }
+
+ CreatePawn(
+ atlases,
+ device,
+ being,
+ new Vector2(pawn.X, pawn.Y),
+ energy: pawn.Energy,
+ action: (PawnAction)pawn.Action,
+ decideIn: pawn.DecideIn,
+ direction: new Vector2(pawn.DirX, pawn.DirY),
+ changeIn: pawn.ChangeIn
+ );
+ }
+ }
+
+ private void CreatePawn(
+ ModAtlases atlases,
+ Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
+ PawnDef being,
+ Vector2 position,
+ float energy,
+ PawnAction action,
+ float decideIn,
+ Vector2 direction,
+ float changeIn
+ )
+ {
+ var body = atlases.GetRegion(device, being.Texture);
+ var sprite = new Sprite(body, GameLayers.Beings);
+ sprite.CenterOrigin();
+ Store.CreateEntity(
+ new Transform2D(position, scale: new Vector2(CellSize * being.SizeCells / body.Width)),
+ sprite,
+ new Wander { Direction = direction, ChangeIn = changeIn },
+ new PawnNeeds { Energy = energy },
+ new PawnBrain { Action = action, DecideIn = decideIn },
+ new PawnId { DefName = being.DefName }
+ );
+ }
+
+ 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
);
}
- var camera = Store.CreateEntity(
- new Camera(WorldBounds.Center, zoom: 1f, bounds: WorldBounds)
- );
+ void Refresh()
+ {
+ foreach (var refresh in _speedRefreshers)
+ {
+ refresh();
+ }
+ }
- // HUD и консоль.
- var desktop = this.UseUI();
- var hudLabel = new Label { Left = 10, Top = 8 };
- desktop.Root = hudLabel;
+ _speed.Changed += Refresh;
+ RegisterUnload(() => _speed.Changed -= Refresh);
+ Refresh();
+ return bar;
+ }
- var console = this.UseDevConsole();
+ 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,
+ };
+
+ Store
+ .Query()
+ .ForEachEntity(
+ (
+ ref PawnId id,
+ ref Transform2D transform,
+ ref Wander wander,
+ ref PawnNeeds needs,
+ ref PawnBrain brain,
+ Entity _
+ ) =>
+ {
+ save.Pawns.Add(
+ new PawnSave
+ {
+ DefName = id.DefName,
+ X = transform.Position.X,
+ Y = transform.Position.Y,
+ Scale = transform.Scale.X,
+ Energy = needs.Energy,
+ Action = (int)brain.Action,
+ DecideIn = brain.DecideIn,
+ DirX = wander.Direction.X,
+ DirY = wander.Direction.Y,
+ ChangeIn = wander.ChangeIn,
+ }
+ );
+ }
+ );
+
+ new SaveStore().Write(save);
+ Log.Info($"World '{_config.Name}' saved ({save.Pawns.Count} pawns)");
+ 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",
+ "regen [seed] — regenerate the world with a new seed",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
@@ -128,13 +383,15 @@ public sealed class WorldScene : Scene
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating world, seed {seed}");
- Context.Scenes.Switch(new WorldScene(seed), Transition.Fade(0.6f));
+ Context.Scenes.Switch(
+ new WorldScene(_config with { Seed = seed }),
+ Transition.Fade(0.6f)
+ );
}
);
-
console.Register(
"terrain",
- "terrain [seed] — switch to the tile terrain scene",
+ "terrain [seed] — switch to the tile terrain demo scene",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
@@ -142,57 +399,14 @@ public sealed class WorldScene : Scene
return;
}
- var seed = args.Length > 0 ? int.Parse(args[0]) : _seed;
- c.WriteLine($"switching to terrain scene, seed {seed}");
+ var seed = args.Length > 0 ? int.Parse(args[0]) : _config.Seed;
Context.Scenes.Switch(new TerrainScene(seed), Transition.Fade(0.6f));
}
);
-
- var decisions = new PawnDecisionSystem();
- UpdateSystems.Add(decisions);
- UpdateSystems.Add(new WanderSystem(_seed, WorldBounds));
- UpdateSystems.Add(new PawnNeedsSystem());
- UpdateSystems.Add(new PawnAppearanceSystem());
- UpdateSystems.Add(new GodCameraSystem(camera, input, console));
- UpdateSystems.Add(
- new HudSystem(Context, content.Languages, hudLabel, "hud.world", _seed, Population)
- );
-
console.Register(
- "ai",
- "ai [energy] — utility scores at the given energy (0..1), plus the rest/wander split",
- (c, args) =>
- {
- var energy =
- args.Length > 0 && float.TryParse(args[0], out var e)
- ? Math.Clamp(e, 0f, 1f)
- : 0.3f;
- c.WriteLine($"utility scores at energy {energy:0.00}:");
- foreach (var (name, score) in decisions.Inspect(energy))
- {
- c.WriteLine($" {name, -8} {score:0.000}");
- }
-
- var resting = 0;
- var total = 0;
- Store
- .Query()
- .ForEachEntity(
- (ref PawnBrain brain, Entity _) =>
- {
- total++;
- if (brain.Action == PawnAction.Rest)
- {
- resting++;
- }
- }
- );
- c.WriteLine($"population: {resting} resting / {total - resting} wandering");
- }
- );
-
- Log.Info(
- $"World generated: seed {_seed}, {WorldWidth}x{WorldHeight} cells, population {Population}"
+ "menu",
+ "menu — return to the main menu",
+ (_, _) => Switch(new MainMenuScene())
);
}
}
diff --git a/src/LittleSim/Sim/SimSystems.cs b/src/LittleSim/Sim/SimSystems.cs
index ab37d9b..8870ce0 100644
--- a/src/LittleSim/Sim/SimSystems.cs
+++ b/src/LittleSim/Sim/SimSystems.cs
@@ -14,6 +14,12 @@ public struct Wander : IComponent
public float ChangeIn;
}
+/// Имя дефа жителя — нужно сохранению, чтобы при загрузке пересобрать спрайт из атласа.
+public struct PawnId : IComponent
+{
+ public string DefName;
+}
+
///
/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде
/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см.
@@ -110,16 +116,20 @@ public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world)
}
}
-/// Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль.
+///
+/// Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль
+/// или активен (например, открыто меню-пауза).
+///
public sealed class GodCameraSystem(
Entity cameraEntity,
InputManager input,
- MrGameEng.DevConsole.DevConsole console
+ MrGameEng.DevConsole.DevConsole console,
+ Func? blocked = null
) : BaseSystem
{
protected override void OnUpdateGroup()
{
- if (console.IsOpen)
+ if (console.IsOpen || blocked?.Invoke() == true)
{
return;
}
diff --git a/src/LittleSim/Sim/WorldGenerator.cs b/src/LittleSim/Sim/WorldGenerator.cs
index e02d3e9..5935539 100644
--- a/src/LittleSim/Sim/WorldGenerator.cs
+++ b/src/LittleSim/Sim/WorldGenerator.cs
@@ -7,7 +7,7 @@ namespace LittleSim.Sim;
///
public static class WorldGenerator
{
- public static float[,] GenerateHeights(int width, int height, int seed)
+ public static float[,] GenerateHeights(int width, int height, int seed, int passes = 4)
{
var random = new Random(seed);
var heights = new float[width, height];
@@ -20,7 +20,8 @@ public static class WorldGenerator
}
// Сглаживание усреднением окрестности 3×3 — дёшево и даёт связные "континенты".
- for (var pass = 0; pass < 4; pass++)
+ // Больше проходов — крупнее и плавнее "континенты".
+ for (var pass = 0; pass < passes; pass++)
{
var smoothed = new float[width, height];
for (var x = 0; x < width; x++)
diff --git a/src/LittleSim/UI/Menu.cs b/src/LittleSim/UI/Menu.cs
new file mode 100644
index 0000000..f1e3f10
--- /dev/null
+++ b/src/LittleSim/UI/Menu.cs
@@ -0,0 +1,86 @@
+using System;
+using Friflo.Engine.ECS.Systems;
+using Microsoft.Xna.Framework;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.UI;
+
+///
+/// Общие фабрики виджетов Myra для экранов меню — единый стиль и отсутствие дублирования
+/// разметки между сценами. Все подписи приходят готовыми строками (локализованными вызывающим).
+///
+internal static class Ui
+{
+ /// Акцентный цвет заголовков и активных элементов.
+ public static readonly Color Accent = new(120, 200, 255);
+
+ /// Приглушённый цвет вторичного текста.
+ public static readonly Color Muted = new(150, 160, 175);
+
+ private const int ButtonWidth = 280;
+
+ /// Крупный заголовок по центру.
+ public static Label Title(string text) =>
+ new()
+ {
+ Text = text,
+ TextColor = Accent,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ /// Подпись вторичным цветом по центру.
+ public static Label Subtitle(string text) =>
+ new()
+ {
+ Text = text,
+ TextColor = Muted,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ /// Кнопка фиксированной ширины с обработчиком клика.
+ public static TextButton Button(string text, Action onClick, int width = ButtonWidth)
+ {
+ var button = new TextButton
+ {
+ Text = text,
+ Width = width,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+ button.Click += (_, _) => onClick();
+ return button;
+ }
+
+ /// Вертикальная колонка по центру экрана.
+ public static VerticalStackPanel Column(int spacing = 10) =>
+ new()
+ {
+ Spacing = spacing,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ /// Горизонтальный ряд.
+ public static HorizontalStackPanel Row(int spacing = 8) =>
+ new() { Spacing = spacing, HorizontalAlignment = HorizontalAlignment.Center };
+
+ /// Оборачивает содержимое в панель на весь экран (абсолютное позиционирование детей).
+ public static Panel Screen(params Widget[] children)
+ {
+ var panel = new Panel();
+ foreach (var child in children)
+ {
+ panel.Widgets.Add(child);
+ }
+
+ return panel;
+ }
+}
+
+///
+/// Минимальная система, вызывающая колбэк каждый кадр обновления. Сцены меню используют её
+/// для опроса ввода (Esc/горячие клавиши) без отдельного класса-системы на каждую сцену.
+///
+internal sealed class CallbackSystem(Action update) : BaseSystem
+{
+ protected override void OnUpdateGroup() => update();
+}
diff --git a/src/LittleSim/UI/SettingsPanel.cs b/src/LittleSim/UI/SettingsPanel.cs
new file mode 100644
index 0000000..2206374
--- /dev/null
+++ b/src/LittleSim/UI/SettingsPanel.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using LittleSim.App;
+using MrGameEng.Mods;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.UI;
+
+///
+/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, громкость).
+/// Возвращает виджет Myra, работающий с переданной копией ; общий
+/// для сцены настроек и оверлея паузы. Вместо неоднозначных комбо/чекбоксов — кнопки-сегменты
+/// и тумблеры (состояние показывает цвет текста), что переносимо между версиями Myra.
+///
+internal static class SettingsPanel
+{
+ private static readonly (int W, int H)[] Resolutions =
+ {
+ (1280, 720),
+ (1600, 900),
+ (1920, 1080),
+ };
+
+ /// Строит панель. / — кнопки внизу.
+ public static Widget Build(
+ GameSettings settings,
+ LanguageManager lang,
+ Action onApply,
+ Action onBack
+ )
+ {
+ var refreshers = new List();
+ var column = Ui.Column(10);
+
+ column.Widgets.Add(Ui.Title(lang.Get("settings.title")));
+ column.Widgets.Add(new Label { Height = 8 });
+
+ // Язык — по одному сегменту на доступный язык.
+ column.Widgets.Add(
+ Segment(
+ lang.Get("settings.language"),
+ lang.AvailableLanguages,
+ code => code.ToUpperInvariant(),
+ () => settings.Language,
+ code => settings.Language = code,
+ refreshers
+ )
+ );
+
+ // Полный экран и VSync — тумблеры (цвет = состояние).
+ column.Widgets.Add(
+ Toggle(
+ lang.Get("settings.fullscreen"),
+ () => settings.Fullscreen,
+ v => settings.Fullscreen = v,
+ refreshers
+ )
+ );
+ column.Widgets.Add(
+ Toggle(
+ lang.Get("settings.vsync"),
+ () => settings.VSync,
+ v => settings.VSync = v,
+ refreshers
+ )
+ );
+
+ // Разрешение — сегменты пресетов.
+ var resolutions = new List<(int, int)>(Resolutions);
+ column.Widgets.Add(
+ Segment(
+ lang.Get("settings.resolution"),
+ resolutions,
+ r => $"{r.Item1}×{r.Item2}",
+ () => (settings.Width, settings.Height),
+ r =>
+ {
+ settings.Width = r.Item1;
+ settings.Height = r.Item2;
+ },
+ refreshers
+ )
+ );
+
+ // Громкость — кнопки −/+ с подписью процента.
+ column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers));
+
+ column.Widgets.Add(new Label { Height = 8 });
+ var buttons = Ui.Row(10);
+ buttons.Widgets.Add(Ui.Button(lang.Get("settings.apply"), onApply, width: 150));
+ buttons.Widgets.Add(Ui.Button(lang.Get("settings.back"), onBack, width: 150));
+ column.Widgets.Add(buttons);
+
+ foreach (var refresh in refreshers)
+ {
+ refresh();
+ }
+
+ return column;
+ }
+
+ private static Widget Segment(
+ string label,
+ IReadOnlyList options,
+ Func text,
+ Func get,
+ Action set,
+ List refreshers
+ )
+ {
+ var row = Ui.Row(6);
+ row.Widgets.Add(new Label { Text = label, TextColor = Ui.Muted });
+ foreach (var option in options)
+ {
+ var value = option;
+ var button = new TextButton { Text = text(value) };
+ button.Click += (_, _) => set(value);
+ row.Widgets.Add(button);
+ refreshers.Add(() =>
+ button.TextColor = EqualityComparer.Default.Equals(get(), value)
+ ? Ui.Accent
+ : Ui.Muted
+ );
+ }
+
+ return row;
+ }
+
+ private static Widget Toggle(
+ string label,
+ Func get,
+ Action set,
+ List refreshers
+ )
+ {
+ var button = new TextButton();
+ button.Click += (_, _) => set(!get());
+ refreshers.Add(() =>
+ {
+ button.Text = (get() ? "● " : "○ ") + label;
+ button.TextColor = get() ? Ui.Accent : Ui.Muted;
+ });
+ return button;
+ }
+
+ private static Widget Volume(string label, GameSettings settings, List refreshers)
+ {
+ var row = Ui.Row(6);
+ var caption = new Label { TextColor = Ui.Muted };
+ refreshers.Add(() => caption.Text = $"{label}: {(int)MathF.Round(settings.Volume * 100)}%");
+
+ void Adjust(float delta)
+ {
+ settings.Volume = Math.Clamp(settings.Volume + delta, 0f, 1f);
+ foreach (var refresh in refreshers)
+ {
+ refresh();
+ }
+ }
+
+ var minus = new TextButton { Text = "−" };
+ minus.Click += (_, _) => Adjust(-0.1f);
+ var plus = new TextButton { Text = "+" };
+ plus.Click += (_, _) => Adjust(+0.1f);
+
+ row.Widgets.Add(caption);
+ row.Widgets.Add(minus);
+ row.Widgets.Add(plus);
+ return row;
+ }
+}
diff --git a/src/LittleSim/UI/TextButton.cs b/src/LittleSim/UI/TextButton.cs
new file mode 100644
index 0000000..6d4ebcd
--- /dev/null
+++ b/src/LittleSim/UI/TextButton.cs
@@ -0,0 +1,31 @@
+using Microsoft.Xna.Framework;
+using Myra.Graphics2D.UI;
+
+namespace LittleSim.UI;
+
+///
+/// Кнопка с текстовой подписью. В Myra 1.6 нет отдельного TextButton: несёт
+/// произвольный , поэтому оборачиваем и проксируем
+/// к нему и .
+///
+internal sealed class TextButton : Button
+{
+ private readonly Label _label = new();
+
+ /// Создаёт кнопку с пустой подписью.
+ public TextButton() => Content = _label;
+
+ /// Текст подписи.
+ public string Text
+ {
+ get => _label.Text;
+ set => _label.Text = value;
+ }
+
+ /// Цвет подписи.
+ public Color TextColor
+ {
+ get => _label.TextColor;
+ set => _label.TextColor = value;
+ }
+}