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; + } +}