From a04e867e873bed6c98b77f0e726a0f5e51fb0801 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 18:21:38 +0300 Subject: [PATCH] Phase C: plant reproduction (Mendel), death and persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plants now live full lives. The genome gains lifespan, dispersal, fecundity, self-pollination, mutation-rate and a discrete dominant/recessive Morph gene; PlantGenome.Breed models meiosis (one allele per parent) plus mutation. A gated PlantLifecycleSystem builds a per-cell grid (density + a species/genome representative), reaps plants past their lifespan, and lets mature plants seed offspring — cross-pollinating with a mature same-species neighbour in dispersal range (falling back to self-pollination by gene) into a nearby land cell under the density cap. A shared PlantFactory creates every plant (initial scatter, births, save restore) and tints recessive-morph plants. Growth keeps aging mature plants so they can die of old age. The whole population (genomes, positions, age, stage) is saved to WorldSave and restored on load instead of re-scattering from the seed. plants.json carries the new genes. Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/plants.json | 12 +- src/LittleSim/App/WorldSave.cs | 30 +++ src/LittleSim/Content/GameDefs.cs | 18 ++ src/LittleSim/Scenes/ContentHelpers.cs | 30 +-- src/LittleSim/Scenes/WorldScene.cs | 129 ++++++++-- src/LittleSim/Sim/PlantFactory.cs | 55 ++++ src/LittleSim/Sim/PlantGenome.cs | 117 +++++++-- src/LittleSim/Sim/PlantGrowthSystem.cs | 3 +- src/LittleSim/Sim/PlantLifecycleSystem.cs | 292 ++++++++++++++++++++++ 9 files changed, 621 insertions(+), 65 deletions(-) create mode 100644 src/LittleSim/Sim/PlantFactory.cs create mode 100644 src/LittleSim/Sim/PlantLifecycleSystem.cs diff --git a/Mods/Core/Defs/plants.json b/Mods/Core/Defs/plants.json index 1a83da0..7014534 100644 --- a/Mods/Core/Defs/plants.json +++ b/Mods/Core/Defs/plants.json @@ -3,7 +3,9 @@ "defs": [ { "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28, "genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 16, - "optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0, "spread": 0.08 }, + "optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0, + "lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2, + "mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08 }, "stages": [ { "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" }, { "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" }, @@ -16,7 +18,9 @@ { "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2, "genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 20, "temperatureTolerance": 12, - "optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3, "spread": 0.1 }, + "optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3, + "lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8, + "mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1 }, "stages": [ { "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 2, "label": "plant.stage.sprout" }, { "sizeCells": 1.2, "label": "plant.stage.mature" } @@ -24,7 +28,9 @@ { "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4, "genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 13, - "optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0, "spread": 0.1 }, + "optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0, + "lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5, + "mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1 }, "stages": [ { "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 3, "label": "plant.stage.sprout" }, { "sizeCells": 1.4, "label": "plant.stage.mature" } diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs index 2b4fac9..6fe6158 100644 --- a/src/LittleSim/App/WorldSave.cs +++ b/src/LittleSim/App/WorldSave.cs @@ -1,7 +1,33 @@ using System.Text.Json; +using LittleSim.Sim; namespace LittleSim.App; +/// Сериализуемое состояние одного растения: вид (деф), позиция, возраст, стадия, почва и геном. +public sealed class PlantSave +{ + /// Имя дефа вида — по нему при загрузке берётся индекс/стадии/текстуры. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Накопленный возраст в игровых днях. + public float AgeDays { get; set; } + + /// Текущая стадия роста. + public int Stage { get; set; } + + /// Плодородность клетки. + public float CellFertility { get; set; } + + /// Геном особи (аллели сериализуются как поля структуры). + public PlantGenome Genome { get; set; } +} + /// Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по ). public sealed class PawnSave { @@ -70,6 +96,9 @@ public sealed class WorldSave /// Снимок жителей. public List Pawns { get; set; } = []; + /// Снимок всех растений (геномы, позиции, возраст, стадия). + public List Plants { get; set; } = []; + /// Конфиг мира для пересоздания сцены. public WorldConfig ToConfig() => new() @@ -90,6 +119,7 @@ public sealed class SaveStore { WriteIndented = true, PropertyNameCaseInsensitive = true, + IncludeFields = true, // аллели генома (PlantGenome) — публичные поля структуры }; private readonly string _directory; diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index a33e1d7..41c8de0 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -91,6 +91,24 @@ public sealed class GenomeDef /// Базовая бодрость роста (множитель скорости в идеальных условиях). public float Vigor { get; init; } = 1f; + /// Продолжительность жизни (игровых дней) до смерти от старости. + public float Lifespan { get; init; } = 80f; + + /// Дальность расселения семян (в клетках). + public float DispersalRange { get; init; } = 2f; + + /// Средний интервал между попытками дать семя (игровых дней). + public float ReproduceInterval { get; init; } = 8f; + + /// Способность к самоопылению (0..1): шанс дать семя без партнёра. + public float SelfPollination { get; init; } = 0.4f; + + /// Темп мутаций (0..1): вероятность сдвига аллеля у потомка. + public float MutationRate { get; init; } = 0.05f; + + /// Доля рецессивного аллеля морфы в стартовой популяции (0..1). + public float VariantChance { get; init; } = 0.15f; + /// Доля разброса аллелей вокруг базы при генерации особи. public float Spread { get; init; } = 0.08f; } diff --git a/src/LittleSim/Scenes/ContentHelpers.cs b/src/LittleSim/Scenes/ContentHelpers.cs index 3f43486..1003ffa 100644 --- a/src/LittleSim/Scenes/ContentHelpers.cs +++ b/src/LittleSim/Scenes/ContentHelpers.cs @@ -36,29 +36,19 @@ internal static class ScatterSpawner var def = content.Defs.Get(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, + PlantGenome.FromDef(def.Genome, random), + terrain.Fertility, + cellSize ); } } diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 1afd90f..9727d26 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -25,8 +25,9 @@ namespace LittleSim.Scenes; /// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из /// (размер/сид/масштаб деталей) процедурной генерацией движка /// (). Каждый тип клетки рисуется текстурой-поверхностью из атласа -/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие -/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн. +/// (вода — тонированным тайлом). Поверх растёт растительность с геномом (рост по свету/температуре/ +/// почве, размножение по Менделю и смерть), климат и день/ночь. UI: HUD, полоса скорости +/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc), дев-консоль и ECS-инспектор (F1). /// public sealed class WorldScene : Scene { @@ -38,6 +39,9 @@ public sealed class WorldScene : Scene /// public const float SecondsPerDay = 480f; + /// Максимум растений на клетку — потолок плотности для размножения. + public const int DensityCap = 4; + private readonly WorldConfig _config; private readonly WorldSave? _save; private readonly RectF _bounds; @@ -46,6 +50,10 @@ public sealed class WorldScene : Scene private PauseMenu _pause = null!; private readonly List _speedRefreshers = []; + private PlantSet _plants = null!; + private float[] _cellFertility = []; + private bool[] _cellLand = []; + /// Новый мир из конфига. public WorldScene(WorldConfig config) : this(config, null) { } @@ -78,10 +86,20 @@ public sealed class WorldScene : Scene 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); + // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва. + _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); + } var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds)); @@ -103,7 +121,22 @@ public sealed class WorldScene : Scene var console = this.UseDevConsole(); RegisterCommands(console, content, atlases); - UpdateSystems.Add(new PlantGrowthSystem(plants, calendar, climate, dayNight, CellSize)); + UpdateSystems.Add(new PlantGrowthSystem(_plants, calendar, climate, dayNight, CellSize)); + 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) ); @@ -127,17 +160,17 @@ public sealed class WorldScene : Scene } /// - /// Строит одну сущность-: тайлсет из дефов рельефа (поверхность из - /// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а - /// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости). + /// Строит сущность- (тайлсет из дефов рельефа + сетка по карте высот) и + /// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если + /// задан (новый мир) — рассыпает растительность по дефам; + /// при загрузке передаётся null (растения восстанавливаются из сейва отдельно). /// private void BuildTerrain( GameContent content, ModAtlases atlases, Microsoft.Xna.Framework.Graphics.GraphicsDevice device, AssetManager assets, - PlantSet plants, - Random random + Random? scatterRandom ) { var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); @@ -160,21 +193,29 @@ public sealed class WorldScene : Scene _config.SmoothPasses ); var grid = new TileGrid(_config.Width, _config.Height); + _cellFertility = new float[_config.Width * _config.Height]; + _cellLand = new bool[_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 - ); + var cell = y * _config.Width + x; + _cellFertility[cell] = terrain.Fertility; + _cellLand[cell] = terrain.IsLand; + if (scatterRandom is not null) + { + ScatterSpawner.Spawn( + this, + content, + _plants, + terrain, + new Point(x, y), + CellSize, + scatterRandom + ); + } } } @@ -254,7 +295,6 @@ public sealed class WorldScene : Scene private string SaveWorld() { - // Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида. var save = new WorldSave { Name = _config.Name, @@ -267,11 +307,54 @@ public sealed class WorldScene : Scene ElapsedSeconds = Context.Clock.TotalTime, }; + // Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном. + Store + .Query() + .ForEachEntity( + (ref PlantGrowth grow, ref PlantGenome gene, 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 = gene, + } + ); + } + ); + new SaveStore().Write(save); - Log.Info($"World '{_config.Name}' saved"); + Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)"); return _config.Name; } + private void RestorePlants(GameContent content) + { + foreach (var plant in _save!.Plants) + { + if (!content.Defs.TryGet(plant.Species, out var def)) + { + continue; // вид пропал (мод убрали) — пропускаем растение + } + + PlantFactory.Create( + Store, + _plants, + _plants.IndexOf(def), + new Vector2(plant.X, plant.Y), + plant.AgeDays, + plant.Genome, + plant.CellFertility, + CellSize + ); + } + } + private void Switch(Scene scene) { if (!Context.Scenes.IsTransitioning) diff --git a/src/LittleSim/Sim/PlantFactory.cs b/src/LittleSim/Sim/PlantFactory.cs new file mode 100644 index 0000000..ebeb22a --- /dev/null +++ b/src/LittleSim/Sim/PlantFactory.cs @@ -0,0 +1,55 @@ +using Friflo.Engine.ECS; +using LittleSim.Content; +using LittleSim.Scenes; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Единая точка создания сущности-растения: спрайт стадии по возрасту, тинт по морфе генома и +/// компоненты роста/генома. Переиспользуется начальным скаттером, размножением и загрузкой сейва — +/// чтобы все растения собирались одинаково. +/// +public static class PlantFactory +{ + // Тинт рецессивной морфы (домножается на текстуру) — делает вариант визуально отличимым. + private static readonly Color VariantTint = new(214, 150, 176); + + /// Создаёт растение вида с заданным возрастом и геномом. + public static Entity Create( + EntityStore store, + PlantSet plants, + int species, + Vector2 position, + float ageDays, + in PlantGenome genome, + float cellFertility, + int cellSize + ) + { + var sp = plants[species]; + var stage = sp.StageAt(ageDays); + var resolved = sp.Stages[stage]; + + var sprite = new Sprite(resolved.Region, GameLayers.Beings); + sprite.CenterOrigin(); + sprite.Color = genome.IsVariant ? VariantTint : Color.White; + + 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, + }, + genome + ); + } +} diff --git a/src/LittleSim/Sim/PlantGenome.cs b/src/LittleSim/Sim/PlantGenome.cs index a7971db..b697088 100644 --- a/src/LittleSim/Sim/PlantGenome.cs +++ b/src/LittleSim/Sim/PlantGenome.cs @@ -4,39 +4,42 @@ using LittleSim.Content; namespace LittleSim.Sim; /// -/// Диплоидный геном растения: по паре аллелей на ген. Для числовых генов экспрессия гибридная — -/// фенотип есть среднее двух аллелей (дискретные доминант/рецессивные гены добавятся с размножением). -/// Аллели — наследуемые данные (правятся в инспекторе); фенотип-свойства читает система роста. +/// Диплоидный геном растения: по паре аллелей на ген. Числовые гены экспрессируются гибридно — +/// фенотип есть среднее двух аллелей; дискретный ген морфы — по Менделю (рецессив виден только в +/// гомозиготе). Аллели наследуются (правятся в инспекторе); фенотип-свойства читают системы роста и +/// жизненного цикла. моделирует мейоз и мутацию. /// public struct PlantGenome : IComponent { - /// Аллели оптимума освещённости (0..1). + // --- Числовые гены (аллель A/B), фенотип = среднее ------------------------------------------ public float OptimalLightA; public float OptimalLightB; - - /// Аллели толерантности по свету. public float LightToleranceA; public float LightToleranceB; - - /// Аллели оптимума температуры (°C). public float OptimalTemperatureA; public float OptimalTemperatureB; - - /// Аллели толерантности по температуре (°C). public float TemperatureToleranceA; public float TemperatureToleranceB; - - /// Аллели оптимума плодородности. public float OptimalFertilityA; public float OptimalFertilityB; - - /// Аллели толерантности по плодородности. public float FertilityToleranceA; public float FertilityToleranceB; - - /// Аллели бодрости роста. public float VigorA; public float VigorB; + public float LifespanA; + public float LifespanB; + public float DispersalRangeA; + public float DispersalRangeB; + public float ReproduceIntervalA; + public float ReproduceIntervalB; + public float SelfPollinationA; + public float SelfPollinationB; + public float MutationRateA; + public float MutationRateB; + + // --- Дискретный ген морфы (0 — доминантный, 1 — рецессивный) -------------------------------- + public int MorphA; + public int MorphB; /// Фенотип: оптимальная освещённость. public readonly float OptimalLight => (OptimalLightA + OptimalLightB) * 0.5f; @@ -60,15 +63,36 @@ public struct PlantGenome : IComponent /// Фенотип: бодрость роста. public readonly float Vigor => (VigorA + VigorB) * 0.5f; + /// Фенотип: продолжительность жизни (дней). + public readonly float Lifespan => (LifespanA + LifespanB) * 0.5f; + + /// Фенотип: дальность расселения (клеток). + public readonly float DispersalRange => (DispersalRangeA + DispersalRangeB) * 0.5f; + + /// Фенотип: интервал размножения (дней). + public readonly float ReproduceInterval => (ReproduceIntervalA + ReproduceIntervalB) * 0.5f; + + /// Фенотип: способность к самоопылению (0..1). + public readonly float SelfPollination => (SelfPollinationA + SelfPollinationB) * 0.5f; + + /// Фенотип: темп мутаций (0..1). + public readonly float MutationRate => (MutationRateA + MutationRateB) * 0.5f; + + /// Фенотип морфы: рецессивный вариант проявляется только в гомозиготе (1,1). + public readonly bool IsVariant => MorphA == 1 && MorphB == 1; + /// - /// Генерирует особь из базового генома вида: каждый аллель — база ± относительный разброс - /// (). Сидируется переданным — детерминизм. + /// Генерирует особь из базового генома вида: числовые аллели — база ± относительный разброс + /// (); аллель морфы = рецессивный (1) с вероятностью + /// . Сидируется — детерминизм. /// public static PlantGenome FromDef(GenomeDef def, Random random) { float Allele(float baseValue) => baseValue + (random.NextSingle() * 2f - 1f) * def.Spread * baseValue; + int MorphAllele() => random.NextSingle() < def.VariantChance ? 1 : 0; + return new PlantGenome { OptimalLightA = Allele(def.OptimalLight), @@ -85,6 +109,63 @@ public struct PlantGenome : IComponent FertilityToleranceB = Allele(def.FertilityTolerance), VigorA = Allele(def.Vigor), VigorB = Allele(def.Vigor), + LifespanA = Allele(def.Lifespan), + LifespanB = Allele(def.Lifespan), + DispersalRangeA = Allele(def.DispersalRange), + DispersalRangeB = Allele(def.DispersalRange), + ReproduceIntervalA = Allele(def.ReproduceInterval), + ReproduceIntervalB = Allele(def.ReproduceInterval), + SelfPollinationA = Allele(def.SelfPollination), + SelfPollinationB = Allele(def.SelfPollination), + MutationRateA = Allele(def.MutationRate), + MutationRateB = Allele(def.MutationRate), + MorphA = MorphAllele(), + MorphB = MorphAllele(), + }; + } + + /// + /// Потомок двух родителей (мейоз): аллель A — случайный из родителя , аллель + /// B — из ; затем мутация (с вероятностью среднего темпа мутаций родителей — + /// сдвиг числового аллеля на малую долю / переключение морфы). Самоопыление = Breed(self, self). + /// + public static PlantGenome Breed(in PlantGenome a, in PlantGenome b, Random rng) + { + var rate = (a.MutationRate + b.MutationRate) * 0.5f; + + float Mut(float v) => + rng.NextSingle() < rate ? v + (rng.NextSingle() * 2f - 1f) * 0.12f * v : v; + float FromA(float x, float y) => Mut(rng.NextSingle() < 0.5f ? x : y); + int MutMorph(int m) => rng.NextSingle() < rate ? 1 - m : m; + + return new PlantGenome + { + OptimalLightA = FromA(a.OptimalLightA, a.OptimalLightB), + OptimalLightB = FromA(b.OptimalLightA, b.OptimalLightB), + LightToleranceA = FromA(a.LightToleranceA, a.LightToleranceB), + LightToleranceB = FromA(b.LightToleranceA, b.LightToleranceB), + OptimalTemperatureA = FromA(a.OptimalTemperatureA, a.OptimalTemperatureB), + OptimalTemperatureB = FromA(b.OptimalTemperatureA, b.OptimalTemperatureB), + TemperatureToleranceA = FromA(a.TemperatureToleranceA, a.TemperatureToleranceB), + TemperatureToleranceB = FromA(b.TemperatureToleranceA, b.TemperatureToleranceB), + OptimalFertilityA = FromA(a.OptimalFertilityA, a.OptimalFertilityB), + OptimalFertilityB = FromA(b.OptimalFertilityA, b.OptimalFertilityB), + FertilityToleranceA = FromA(a.FertilityToleranceA, a.FertilityToleranceB), + FertilityToleranceB = FromA(b.FertilityToleranceA, b.FertilityToleranceB), + VigorA = FromA(a.VigorA, a.VigorB), + VigorB = FromA(b.VigorA, b.VigorB), + LifespanA = FromA(a.LifespanA, a.LifespanB), + LifespanB = FromA(b.LifespanA, b.LifespanB), + DispersalRangeA = FromA(a.DispersalRangeA, a.DispersalRangeB), + DispersalRangeB = FromA(b.DispersalRangeA, b.DispersalRangeB), + ReproduceIntervalA = FromA(a.ReproduceIntervalA, a.ReproduceIntervalB), + ReproduceIntervalB = FromA(b.ReproduceIntervalA, b.ReproduceIntervalB), + SelfPollinationA = FromA(a.SelfPollinationA, a.SelfPollinationB), + SelfPollinationB = FromA(b.SelfPollinationA, b.SelfPollinationB), + MutationRateA = FromA(a.MutationRateA, a.MutationRateB), + MutationRateB = FromA(b.MutationRateA, b.MutationRateB), + MorphA = MutMorph(rng.NextSingle() < 0.5f ? a.MorphA : a.MorphB), + MorphB = MutMorph(rng.NextSingle() < 0.5f ? b.MorphA : b.MorphB), }; } } diff --git a/src/LittleSim/Sim/PlantGrowthSystem.cs b/src/LittleSim/Sim/PlantGrowthSystem.cs index aafdc72..54e8fb9 100644 --- a/src/LittleSim/Sim/PlantGrowthSystem.cs +++ b/src/LittleSim/Sim/PlantGrowthSystem.cs @@ -47,7 +47,8 @@ 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]; diff --git a/src/LittleSim/Sim/PlantLifecycleSystem.cs b/src/LittleSim/Sim/PlantLifecycleSystem.cs new file mode 100644 index 0000000..7d49d00 --- /dev/null +++ b/src/LittleSim/Sim/PlantLifecycleSystem.cs @@ -0,0 +1,292 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Жизненный цикл растений: размножение (наследование по Менделю) и смерть от старости. Запускается +/// не каждый кадр, а раз в игрового дня. За тик: строит по-клеточную сетку +/// (счётчик плотности + «представитель» вида/генома), собирает умерших по возрасту, затем зрелые с +/// вероятностью elapsed/ReproduceInterval сеют потомка — приоритет перекрёстного опыления +/// (зрелый сосед того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в +/// случайную клетку радиуса, если это суша и не превышен лимит плотности. Структурные изменения +/// (создание/удаление сущностей) применяются после проходов по запросу — это безопасно. +/// +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 _query; + + private readonly int[] _count; + private readonly CellRep[] _rep; + private readonly List _deaths = []; + private readonly List _candidates = []; + private readonly List _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(); + _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, genomes, transforms, entities) in _query.Chunks) + { + var g = growths.Span; + var dna = genomes.Span; + var t = transforms.Span; + for (var i = 0; i < g.Length; i++) + { + ref var grow = ref g[i]; + ref var gene = ref dna[i]; + if (grow.AgeDays > gene.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 = gene, + }; + if (mature) + { + _candidates.Add( + new Candidate + { + Cell = cell, + Species = grow.Species, + Genome = gene, + } + ); + } + } + } + } + + // Проход 2: зрелые сеют потомка (перекрёстно или само-) в подходящую клетку радиуса. + private void Reproduce(float elapsed) + { + foreach (var cand in _candidates) + { + var interval = MathF.Max(0.5f, cand.Genome.ReproduceInterval); + if (_rng.NextSingle() >= elapsed / interval) + { + continue; // в этот тик не сеет + } + + var range = Math.Clamp((int)MathF.Round(cand.Genome.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.Genome.SelfPollination) + { + continue; // нет партнёра и самоопыление не удалось + } + + partner = cand.Genome; // самоопыление + } + + if (!TryPickTarget(cx, cy, range, out var tcell)) + { + continue; // некуда сеять (нет суши/свободного места рядом) + } + + var child = PlantGenome.Breed(cand.Genome, partner, _rng); + 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 PlantGenome 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 = rep.Genome; + 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 PlantGenome Genome; + } + + private struct Candidate + { + public int Cell; + public int Species; + public PlantGenome Genome; + } + + private struct Birth + { + public int Species; + public Vector2 Position; + public PlantGenome Genome; + public float Fertility; + } +}