Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9433db6212 | ||
|
|
dc13b9b8c3 |
@@ -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": "вода",
|
||||
|
||||
+1
-1
Submodule engine updated: 3f3c0200a8...ef1111bcb6
@@ -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)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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)];
|
||||
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(
|
||||
new Vector2((cell.X + 0.5f) * CellSize, (cell.Y + 0.5f) * CellSize),
|
||||
scale: new Vector2(CellSize * being.SizeCells / body.Width)
|
||||
),
|
||||
new Transform2D(position, 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 }
|
||||
new Wander { Direction = direction, ChangeIn = changeIn },
|
||||
new PawnNeeds { Energy = energy },
|
||||
new PawnBrain { Action = action, DecideIn = decideIn },
|
||||
new PawnId { DefName = being.DefName }
|
||||
);
|
||||
}
|
||||
|
||||
var camera = Store.CreateEntity(
|
||||
new Camera(WorldBounds.Center, zoom: 1f, bounds: WorldBounds)
|
||||
private HorizontalStackPanel BuildSpeedBar(GameContent content)
|
||||
{
|
||||
var bar = Ui.Row(6);
|
||||
bar.HorizontalAlignment = HorizontalAlignment.Center;
|
||||
bar.VerticalAlignment = VerticalAlignment.Bottom;
|
||||
bar.Margin = new Thickness(0, 0, 0, 12);
|
||||
|
||||
var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
|
||||
pause.Click += (_, _) => _speed.Pause();
|
||||
bar.Widgets.Add(pause);
|
||||
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
|
||||
|
||||
for (var i = 0; i < _speed.Steps.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
|
||||
button.Click += (_, _) => _speed.SetStep(index);
|
||||
bar.Widgets.Add(button);
|
||||
_speedRefreshers.Add(() =>
|
||||
button.TextColor =
|
||||
!_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
|
||||
);
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
foreach (var refresh in _speedRefreshers)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
_speed.Changed += Refresh;
|
||||
RegisterUnload(() => _speed.Changed -= Refresh);
|
||||
Refresh();
|
||||
return bar;
|
||||
}
|
||||
|
||||
private void Hotkeys(InputManager input)
|
||||
{
|
||||
if (input.IsKeyPressed(Keys.Escape))
|
||||
{
|
||||
_pause.Toggle();
|
||||
}
|
||||
|
||||
if (_pause.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.Space))
|
||||
{
|
||||
_speed.TogglePause();
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D1))
|
||||
{
|
||||
_speed.SetStep(0);
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
|
||||
{
|
||||
_speed.SetStep(1);
|
||||
}
|
||||
|
||||
if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
|
||||
{
|
||||
_speed.SetStep(2);
|
||||
}
|
||||
}
|
||||
|
||||
private string SaveWorld()
|
||||
{
|
||||
var save = new WorldSave
|
||||
{
|
||||
Name = _config.Name,
|
||||
Width = _config.Width,
|
||||
Height = _config.Height,
|
||||
Seed = _config.Seed,
|
||||
SmoothPasses = _config.SmoothPasses,
|
||||
Population = _config.Population,
|
||||
SavedUtc = DateTime.UtcNow,
|
||||
ElapsedSeconds = Context.Clock.TotalTime,
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// HUD и консоль.
|
||||
var desktop = this.UseUI();
|
||||
var hudLabel = new Label { Left = 10, Top = 8 };
|
||||
desktop.Root = hudLabel;
|
||||
new SaveStore().Write(save);
|
||||
Log.Info($"World '{_config.Name}' saved ({save.Pawns.Count} pawns)");
|
||||
return _config.Name;
|
||||
}
|
||||
|
||||
var console = this.UseDevConsole();
|
||||
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