Enhance localization and world generation features
- Added new UI strings for menus, settings, and pause functionality in both English and Russian localization files. - Introduced a new WorldPresetDef class to define world size and population settings, allowing for dynamic world creation. - Updated world generation logic to support customizable terrain smoothing and population settings based on user-defined presets. - Refactored WorldScene to utilize the new WorldConfig structure for improved world initialization and loading. This commit improves the user experience by providing more options for world creation and enhancing the overall localization of the game.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "вода",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using MrGameEng.Mods;
|
||||
|
||||
namespace LittleSim.App;
|
||||
|
||||
/// <summary>
|
||||
/// Расположение пользовательских данных игры рядом с папкой <c>Mods</c> (где живёт и
|
||||
/// <c>Cache/</c>): файл настроек и каталог сохранений. Корень ищется один раз — поднятием
|
||||
/// вверх по дереву от исполняемого файла (как делает загрузка модов).
|
||||
/// </summary>
|
||||
public static class GamePaths
|
||||
{
|
||||
private static string? _root;
|
||||
|
||||
/// <summary>Корневая папка данных (родитель папки <c>Mods</c>).</summary>
|
||||
public static string Root =>
|
||||
_root ??= Path.GetDirectoryName(
|
||||
ModLoader.FindModsRoot(AppContext.BaseDirectory)
|
||||
?? throw new DirectoryNotFoundException("Папка Mods не найдена.")
|
||||
)!;
|
||||
|
||||
/// <summary>Путь к файлу настроек игры.</summary>
|
||||
public static string SettingsFile => Path.Combine(Root, "settings.json");
|
||||
|
||||
/// <summary>Каталог файлов сохранений.</summary>
|
||||
public static string SavesDirectory => Path.Combine(Root, "Saves");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Mods;
|
||||
|
||||
namespace LittleSim.App;
|
||||
|
||||
/// <summary>
|
||||
/// Пользовательские настройки игры, сохраняемые в <c>settings.json</c>: язык интерфейса,
|
||||
/// полноэкранный режим, вертикальная синхронизация, размер окна и общая громкость.
|
||||
/// </summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
/// <summary>Код языка интерфейса (например, "ru" или "en").</summary>
|
||||
public string Language { get; set; } = "ru";
|
||||
|
||||
/// <summary>Полноэкранный (безрамочный) режим.</summary>
|
||||
public bool Fullscreen { get; set; }
|
||||
|
||||
/// <summary>Вертикальная синхронизация.</summary>
|
||||
public bool VSync { get; set; } = true;
|
||||
|
||||
/// <summary>Ширина окна в пикселях.</summary>
|
||||
public int Width { get; set; } = 1280;
|
||||
|
||||
/// <summary>Высота окна в пикселях.</summary>
|
||||
public int Height { get; set; } = 720;
|
||||
|
||||
/// <summary>Общая громкость 0..1.</summary>
|
||||
public float Volume { get; set; } = 1f;
|
||||
}
|
||||
|
||||
/// <summary>Чтение/запись <see cref="GameSettings"/> и применение их к движку.</summary>
|
||||
public static class GameSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>Загружает настройки из <see cref="GamePaths.SettingsFile"/> или возвращает значения по умолчанию.</summary>
|
||||
public static GameSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(GamePaths.SettingsFile))
|
||||
{
|
||||
return JsonSerializer.Deserialize<GameSettings>(
|
||||
File.ReadAllText(GamePaths.SettingsFile),
|
||||
JsonOptions
|
||||
) ?? new GameSettings();
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException)
|
||||
{
|
||||
// Битый файл настроек не должен ронять запуск — откатываемся к умолчаниям.
|
||||
}
|
||||
|
||||
return new GameSettings();
|
||||
}
|
||||
|
||||
/// <summary>Сохраняет настройки в <see cref="GamePaths.SettingsFile"/>.</summary>
|
||||
public static void Save(GameSettings settings)
|
||||
{
|
||||
Directory.CreateDirectory(GamePaths.Root);
|
||||
File.WriteAllText(GamePaths.SettingsFile, JsonSerializer.Serialize(settings, JsonOptions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Применяет настройки к движку: язык (<paramref name="languages"/>), полноэкранный
|
||||
/// режим/VSync/размер окна (<paramref name="graphics"/>) и громкость (<paramref name="audio"/>).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace LittleSim.App;
|
||||
|
||||
/// <summary>
|
||||
/// Параметры одного мира: имя, размер сетки, сид, число проходов сглаживания рельефа и
|
||||
/// население. Заменяет хардкод в <see cref="Scenes.WorldScene"/>; строится из выбранного
|
||||
/// <see cref="Content.WorldPresetDef"/> и пользовательского ввода в меню «Новый мир» либо
|
||||
/// восстанавливается из <see cref="WorldSave"/>.
|
||||
/// </summary>
|
||||
public sealed record WorldConfig
|
||||
{
|
||||
/// <summary>Отображаемое имя мира (и имя файла сохранения).</summary>
|
||||
public string Name { get; init; } = "World";
|
||||
|
||||
/// <summary>Ширина мира в клетках.</summary>
|
||||
public int Width { get; init; } = 120;
|
||||
|
||||
/// <summary>Высота мира в клетках.</summary>
|
||||
public int Height { get; init; } = 70;
|
||||
|
||||
/// <summary>Целочисленный сид — весь мир воспроизводится из него.</summary>
|
||||
public int Seed { get; init; }
|
||||
|
||||
/// <summary>Число проходов сглаживания рельефа (больше — крупнее «континенты»).</summary>
|
||||
public int SmoothPasses { get; init; } = 4;
|
||||
|
||||
/// <summary>Число жителей при генерации.</summary>
|
||||
public int Population { get; init; } = 80;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LittleSim.App;
|
||||
|
||||
/// <summary>Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по <see cref="DefName"/>).</summary>
|
||||
public sealed class PawnSave
|
||||
{
|
||||
/// <summary>Имя дефа жителя — по нему при загрузке берётся текстура из атласов.</summary>
|
||||
public string DefName { get; set; } = "";
|
||||
|
||||
/// <summary>Позиция X в мировых координатах.</summary>
|
||||
public float X { get; set; }
|
||||
|
||||
/// <summary>Позиция Y в мировых координатах.</summary>
|
||||
public float Y { get; set; }
|
||||
|
||||
/// <summary>Масштаб трансформа (зависит от размера дефа и клетки).</summary>
|
||||
public float Scale { get; set; } = 1f;
|
||||
|
||||
/// <summary>Запас энергии [0, 1].</summary>
|
||||
public float Energy { get; set; }
|
||||
|
||||
/// <summary>Текущее действие (значение <see cref="Sim.PawnAction"/>).</summary>
|
||||
public int Action { get; set; }
|
||||
|
||||
/// <summary>Секунды до следующего пересмотра решения.</summary>
|
||||
public float DecideIn { get; set; }
|
||||
|
||||
/// <summary>Направление блуждания X.</summary>
|
||||
public float DirX { get; set; }
|
||||
|
||||
/// <summary>Направление блуждания Y.</summary>
|
||||
public float DirY { get; set; }
|
||||
|
||||
/// <summary>Секунды до смены направления.</summary>
|
||||
public float ChangeIn { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
|
||||
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
|
||||
/// детерминированно регенерируется из сида при загрузке.
|
||||
/// </summary>
|
||||
public sealed class WorldSave
|
||||
{
|
||||
/// <summary>Имя мира (и основа имени файла).</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Ширина мира в клетках.</summary>
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>Высота мира в клетках.</summary>
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>Сид генерации.</summary>
|
||||
public int Seed { get; set; }
|
||||
|
||||
/// <summary>Число проходов сглаживания рельефа.</summary>
|
||||
public int SmoothPasses { get; set; } = 4;
|
||||
|
||||
/// <summary>Население на момент сохранения.</summary>
|
||||
public int Population { get; set; }
|
||||
|
||||
/// <summary>Момент сохранения (UTC).</summary>
|
||||
public DateTime SavedUtc { get; set; }
|
||||
|
||||
/// <summary>Прошедшее время симуляции в секундах.</summary>
|
||||
public double ElapsedSeconds { get; set; }
|
||||
|
||||
/// <summary>Снимок жителей.</summary>
|
||||
public List<PawnSave> Pawns { get; set; } = [];
|
||||
|
||||
/// <summary>Конфиг мира для пересоздания сцены.</summary>
|
||||
public WorldConfig ToConfig() =>
|
||||
new()
|
||||
{
|
||||
Name = Name,
|
||||
Width = Width,
|
||||
Height = Height,
|
||||
Seed = Seed,
|
||||
SmoothPasses = SmoothPasses,
|
||||
Population = Population,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Список/чтение/запись/удаление файлов сохранений <c>Saves/*.json</c>.</summary>
|
||||
public sealed class SaveStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
|
||||
/// <summary>Создаёт хранилище поверх каталога (по умолчанию <see cref="GamePaths.SavesDirectory"/>).</summary>
|
||||
public SaveStore(string? directory = null) =>
|
||||
_directory = directory ?? GamePaths.SavesDirectory;
|
||||
|
||||
/// <summary>Запись сохранения: один файл на имя мира (повторное сохранение перезаписывает).</summary>
|
||||
public readonly record struct Entry(string FileName, WorldSave Save);
|
||||
|
||||
/// <summary>Все сохранения, новые сверху. Битые файлы пропускаются.</summary>
|
||||
public IReadOnlyList<Entry> List()
|
||||
{
|
||||
if (!Directory.Exists(_directory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var entries = new List<Entry>();
|
||||
foreach (var file in Directory.EnumerateFiles(_directory, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var save = JsonSerializer.Deserialize<WorldSave>(
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Пишет сохранение в файл по имени мира и возвращает имя файла.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Удаляет файл сохранения по имени.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ public sealed class GameContent
|
||||
defs.RegisterType<TerrainDef>("Terrain");
|
||||
defs.RegisterType<PlantDef>("Plant");
|
||||
defs.RegisterType<PawnDef>("Pawn");
|
||||
defs.RegisterType<WorldPresetDef>("WorldPreset");
|
||||
defs.Load(mods);
|
||||
|
||||
var languages = new LanguageManager(defaultLanguage: "ru");
|
||||
|
||||
@@ -50,6 +50,26 @@ public sealed class PlantDef : Def
|
||||
public float TrunkRadiusCells { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пресет размера мира (Defs/worldpresets.json): габариты сетки и население. Меню «Новый
|
||||
/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не
|
||||
/// хардкодом. <see cref="Def.Label"/> — ключ локализации (preset.*).
|
||||
/// </summary>
|
||||
public sealed class WorldPresetDef : Def
|
||||
{
|
||||
/// <summary>Ширина мира в клетках.</summary>
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>Высота мира в клетках.</summary>
|
||||
public int Height { get; init; }
|
||||
|
||||
/// <summary>Число жителей при генерации.</summary>
|
||||
public int Population { get; init; }
|
||||
|
||||
/// <summary>Порядок в списке UI (по возрастанию).</summary>
|
||||
public int Order { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Существо (Defs/pawns.json): текстура, размер и вид (житель/животное).</summary>
|
||||
public sealed class PawnDef : Def
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
|
||||
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
|
||||
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
|
||||
/// </summary>
|
||||
public sealed class BootScene : Scene
|
||||
{
|
||||
private readonly string[] _steps = { "Загрузка", "Loading" };
|
||||
private Task<GameContent>? _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<Game>();
|
||||
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<AudioManager>().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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Экран «Авторы»: демонстрационный, пока один автор. Назад — кнопкой или Esc.</summary>
|
||||
public sealed class CreditsScene : Scene
|
||||
{
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var lang = Context.Services.Get<GameContent>().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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Экран загрузки: список сохранений из <see cref="SaveStore"/> (имя, размер, дата) с
|
||||
/// кнопками «Загрузить» и «Удалить». Загрузка пересоздаёт мир из полного состояния симуляции.
|
||||
/// </summary>
|
||||
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<GameContent>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using LittleSim.Content;
|
||||
using LittleSim.UI;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.UI;
|
||||
|
||||
namespace LittleSim.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Главное меню: заголовок и кнопки Новый мир / Загрузка / Настройки / Авторы / Выход.
|
||||
/// Все подписи — из локализации Core-мода. Чистый UI-экран (Myra), без мира и рендерера.
|
||||
/// </summary>
|
||||
public sealed class MainMenuScene : Scene
|
||||
{
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
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<Game>().Exit())
|
||||
);
|
||||
|
||||
desktop.Root = Ui.Screen(column);
|
||||
}
|
||||
|
||||
private void Go(Scene scene)
|
||||
{
|
||||
if (!Context.Scenes.IsTransitioning)
|
||||
{
|
||||
Context.Scenes.Switch(scene, Transition.Fade(0.4f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Окно настройки нового мира: имя, размер (пресеты из <see cref="WorldPresetDef"/> Core-мода),
|
||||
/// сид (с кнопкой «случайно») и сглаживание рельефа. «Создать» запускает <see cref="WorldScene"/>.
|
||||
/// </summary>
|
||||
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<Action> _refreshers = [];
|
||||
|
||||
protected override void OnLoad()
|
||||
{
|
||||
var content = Context.Services.Get<GameContent>();
|
||||
var lang = content.Languages;
|
||||
var input = this.UseInput();
|
||||
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
|
||||
|
||||
var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList();
|
||||
_preset = presets[0];
|
||||
|
||||
var 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Внутриигровое меню-пауза: затемняющий оверлей поверх мира (мир остаётся загруженным).
|
||||
/// Открытие ставит игру на паузу через <see cref="GameSpeed"/>, закрытие — снимает.
|
||||
/// Кнопки: Продолжить / Настройки (общая <see cref="SettingsPanel"/>) / Сохранить /
|
||||
/// Главное меню / Выход. <see cref="Root"/> добавляется в корневую панель сцены.
|
||||
/// </summary>
|
||||
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<string> _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<string> onSave,
|
||||
Action onMainMenu,
|
||||
Action onQuit
|
||||
)
|
||||
{
|
||||
_context = context;
|
||||
_content = content;
|
||||
_speed = speed;
|
||||
_host = (GameHost)context.Services.Get<Game>();
|
||||
_audio = context.Services.Get<AudioManager>();
|
||||
_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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Виджет оверлея для добавления в корень сцены.</summary>
|
||||
public Widget Root => _overlay;
|
||||
|
||||
/// <summary>Открыт ли оверлей паузы.</summary>
|
||||
public bool IsOpen { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Реакция на Esc: закрыть подменю настроек → вернуться к кнопкам; иначе открыть/закрыть
|
||||
/// меню паузы.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Экран настроек игры (язык, экран, громкость, разрешение). Использует общий
|
||||
/// <see cref="SettingsPanel"/>. «Применить» сохраняет в settings.json и применяет к движку
|
||||
/// (смена языка перестраивает подписи на лету); «Назад»/Esc — в главное меню.
|
||||
/// </summary>
|
||||
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<GameContent>();
|
||||
_audio = Context.Services.Get<AudioManager>();
|
||||
_host = (GameHost)Context.Services.Get<Game>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+317
-103
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Мир LittleSim: рельеф, растения и жители целиком описаны дефами Core-мода
|
||||
/// (Mods/Core/Defs), текстуры — атласами из дерева текстур модов, строки HUD —
|
||||
/// локализацией. Камера бога, HUD и консоль ("regen", "terrain", "mods", "lang", …).
|
||||
/// Мир LittleSim: рельеф, растения и жители целиком описаны дефами Core-мода. Сцена строится
|
||||
/// из <see cref="WorldConfig"/> (размер/сид/население/сглаживание) и опционально восстанавливает
|
||||
/// полное состояние симуляции из <see cref="WorldSave"/>. Поверх мира — HUD, полоса скорости
|
||||
/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc) и дев-консоль.
|
||||
/// </summary>
|
||||
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<Action> _speedRefreshers = [];
|
||||
|
||||
public WorldScene(int seed) => _seed = seed;
|
||||
/// <summary>Новый мир из конфига.</summary>
|
||||
public WorldScene(WorldConfig config)
|
||||
: this(config, null) { }
|
||||
|
||||
/// <summary>Мир, восстановленный из сохранения (рельеф — из сида, жители — из снимка).</summary>
|
||||
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<ModAtlases>();
|
||||
var device = Context.GraphicsDevice;
|
||||
var input = this.UseInput();
|
||||
_speed = Context.Services.Get<GameSpeed>();
|
||||
_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<Point>();
|
||||
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<Game>().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<Point>();
|
||||
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<PawnDef>().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<PawnDef>(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<PawnId, Transform2D, Wander, PawnNeeds, PawnBrain>()
|
||||
.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<PawnBrain>()
|
||||
.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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ public struct Wander : IComponent
|
||||
public float ChangeIn;
|
||||
}
|
||||
|
||||
/// <summary>Имя дефа жителя — нужно сохранению, чтобы при загрузке пересобрать спрайт из атласа.</summary>
|
||||
public struct PawnId : IComponent
|
||||
{
|
||||
public string DefName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Блуждание жителей. Один Random на систему, сид от мира — при одинаковом сиде
|
||||
/// и порядке обновления симуляция воспроизводима. Отдыхающие жители (см.
|
||||
@@ -110,16 +116,20 @@ public sealed class SeparationSystem(MrGameEng.Collisions.CollisionWorld world)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль.</summary>
|
||||
/// <summary>
|
||||
/// Камера бога: WASD/стрелки — панорамирование, колесо — зум. Молчит, пока открыта консоль
|
||||
/// или активен <paramref name="blocked"/> (например, открыто меню-пауза).
|
||||
/// </summary>
|
||||
public sealed class GodCameraSystem(
|
||||
Entity cameraEntity,
|
||||
InputManager input,
|
||||
MrGameEng.DevConsole.DevConsole console
|
||||
MrGameEng.DevConsole.DevConsole console,
|
||||
Func<bool>? blocked = null
|
||||
) : BaseSystem
|
||||
{
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
if (console.IsOpen)
|
||||
if (console.IsOpen || blocked?.Invoke() == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace LittleSim.Sim;
|
||||
/// </summary>
|
||||
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++)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Общие фабрики виджетов Myra для экранов меню — единый стиль и отсутствие дублирования
|
||||
/// разметки между сценами. Все подписи приходят готовыми строками (локализованными вызывающим).
|
||||
/// </summary>
|
||||
internal static class Ui
|
||||
{
|
||||
/// <summary>Акцентный цвет заголовков и активных элементов.</summary>
|
||||
public static readonly Color Accent = new(120, 200, 255);
|
||||
|
||||
/// <summary>Приглушённый цвет вторичного текста.</summary>
|
||||
public static readonly Color Muted = new(150, 160, 175);
|
||||
|
||||
private const int ButtonWidth = 280;
|
||||
|
||||
/// <summary>Крупный заголовок по центру.</summary>
|
||||
public static Label Title(string text) =>
|
||||
new()
|
||||
{
|
||||
Text = text,
|
||||
TextColor = Accent,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
|
||||
/// <summary>Подпись вторичным цветом по центру.</summary>
|
||||
public static Label Subtitle(string text) =>
|
||||
new()
|
||||
{
|
||||
Text = text,
|
||||
TextColor = Muted,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
|
||||
/// <summary>Кнопка фиксированной ширины с обработчиком клика.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Вертикальная колонка по центру экрана.</summary>
|
||||
public static VerticalStackPanel Column(int spacing = 10) =>
|
||||
new()
|
||||
{
|
||||
Spacing = spacing,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
|
||||
/// <summary>Горизонтальный ряд.</summary>
|
||||
public static HorizontalStackPanel Row(int spacing = 8) =>
|
||||
new() { Spacing = spacing, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
|
||||
/// <summary>Оборачивает содержимое в панель на весь экран (абсолютное позиционирование детей).</summary>
|
||||
public static Panel Screen(params Widget[] children)
|
||||
{
|
||||
var panel = new Panel();
|
||||
foreach (var child in children)
|
||||
{
|
||||
panel.Widgets.Add(child);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная система, вызывающая колбэк каждый кадр обновления. Сцены меню используют её
|
||||
/// для опроса ввода (Esc/горячие клавиши) без отдельного класса-системы на каждую сцену.
|
||||
/// </summary>
|
||||
internal sealed class CallbackSystem(Action update) : BaseSystem
|
||||
{
|
||||
protected override void OnUpdateGroup() => update();
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using LittleSim.App;
|
||||
using MrGameEng.Mods;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Конструктор панели настроек (язык, полный экран, VSync, разрешение, громкость).
|
||||
/// Возвращает виджет Myra, работающий с переданной копией <see cref="GameSettings"/>; общий
|
||||
/// для сцены настроек и оверлея паузы. Вместо неоднозначных комбо/чекбоксов — кнопки-сегменты
|
||||
/// и тумблеры (состояние показывает цвет текста), что переносимо между версиями Myra.
|
||||
/// </summary>
|
||||
internal static class SettingsPanel
|
||||
{
|
||||
private static readonly (int W, int H)[] Resolutions =
|
||||
{
|
||||
(1280, 720),
|
||||
(1600, 900),
|
||||
(1920, 1080),
|
||||
};
|
||||
|
||||
/// <summary>Строит панель. <paramref name="onApply"/>/<paramref name="onBack"/> — кнопки внизу.</summary>
|
||||
public static Widget Build(
|
||||
GameSettings settings,
|
||||
LanguageManager lang,
|
||||
Action onApply,
|
||||
Action onBack
|
||||
)
|
||||
{
|
||||
var refreshers = new List<Action>();
|
||||
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<T>(
|
||||
string label,
|
||||
IReadOnlyList<T> options,
|
||||
Func<T, string> text,
|
||||
Func<T> get,
|
||||
Action<T> set,
|
||||
List<Action> 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<T>.Default.Equals(get(), value)
|
||||
? Ui.Accent
|
||||
: Ui.Muted
|
||||
);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Widget Toggle(
|
||||
string label,
|
||||
Func<bool> get,
|
||||
Action<bool> set,
|
||||
List<Action> 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<Action> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace LittleSim.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Кнопка с текстовой подписью. В Myra 1.6 нет отдельного TextButton: <see cref="Button"/> несёт
|
||||
/// произвольный <see cref="Button.Content"/>, поэтому оборачиваем <see cref="Label"/> и проксируем
|
||||
/// к нему <see cref="Text"/> и <see cref="TextColor"/>.
|
||||
/// </summary>
|
||||
internal sealed class TextButton : Button
|
||||
{
|
||||
private readonly Label _label = new();
|
||||
|
||||
/// <summary>Создаёт кнопку с пустой подписью.</summary>
|
||||
public TextButton() => Content = _label;
|
||||
|
||||
/// <summary>Текст подписи.</summary>
|
||||
public string Text
|
||||
{
|
||||
get => _label.Text;
|
||||
set => _label.Text = value;
|
||||
}
|
||||
|
||||
/// <summary>Цвет подписи.</summary>
|
||||
public Color TextColor
|
||||
{
|
||||
get => _label.TextColor;
|
||||
set => _label.TextColor = value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user