From 8a7e2cce5211e64bd2e14d346251699a4ba373c2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 23:13:40 +0300 Subject: [PATCH] Adopt the engine Core/Host split; add LittleSim.Server headless prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine bump: MrGameEng.Core is now platform-free (Friflo only), the windowed MonoGame host lives in the new MrGameEng.Host library, and the core gains HeadlessHost — a fixed-timestep loop without a window or GPU. Game side: scenes switch to Transitions.Fade from the Host library, WorldScene reads the graphics device via Context.GetGraphicsDevice(), GameContent.Load(buildAtlases: false) skips atlas building for headless runs. LittleSim.Server is the dedicated-server seed and the showcase for HeadlessHost: it loads mods/defs without textures and fast-forwards the world calendar and climate on a fixed tick (~3.6M ticks/s in Debug): dotnet run --project src/LittleSim.Server -- --days 10 --tps 60 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 15 +- LittleSim.sln | 45 ++ engine | 2 +- src/LittleSim.Server/HeadlessWorldScene.cs | 46 ++ src/LittleSim.Server/LittleSim.Server.csproj | 10 + src/LittleSim.Server/Program.cs | 44 ++ src/LittleSim/Content/GameContent.cs | 37 +- src/LittleSim/LittleSim.csproj | 1 + src/LittleSim/Program.cs | 2 +- src/LittleSim/Scenes/BootScene.cs | 157 ++--- src/LittleSim/Scenes/CreditsScene.cs | 93 +-- src/LittleSim/Scenes/LoadGameScene.cs | 221 +++---- src/LittleSim/Scenes/MainMenuScene.cs | 87 +-- src/LittleSim/Scenes/NewWorldScene.cs | 333 +++++----- src/LittleSim/Scenes/PauseMenu.cs | 335 +++++----- src/LittleSim/Scenes/SettingsScene.cs | 131 ++-- src/LittleSim/Scenes/WorldScene.cs | 623 ++++++++++--------- 17 files changed, 1174 insertions(+), 1008 deletions(-) create mode 100644 src/LittleSim.Server/HeadlessWorldScene.cs create mode 100644 src/LittleSim.Server/LittleSim.Server.csproj create mode 100644 src/LittleSim.Server/Program.cs 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()) + ); + } +}