Adopt the engine Core/Host split; add LittleSim.Server headless prototype

Engine bump: MrGameEng.Core is now platform-free (Friflo only), the
windowed MonoGame host lives in the new MrGameEng.Host library, and the
core gains HeadlessHost — a fixed-timestep loop without a window or GPU.

Game side: scenes switch to Transitions.Fade from the Host library,
WorldScene reads the graphics device via Context.GetGraphicsDevice(),
GameContent.Load(buildAtlases: false) skips atlas building for headless
runs.

LittleSim.Server is the dedicated-server seed and the showcase for
HeadlessHost: it loads mods/defs without textures and fast-forwards the
world calendar and climate on a fixed tick (~3.6M ticks/s in Debug):

  dotnet run --project src/LittleSim.Server -- --days 10 --tps 60

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 23:13:40 +03:00
co-authored by Claude Fable 5
parent bfe1b139f0
commit 8a7e2cce52
17 changed files with 1174 additions and 1008 deletions
@@ -0,0 +1,46 @@
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using LittleSim.Scenes;
using MrGameEng.Core;
namespace LittleSim.Server;
/// <summary>
/// Серверная сцена-зародыш: календарь и климат мира идут на фиксированном тике
/// <see cref="HeadlessHost"/>, без окна, GPU и текстур. Демонстрирует headless-режим
/// движка; дальше сюда переедет полная симуляция мира (растения, жители) и сетевой слой.
/// </summary>
internal sealed class HeadlessWorldScene : Scene
{
private readonly GameContent _content;
public HeadlessWorldScene(GameContent content) => _content = content;
protected override void OnLoad()
{
Context.Services.Add(_content);
var calendar = Context.UseCalendar(WorldScene.SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
UpdateSystems.Add(new DayReportSystem(calendar, climate));
}
/// <summary>Пишет строку состояния мира в лог на рассвете каждого игрового дня.</summary>
private sealed class DayReportSystem(Calendar calendar, Climate climate) : BaseSystem
{
private int _lastDay;
protected override void OnUpdateGroup()
{
if (calendar.Day == _lastDay)
{
return;
}
_lastDay = calendar.Day;
Log.Info(
$"День {calendar.Day} | год {climate.Year}, день года {climate.DayOfYear + 1}, "
+ $"{climate.Season} | {climate.Temperature:+0.0;-0.0;0.0} °C"
);
}
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LittleSim\LittleSim.csproj" />
</ItemGroup>
</Project>
+44
View File
@@ -0,0 +1,44 @@
using System.Diagnostics;
using LittleSim.Content;
using LittleSim.Scenes;
using LittleSim.Server;
using MrGameEng.Core;
// Дедикейтед-сервер LittleSim (прототип): мир без окна и GPU на HeadlessHost движка.
// Пока умеет загрузить моды/дефы (без атласов) и прогнать календарь и климат на
// фиксированном тике. Использование:
// dotnet run --project src/LittleSim.Server [-- --days N] [--tps N]
var days = ReadOption("--days", 10);
var ticksPerSecond = ReadOption("--tps", 60);
Log.MessageLogged += (level, message) => Console.WriteLine($"[{level}] {message}");
var content = GameContent.Load(buildAtlases: false);
Log.Info(
$"Моды: {string.Join(", ", content.Mods.Select(m => m.ToString()))} | "
+ $"дефов: {content.Defs.All<TerrainDef>().Count} террейна, "
+ $"{content.Defs.All<PlantDef>().Count} растений, "
+ $"{content.Defs.All<PawnDef>().Count} жителей"
);
using var host = new HeadlessHost(
new HeadlessHostOptions { TicksPerSecond = ticksPerSecond, Realtime = false },
new HeadlessWorldScene(content)
);
var ticks = (long)((double)days * WorldScene.SecondsPerDay * ticksPerSecond);
var stopwatch = Stopwatch.StartNew();
host.RunTicks(ticks);
Log.Info(
$"{days} игровых дней за {stopwatch.Elapsed.TotalSeconds:F1} с реального времени "
+ $"({ticks} тиков, {ticks / Math.Max(stopwatch.Elapsed.TotalSeconds, 0.001):F0} тиков/с)"
);
int ReadOption(string name, int fallback)
{
var index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value)
? value
: fallback;
}
+23 -14
View File
@@ -44,8 +44,10 @@ public sealed class GameContent
/// <summary>
/// Находит папку Mods (вверх по дереву от исполняемого файла), загружает моды, дефы и
/// языки и инкрементально собирает атласы из смерженного дерева текстур всех модов.
/// С <paramref name="buildAtlases"/> = false атласы не собираются — headless-режим
/// (дедикейтед-сервер) текстур не рисует.
/// </summary>
public static GameContent Load()
public static GameContent Load(bool buildAtlases = true)
{
var modsRoot =
ModLoader.FindModsRoot(AppContext.BaseDirectory)
@@ -71,19 +73,26 @@ public sealed class GameContent
"Cache",
"Atlases"
);
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
var result = AtlasBuilder.Build(
new AtlasBuildOptions
{
OutputDirectory = atlasCacheDirectory,
GroupDepth = ModAtlases.GroupDepth,
},
textures.Files.Select(f => (f.FullPath, f.RelativePath))
);
var built = result.Groups.Count(g => !g.Skipped);
Log.Info(
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
);
if (buildAtlases)
{
var textures = ModContentTree.Build(mods, "Textures", ".png", ".jpg", ".jpeg", ".bmp");
var result = AtlasBuilder.Build(
new AtlasBuildOptions
{
OutputDirectory = atlasCacheDirectory,
GroupDepth = ModAtlases.GroupDepth,
},
textures.Files.Select(f => (f.FullPath, f.RelativePath))
);
var built = result.Groups.Count(g => !g.Skipped);
Log.Info(
$"Atlases: {built} built, {result.Groups.Count - built} up to date ({result.Groups.Count} total)"
);
}
else
{
Log.Info("Atlases: skipped (headless)");
}
return new GameContent(
mods,
+1
View File
@@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="..\..\engine\src\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Host\MrGameEng.Host.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\engine\src\MrGameEng.Content\MrGameEng.Content.csproj" />
+1 -1
View File
@@ -1,6 +1,6 @@
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Host;
// Контент Core-мода грузится уже в окне — на загрузочном экране (BootScene), в фоне.
using var host = new GameHost(
+79 -78
View File
@@ -1,78 +1,79 @@
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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));
}
}
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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(), Transitions.Fade(0.6f));
}
}
+47 -46
View File
@@ -1,46 +1,47 @@
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using MrGameEng.Input;
using MrGameEng.UI;
namespace LittleSim.Scenes;
/// <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));
}
}
}
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.UI;
namespace LittleSim.Scenes;
/// <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(), Transitions.Fade(0.4f));
}
}
}
+111 -110
View File
@@ -1,110 +1,111 @@
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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));
}
}
}
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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),
Transitions.Fade(0.6f)
);
}
},
width: 130
)
);
row.Widgets.Add(
Ui.Button(
lang.Get("load.delete"),
() =>
{
_store.Delete(file);
Rebuild();
},
width: 130
)
);
column.Widgets.Add(row);
}
column.Widgets.Add(new Label { Height = 8 });
column.Widgets.Add(Ui.Button(lang.Get("load.back"), Back));
_desktop.Root = Ui.Screen(column);
}
private void Back()
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+44 -43
View File
@@ -1,43 +1,44 @@
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));
}
}
}
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Host;
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, Transitions.Fade(0.4f));
}
}
}
+167 -166
View File
@@ -1,166 +1,167 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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));
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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), Transitions.Fade(0.6f));
}
private void Back()
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
}
}
}
+168 -167
View File
@@ -1,167 +1,168 @@
using System;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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);
}
}
using System;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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);
}
}
+66 -65
View File
@@ -1,65 +1,66 @@
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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));
}
}
}
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <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(), Transitions.Fade(0.4f));
}
}
}
+312 -311
View File
@@ -1,311 +1,312 @@
using System;
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.Sim;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.Inspector;
using MrGameEng.Lighting;
using MrGameEng.Tilemaps;
using MrGameEng.UI;
using Myra.Graphics2D;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
/// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
/// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
/// </summary>
public sealed class WorldScene : Scene
{
public const int CellSize = 16;
/// <summary>
/// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
/// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
/// </summary>
public const float SecondsPerDay = 480f;
private readonly WorldConfig _config;
private readonly WorldSave? _save;
private readonly RectF _bounds;
private GameSpeed _speed = null!;
private PauseMenu _pause = null!;
private readonly List<Action> _speedRefreshers = [];
/// <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()
{
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
var content = Context.Services.Get<GameContent>();
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) }
);
GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
var calendar = Context.UseCalendar(SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
// Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
var plants = new PlantSet(content, atlases, device);
var random = new Random(_config.Seed);
BuildTerrain(content, atlases, device, assets, plants, random);
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели.
var desktop = this.UseUI();
var hudLabel = new Label { Left = 10, Top = 8 };
var speedBar = BuildSpeedBar(content);
_pause = new PauseMenu(
Context,
content,
_speed,
onSave: SaveWorld,
onMainMenu: () => Switch(new MainMenuScene()),
onQuit: () => Context.Services.Get<Game>().Exit()
);
desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
this.UseInspector(renderer);
var console = this.UseDevConsole();
RegisterCommands(console, content, atlases);
UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
);
UpdateSystems.Add(
new HudSystem(
Context,
content.Languages,
hudLabel,
"hud.world",
_config.Seed,
calendar,
climate
)
);
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
Log.Info(
$"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
+ (_save is null ? " (new)" : " (loaded)")
);
}
/// <summary>
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
/// </summary>
private void BuildTerrain(
GameContent content,
ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
AssetManager assets,
PlantSet plants,
Random random
)
{
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
// Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
var tiles = new TileSet();
var tileByDef = new Dictionary<TerrainDef, ushort>();
foreach (var terrain in content.Terrains.All)
{
tileByDef[terrain] = terrain.Surface is { } surface
? tiles.Add(atlases.GetRegion(device, surface))
: tiles.Add(white, terrain.Tint);
}
// Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
var heights = WorldGenerator.Generate(
_config.Width,
_config.Height,
_config.Seed,
_config.SmoothPasses
);
var grid = new TileGrid(_config.Width, _config.Height);
for (var x = 0; x < _config.Width; x++)
{
for (var y = 0; y < _config.Height; y++)
{
var terrain = content.Terrains.Classify(heights[x, y]);
grid[x, y] = tileByDef[terrain];
ScatterSpawner.Spawn(
this,
content,
plants,
terrain,
new Point(x, y),
CellSize,
random
);
}
}
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
}
private HorizontalStackPanel BuildSpeedBar(GameContent content)
{
var bar = Ui.Row(6);
bar.HorizontalAlignment = HorizontalAlignment.Center;
bar.VerticalAlignment = VerticalAlignment.Bottom;
bar.Margin = new Thickness(0, 0, 0, 12);
var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
pause.Click += (_, _) => _speed.Pause();
bar.Widgets.Add(pause);
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
for (var i = 0; i < _speed.Steps.Count; i++)
{
var index = i;
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
button.Click += (_, _) => _speed.SetStep(index);
bar.Widgets.Add(button);
_speedRefreshers.Add(() =>
button.TextColor =
!_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
);
}
void Refresh()
{
foreach (var refresh in _speedRefreshers)
{
refresh();
}
}
_speed.Changed += Refresh;
RegisterUnload(() => _speed.Changed -= Refresh);
Refresh();
return bar;
}
private void Hotkeys(InputManager input)
{
if (input.IsKeyPressed(Keys.Escape))
{
_pause.Toggle();
}
if (_pause.IsOpen)
{
return;
}
if (input.IsKeyPressed(Keys.Space))
{
_speed.TogglePause();
}
if (input.IsKeyPressed(Keys.D1))
{
_speed.SetStep(0);
}
if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
{
_speed.SetStep(1);
}
if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
{
_speed.SetStep(2);
}
}
private string SaveWorld()
{
// Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
var save = new WorldSave
{
Name = _config.Name,
Width = _config.Width,
Height = _config.Height,
Seed = _config.Seed,
SmoothPasses = _config.SmoothPasses,
Population = _config.Population,
SavedUtc = DateTime.UtcNow,
ElapsedSeconds = Context.Clock.TotalTime,
};
new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved");
return _config.Name;
}
private void Switch(Scene scene)
{
if (!Context.Scenes.IsTransitioning)
{
_speed.Resume();
Context.Scenes.Switch(scene, Transition.Fade(0.5f));
}
}
private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
{
ContentCommands.Register(console, content, atlases);
console.Register(
"regen",
"regen [seed] — regenerate the world with a new seed",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating world, seed {seed}");
Context.Scenes.Switch(
new WorldScene(_config with { Seed = seed }),
Transition.Fade(0.6f)
);
}
);
console.Register(
"menu",
"menu — return to the main menu",
(_, _) => Switch(new MainMenuScene())
);
}
}
using System;
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.Sim;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Host;
using MrGameEng.Input;
using MrGameEng.Inspector;
using MrGameEng.Lighting;
using MrGameEng.Tilemaps;
using MrGameEng.UI;
using Myra.Graphics2D;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
/// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
/// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие
/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн.
/// </summary>
public sealed class WorldScene : Scene
{
public const int CellSize = 16;
/// <summary>
/// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
/// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
/// </summary>
public const float SecondsPerDay = 480f;
private readonly WorldConfig _config;
private readonly WorldSave? _save;
private readonly RectF _bounds;
private GameSpeed _speed = null!;
private PauseMenu _pause = null!;
private readonly List<Action> _speedRefreshers = [];
/// <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()
{
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
var content = Context.Services.Get<GameContent>();
var atlases = Context.Services.Get<ModAtlases>();
var device = Context.GetGraphicsDevice();
var input = this.UseInput();
_speed = Context.Services.Get<GameSpeed>();
_speed.SetStep(0); // новый мир/загрузка стартуют на x1
var renderer = this.UseRenderer2D(
new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }
);
GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
var calendar = Context.UseCalendar(SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
// Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed).
var plants = new PlantSet(content, atlases, device);
var random = new Random(_config.Seed);
BuildTerrain(content, atlases, device, assets, plants, random);
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели.
var desktop = this.UseUI();
var hudLabel = new Label { Left = 10, Top = 8 };
var speedBar = BuildSpeedBar(content);
_pause = new PauseMenu(
Context,
content,
_speed,
onSave: SaveWorld,
onMainMenu: () => Switch(new MainMenuScene()),
onQuit: () => Context.Services.Get<Game>().Exit()
);
desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root);
this.UseInspector(renderer);
var console = this.UseDevConsole();
RegisterCommands(console, content, atlases);
UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize));
UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
);
UpdateSystems.Add(
new HudSystem(
Context,
content.Languages,
hudLabel,
"hud.world",
_config.Seed,
calendar,
climate
)
);
UpdateSystems.Add(new CallbackSystem(() => Hotkeys(input)));
Log.Info(
$"World '{_config.Name}': {_config.Width}x{_config.Height}, seed {_config.Seed}"
+ (_save is null ? " (new)" : " (loaded)")
);
}
/// <summary>
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости).
/// </summary>
private void BuildTerrain(
GameContent content,
ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
AssetManager assets,
PlantSet plants,
Random random
)
{
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
// Тайлсет из дефов: есть поверхность — текстура из атласа, нет — тонированный тайл (вода).
var tiles = new TileSet();
var tileByDef = new Dictionary<TerrainDef, ushort>();
foreach (var terrain in content.Terrains.All)
{
tileByDef[terrain] = terrain.Surface is { } surface
? tiles.Add(atlases.GetRegion(device, surface))
: tiles.Add(white, terrain.Tint);
}
// Рельеф детерминирован сидом и масштабом деталей — и для нового мира, и для загрузки.
var heights = WorldGenerator.Generate(
_config.Width,
_config.Height,
_config.Seed,
_config.SmoothPasses
);
var grid = new TileGrid(_config.Width, _config.Height);
for (var x = 0; x < _config.Width; x++)
{
for (var y = 0; y < _config.Height; y++)
{
var terrain = content.Terrains.Classify(heights[x, y]);
grid[x, y] = tileByDef[terrain];
ScatterSpawner.Spawn(
this,
content,
plants,
terrain,
new Point(x, y),
CellSize,
random
);
}
}
Store.CreateEntity(new Tilemap(grid, tiles, CellSize));
}
private HorizontalStackPanel BuildSpeedBar(GameContent content)
{
var bar = Ui.Row(6);
bar.HorizontalAlignment = HorizontalAlignment.Center;
bar.VerticalAlignment = VerticalAlignment.Bottom;
bar.Margin = new Thickness(0, 0, 0, 12);
var pause = new TextButton { Text = content.Languages.Get("speed.pause") };
pause.Click += (_, _) => _speed.Pause();
bar.Widgets.Add(pause);
_speedRefreshers.Add(() => pause.TextColor = _speed.IsPaused ? Ui.Accent : Ui.Muted);
for (var i = 0; i < _speed.Steps.Count; i++)
{
var index = i;
var button = new TextButton { Text = $"x{_speed.Steps[i]:0}" };
button.Click += (_, _) => _speed.SetStep(index);
bar.Widgets.Add(button);
_speedRefreshers.Add(() =>
button.TextColor =
!_speed.IsPaused && _speed.StepIndex == index ? Ui.Accent : Ui.Muted
);
}
void Refresh()
{
foreach (var refresh in _speedRefreshers)
{
refresh();
}
}
_speed.Changed += Refresh;
RegisterUnload(() => _speed.Changed -= Refresh);
Refresh();
return bar;
}
private void Hotkeys(InputManager input)
{
if (input.IsKeyPressed(Keys.Escape))
{
_pause.Toggle();
}
if (_pause.IsOpen)
{
return;
}
if (input.IsKeyPressed(Keys.Space))
{
_speed.TogglePause();
}
if (input.IsKeyPressed(Keys.D1))
{
_speed.SetStep(0);
}
if (input.IsKeyPressed(Keys.D2) && _speed.Steps.Count > 1)
{
_speed.SetStep(1);
}
if (input.IsKeyPressed(Keys.D3) && _speed.Steps.Count > 2)
{
_speed.SetStep(2);
}
}
private string SaveWorld()
{
// Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
var save = new WorldSave
{
Name = _config.Name,
Width = _config.Width,
Height = _config.Height,
Seed = _config.Seed,
SmoothPasses = _config.SmoothPasses,
Population = _config.Population,
SavedUtc = DateTime.UtcNow,
ElapsedSeconds = Context.Clock.TotalTime,
};
new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved");
return _config.Name;
}
private void Switch(Scene scene)
{
if (!Context.Scenes.IsTransitioning)
{
_speed.Resume();
Context.Scenes.Switch(scene, Transitions.Fade(0.5f));
}
}
private void RegisterCommands(DevConsole console, GameContent content, ModAtlases atlases)
{
ContentCommands.Register(console, content, atlases);
console.Register(
"regen",
"regen [seed] — regenerate the world with a new seed",
(c, args) =>
{
if (Context.Scenes.IsTransitioning)
{
return;
}
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
c.WriteLine($"regenerating world, seed {seed}");
Context.Scenes.Switch(
new WorldScene(_config with { Seed = seed }),
Transitions.Fade(0.6f)
);
}
);
console.Register(
"menu",
"menu — return to the main menu",
(_, _) => Switch(new MainMenuScene())
);
}
}