Merge main (gene system G1-G5, phases C-D) into the multiplayer branch
Main independently adopted the engine Core/Host split in G1, so the scene adaptations conflicted with identical changes here — resolved in main's favor (its scenes also carry the gene-driven world). Kept from this branch: BootScene's --connect path into MultiplayerScene, the buildAtlases switch in GameContent.Load (with the Task.Run lambda fix its optional parameter requires), net.* localization keys, and the Net/Server/Web projects in the solution. Program.cs now has both CLI modes: --check-content (main) and --connect (here). The engine pointer lands on d044caf, which linearly contains both lines of work. Verified on the merged tree: 366 engine tests pass, --check-content reports genes/traits intact, and a live --listen server replicates moving pawns to the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,33 @@
|
||||
using System.Text.Json;
|
||||
using MrGameEng.Genetics;
|
||||
|
||||
namespace LittleSim.App;
|
||||
|
||||
/// <summary>Сериализуемое состояние одного растения: вид (деф), позиция, возраст, стадия, почва и геном.</summary>
|
||||
public sealed class PlantSave
|
||||
{
|
||||
/// <summary>Имя дефа вида — по нему при загрузке берётся индекс/стадии/текстуры.</summary>
|
||||
public string Species { get; set; } = "";
|
||||
|
||||
/// <summary>Позиция X в мировых координатах.</summary>
|
||||
public float X { get; set; }
|
||||
|
||||
/// <summary>Позиция Y в мировых координатах.</summary>
|
||||
public float Y { get; set; }
|
||||
|
||||
/// <summary>Накопленный возраст в игровых днях.</summary>
|
||||
public float AgeDays { get; set; }
|
||||
|
||||
/// <summary>Текущая стадия роста.</summary>
|
||||
public int Stage { get; set; }
|
||||
|
||||
/// <summary>Плодородность клетки.</summary>
|
||||
public float CellFertility { get; set; }
|
||||
|
||||
/// <summary>Геном особи: аллели по id генов (переменного состава).</summary>
|
||||
public Dictionary<string, Allele> Genome { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по <see cref="DefName"/>).</summary>
|
||||
public sealed class PawnSave
|
||||
{
|
||||
@@ -70,6 +96,9 @@ public sealed class WorldSave
|
||||
/// <summary>Снимок жителей.</summary>
|
||||
public List<PawnSave> Pawns { get; set; } = [];
|
||||
|
||||
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
|
||||
public List<PlantSave> Plants { get; set; } = [];
|
||||
|
||||
/// <summary>Конфиг мира для пересоздания сцены.</summary>
|
||||
public WorldConfig ToConfig() =>
|
||||
new()
|
||||
@@ -90,6 +119,7 @@ public sealed class SaveStore
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
IncludeFields = true, // на случай публичных полей в сохраняемых sim-структурах
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MrGameEng.Atlases;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Genetics;
|
||||
using MrGameEng.Mods;
|
||||
|
||||
namespace LittleSim.Content;
|
||||
@@ -58,9 +59,19 @@ public sealed class GameContent
|
||||
|
||||
var defs = new DefDatabase();
|
||||
defs.RegisterType<TerrainDef>("Terrain");
|
||||
defs.RegisterType<GeneDef>("Gene");
|
||||
defs.RegisterType<ProductDef>("Product");
|
||||
defs.RegisterType<PlantDef>("Plant");
|
||||
defs.RegisterType<PawnDef>("Pawn");
|
||||
defs.RegisterType<WorldPresetDef>("WorldPreset");
|
||||
// Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
|
||||
defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
|
||||
defs.RegisterValidator(
|
||||
"Product",
|
||||
"defName",
|
||||
"^Product",
|
||||
"product defName must start with 'Product'"
|
||||
);
|
||||
defs.Load(mods);
|
||||
|
||||
var languages = new LanguageManager(defaultLanguage: "ru");
|
||||
|
||||
@@ -27,6 +27,9 @@ public sealed class TerrainDef : Def
|
||||
/// <summary>Суша: здесь появляются жители, животные и растения.</summary>
|
||||
public bool IsLand { get; init; }
|
||||
|
||||
/// <summary>Загораживает свет (горы) — базовый окклюдер для лайтмапа/теней.</summary>
|
||||
public bool BlocksLight { get; init; }
|
||||
|
||||
/// <summary>Ключ текстуры поверхности для тайловой сцены; null — тонированный тайл.</summary>
|
||||
public string? Surface { get; init; }
|
||||
|
||||
@@ -63,6 +66,17 @@ public sealed class PlantGrowthStage
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Продукт (Defs/products.json): что растение даёт при сборе (древесина, трава) или плодоношении
|
||||
/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество
|
||||
/// определяют гены. <see cref="Def.Label"/> — ключ локализации (product.*).
|
||||
/// </summary>
|
||||
public sealed class ProductDef : Def
|
||||
{
|
||||
/// <summary>Категория продукта (например "material" или "food") — для будущей экономики/инвентаря.</summary>
|
||||
public string Kind { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Базовый геном вида (вложенный объект <see cref="PlantDef.Genome"/>): оптимумы и толерантности по
|
||||
/// факторам среды + базовая бодрость роста. Особь при спавне получает аллели рядом с этими базами
|
||||
@@ -91,8 +105,38 @@ public sealed class GenomeDef
|
||||
/// <summary>Базовая бодрость роста (множитель скорости в идеальных условиях).</summary>
|
||||
public float Vigor { get; init; } = 1f;
|
||||
|
||||
/// <summary>Продолжительность жизни (игровых дней) до смерти от старости.</summary>
|
||||
public float Lifespan { get; init; } = 80f;
|
||||
|
||||
/// <summary>Дальность расселения семян (в клетках).</summary>
|
||||
public float DispersalRange { get; init; } = 2f;
|
||||
|
||||
/// <summary>Средний интервал между попытками дать семя (игровых дней).</summary>
|
||||
public float ReproduceInterval { get; init; } = 8f;
|
||||
|
||||
/// <summary>Способность к самоопылению (0..1): шанс дать семя без партнёра.</summary>
|
||||
public float SelfPollination { get; init; } = 0.4f;
|
||||
|
||||
/// <summary>Темп мутаций (0..1): вероятность сдвига аллеля у потомка.</summary>
|
||||
public float MutationRate { get; init; } = 0.05f;
|
||||
|
||||
/// <summary>Доля рецессивного аллеля морфы в стартовой популяции (0..1).</summary>
|
||||
public float VariantChance { get; init; } = 0.15f;
|
||||
|
||||
/// <summary>Доля разброса аллелей вокруг базы при генерации особи.</summary>
|
||||
public float Spread { get; init; } = 0.08f;
|
||||
|
||||
/// <summary>Плодовитость: сколько плодов несёт зрелое растение в сезон (0 — не плодоносит).</summary>
|
||||
public float FruitYield { get; init; }
|
||||
|
||||
/// <summary>Сезон плодоношения (0 — весна, 1 — лето, 2 — осень, 3 — зима).</summary>
|
||||
public float FruitSeason { get; init; } = 1f;
|
||||
|
||||
/// <summary>Сколько продукта даёт сбор растения (масштаб <see cref="PlantDef.HarvestProduct"/>).</summary>
|
||||
public float HarvestAmount { get; init; } = 1f;
|
||||
|
||||
/// <summary>Оттенок листвы (0..1) — сдвиг тинта спрайта; ген цвета.</summary>
|
||||
public float LeafHue { get; init; } = 0.33f;
|
||||
}
|
||||
|
||||
/// <summary>Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary>
|
||||
@@ -112,6 +156,12 @@ public sealed class PlantDef : Def
|
||||
|
||||
/// <summary>Базовый геном вида: оптимумы/толерантности по среде и бодрость роста.</summary>
|
||||
public GenomeDef Genome { get; init; } = new();
|
||||
|
||||
/// <summary>Имя <see cref="ProductDef"/>, выдаваемого при сборе растения (дерево/трава); null — несборное.</summary>
|
||||
public string? HarvestProduct { get; init; }
|
||||
|
||||
/// <summary>Имя <see cref="ProductDef"/>, выдаваемого плодами (ягоды/жёлудь); null — не плодоносит.</summary>
|
||||
public string? FruitProduct { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using MrGameEng.Genetics;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace LittleSim.Content;
|
||||
@@ -23,6 +24,9 @@ public sealed class PlantSet
|
||||
/// <summary>Стадии роста по порядку; последняя — терминальная.</summary>
|
||||
public required Stage[] Stages { get; init; }
|
||||
|
||||
/// <summary>Набор генов вида: базовые значения для генерации особи (геном из общих <see cref="GeneDef"/>).</summary>
|
||||
public required GenomeTemplate Template { get; init; }
|
||||
|
||||
/// <summary>День, с которого растение достигает последней (зрелой) стадии.</summary>
|
||||
public float MaturityDays => Stages[^1].EnterDay;
|
||||
|
||||
@@ -42,15 +46,28 @@ public sealed class PlantSet
|
||||
private readonly Species[] _species;
|
||||
private readonly Dictionary<PlantDef, int> _index = new();
|
||||
|
||||
/// <summary>Общий реестр генов (по id) для скрещивания и вычисления признаков растений.</summary>
|
||||
public IReadOnlyDictionary<string, GeneDef> GeneRegistry { get; }
|
||||
|
||||
/// <summary>Строит таблицу из всех (не-abstract) <see cref="PlantDef"/> мода.</summary>
|
||||
public PlantSet(GameContent content, ModAtlases atlases, GraphicsDevice device)
|
||||
{
|
||||
var genes = content
|
||||
.Defs.All<GeneDef>()
|
||||
.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
|
||||
GeneRegistry = genes;
|
||||
|
||||
var defs = content.Defs.All<PlantDef>().ToArray();
|
||||
_species = new Species[defs.Length];
|
||||
for (var i = 0; i < defs.Length; i++)
|
||||
{
|
||||
var def = defs[i];
|
||||
_species[i] = new Species { Def = def, Stages = BuildStages(def, atlases, device) };
|
||||
_species[i] = new Species
|
||||
{
|
||||
Def = def,
|
||||
Stages = BuildStages(def, atlases, device),
|
||||
Template = BuildTemplate(def.Genome, genes),
|
||||
};
|
||||
_index[def] = i;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +81,50 @@ public sealed class PlantSet
|
||||
/// <summary>Индекс вида по дефу.</summary>
|
||||
public int IndexOf(PlantDef def) => _index[def];
|
||||
|
||||
// Переводит базовый геном вида (числа из plants.json) в набор генов: каждое число — центр
|
||||
// аллелей соответствующего общего GeneDef; морфа — дискретный ген с долей рецессива из вида.
|
||||
private static GenomeTemplate BuildTemplate(
|
||||
GenomeDef g,
|
||||
IReadOnlyDictionary<string, GeneDef> genes
|
||||
)
|
||||
{
|
||||
GeneDef Gene(string id) =>
|
||||
genes.TryGetValue(id, out var def)
|
||||
? def
|
||||
: throw new KeyNotFoundException(
|
||||
$"Gene def '{id}' not found — is Mods/Core/Defs/genes.json loaded?"
|
||||
);
|
||||
|
||||
GenomeTemplate.Entry Numeric(string id, float baseValue) =>
|
||||
new(Gene(id), baseValue, g.Spread);
|
||||
|
||||
return new GenomeTemplate([
|
||||
Numeric("GeneOptimalLight", g.OptimalLight),
|
||||
Numeric("GeneLightTolerance", g.LightTolerance),
|
||||
Numeric("GeneOptimalTemperature", g.OptimalTemperature),
|
||||
Numeric("GeneTemperatureTolerance", g.TemperatureTolerance),
|
||||
Numeric("GeneOptimalFertility", g.OptimalFertility),
|
||||
Numeric("GeneFertilityTolerance", g.FertilityTolerance),
|
||||
Numeric("GeneVigor", g.Vigor),
|
||||
Numeric("GeneLifespan", g.Lifespan),
|
||||
Numeric("GeneDispersalRange", g.DispersalRange),
|
||||
Numeric("GeneReproduceInterval", g.ReproduceInterval),
|
||||
Numeric("GeneSelfPollination", g.SelfPollination),
|
||||
Numeric("GeneMutationRate", g.MutationRate),
|
||||
// Контент (G4): плодоношение, добыча, цвет.
|
||||
Numeric("GeneFruitYield", g.FruitYield),
|
||||
new GenomeTemplate.Entry(Gene("GeneFruitSeason"), g.FruitSeason, 0f), // сезон фиксирован по виду
|
||||
Numeric("GeneHarvestAmount", g.HarvestAmount),
|
||||
new GenomeTemplate.Entry(Gene("GeneLeafHue"), g.LeafHue, 0.04f),
|
||||
new GenomeTemplate.Entry(
|
||||
Gene("GeneMorph"),
|
||||
0f,
|
||||
0f,
|
||||
[1f - g.VariantChance, g.VariantChance]
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private static Stage[] BuildStages(PlantDef def, ModAtlases atlases, GraphicsDevice device)
|
||||
{
|
||||
// Без явных стадий растение существует как одна терминальная стадия из базовой текстуры.
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
using LittleSim.Content;
|
||||
using LittleSim.Net;
|
||||
using LittleSim.Scenes;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Genetics;
|
||||
using MrGameEng.Host;
|
||||
|
||||
// Контент-линт без окна/GPU: грузит моды/дефы (патчи и валидаторы тоже), компилирует формулы генов
|
||||
// (включая группировку gsum) и выходит. Удобно для CI.
|
||||
if (args.Contains("--check-content"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = GameContent.Load();
|
||||
var genes = content.Defs.All<GeneDef>();
|
||||
var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
|
||||
var traits = Phenotype.Compute(Genome.Generate(genes, new Random(1)), registry);
|
||||
Console.WriteLine(
|
||||
$"content ok: {content.Defs.TypeKeys.Count} def types, {genes.Count} genes, "
|
||||
+ $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits "
|
||||
+ $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})"
|
||||
);
|
||||
return;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine($"content FAILED: {error.Message}");
|
||||
Environment.Exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --connect [ws://host:port] — после загрузки контента сразу в сетевую сцену
|
||||
// (см. MultiplayerScene); без аргументов — обычный запуск в главное меню.
|
||||
string? connect = null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using LittleSim.App;
|
||||
using LittleSim.Content;
|
||||
|
||||
@@ -36,29 +36,19 @@ internal static class ScatterSpawner
|
||||
|
||||
var def = content.Defs.Get<PlantDef>(entry.Options[random.Next(entry.Options.Count)]);
|
||||
var index = plants.IndexOf(def);
|
||||
var species = plants[index];
|
||||
var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize;
|
||||
var py = (cell.Y + 0.3f + random.NextSingle() * 0.4f) * cellSize;
|
||||
var age = random.NextSingle() * species.MaturityDays;
|
||||
var stage = species.StageAt(age);
|
||||
var resolved = species.Stages[stage];
|
||||
var age = random.NextSingle() * plants[index].MaturityDays;
|
||||
|
||||
var sprite = new Sprite(resolved.Region, GameLayers.Beings);
|
||||
sprite.CenterOrigin();
|
||||
scene.Store.CreateEntity(
|
||||
new Transform2D(
|
||||
new Vector2(px, py),
|
||||
scale: new Vector2(cellSize * resolved.SizeCells / resolved.Region.Width)
|
||||
),
|
||||
sprite,
|
||||
new PlantGrowth
|
||||
{
|
||||
Species = index,
|
||||
Stage = stage,
|
||||
AgeDays = age,
|
||||
CellFertility = terrain.Fertility,
|
||||
},
|
||||
PlantGenome.FromDef(def.Genome, random)
|
||||
PlantFactory.Create(
|
||||
scene.Store,
|
||||
plants,
|
||||
index,
|
||||
new Vector2(px, py),
|
||||
age,
|
||||
plants[index].Template.Generate(random),
|
||||
terrain.Fertility,
|
||||
cellSize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +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.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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
-168
@@ -1,168 +1,168 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +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.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+617
-312
@@ -1,312 +1,617 @@
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
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.Formulas;
|
||||
using MrGameEng.Genetics;
|
||||
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"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
|
||||
/// (вода — тонированным тайлом). Поверх растёт растительность с геномом (рост по свету/температуре/
|
||||
/// почве, размножение по Менделю и смерть), климат и день/ночь. UI: HUD, полоса скорости
|
||||
/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc), дев-консоль и ECS-инспектор (F1).
|
||||
/// </summary>
|
||||
public sealed class WorldScene : Scene
|
||||
{
|
||||
public const int CellSize = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько секунд масштабированного времени длится один игровой день. База (x1) — 3 игровые
|
||||
/// минуты за 1 реальную секунду: сутки = 1440 мин ÷ 3 = 480 с.
|
||||
/// </summary>
|
||||
public const float SecondsPerDay = 480f;
|
||||
|
||||
/// <summary>Максимум растений на клетку — потолок плотности для размножения.</summary>
|
||||
public const int DensityCap = 4;
|
||||
|
||||
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 = [];
|
||||
|
||||
private PlantSet _plants = null!;
|
||||
private float[] _cellFertility = [];
|
||||
private bool[] _cellLand = [];
|
||||
private bool[] _cellOccluderBase = []; // горы (статично из террейна)
|
||||
private bool[] _cellOccluder = []; // горы + зрелые деревья (пересобирается лайтмапом)
|
||||
|
||||
/// <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 = new DayNight(calendar, DayNightSettings.Default);
|
||||
|
||||
// Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
|
||||
_plants = new PlantSet(content, atlases, device);
|
||||
var loadingPlants = _save?.Plants is { Count: > 0 };
|
||||
BuildTerrain(
|
||||
content,
|
||||
atlases,
|
||||
device,
|
||||
assets,
|
||||
loadingPlants ? null : new Random(_config.Seed)
|
||||
);
|
||||
if (loadingPlants)
|
||||
{
|
||||
RestorePlants(content);
|
||||
}
|
||||
|
||||
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
|
||||
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
|
||||
var lighting = this.UseLighting(
|
||||
renderer,
|
||||
dayNight,
|
||||
_config.Width,
|
||||
_config.Height,
|
||||
CellSize,
|
||||
Vector2.Zero,
|
||||
BuildOccluders
|
||||
);
|
||||
|
||||
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);
|
||||
console.Register(
|
||||
"light",
|
||||
"light [radius] — place a point light at the cursor (night shadows demo)",
|
||||
(c, args) =>
|
||||
{
|
||||
var radius =
|
||||
args.Length > 0
|
||||
? float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture)
|
||||
: 120f;
|
||||
var mouse = Mouse.GetState();
|
||||
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
|
||||
Store.CreateEntity(
|
||||
Transform2D.At(world),
|
||||
new PointLight
|
||||
{
|
||||
Radius = radius,
|
||||
Color = Color.White,
|
||||
Intensity = 0.9f,
|
||||
}
|
||||
);
|
||||
c.WriteLine($"light at {world.X:0},{world.Y:0} r{radius:0}");
|
||||
}
|
||||
);
|
||||
|
||||
UpdateSystems.Add(new PlantGrowthSystem(_plants, calendar, climate, lighting, CellSize));
|
||||
UpdateSystems.Add(new PlantFruitingSystem(_plants, calendar, climate));
|
||||
UpdateSystems.Add(
|
||||
new PlantLifecycleSystem(
|
||||
Store,
|
||||
_plants,
|
||||
calendar,
|
||||
Context.Clock,
|
||||
_config.Width,
|
||||
_config.Height,
|
||||
CellSize,
|
||||
_cellFertility,
|
||||
_cellLand,
|
||||
DensityCap,
|
||||
_config.Seed
|
||||
)
|
||||
);
|
||||
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"/> (тайлсет из дефов рельефа + сетка по карте высот) и
|
||||
/// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если
|
||||
/// <paramref name="scatterRandom"/> задан (новый мир) — рассыпает растительность по дефам;
|
||||
/// при загрузке передаётся null (растения восстанавливаются из сейва отдельно).
|
||||
/// </summary>
|
||||
private void BuildTerrain(
|
||||
GameContent content,
|
||||
ModAtlases atlases,
|
||||
Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
|
||||
AssetManager assets,
|
||||
Random? scatterRandom
|
||||
)
|
||||
{
|
||||
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);
|
||||
var cells = _config.Width * _config.Height;
|
||||
_cellFertility = new float[cells];
|
||||
_cellLand = new bool[cells];
|
||||
_cellOccluderBase = new bool[cells];
|
||||
_cellOccluder = new bool[cells];
|
||||
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];
|
||||
var cell = y * _config.Width + x;
|
||||
_cellFertility[cell] = terrain.Fertility;
|
||||
_cellLand[cell] = terrain.IsLand;
|
||||
_cellOccluderBase[cell] = terrain.BlocksLight;
|
||||
if (scatterRandom is not null)
|
||||
{
|
||||
ScatterSpawner.Spawn(
|
||||
this,
|
||||
content,
|
||||
_plants,
|
||||
terrain,
|
||||
new Point(x, y),
|
||||
CellSize,
|
||||
scatterRandom
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном.
|
||||
Store
|
||||
.Query<PlantGrowth, PlantOrganism, Transform2D>()
|
||||
.ForEachEntity(
|
||||
(
|
||||
ref PlantGrowth grow,
|
||||
ref PlantOrganism org,
|
||||
ref Transform2D transform,
|
||||
Entity _
|
||||
) =>
|
||||
{
|
||||
save.Plants.Add(
|
||||
new PlantSave
|
||||
{
|
||||
Species = _plants[grow.Species].Def.DefName,
|
||||
X = transform.Position.X,
|
||||
Y = transform.Position.Y,
|
||||
AgeDays = grow.AgeDays,
|
||||
Stage = grow.Stage,
|
||||
CellFertility = grow.CellFertility,
|
||||
Genome = org.Genome.ToDictionary(),
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
new SaveStore().Write(save);
|
||||
Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)");
|
||||
return _config.Name;
|
||||
}
|
||||
|
||||
private void RestorePlants(GameContent content)
|
||||
{
|
||||
var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома
|
||||
foreach (var plant in _save!.Plants)
|
||||
{
|
||||
if (!content.Defs.TryGet<PlantDef>(plant.Species, out var def))
|
||||
{
|
||||
continue; // вид пропал (мод убрали) — пропускаем растение
|
||||
}
|
||||
|
||||
var index = _plants.IndexOf(def);
|
||||
var genome = plant.Genome is { Count: > 0 }
|
||||
? new Genome(plant.Genome)
|
||||
: _plants[index].Template.Generate(fallback);
|
||||
PlantFactory.Create(
|
||||
Store,
|
||||
_plants,
|
||||
index,
|
||||
new Vector2(plant.X, plant.Y),
|
||||
plant.AgeDays,
|
||||
genome,
|
||||
plant.CellFertility,
|
||||
CellSize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
|
||||
private bool[] BuildOccluders()
|
||||
{
|
||||
Array.Copy(_cellOccluderBase, _cellOccluder, _cellOccluder.Length);
|
||||
Store
|
||||
.Query<PlantGrowth, Transform2D>()
|
||||
.ForEachEntity(
|
||||
(ref PlantGrowth grow, ref Transform2D transform, Entity _) =>
|
||||
{
|
||||
var species = _plants[grow.Species];
|
||||
if (
|
||||
species.Def.TrunkRadiusCells <= 0f
|
||||
|| grow.Stage < species.Stages.Length - 1
|
||||
)
|
||||
{
|
||||
return; // затеняют только зрелые деревья (со стволом)
|
||||
}
|
||||
|
||||
var cx = Math.Clamp(
|
||||
(int)(transform.Position.X / CellSize),
|
||||
0,
|
||||
_config.Width - 1
|
||||
);
|
||||
var cy = Math.Clamp(
|
||||
(int)(transform.Position.Y / CellSize),
|
||||
0,
|
||||
_config.Height - 1
|
||||
);
|
||||
_cellOccluder[cy * _config.Width + cx] = true;
|
||||
}
|
||||
);
|
||||
return _cellOccluder;
|
||||
}
|
||||
|
||||
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(
|
||||
"formula",
|
||||
"formula <expr> — compile and evaluate an expression (gene-formula engine demo)",
|
||||
(c, args) =>
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
c.WriteLine(
|
||||
"usage: formula <expr> e.g. formula clamp(lerp(0, 10, 0.5), 0, 8)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var expression = string.Join(' ', args);
|
||||
try
|
||||
{
|
||||
c.WriteLine($"{expression} = {Formula.Compile(expression).Evaluate()}");
|
||||
}
|
||||
catch (FormulaException e)
|
||||
{
|
||||
c.WriteLine($"error: {e.Message}");
|
||||
}
|
||||
}
|
||||
);
|
||||
console.Register(
|
||||
"gene",
|
||||
"gene [seed] — generate a genome from Gene defs, show alleles/traits and a bred child",
|
||||
(c, args) => RunGeneDemo(c, content, args)
|
||||
);
|
||||
console.Register(
|
||||
"plant",
|
||||
"plant <species> [seed] — sample a species genome and show its gene-driven traits/products",
|
||||
(c, args) => RunPlantDemo(c, content, args)
|
||||
);
|
||||
console.Register(
|
||||
"menu",
|
||||
"menu — return to the main menu",
|
||||
(_, _) => Switch(new MainMenuScene())
|
||||
);
|
||||
}
|
||||
|
||||
// Демонстрация генной системы (фаза G2): из Gene-дефов генерируем геном, печатаем аллели,
|
||||
// выраженные значения и признаки (вычисленные формулами), затем скрещиваем двух особей.
|
||||
private static void RunGeneDemo(DevConsole console, GameContent content, string[] args)
|
||||
{
|
||||
var genes = content.Defs.All<GeneDef>();
|
||||
if (genes.Count == 0)
|
||||
{
|
||||
console.WriteLine("no Gene defs loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
var registry = genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
|
||||
var seed = args.Length > 0 ? int.Parse(args[0]) : Random.Shared.Next();
|
||||
var random = new Random(seed);
|
||||
|
||||
console.WriteLine($"genome from {genes.Count} genes, seed {seed}:");
|
||||
var parentA = Genome.Generate(genes, random);
|
||||
foreach (var gene in genes)
|
||||
{
|
||||
var allele = parentA[gene.DefName];
|
||||
console.WriteLine(
|
||||
$" {gene.DefName}: [{allele.A:0.##}, {allele.B:0.##}] -> {parentA.Express(gene):0.###}"
|
||||
);
|
||||
}
|
||||
|
||||
console.WriteLine("traits:");
|
||||
foreach (var (trait, value) in Phenotype.Compute(parentA, registry).OrderBy(t => t.Key))
|
||||
{
|
||||
console.WriteLine($" {trait} = {value:0.###}");
|
||||
}
|
||||
|
||||
var parentB = Genome.Generate(genes, random);
|
||||
var child = Genome.Breed(parentA, parentB, registry, random);
|
||||
var childTraits = Phenotype.Compute(child, registry);
|
||||
console.WriteLine(
|
||||
$"bred child: {child.Alleles.Count} genes, "
|
||||
+ $"vigor={childTraits.GetValueOrDefault("vigor"):0.###}, "
|
||||
+ $"lifespan={childTraits.GetValueOrDefault("lifespan"):0.#}, "
|
||||
+ $"variant={childTraits.GetValueOrDefault("variant"):0}"
|
||||
);
|
||||
}
|
||||
|
||||
// Демонстрация генного контента (фаза G4): по виду берём его набор генов, генерируем особь и
|
||||
// печатаем признаки роста/жизни и продукты (плоды/добыча с количеством от генов).
|
||||
private void RunPlantDemo(DevConsole console, GameContent content, string[] args)
|
||||
{
|
||||
var species = content.Defs.NamesOf("Plant");
|
||||
if (args.Length == 0)
|
||||
{
|
||||
console.WriteLine("usage: plant <species> [seed] e.g. plant TreeOakA");
|
||||
console.WriteLine($"species: {string.Join(", ", species)}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.Defs.TryGet<PlantDef>(args[0], out var def))
|
||||
{
|
||||
console.WriteLine($"no plant '{args[0]}'; species: {string.Join(", ", species)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var index = _plants.IndexOf(def);
|
||||
var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next();
|
||||
var genome = _plants[index].Template.Generate(new Random(seed));
|
||||
var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, _plants.GeneRegistry));
|
||||
|
||||
console.WriteLine(
|
||||
$"{def.DefName} (seed {seed}){(traits.IsVariant ? " [variant morph]" : "")}:"
|
||||
);
|
||||
console.WriteLine(
|
||||
$" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, "
|
||||
+ $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}"
|
||||
);
|
||||
|
||||
if (def.HarvestProduct is { } harvest)
|
||||
{
|
||||
console.WriteLine(
|
||||
$" harvest: {ProductLabel(content, harvest)} x{traits.HarvestAmount:0.#}"
|
||||
);
|
||||
}
|
||||
|
||||
if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f)
|
||||
{
|
||||
var season = content.Languages.Get(
|
||||
$"season.{((Season)traits.FruitSeason).ToString().ToLowerInvariant()}"
|
||||
);
|
||||
console.WriteLine(
|
||||
$" fruit: {ProductLabel(content, fruit)} x{traits.FruitYield:0.#} in {season}"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.WriteLine(" barren (no fruit)");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProductLabel(GameContent content, string productDefName) =>
|
||||
content.Defs.TryGet<ProductDef>(productDefName, out var product)
|
||||
? content.Languages.Get(product.Label)
|
||||
: productDefName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using LittleSim.Content;
|
||||
using LittleSim.Scenes;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Genetics;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace LittleSim.Sim;
|
||||
|
||||
/// <summary>
|
||||
/// Единая точка создания сущности-растения: спрайт стадии по возрасту, признаки из генома формулами
|
||||
/// генов, тинт по морфе и компоненты роста/организма. Переиспользуется начальным скаттером,
|
||||
/// размножением и загрузкой сейва — чтобы все растения собирались одинаково.
|
||||
/// </summary>
|
||||
public static class PlantFactory
|
||||
{
|
||||
// Тинт рецессивной морфы (домножается на текстуру) — делает вариант визуально отличимым.
|
||||
private static readonly Color VariantTint = new(214, 150, 176);
|
||||
|
||||
// Якоря оттенка листвы: тёплый жёлто-зелёный → холодный сине-зелёный (по гену leafHue).
|
||||
private static readonly Color LeafWarm = new(170, 200, 90);
|
||||
private static readonly Color LeafCool = new(90, 170, 150);
|
||||
|
||||
/// <summary>Создаёт растение вида <paramref name="species"/> с заданным возрастом и геномом.</summary>
|
||||
public static Entity Create(
|
||||
EntityStore store,
|
||||
PlantSet plants,
|
||||
int species,
|
||||
Vector2 position,
|
||||
float ageDays,
|
||||
Genome genome,
|
||||
float cellFertility,
|
||||
int cellSize
|
||||
)
|
||||
{
|
||||
var sp = plants[species];
|
||||
var stage = sp.StageAt(ageDays);
|
||||
var resolved = sp.Stages[stage];
|
||||
var traits = PlantPhenotype.FromTraits(Phenotype.Compute(genome, plants.GeneRegistry));
|
||||
|
||||
var sprite = new Sprite(resolved.Region, GameLayers.Beings);
|
||||
sprite.CenterOrigin();
|
||||
sprite.Color = Tint(traits);
|
||||
|
||||
return store.CreateEntity(
|
||||
new Transform2D(
|
||||
position,
|
||||
scale: new Vector2(cellSize * resolved.SizeCells / resolved.Region.Width)
|
||||
),
|
||||
sprite,
|
||||
new PlantGrowth
|
||||
{
|
||||
Species = species,
|
||||
Stage = stage,
|
||||
AgeDays = ageDays,
|
||||
CellFertility = cellFertility,
|
||||
},
|
||||
new PlantOrganism { Genome = genome, Traits = traits },
|
||||
new Fruiting()
|
||||
);
|
||||
}
|
||||
|
||||
// Цвет растения из генов: лёгкий оттенок листвы (ген leafHue) поверх текстуры; рецессивная морфа
|
||||
// дополнительно сдвигает тинт в свою сторону.
|
||||
private static Color Tint(in PlantPhenotype traits)
|
||||
{
|
||||
var leaf = Color.Lerp(LeafWarm, LeafCool, Math.Clamp(traits.LeafHue, 0f, 1f));
|
||||
var tint = Color.Lerp(Color.White, leaf, 0.35f);
|
||||
return traits.IsVariant ? Color.Lerp(tint, VariantTint, 0.6f) : tint;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using LittleSim.Content;
|
||||
using MrGameEng.AI;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace LittleSim.Sim;
|
||||
|
||||
/// <summary>
|
||||
/// Плодоношение (фаза G4): у зрелого растения с признаком <see cref="PlantPhenotype.FruitYield"/> > 0
|
||||
/// в его генетический сезон (<see cref="PlantPhenotype.FruitSeason"/>) копятся зрелые плоды до потолка
|
||||
/// <see cref="PlantPhenotype.FruitYield"/>; вне сезона плоды опадают. Полностью управляется генами через
|
||||
/// признаки — система лишь применяет их к компоненту <see cref="Fruiting"/>. Незрелые/бесплодные
|
||||
/// растения остаются с нулём.
|
||||
/// </summary>
|
||||
public sealed class PlantFruitingSystem(PlantSet plants, Calendar calendar, Climate climate)
|
||||
: QuerySystem<PlantGrowth, PlantOrganism, Fruiting>
|
||||
{
|
||||
// За сколько игровых дней набирается/опадает полный урожай.
|
||||
private const float RipenDays = 10f;
|
||||
private const float DropDays = 20f;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var deltaDays = Tick.deltaTime / calendar.SecondsPerDay;
|
||||
if (deltaDays <= 0f)
|
||||
{
|
||||
return; // пауза
|
||||
}
|
||||
|
||||
var season = (int)climate.Season;
|
||||
|
||||
foreach (var (growths, organisms, fruits, _) in Query.Chunks)
|
||||
{
|
||||
var g = growths.Span;
|
||||
var orgs = organisms.Span;
|
||||
var f = fruits.Span;
|
||||
for (var i = 0; i < g.Length; i++)
|
||||
{
|
||||
ref var traits = ref orgs[i].Traits;
|
||||
var mature = g[i].Stage >= plants[g[i].Species].Stages.Length - 1;
|
||||
if (!mature || traits.FruitYield < 1f)
|
||||
{
|
||||
f[i].RipeFruit = 0f; // незрелое или бесплодное — плодов нет
|
||||
continue;
|
||||
}
|
||||
|
||||
ref var ripe = ref f[i].RipeFruit;
|
||||
if (season == traits.FruitSeason)
|
||||
{
|
||||
ripe = MathF.Min(
|
||||
traits.FruitYield,
|
||||
ripe + deltaDays * (traits.FruitYield / RipenDays)
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
ripe = MathF.Max(0f, ripe - deltaDays * (traits.FruitYield / DropDays));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using LittleSim.Content;
|
||||
|
||||
namespace LittleSim.Sim;
|
||||
|
||||
/// <summary>
|
||||
/// Диплоидный геном растения: по паре аллелей на ген. Для числовых генов экспрессия гибридная —
|
||||
/// фенотип есть среднее двух аллелей (дискретные доминант/рецессивные гены добавятся с размножением).
|
||||
/// Аллели — наследуемые данные (правятся в инспекторе); фенотип-свойства читает система роста.
|
||||
/// </summary>
|
||||
public struct PlantGenome : IComponent
|
||||
{
|
||||
/// <summary>Аллели оптимума освещённости (0..1).</summary>
|
||||
public float OptimalLightA;
|
||||
public float OptimalLightB;
|
||||
|
||||
/// <summary>Аллели толерантности по свету.</summary>
|
||||
public float LightToleranceA;
|
||||
public float LightToleranceB;
|
||||
|
||||
/// <summary>Аллели оптимума температуры (°C).</summary>
|
||||
public float OptimalTemperatureA;
|
||||
public float OptimalTemperatureB;
|
||||
|
||||
/// <summary>Аллели толерантности по температуре (°C).</summary>
|
||||
public float TemperatureToleranceA;
|
||||
public float TemperatureToleranceB;
|
||||
|
||||
/// <summary>Аллели оптимума плодородности.</summary>
|
||||
public float OptimalFertilityA;
|
||||
public float OptimalFertilityB;
|
||||
|
||||
/// <summary>Аллели толерантности по плодородности.</summary>
|
||||
public float FertilityToleranceA;
|
||||
public float FertilityToleranceB;
|
||||
|
||||
/// <summary>Аллели бодрости роста.</summary>
|
||||
public float VigorA;
|
||||
public float VigorB;
|
||||
|
||||
/// <summary>Фенотип: оптимальная освещённость.</summary>
|
||||
public readonly float OptimalLight => (OptimalLightA + OptimalLightB) * 0.5f;
|
||||
|
||||
/// <summary>Фенотип: толерантность по свету.</summary>
|
||||
public readonly float LightTolerance => (LightToleranceA + LightToleranceB) * 0.5f;
|
||||
|
||||
/// <summary>Фенотип: оптимальная температура.</summary>
|
||||
public readonly float OptimalTemperature => (OptimalTemperatureA + OptimalTemperatureB) * 0.5f;
|
||||
|
||||
/// <summary>Фенотип: толерантность по температуре.</summary>
|
||||
public readonly float TemperatureTolerance =>
|
||||
(TemperatureToleranceA + TemperatureToleranceB) * 0.5f;
|
||||
|
||||
/// <summary>Фенотип: оптимальная плодородность.</summary>
|
||||
public readonly float OptimalFertility => (OptimalFertilityA + OptimalFertilityB) * 0.5f;
|
||||
|
||||
/// <summary>Фенотип: толерантность по плодородности.</summary>
|
||||
public readonly float FertilityTolerance => (FertilityToleranceA + FertilityToleranceB) * 0.5f;
|
||||
|
||||
/// <summary>Фенотип: бодрость роста.</summary>
|
||||
public readonly float Vigor => (VigorA + VigorB) * 0.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Генерирует особь из базового генома вида: каждый аллель — база ± относительный разброс
|
||||
/// (<see cref="GenomeDef.Spread"/>). Сидируется переданным <paramref name="random"/> — детерминизм.
|
||||
/// </summary>
|
||||
public static PlantGenome FromDef(GenomeDef def, Random random)
|
||||
{
|
||||
float Allele(float baseValue) =>
|
||||
baseValue + (random.NextSingle() * 2f - 1f) * def.Spread * baseValue;
|
||||
|
||||
return new PlantGenome
|
||||
{
|
||||
OptimalLightA = Allele(def.OptimalLight),
|
||||
OptimalLightB = Allele(def.OptimalLight),
|
||||
LightToleranceA = Allele(def.LightTolerance),
|
||||
LightToleranceB = Allele(def.LightTolerance),
|
||||
OptimalTemperatureA = Allele(def.OptimalTemperature),
|
||||
OptimalTemperatureB = Allele(def.OptimalTemperature),
|
||||
TemperatureToleranceA = Allele(def.TemperatureTolerance),
|
||||
TemperatureToleranceB = Allele(def.TemperatureTolerance),
|
||||
OptimalFertilityA = Allele(def.OptimalFertility),
|
||||
OptimalFertilityB = Allele(def.OptimalFertility),
|
||||
FertilityToleranceA = Allele(def.FertilityTolerance),
|
||||
FertilityToleranceB = Allele(def.FertilityTolerance),
|
||||
VigorA = Allele(def.Vigor),
|
||||
VigorB = Allele(def.Vigor),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,18 @@ namespace LittleSim.Sim;
|
||||
/// <summary>
|
||||
/// Двигает рост растений по игровому календарю: прирост возраста = пригодность среды для генов
|
||||
/// растения. Скорость = <c>бодрость · пригодность(свет) · пригодность(температура) ·
|
||||
/// пригодность(плодородность)</c>, каждая пригодность — гауссов «колокол» вокруг оптимума из генов
|
||||
/// (см. <see cref="PlantGenome"/>). Свет/температура в фазе B глобальные (день/ночь и сезон); стадии
|
||||
/// и спрайт переключаются по накопленному возрасту как прежде. Терминальную стадию пропускает.
|
||||
/// пригодность(плодородность)</c>, каждая пригодность — гауссов «колокол» вокруг оптимума из
|
||||
/// признаков (<see cref="PlantPhenotype"/>), вычисленных формулами генов. Свет локальный (лайтмап),
|
||||
/// температура глобальная (сезон/день-ночь); стадии и спрайт переключаются по накопленному
|
||||
/// возрасту как прежде. Терминальную стадию пропускает.
|
||||
/// </summary>
|
||||
public sealed class PlantGrowthSystem(
|
||||
PlantSet plants,
|
||||
Calendar calendar,
|
||||
Climate climate,
|
||||
DayNight dayNight,
|
||||
Lighting lighting,
|
||||
int cellSize
|
||||
) : QuerySystem<PlantGrowth, PlantGenome, Sprite, Transform2D>
|
||||
) : QuerySystem<PlantGrowth, PlantOrganism, Sprite, Transform2D>
|
||||
{
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
@@ -31,14 +32,13 @@ public sealed class PlantGrowthSystem(
|
||||
return; // пауза — рост стоит
|
||||
}
|
||||
|
||||
// В фазе B свет и температура глобальные — считаем раз за кадр (фаза D даст локальный свет).
|
||||
var light = dayNight.Intensity;
|
||||
// Температура глобальная; свет — локальный (лайтмап: подлесок под кронами темнее).
|
||||
var temperature = climate.Temperature;
|
||||
|
||||
foreach (var (growths, genomes, sprites, transforms, _) in Query.Chunks)
|
||||
foreach (var (growths, organisms, sprites, transforms, _) in Query.Chunks)
|
||||
{
|
||||
var g = growths.Span;
|
||||
var dna = genomes.Span;
|
||||
var orgs = organisms.Span;
|
||||
var s = sprites.Span;
|
||||
var t = transforms.Span;
|
||||
for (var i = 0; i < g.Length; i++)
|
||||
@@ -47,22 +47,24 @@ public sealed class PlantGrowthSystem(
|
||||
var stages = plants[grow.Species].Stages;
|
||||
if (grow.Stage >= stages.Length - 1)
|
||||
{
|
||||
continue; // уже зрелое — рост окончен
|
||||
grow.AgeDays += deltaDays; // зрелое: старение в реальном времени → смерть по старости
|
||||
continue;
|
||||
}
|
||||
|
||||
ref var gene = ref dna[i];
|
||||
ref var traits = ref orgs[i].Traits;
|
||||
var light = lighting.SampleAt(t[i].Position);
|
||||
var rate =
|
||||
gene.Vigor
|
||||
* Suitability.Gaussian(light, gene.OptimalLight, gene.LightTolerance)
|
||||
traits.Vigor
|
||||
* Suitability.Gaussian(light, traits.OptimalLight, traits.LightTolerance)
|
||||
* Suitability.Gaussian(
|
||||
temperature,
|
||||
gene.OptimalTemperature,
|
||||
gene.TemperatureTolerance
|
||||
traits.OptimalTemperature,
|
||||
traits.TemperatureTolerance
|
||||
)
|
||||
* Suitability.Gaussian(
|
||||
grow.CellFertility,
|
||||
gene.OptimalFertility,
|
||||
gene.FertilityTolerance
|
||||
traits.OptimalFertility,
|
||||
traits.FertilityTolerance
|
||||
);
|
||||
|
||||
grow.AgeDays += deltaDays * rate;
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using LittleSim.Content;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Genetics;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace LittleSim.Sim;
|
||||
|
||||
/// <summary>
|
||||
/// Жизненный цикл растений: размножение (наследование по Менделю) и смерть от старости. Запускается
|
||||
/// не каждый кадр, а раз в <see cref="IntervalDays"/> игрового дня. За тик: строит по-клеточную сетку
|
||||
/// (счётчик плотности + «представитель» вида/генома), собирает умерших по возрасту, затем зрелые с
|
||||
/// вероятностью <c>elapsed/ReproduceInterval</c> сеют потомка — приоритет перекрёстного опыления
|
||||
/// (зрелый сосед того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в
|
||||
/// случайную клетку радиуса, если это суша и не превышен лимит плотности. Структурные изменения
|
||||
/// (создание/удаление сущностей) применяются после проходов по запросу — это безопасно.
|
||||
/// </summary>
|
||||
public sealed class PlantLifecycleSystem : BaseSystem
|
||||
{
|
||||
private const float IntervalDays = 0.25f;
|
||||
|
||||
private readonly EntityStore _store;
|
||||
private readonly PlantSet _plants;
|
||||
private readonly Calendar _calendar;
|
||||
private readonly GameClock _clock;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly int _cellSize;
|
||||
private readonly float[] _cellFertility;
|
||||
private readonly bool[] _cellLand;
|
||||
private readonly int _densityCap;
|
||||
private readonly Random _rng;
|
||||
private readonly ArchetypeQuery<PlantGrowth, PlantOrganism, Transform2D> _query;
|
||||
|
||||
private readonly int[] _count;
|
||||
private readonly CellRep[] _rep;
|
||||
private readonly List<Entity> _deaths = [];
|
||||
private readonly List<Candidate> _candidates = [];
|
||||
private readonly List<Birth> _births = [];
|
||||
private float _accumulator;
|
||||
|
||||
public PlantLifecycleSystem(
|
||||
EntityStore store,
|
||||
PlantSet plants,
|
||||
Calendar calendar,
|
||||
GameClock clock,
|
||||
int width,
|
||||
int height,
|
||||
int cellSize,
|
||||
float[] cellFertility,
|
||||
bool[] cellLand,
|
||||
int densityCap,
|
||||
int seed
|
||||
)
|
||||
{
|
||||
_store = store;
|
||||
_plants = plants;
|
||||
_calendar = calendar;
|
||||
_clock = clock;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_cellSize = cellSize;
|
||||
_cellFertility = cellFertility;
|
||||
_cellLand = cellLand;
|
||||
_densityCap = densityCap;
|
||||
_rng = new Random(seed);
|
||||
_query = store.Query<PlantGrowth, PlantOrganism, Transform2D>();
|
||||
_count = new int[width * height];
|
||||
_rep = new CellRep[width * height];
|
||||
}
|
||||
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
_accumulator += _clock.DeltaTime / _calendar.SecondsPerDay; // прошло игровых дней (с учётом паузы/скорости)
|
||||
if (_accumulator < IntervalDays)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsed = _accumulator;
|
||||
_accumulator = 0f;
|
||||
|
||||
BuildGridAndCollect();
|
||||
Reproduce(elapsed);
|
||||
Apply();
|
||||
}
|
||||
|
||||
// Проход 1: сетка плотности + представители, сбор умерших и зрелых-кандидатов.
|
||||
private void BuildGridAndCollect()
|
||||
{
|
||||
Array.Clear(_count);
|
||||
for (var c = 0; c < _rep.Length; c++)
|
||||
{
|
||||
_rep[c].Species = -1;
|
||||
}
|
||||
|
||||
_deaths.Clear();
|
||||
_candidates.Clear();
|
||||
_births.Clear();
|
||||
|
||||
foreach (var (growths, organisms, transforms, entities) in _query.Chunks)
|
||||
{
|
||||
var g = growths.Span;
|
||||
var orgs = organisms.Span;
|
||||
var t = transforms.Span;
|
||||
for (var i = 0; i < g.Length; i++)
|
||||
{
|
||||
ref var grow = ref g[i];
|
||||
ref var org = ref orgs[i];
|
||||
if (grow.AgeDays > org.Traits.Lifespan)
|
||||
{
|
||||
_deaths.Add(entities.EntityAt(i)); // умер от старости
|
||||
continue;
|
||||
}
|
||||
|
||||
var pos = t[i].Position;
|
||||
var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1);
|
||||
var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1);
|
||||
var cell = cy * _width + cx;
|
||||
_count[cell]++;
|
||||
|
||||
var mature = grow.Stage >= _plants[grow.Species].Stages.Length - 1;
|
||||
_rep[cell] = new CellRep
|
||||
{
|
||||
Species = grow.Species,
|
||||
Mature = mature,
|
||||
Genome = org.Genome,
|
||||
MutationRate = org.Traits.MutationRate,
|
||||
};
|
||||
if (mature)
|
||||
{
|
||||
_candidates.Add(
|
||||
new Candidate
|
||||
{
|
||||
Cell = cell,
|
||||
Species = grow.Species,
|
||||
Genome = org.Genome,
|
||||
Traits = org.Traits,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проход 2: зрелые сеют потомка (перекрёстно или само-) в подходящую клетку радиуса.
|
||||
private void Reproduce(float elapsed)
|
||||
{
|
||||
foreach (var cand in _candidates)
|
||||
{
|
||||
var interval = MathF.Max(0.5f, cand.Traits.ReproduceInterval);
|
||||
if (_rng.NextSingle() >= elapsed / interval)
|
||||
{
|
||||
continue; // в этот тик не сеет
|
||||
}
|
||||
|
||||
var range = Math.Clamp((int)MathF.Round(cand.Traits.DispersalRange), 1, 6);
|
||||
var cx = cand.Cell % _width;
|
||||
var cy = cand.Cell / _width;
|
||||
|
||||
if (!TryFindPartner(cx, cy, range, cand.Species, out var partner))
|
||||
{
|
||||
if (_rng.NextSingle() >= cand.Traits.SelfPollination)
|
||||
{
|
||||
continue; // нет партнёра и самоопыление не удалось
|
||||
}
|
||||
|
||||
// самоопыление — партнёр это сам кандидат
|
||||
partner = new Partner
|
||||
{
|
||||
Genome = cand.Genome,
|
||||
MutationRate = cand.Traits.MutationRate,
|
||||
};
|
||||
}
|
||||
|
||||
if (!TryPickTarget(cx, cy, range, out var tcell))
|
||||
{
|
||||
continue; // некуда сеять (нет суши/свободного места рядом)
|
||||
}
|
||||
|
||||
// Темп мутаций потомка — средний эволюционирующий признак родителей (ген управляет мутацией).
|
||||
var mutationChance = (cand.Traits.MutationRate + partner.MutationRate) * 0.5f;
|
||||
var child = Genome.Breed(
|
||||
cand.Genome,
|
||||
partner.Genome,
|
||||
_plants.GeneRegistry,
|
||||
_rng,
|
||||
mutationChance
|
||||
);
|
||||
var tx = tcell % _width;
|
||||
var ty = tcell / _width;
|
||||
var position = new Vector2(
|
||||
(tx + 0.3f + _rng.NextSingle() * 0.4f) * _cellSize,
|
||||
(ty + 0.3f + _rng.NextSingle() * 0.4f) * _cellSize
|
||||
);
|
||||
_births.Add(
|
||||
new Birth
|
||||
{
|
||||
Species = cand.Species,
|
||||
Position = position,
|
||||
Genome = child,
|
||||
Fertility = _cellFertility[tcell],
|
||||
}
|
||||
);
|
||||
_count[tcell]++; // резервируем место, чтобы не переполнить клетку за тик
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFindPartner(int cx, int cy, int range, int species, out Partner partner)
|
||||
{
|
||||
for (var dy = -range; dy <= range; dy++)
|
||||
{
|
||||
for (var dx = -range; dx <= range; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0)
|
||||
{
|
||||
continue; // партнёр — другая клетка (приоритет перекрёстного)
|
||||
}
|
||||
|
||||
var nx = cx + dx;
|
||||
var ny = cy + dy;
|
||||
if (nx < 0 || nx >= _width || ny < 0 || ny >= _height)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ref var rep = ref _rep[ny * _width + nx];
|
||||
if (rep.Species == species && rep.Mature)
|
||||
{
|
||||
partner = new Partner { Genome = rep.Genome, MutationRate = rep.MutationRate };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partner = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryPickTarget(int cx, int cy, int range, out int cell)
|
||||
{
|
||||
for (var attempt = 0; attempt < 6; attempt++)
|
||||
{
|
||||
var tx = cx + _rng.Next(-range, range + 1);
|
||||
var ty = cy + _rng.Next(-range, range + 1);
|
||||
if (tx < 0 || tx >= _width || ty < 0 || ty >= _height)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = ty * _width + tx;
|
||||
if (_cellLand[candidate] && _count[candidate] < _densityCap)
|
||||
{
|
||||
cell = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
cell = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
foreach (var entity in _deaths)
|
||||
{
|
||||
entity.DeleteEntity();
|
||||
}
|
||||
|
||||
foreach (var birth in _births)
|
||||
{
|
||||
PlantFactory.Create(
|
||||
_store,
|
||||
_plants,
|
||||
birth.Species,
|
||||
birth.Position,
|
||||
ageDays: 0f,
|
||||
birth.Genome,
|
||||
birth.Fertility,
|
||||
_cellSize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private struct CellRep
|
||||
{
|
||||
public int Species;
|
||||
public bool Mature;
|
||||
public Genome Genome;
|
||||
public float MutationRate;
|
||||
}
|
||||
|
||||
private struct Candidate
|
||||
{
|
||||
public int Cell;
|
||||
public int Species;
|
||||
public Genome Genome;
|
||||
public PlantPhenotype Traits;
|
||||
}
|
||||
|
||||
private struct Partner
|
||||
{
|
||||
public Genome Genome;
|
||||
public float MutationRate;
|
||||
}
|
||||
|
||||
private struct Birth
|
||||
{
|
||||
public int Species;
|
||||
public Vector2 Position;
|
||||
public Genome Genome;
|
||||
public float Fertility;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Generic;
|
||||
using Friflo.Engine.ECS;
|
||||
using MrGameEng.Genetics;
|
||||
|
||||
namespace LittleSim.Sim;
|
||||
|
||||
/// <summary>
|
||||
/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>genes.json</c>):
|
||||
/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую.
|
||||
/// Имена полей соответствуют именам признаков в эффектах генов.
|
||||
/// </summary>
|
||||
public struct PlantPhenotype
|
||||
{
|
||||
public float OptimalLight;
|
||||
public float LightTolerance;
|
||||
public float OptimalTemperature;
|
||||
public float TemperatureTolerance;
|
||||
public float OptimalFertility;
|
||||
public float FertilityTolerance;
|
||||
public float Vigor;
|
||||
public float Lifespan;
|
||||
public float DispersalRange;
|
||||
public float ReproduceInterval;
|
||||
public float SelfPollination;
|
||||
public float MutationRate;
|
||||
|
||||
// --- Контент (G4): плодоношение, добыча, цвет ---
|
||||
public float FruitYield;
|
||||
public int FruitSeason;
|
||||
public float HarvestAmount;
|
||||
public float LeafHue;
|
||||
|
||||
/// <summary>Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта.</summary>
|
||||
public bool IsVariant;
|
||||
|
||||
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
|
||||
public static PlantPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
|
||||
{
|
||||
float T(string name) => traits.GetValueOrDefault(name);
|
||||
return new PlantPhenotype
|
||||
{
|
||||
OptimalLight = T("optimalLight"),
|
||||
LightTolerance = T("lightTolerance"),
|
||||
OptimalTemperature = T("optimalTemperature"),
|
||||
TemperatureTolerance = T("temperatureTolerance"),
|
||||
OptimalFertility = T("optimalFertility"),
|
||||
FertilityTolerance = T("fertilityTolerance"),
|
||||
Vigor = T("vigor"),
|
||||
Lifespan = T("lifespan"),
|
||||
DispersalRange = T("dispersalRange"),
|
||||
ReproduceInterval = T("reproduceInterval"),
|
||||
SelfPollination = T("selfPollination"),
|
||||
MutationRate = T("mutationRate"),
|
||||
FruitYield = T("fruitYield"),
|
||||
FruitSeason = (int)MathF.Round(Math.Clamp(T("fruitSeason"), 0f, 3f)),
|
||||
HarvestAmount = T("harvestAmount"),
|
||||
LeafHue = T("leafHue"),
|
||||
IsVariant = T("variant") >= 0.5f,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Компонент-организм растения: его управляемый <see cref="Genome"/> (для размножения, сейва и
|
||||
/// пересчёта) и выраженный фенотип <see cref="Traits"/>, по которому работают системы. Заменяет
|
||||
/// прежний фиксированный <c>PlantGenome</c> — теперь геном переменного состава и признаки из формул
|
||||
/// генов (фаза G3). Геном — ссылочный объект, поэтому компонент несёт ссылку, а не копию.
|
||||
/// </summary>
|
||||
public struct PlantOrganism : IComponent
|
||||
{
|
||||
/// <summary>Геном особи (аллели по генам); общий источник истины для признаков и размножения.</summary>
|
||||
public Genome Genome;
|
||||
|
||||
/// <summary>Выраженные признаки — то, что читают системы роста/жизненного цикла.</summary>
|
||||
public PlantPhenotype Traits;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Плодоношение растения: сколько зрелых плодов сейчас на нём. Копится у зрелого растения в его
|
||||
/// сезон плодоношения (признак <see cref="PlantPhenotype.FruitSeason"/>) до потолка
|
||||
/// <see cref="PlantPhenotype.FruitYield"/>, вне сезона опадает. Управляется <c>PlantFruitingSystem</c>.
|
||||
/// </summary>
|
||||
public struct Fruiting : IComponent
|
||||
{
|
||||
/// <summary>Накоплено зрелых плодов (0..FruitYield).</summary>
|
||||
public float RipeFruit;
|
||||
}
|
||||
Reference in New Issue
Block a user