Phase C: plant reproduction (Mendel), death and persistence

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 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 18:21:38 +03:00
co-authored by Claude Opus 4.8
parent bfe1b139f0
commit a04e867e87
9 changed files with 621 additions and 65 deletions
+9 -3
View File
@@ -3,7 +3,9 @@
"defs": [ "defs": [
{ "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28, { "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 16, "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": [ "stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" }, { "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" }, { "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, { "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2,
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 20, "temperatureTolerance": 12, "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": [ "stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 2, "label": "plant.stage.sprout" }, { "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 2, "label": "plant.stage.sprout" },
{ "sizeCells": 1.2, "label": "plant.stage.mature" } { "sizeCells": 1.2, "label": "plant.stage.mature" }
@@ -24,7 +28,9 @@
{ "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4, { "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4,
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 13, "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": [ "stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 3, "label": "plant.stage.sprout" }, { "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 3, "label": "plant.stage.sprout" },
{ "sizeCells": 1.4, "label": "plant.stage.mature" } { "sizeCells": 1.4, "label": "plant.stage.mature" }
+30
View File
@@ -1,7 +1,33 @@
using System.Text.Json; using System.Text.Json;
using LittleSim.Sim;
namespace LittleSim.App; 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>Геном особи (аллели сериализуются как поля структуры).</summary>
public PlantGenome Genome { get; set; }
}
/// <summary>Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по <see cref="DefName"/>).</summary> /// <summary>Сериализуемое состояние одного жителя (sim-компоненты; спрайт пересобирается из дефа по <see cref="DefName"/>).</summary>
public sealed class PawnSave public sealed class PawnSave
{ {
@@ -70,6 +96,9 @@ public sealed class WorldSave
/// <summary>Снимок жителей.</summary> /// <summary>Снимок жителей.</summary>
public List<PawnSave> Pawns { get; set; } = []; public List<PawnSave> Pawns { get; set; } = [];
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
public List<PlantSave> Plants { get; set; } = [];
/// <summary>Конфиг мира для пересоздания сцены.</summary> /// <summary>Конфиг мира для пересоздания сцены.</summary>
public WorldConfig ToConfig() => public WorldConfig ToConfig() =>
new() new()
@@ -90,6 +119,7 @@ public sealed class SaveStore
{ {
WriteIndented = true, WriteIndented = true,
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
IncludeFields = true, // аллели генома (PlantGenome) — публичные поля структуры
}; };
private readonly string _directory; private readonly string _directory;
+18
View File
@@ -91,6 +91,24 @@ public sealed class GenomeDef
/// <summary>Базовая бодрость роста (множитель скорости в идеальных условиях).</summary> /// <summary>Базовая бодрость роста (множитель скорости в идеальных условиях).</summary>
public float Vigor { get; init; } = 1f; 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> /// <summary>Доля разброса аллелей вокруг базы при генерации особи.</summary>
public float Spread { get; init; } = 0.08f; public float Spread { get; init; } = 0.08f;
} }
+10 -20
View File
@@ -36,29 +36,19 @@ internal static class ScatterSpawner
var def = content.Defs.Get<PlantDef>(entry.Options[random.Next(entry.Options.Count)]); var def = content.Defs.Get<PlantDef>(entry.Options[random.Next(entry.Options.Count)]);
var index = plants.IndexOf(def); var index = plants.IndexOf(def);
var species = plants[index];
var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize; var px = (cell.X + 0.3f + random.NextSingle() * 0.4f) * cellSize;
var py = (cell.Y + 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 age = random.NextSingle() * plants[index].MaturityDays;
var stage = species.StageAt(age);
var resolved = species.Stages[stage];
var sprite = new Sprite(resolved.Region, GameLayers.Beings); PlantFactory.Create(
sprite.CenterOrigin(); scene.Store,
scene.Store.CreateEntity( plants,
new Transform2D( index,
new Vector2(px, py), new Vector2(px, py),
scale: new Vector2(cellSize * resolved.SizeCells / resolved.Region.Width) age,
), PlantGenome.FromDef(def.Genome, random),
sprite, terrain.Fertility,
new PlantGrowth cellSize
{
Species = index,
Stage = stage,
AgeDays = age,
CellFertility = terrain.Fertility,
},
PlantGenome.FromDef(def.Genome, random)
); );
} }
} }
+106 -23
View File
@@ -25,8 +25,9 @@ namespace LittleSim.Scenes;
/// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из /// Мир LittleSim: тайловый рельеф целиком описан дефами Core-мода и строится из
/// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка /// <see cref="WorldConfig"/> (размер/сид/масштаб деталей) процедурной генерацией движка
/// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа /// (<see cref="WorldGenerator"/>). Каждый тип клетки рисуется текстурой-поверхностью из атласа
/// (вода — тонированным тайлом). Поверх мира — HUD, полоса скорости (пауза/x1/x3/x6 + горячие /// (вода — тонированным тайлом). Поверх растёт растительность с геномом (рост по свету/температуре/
/// клавиши), меню-пауза (Esc) и дев-консоль. Жителей/растений пока нет — только террейн. /// почве, размножение по Менделю и смерть), климат и день/ночь. UI: HUD, полоса скорости
/// (пауза/x1/x3/x6 + горячие клавиши), меню-пауза (Esc), дев-консоль и ECS-инспектор (F1).
/// </summary> /// </summary>
public sealed class WorldScene : Scene public sealed class WorldScene : Scene
{ {
@@ -38,6 +39,9 @@ public sealed class WorldScene : Scene
/// </summary> /// </summary>
public const float SecondsPerDay = 480f; public const float SecondsPerDay = 480f;
/// <summary>Максимум растений на клетку — потолок плотности для размножения.</summary>
public const int DensityCap = 4;
private readonly WorldConfig _config; private readonly WorldConfig _config;
private readonly WorldSave? _save; private readonly WorldSave? _save;
private readonly RectF _bounds; private readonly RectF _bounds;
@@ -46,6 +50,10 @@ public sealed class WorldScene : Scene
private PauseMenu _pause = null!; private PauseMenu _pause = null!;
private readonly List<Action> _speedRefreshers = []; private readonly List<Action> _speedRefreshers = [];
private PlantSet _plants = null!;
private float[] _cellFertility = [];
private bool[] _cellLand = [];
/// <summary>Новый мир из конфига.</summary> /// <summary>Новый мир из конфига.</summary>
public WorldScene(WorldConfig config) public WorldScene(WorldConfig config)
: this(config, null) { } : this(config, null) { }
@@ -78,10 +86,20 @@ public sealed class WorldScene : Scene
var climate = Context.UseClimate(ClimateSettings.Default); var climate = Context.UseClimate(ClimateSettings.Default);
var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер var dayNight = this.UseDayNight(renderer); // мир темнеет ночью — амбиент идёт в рендер
// Рельеф и расстановка растений детерминированы сидом мира (независимые потоки seed). // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
var plants = new PlantSet(content, atlases, device); _plants = new PlantSet(content, atlases, device);
var random = new Random(_config.Seed); var loadingPlants = _save?.Plants is { Count: > 0 };
BuildTerrain(content, atlases, device, assets, plants, random); 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)); 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(); var console = this.UseDevConsole();
RegisterCommands(console, content, atlases); 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( UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
); );
@@ -127,17 +160,17 @@ public sealed class WorldScene : Scene
} }
/// <summary> /// <summary>
/// Строит одну сущность-<see cref="Tilemap"/>: тайлсет из дефов рельефа (поверхность из /// Строит сущность-<see cref="Tilemap"/> (тайлсет из дефов рельефа + сетка по карте высот) и
/// атласа либо тонированный тайл) и сетка тайлов по карте высот процедурной генерации, а /// заполняет по-клеточные массивы плодородности/суши для жизненного цикла. Если
/// поверх — растительность по скаттеру дефов (с компонентом роста, разной зрелости). /// <paramref name="scatterRandom"/> задан (новый мир) — рассыпает растительность по дефам;
/// при загрузке передаётся null (растения восстанавливаются из сейва отдельно).
/// </summary> /// </summary>
private void BuildTerrain( private void BuildTerrain(
GameContent content, GameContent content,
ModAtlases atlases, ModAtlases atlases,
Microsoft.Xna.Framework.Graphics.GraphicsDevice device, Microsoft.Xna.Framework.Graphics.GraphicsDevice device,
AssetManager assets, AssetManager assets,
PlantSet plants, Random? scatterRandom
Random random
) )
{ {
var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White)); var white = new Texture2DRegion(assets.Load(GameAssets.Textures.White));
@@ -160,21 +193,29 @@ public sealed class WorldScene : Scene
_config.SmoothPasses _config.SmoothPasses
); );
var grid = new TileGrid(_config.Width, _config.Height); 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 x = 0; x < _config.Width; x++)
{ {
for (var y = 0; y < _config.Height; y++) for (var y = 0; y < _config.Height; y++)
{ {
var terrain = content.Terrains.Classify(heights[x, y]); var terrain = content.Terrains.Classify(heights[x, y]);
grid[x, y] = tileByDef[terrain]; grid[x, y] = tileByDef[terrain];
ScatterSpawner.Spawn( var cell = y * _config.Width + x;
this, _cellFertility[cell] = terrain.Fertility;
content, _cellLand[cell] = terrain.IsLand;
plants, if (scatterRandom is not null)
terrain, {
new Point(x, y), ScatterSpawner.Spawn(
CellSize, this,
random content,
); _plants,
terrain,
new Point(x, y),
CellSize,
scatterRandom
);
}
} }
} }
@@ -254,7 +295,6 @@ public sealed class WorldScene : Scene
private string SaveWorld() private string SaveWorld()
{ {
// Жителей пока нет — сохраняем только конфиг мира; рельеф воспроизводится из сида.
var save = new WorldSave var save = new WorldSave
{ {
Name = _config.Name, Name = _config.Name,
@@ -267,11 +307,54 @@ public sealed class WorldScene : Scene
ElapsedSeconds = Context.Clock.TotalTime, ElapsedSeconds = Context.Clock.TotalTime,
}; };
// Снимок всей популяции растений: вид (деф), позиция, возраст, стадия, почва и геном.
Store
.Query<PlantGrowth, PlantGenome, Transform2D>()
.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); new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved"); Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)");
return _config.Name; return _config.Name;
} }
private void RestorePlants(GameContent content)
{
foreach (var plant in _save!.Plants)
{
if (!content.Defs.TryGet<PlantDef>(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) private void Switch(Scene scene)
{ {
if (!Context.Scenes.IsTransitioning) if (!Context.Scenes.IsTransitioning)
+55
View File
@@ -0,0 +1,55 @@
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Единая точка создания сущности-растения: спрайт стадии по возрасту, тинт по морфе генома и
/// компоненты роста/генома. Переиспользуется начальным скаттером, размножением и загрузкой сейва —
/// чтобы все растения собирались одинаково.
/// </summary>
public static class PlantFactory
{
// Тинт рецессивной морфы (домножается на текстуру) — делает вариант визуально отличимым.
private static readonly Color VariantTint = new(214, 150, 176);
/// <summary>Создаёт растение вида <paramref name="species"/> с заданным возрастом и геномом.</summary>
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
);
}
}
+99 -18
View File
@@ -4,39 +4,42 @@ using LittleSim.Content;
namespace LittleSim.Sim; namespace LittleSim.Sim;
/// <summary> /// <summary>
/// Диплоидный геном растения: по паре аллелей на ген. Для числовых генов экспрессия гибридная /// Диплоидный геном растения: по паре аллелей на ген. Числовые гены экспрессируются гибридно
/// фенотип есть среднее двух аллелей (дискретные доминант/рецессивные гены добавятся с размножением). /// фенотип есть среднее двух аллелей; дискретный ген морфы — по Менделю (рецессив виден только в
/// Аллели наследуемые данные (правятся в инспекторе); фенотип-свойства читает система роста. /// гомозиготе). Аллели наследуются (правятся в инспекторе); фенотип-свойства читают системы роста и
/// жизненного цикла. <see cref="Breed"/> моделирует мейоз и мутацию.
/// </summary> /// </summary>
public struct PlantGenome : IComponent public struct PlantGenome : IComponent
{ {
/// <summary>Аллели оптимума освещённости (0..1).</summary> // --- Числовые гены (аллель A/B), фенотип = среднее ------------------------------------------
public float OptimalLightA; public float OptimalLightA;
public float OptimalLightB; public float OptimalLightB;
/// <summary>Аллели толерантности по свету.</summary>
public float LightToleranceA; public float LightToleranceA;
public float LightToleranceB; public float LightToleranceB;
/// <summary>Аллели оптимума температуры (°C).</summary>
public float OptimalTemperatureA; public float OptimalTemperatureA;
public float OptimalTemperatureB; public float OptimalTemperatureB;
/// <summary>Аллели толерантности по температуре (°C).</summary>
public float TemperatureToleranceA; public float TemperatureToleranceA;
public float TemperatureToleranceB; public float TemperatureToleranceB;
/// <summary>Аллели оптимума плодородности.</summary>
public float OptimalFertilityA; public float OptimalFertilityA;
public float OptimalFertilityB; public float OptimalFertilityB;
/// <summary>Аллели толерантности по плодородности.</summary>
public float FertilityToleranceA; public float FertilityToleranceA;
public float FertilityToleranceB; public float FertilityToleranceB;
/// <summary>Аллели бодрости роста.</summary>
public float VigorA; public float VigorA;
public float VigorB; 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;
/// <summary>Фенотип: оптимальная освещённость.</summary> /// <summary>Фенотип: оптимальная освещённость.</summary>
public readonly float OptimalLight => (OptimalLightA + OptimalLightB) * 0.5f; public readonly float OptimalLight => (OptimalLightA + OptimalLightB) * 0.5f;
@@ -60,15 +63,36 @@ public struct PlantGenome : IComponent
/// <summary>Фенотип: бодрость роста.</summary> /// <summary>Фенотип: бодрость роста.</summary>
public readonly float Vigor => (VigorA + VigorB) * 0.5f; public readonly float Vigor => (VigorA + VigorB) * 0.5f;
/// <summary>Фенотип: продолжительность жизни (дней).</summary>
public readonly float Lifespan => (LifespanA + LifespanB) * 0.5f;
/// <summary>Фенотип: дальность расселения (клеток).</summary>
public readonly float DispersalRange => (DispersalRangeA + DispersalRangeB) * 0.5f;
/// <summary>Фенотип: интервал размножения (дней).</summary>
public readonly float ReproduceInterval => (ReproduceIntervalA + ReproduceIntervalB) * 0.5f;
/// <summary>Фенотип: способность к самоопылению (0..1).</summary>
public readonly float SelfPollination => (SelfPollinationA + SelfPollinationB) * 0.5f;
/// <summary>Фенотип: темп мутаций (0..1).</summary>
public readonly float MutationRate => (MutationRateA + MutationRateB) * 0.5f;
/// <summary>Фенотип морфы: рецессивный вариант проявляется только в гомозиготе (1,1).</summary>
public readonly bool IsVariant => MorphA == 1 && MorphB == 1;
/// <summary> /// <summary>
/// Генерирует особь из базового генома вида: каждый аллель — база ± относительный разброс /// Генерирует особь из базового генома вида: числовые аллели — база ± относительный разброс
/// (<see cref="GenomeDef.Spread"/>). Сидируется переданным <paramref name="random"/> — детерминизм. /// (<see cref="GenomeDef.Spread"/>); аллель морфы = рецессивный (1) с вероятностью
/// <see cref="GenomeDef.VariantChance"/>. Сидируется <paramref name="random"/> — детерминизм.
/// </summary> /// </summary>
public static PlantGenome FromDef(GenomeDef def, Random random) public static PlantGenome FromDef(GenomeDef def, Random random)
{ {
float Allele(float baseValue) => float Allele(float baseValue) =>
baseValue + (random.NextSingle() * 2f - 1f) * def.Spread * baseValue; baseValue + (random.NextSingle() * 2f - 1f) * def.Spread * baseValue;
int MorphAllele() => random.NextSingle() < def.VariantChance ? 1 : 0;
return new PlantGenome return new PlantGenome
{ {
OptimalLightA = Allele(def.OptimalLight), OptimalLightA = Allele(def.OptimalLight),
@@ -85,6 +109,63 @@ public struct PlantGenome : IComponent
FertilityToleranceB = Allele(def.FertilityTolerance), FertilityToleranceB = Allele(def.FertilityTolerance),
VigorA = Allele(def.Vigor), VigorA = Allele(def.Vigor),
VigorB = 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(),
};
}
/// <summary>
/// Потомок двух родителей (мейоз): аллель A — случайный из родителя <paramref name="a"/>, аллель
/// B — из <paramref name="b"/>; затем мутация (с вероятностью среднего темпа мутаций родителей —
/// сдвиг числового аллеля на малую долю / переключение морфы). Самоопыление = <c>Breed(self, self)</c>.
/// </summary>
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),
}; };
} }
} }
+2 -1
View File
@@ -47,7 +47,8 @@ public sealed class PlantGrowthSystem(
var stages = plants[grow.Species].Stages; var stages = plants[grow.Species].Stages;
if (grow.Stage >= stages.Length - 1) if (grow.Stage >= stages.Length - 1)
{ {
continue; // уже зрелое — рост окончен grow.AgeDays += deltaDays; // зрелое: старение в реальном времени → смерть по старости
continue;
} }
ref var gene = ref dna[i]; ref var gene = ref dna[i];
+292
View File
@@ -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;
/// <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, PlantGenome, 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, PlantGenome, 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, 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;
}
}