Birth type gene (live-bearing vs egg-laying) + hybrid-creation foundation

GeneEggLaying (species-fixed) selects the conception cycle: viviparous animals
carry to a live birth (existing path); oviparous animals, after the same gravid
period, LAY eggs instead. New Egg entity (carries the already-bred offspring
genome) + EggSystem incubates and hatches it into a baby. AnimalPregnancySystem
branches on the mother's birth type. Adds an oviparous showcase species, Chicken.
Eggs are serialized (WorldSave.Eggs) like corpses, so clutches survive save/load.

Also lays the foundation for creating any species or hybrid (full command/UI
deferred): AnimalSet.HybridGenome(base, other) produces a cross anchored to a
base species' body/gene-set with shared genes blended Mendelian-style — so
Create(species, GenerateGenome) makes any species and Create(species,
HybridGenome) makes any hybrid.

EggTexture on AnimalDef (defaults to the seed dot placeholder); reproduction line
added to the `animal` console command. Build + --check-content clean (41 genes,
40 traits). Not GUI-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 15:24:04 +03:00
co-authored by Claude Opus 4.8
parent e6b1367a3a
commit b2e3eb490e
11 changed files with 375 additions and 17 deletions
+25
View File
@@ -173,6 +173,28 @@ public sealed class CorpseSave
public float Meat { get; set; }
}
/// <summary>Сериализуемое яйцо (тип рождения «яйцекладка»): вид, позиция, геном детёныша, поколение, инкубация.</summary>
public sealed class EggSave
{
/// <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 int Generation { get; set; }
/// <summary>Остаток инкубации в игровых днях.</summary>
public float IncubateDays { get; set; }
/// <summary>Геном будущего детёныша (уже скрещён на момент кладки).</summary>
public Dictionary<string, Allele> Genome { get; set; } = new();
}
/// <summary>
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
@@ -216,6 +238,9 @@ public sealed class WorldSave
/// <summary>Снимок трупов (вид, позиция, таймер/стадия разложения, мясо).</summary>
public List<CorpseSave> Corpses { get; set; } = [];
/// <summary>Снимок яиц (вид, позиция, геном детёныша, поколение, инкубация).</summary>
public List<EggSave> Eggs { get; set; } = [];
/// <summary>Конфиг мира для пересоздания сцены.</summary>
public WorldConfig ToConfig() =>
new()
+69 -4
View File
@@ -30,6 +30,9 @@ public sealed class AnimalSet
/// <summary>Спрайт трупа (fallback — самка).</summary>
public required Texture2DRegion Corpse { get; init; }
/// <summary>Спрайт яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт-точка.</summary>
public required Texture2DRegion Egg { get; init; }
/// <summary>Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа.</summary>
public required AnimalStageDef[] Stages { get; init; }
@@ -43,10 +46,40 @@ public sealed class AnimalSet
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
private static readonly AnimalStageDef[] DefaultStages =
[
new() { Name = "Baby", EnterAt = 0f, RelativeTo = "maturity", Scale = 0.45f, Texture = "baby" },
new() { Name = "Juvenile", EnterAt = 0.22f, RelativeTo = "maturity", Scale = 0.7f, Texture = "baby" },
new() { Name = "Adult", EnterAt = 1f, RelativeTo = "maturity", Scale = 1f, Texture = "adult", Adult = true },
new() { Name = "Senior", EnterAt = 0.8f, RelativeTo = "lifespan", Scale = 0.92f, Texture = "adult", Adult = true },
new()
{
Name = "Baby",
EnterAt = 0f,
RelativeTo = "maturity",
Scale = 0.45f,
Texture = "baby",
},
new()
{
Name = "Juvenile",
EnterAt = 0.22f,
RelativeTo = "maturity",
Scale = 0.7f,
Texture = "baby",
},
new()
{
Name = "Adult",
EnterAt = 1f,
RelativeTo = "maturity",
Scale = 1f,
Texture = "adult",
Adult = true,
},
new()
{
Name = "Senior",
EnterAt = 0.8f,
RelativeTo = "lifespan",
Scale = 0.92f,
Texture = "adult",
Adult = true,
},
];
private readonly Species[] _species;
@@ -82,6 +115,9 @@ public sealed class AnimalSet
Corpse = string.IsNullOrEmpty(def.CorpseTexture)
? female
: atlases.GetRegion(device, def.CorpseTexture),
Egg = string.IsNullOrEmpty(def.EggTexture)
? female
: atlases.GetRegion(device, def.EggTexture),
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null,
Template = BuildTemplate(def.Genome, genes),
@@ -111,6 +147,35 @@ public sealed class AnimalSet
return genome;
}
/// <summary>
/// ОСНОВА создания гибрида (полноценная команда/UI — позже): геном помеси, привязанный к базовому виду
/// <paramref name="baseSpecies"/> (его тело/спрайт/набор генов), но с общими генами, скрещёнными с
/// <paramref name="otherSpecies"/> (по одной аллели-гамете от каждого родителя, как при размножении).
/// Пол берётся от базового вида; гены, которых нет у базового, игнорируются (его тело их не читает).
/// Сущность создаётся обычной <see cref="Sim.AnimalFactory.Create"/> с этим геномом и базовым видом —
/// то есть «создать любой вид» = Create(вид, GenerateGenome), «любой гибрид» = Create(вид, HybridGenome).
/// </summary>
public Genome HybridGenome(int baseSpecies, int otherSpecies, Random random)
{
var hybrid = GenerateGenome(baseSpecies, random);
var other = _species[otherSpecies].Template.Generate(random);
foreach (var (geneId, allele) in other.ToDictionary())
{
if (string.Equals(geneId, "GeneSex", StringComparison.Ordinal) || !hybrid.Has(geneId))
{
continue; // пол — от базового вида; чужие гены базовое тело не читает
}
var mine = hybrid[geneId];
hybrid[geneId] = new Allele(
random.NextSingle() < 0.5f ? mine.A : mine.B,
random.NextSingle() < 0.5f ? allele.A : allele.B
);
}
return hybrid;
}
// Строит шаблон генома вида из ДАННЫХ (geneId→base из Defs/Animals/) generically: разброс берётся из
// самого гена. Состав открыт — модер добавляет ген строкой JSON, без правки кода. Дискретные гены
// (база/разброс не нужны — варианты из GeneDef) тоже поддержаны.
+3
View File
@@ -261,6 +261,9 @@ public sealed class AnimalDef : PawnDef
/// <summary>Текстура трупа (фаза A5); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string CorpseTexture { get; init; } = "";
/// <summary>Текстура яйца (тип рождения «яйцекладка»); по умолчанию — общий спрайт семени-точки.</summary>
public string EggTexture { get; init; } = "things/plant/seed_default";
/// <summary>Имя <see cref="BodyDef"/> — анатомия вида (части тела/органы); пусто — без частей тела.</summary>
public string Body { get; init; } = "";
+57 -2
View File
@@ -294,6 +294,17 @@ public sealed class WorldScene : Scene
_config.Seed + 0x4BED
)
);
UpdateSystems.Add(
new EggSystem(
Store,
_animals,
_needs,
Context.Clock,
SecondsPerDay,
CellSize,
_config.Seed + 0x6E66
)
);
UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
@@ -519,7 +530,7 @@ public sealed class WorldScene : Scene
new SaveStore().Write(save);
Log.Info(
$"World '{_config.Name}' saved ({save.Plants.Count} plants, "
+ $"{save.Animals.Count} animals, {save.Corpses.Count} corpses)"
+ $"{save.Animals.Count} animals, {save.Corpses.Count} corpses, {save.Eggs.Count} eggs)"
);
return _config.Name;
}
@@ -614,6 +625,23 @@ public sealed class WorldScene : Scene
}
)
);
Store
.Query<Egg, Transform2D>()
.ForEachEntity(
(ref Egg egg, ref Transform2D transform, Entity _) =>
save.Eggs.Add(
new EggSave
{
Species = _animals[egg.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
Generation = egg.Generation,
IncubateDays = egg.IncubateDays,
Genome = egg.Genome?.ToDictionary() ?? new(),
}
)
);
}
private void RestorePlants(GameContent content)
@@ -695,8 +723,31 @@ public sealed class WorldScene : Scene
data.Meat = saved.Meat;
}
foreach (var saved in _save.Eggs)
{
if (!content.Defs.TryGet<AnimalDef>(saved.Species, out var def))
{
continue; // вид пропал (мод убрали) — пропускаем яйцо
}
var index = _animals.IndexOf(def);
var genome = saved.Genome is { Count: > 0 }
? new Genome(saved.Genome)
: _animals.GenerateGenome(index, fallback);
AnimalFactory.CreateEgg(
Store,
_animals,
index,
new Vector2(saved.X, saved.Y),
genome,
saved.Generation,
saved.IncubateDays,
CellSize
);
}
Log.Info(
$"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses from save"
$"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses, {_save.Eggs.Count} eggs from save"
);
}
@@ -1136,6 +1187,10 @@ public sealed class WorldScene : Scene
$" diet: herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / omni {traits.Omnivory:0.##}, "
+ $"toxinTolerance {traits.ToxinTolerance:0.##}"
);
console.WriteLine(
$" reproduction: {(traits.Oviparous ? "oviparous (lays eggs)" : "viviparous (live birth)")}, "
+ $"gestation {traits.GestationDays:0} d, litter {traits.LitterSize:0.#}"
);
}
// Диета из генов (предатор-кластер): по каждому виду печатает herbivory/carnivory/omnivory,
+40
View File
@@ -110,6 +110,46 @@ public static class AnimalFactory
);
}
// Тинт яйца (бледно-кремовый) и доля размера клетки, до которой масштабируется спрайт яйца.
private static readonly Color EggTint = new(238, 230, 208);
private const float EggSizeFraction = 0.45f;
/// <summary>
/// Создаёт сущность-яйцо (тип рождения «яйцекладка»): спрайт-яйцо и компонент <see cref="Egg"/> с уже
/// скрещённым геномом будущего детёныша. Инкубируется и вылупляется в <see cref="EggSystem"/>.
/// </summary>
public static Entity CreateEgg(
EntityStore store,
AnimalSet animals,
int species,
Vector2 position,
Genome genome,
int generation,
float incubateDays,
int cellSize
)
{
var sp = animals[species];
var region = sp.Egg;
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = EggTint;
return store.CreateEntity(
new Transform2D(
position,
scale: new Vector2(cellSize * EggSizeFraction / region.Width)
),
sprite,
new Egg
{
Species = species,
Genome = genome,
Generation = generation,
IncubateDays = incubateDays,
}
);
}
/// <summary>Порог выраженности признака диеты, с которого вид реально ест данный корм.</summary>
public const float DietThreshold = 0.5f;
+27
View File
@@ -63,6 +63,12 @@ public struct AnimalPhenotype
/// <summary>Размер помёта.</summary>
public float LitterSize;
/// <summary>Тип рождения [0..1]: ≥0.5 — яйцекладущий (откладывает яйца), иначе живородящий.</summary>
public float EggLaying;
/// <summary>Яйцекладущий ли вид (тип рождения) — определяет цикл зачатия.</summary>
public readonly bool Oviparous => EggLaying >= 0.5f;
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
@@ -86,6 +92,7 @@ public struct AnimalPhenotype
BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)),
GestationDays = T("gestationDays"),
LitterSize = T("litterSize"),
EggLaying = T("eggLaying"),
};
}
}
@@ -457,6 +464,26 @@ public struct Corpse : IComponent
public float Meat;
}
/// <summary>
/// Яйцо (тип рождения «яйцекладка»): отдельная сущность, отложенная яйцекладущей самкой вместо живых
/// родов. Несёт уже скрещённый геном будущего детёныша, вид и поколение; инкубируется во времени и
/// вылупляется в детёныша (<see cref="Sim.EggSystem"/>). Геном — managed-ссылка (как у трупа/беременности).
/// </summary>
public struct Egg : IComponent
{
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/> (для спрайта при вылуплении и сейва).</summary>
public int Species;
/// <summary>Геном будущего детёныша (уже скрещён из родителей на момент кладки).</summary>
public Genome Genome;
/// <summary>Поколение будущего детёныша.</summary>
public int Generation;
/// <summary>Остаток инкубации в игровых днях; ≤0 — вылупление.</summary>
public float IncubateDays;
}
/// <summary>Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания.</summary>
public struct ThoughtInstance
{
+115 -11
View File
@@ -1656,6 +1656,8 @@ public sealed class AnimalPregnancySystem : BaseSystem
Litter = preg.Litter,
Generation = o[i].Generation + 1,
Position = t[i].Position,
Oviparous = o[i].Traits.Oviparous,
IncubateDays = MathF.Max(1f, o[i].Traits.GestationDays),
}
);
}
@@ -1685,17 +1687,35 @@ public sealed class AnimalPregnancySystem : BaseSystem
);
var offset =
new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize;
AnimalFactory.Create(
_store,
_animals,
_needs,
birth.Species,
birth.Position + offset,
ageDays: 0f,
child,
birth.Generation,
_cellSize
);
if (birth.Oviparous)
{
// Яйцекладка: вместо живого детёныша откладываем яйцо с его геномом — вылупится позже.
AnimalFactory.CreateEgg(
_store,
_animals,
birth.Species,
birth.Position + offset,
child,
birth.Generation,
birth.IncubateDays,
_cellSize
);
}
else
{
AnimalFactory.Create(
_store,
_animals,
_needs,
birth.Species,
birth.Position + offset,
ageDays: 0f,
child,
birth.Generation,
_cellSize
);
}
count++;
}
}
@@ -1710,6 +1730,90 @@ public sealed class AnimalPregnancySystem : BaseSystem
public int Litter;
public int Generation;
public Vector2 Position;
public bool Oviparous;
public float IncubateDays;
}
}
/// <summary>
/// Инкубация яиц (тип рождения «яйцекладка»): тикает остаток инкубации каждого яйца; по истечении
/// вылупляет детёныша (<see cref="AnimalFactory.Create"/> из хранимого генома, стадия Baby) рядом с
/// яйцом и удаляет яйцо. Зеркало родов для яйцекладущих видов.
/// </summary>
public sealed class EggSystem : BaseSystem
{
private readonly EntityStore _store;
private readonly AnimalSet _animals;
private readonly NeedSet _needs;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly int _cellSize;
private readonly Random _rng;
private readonly ArchetypeQuery<Egg, Transform2D> _query;
private readonly List<Entity> _hatched = [];
public EggSystem(
EntityStore store,
AnimalSet animals,
NeedSet needs,
GameClock clock,
float secondsPerDay,
int cellSize,
int seed
)
{
_store = store;
_animals = animals;
_needs = needs;
_clock = clock;
_secondsPerDay = secondsPerDay;
_cellSize = cellSize;
_rng = new Random(seed);
_query = store.Query<Egg, Transform2D>();
}
protected override void OnUpdateGroup()
{
var days = _clock.DeltaTime / _secondsPerDay;
if (days <= 0f)
{
return;
}
_hatched.Clear();
foreach (var (eggs, transforms, entities) in _query.Chunks)
{
var e = eggs.Span;
for (var i = 0; i < e.Length; i++)
{
ref var egg = ref e[i];
egg.IncubateDays -= days;
if (egg.IncubateDays > 0f)
{
continue;
}
var offset =
new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize;
AnimalFactory.Create(
_store,
_animals,
_needs,
egg.Species,
transforms.Span[i].Position + offset,
ageDays: 0f,
egg.Genome,
egg.Generation,
_cellSize
);
_hatched.Add(entities.EntityAt(i));
}
}
foreach (var egg in _hatched)
{
egg.DeleteEntity();
}
}
}