Животные A4: размножение (гон-хедиф, беременность, роды)

Нужда Mating + лёгкий hediff-каркас (HediffDef, hediffs.json с Rut, Health/HealthState). Гены GeneBreedingSeason/GeneGestationDays/GeneLitterSize. AnimalRutSystem навешивает сезонный гон-хедиф взрослым и поднимает влечение (вне сезона зачатия нет). Действие Mate ищет партнёра противоположного пола и сближается; при контакте самка получает Pregnant (геном отца). AnimalPregnancySystem тикает срок и рожает помёт через Genome.Breed (пол наследуется без YY, поколение+1), с потолком численности.

Сборка чистая, --check-content (8 типов дефов, 33 гена, 32 признака).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 04:32:18 +03:00
co-authored by Claude Opus 4.8
parent 355ced4937
commit 97521f81b8
11 changed files with 498 additions and 9 deletions
+334 -7
View File
@@ -4,6 +4,7 @@ using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
@@ -22,6 +23,9 @@ public enum AnimalAction
/// <summary>Стоит и спит (восстанавливает отдых).</summary>
Sleep,
/// <summary>Идёт к партнёру и спаривается (фаза A4) — у самки наступает беременность.</summary>
Mate,
}
/// <summary>
@@ -43,7 +47,10 @@ public struct AnimalNeeds : IComponent
/// <summary>Накоплено игровых дней истощения (голод/жажда на нуле); порог — смерть (A3).</summary>
public float Starve;
/// <summary>Наименьшая (самая острая) нужда — для выбора и внешнего вида.</summary>
/// <summary>Половое влечение [0,1] (фаза A4): растёт под гоном (хедиф Rut), обнуляется спариванием.</summary>
public float Mating;
/// <summary>Наименьшая (самая острая) витальная нужда — для выбора и внешнего вида (без влечения).</summary>
public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest));
}
@@ -63,12 +70,15 @@ public struct AnimalBrain : IComponent
/// <summary>Id растения-цели для <see cref="AnimalAction.Eat"/>; -1 — нет.</summary>
public int TargetPlant;
/// <summary>Id партнёра для <see cref="AnimalAction.Mate"/>; -1 — нет.</summary>
public int TargetMate;
/// <summary>Секунды до следующего пересмотра решения.</summary>
public float DecideIn;
}
/// <summary>Снимок состояния зверя для соображений utility-выбора.</summary>
public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest);
public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest, float Mating);
/// <summary>
/// Падение нужд со временем (игровые дни через <see cref="GameClock"/>, с учётом паузы/скорости):
@@ -152,6 +162,11 @@ public sealed class AnimalDecisionSystem : BaseSystem
curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f)
)
),
// Спаривание (A4): тем привлекательнее, чем выше влечение (растёт только под гоном).
new UtilityAction<AnimalContext>(
"mate",
new Consideration<AnimalContext>("lustful", c => c.Mating)
),
new UtilityAction<AnimalContext>(
"wander",
new Consideration<AnimalContext>("idle", _ => 0.12f)
@@ -162,7 +177,13 @@ public sealed class AnimalDecisionSystem : BaseSystem
private readonly int _cellSize;
private readonly Vector2[] _shore;
private readonly Random _rng;
private readonly ArchetypeQuery<AnimalNeeds, AnimalBrain, AnimalOrganism, Transform2D> _animals;
private readonly ArchetypeQuery<
AnimalNeeds,
AnimalBrain,
AnimalOrganism,
AnimalGrowth,
Transform2D
> _animals;
private readonly ArchetypeQuery<Transform2D, PlantGrowth> _plants;
public AnimalDecisionSystem(
@@ -177,18 +198,19 @@ public sealed class AnimalDecisionSystem : BaseSystem
_cellSize = cellSize;
_shore = shore;
_rng = new Random(seed);
_animals = store.Query<AnimalNeeds, AnimalBrain, AnimalOrganism, Transform2D>();
_animals = store.Query<AnimalNeeds, AnimalBrain, AnimalOrganism, AnimalGrowth, Transform2D>();
_plants = store.Query<Transform2D, PlantGrowth>();
}
protected override void OnUpdateGroup()
{
var delta = _clock.DeltaTime;
foreach (var (needs, brains, organisms, transforms, _) in _animals.Chunks)
foreach (var (needs, brains, organisms, growths, transforms, _) in _animals.Chunks)
{
var n = needs.Span;
var b = brains.Span;
var o = organisms.Span;
var gr = growths.Span;
var t = transforms.Span;
for (var i = 0; i < b.Length; i++)
{
@@ -201,8 +223,9 @@ public sealed class AnimalDecisionSystem : BaseSystem
brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан
var pos = t[i].Position;
var adult = gr[i].Stage >= AnimalFactory.StageAdult;
var name = _brain
.Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest))
.Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest, n[i].Mating))
?.Name;
switch (name)
@@ -221,6 +244,20 @@ public sealed class AnimalDecisionSystem : BaseSystem
brain.Action = AnimalAction.Sleep;
brain.TargetPlant = -1;
break;
case "mate"
when adult
&& TryFindMate(
pos,
o[i].IsMale,
o[i].Traits.Vision,
out var mateId,
out var matePos
):
brain.Action = AnimalAction.Mate;
brain.TargetPlant = -1;
brain.TargetMate = mateId;
brain.Target = matePos;
break;
default:
brain.Action = AnimalAction.Wander;
brain.TargetPlant = -1;
@@ -284,6 +321,49 @@ public sealed class AnimalDecisionSystem : BaseSystem
return true;
}
// Ближайший подходящий партнёр в радиусе зрения: взрослый, противоположного пола, с влечением > 0.5.
private bool TryFindMate(
Vector2 from,
bool selfMale,
float vision,
out int mateId,
out Vector2 position
)
{
var radius = VisionCells * MathF.Max(0.2f, vision) * _cellSize;
var bestSq = radius * radius;
mateId = -1;
position = default;
foreach (var (needs, _, organisms, growths, transforms, entities) in _animals.Chunks)
{
var nn = needs.Span;
var oo = organisms.Span;
var gg = growths.Span;
var tt = transforms.Span;
for (var i = 0; i < tt.Length; i++)
{
if (
oo[i].IsMale == selfMale
|| gg[i].Stage < AnimalFactory.StageAdult
|| nn[i].Mating < 0.5f
)
{
continue; // тот же пол / не взрослый / без влечения (self отсеивается по полу)
}
var sq = Vector2.DistanceSquared(from, tt[i].Position);
if (sq > 0.01f && sq < bestSq)
{
bestSq = sq;
mateId = entities.EntityAt(i).Id;
position = tt[i].Position;
}
}
}
return mateId >= 0;
}
}
/// <summary>
@@ -308,6 +388,7 @@ public sealed class AnimalActionSystem : BaseSystem
private readonly RectF _bounds;
private readonly ArchetypeQuery<AnimalBrain, AnimalNeeds, AnimalOrganism, Transform2D> _query;
private readonly List<Entity> _eaten = [];
private readonly List<(Entity Self, Entity Partner)> _matings = [];
public AnimalActionSystem(
EntityStore store,
@@ -335,8 +416,9 @@ public sealed class AnimalActionSystem : BaseSystem
var days = seconds / _secondsPerDay;
_eaten.Clear();
_matings.Clear();
foreach (var (brains, needs, organisms, transforms, _) in _query.Chunks)
foreach (var (brains, needs, organisms, transforms, entities) in _query.Chunks)
{
var b = brains.Span;
var n = needs.Span;
@@ -372,6 +454,23 @@ public sealed class AnimalActionSystem : BaseSystem
break;
case AnimalAction.Mate:
if (
brain.TargetMate >= 0
&& _store.TryGetEntityById(brain.TargetMate, out var partner)
&& !partner.IsNull
&& partner.HasComponent<Transform2D>()
)
{
var partnerPos = partner.GetComponent<Transform2D>().Position;
if (MoveTo(ref pos, partnerPos, speed * seconds))
{
_matings.Add((entities.EntityAt(i), partner));
}
}
break;
default: // Wander
MoveTo(ref pos, brain.Target, speed * seconds);
break;
@@ -386,6 +485,58 @@ public sealed class AnimalActionSystem : BaseSystem
{
plant.DeleteEntity();
}
ApplyMatings();
}
// Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление
// компонента — после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются.
private void ApplyMatings()
{
foreach (var (a, b) in _matings)
{
if (
a.IsNull
|| b.IsNull
|| !a.HasComponent<AnimalOrganism>()
|| !b.HasComponent<AnimalOrganism>()
)
{
continue;
}
var aMale = a.GetComponent<AnimalOrganism>().IsMale;
var bMale = b.GetComponent<AnimalOrganism>().IsMale;
if (aMale == bMale)
{
continue; // один пол — не пара (страховка)
}
var mother = aMale ? b : a;
var father = aMale ? a : b;
if (!mother.HasComponent<Pregnant>())
{
ref readonly var mom = ref mother.GetComponent<AnimalOrganism>();
mother.AddComponent(
new Pregnant
{
FatherGenome = father.GetComponent<AnimalOrganism>().Genome,
DueInDays = MathF.Max(1f, mom.Traits.GestationDays),
Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize)),
}
);
}
if (a.HasComponent<AnimalNeeds>())
{
a.GetComponent<AnimalNeeds>().Mating = 0f;
}
if (b.HasComponent<AnimalNeeds>())
{
b.GetComponent<AnimalNeeds>().Mating = 0f;
}
}
}
// Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие).
@@ -544,3 +695,179 @@ public sealed class AnimalMortalitySystem : BaseSystem
}
}
}
/// <summary>
/// Гон и влечение (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut и поднимает половое
/// влечение (<see cref="AnimalNeeds.Mating"/>); вне сезона снимает хедиф, влечение спадает. Только под
/// гоном влечение переходит порог действия — вне сезона зачатия фактически нет.
/// </summary>
public sealed class AnimalRutSystem(
GameClock clock,
float secondsPerDay,
Climate climate,
HediffDef rut
) : QuerySystem<AnimalGrowth, AnimalOrganism, AnimalNeeds, Health>
{
private const float RutDrivePerDay = 1.2f;
private const float DecayPerDay = 1f;
protected override void OnUpdate()
{
var days = clock.DeltaTime / secondsPerDay;
if (days <= 0f)
{
return;
}
var season = (int)climate.Season;
foreach (var (growths, organisms, needs, healths, _) in Query.Chunks)
{
var g = growths.Span;
var o = organisms.Span;
var n = needs.Span;
var h = healths.Span;
for (var i = 0; i < g.Length; i++)
{
var inRut =
g[i].Stage >= AnimalFactory.StageAdult && season == o[i].Traits.BreedingSeason;
var state = h[i].State;
if (inRut)
{
state.Add(rut);
}
else
{
state.Remove(rut.DefName);
}
ref var need = ref n[i];
need.Mating = inRut
? Math.Clamp(need.Mating + RutDrivePerDay * days, 0f, 1f)
: MathF.Max(0f, need.Mating - DecayPerDay * days);
}
}
}
}
/// <summary>
/// Беременность и роды (фаза A4): тикает срок вынашивания; по его истечении рожает помёт — каждый
/// детёныш = <see cref="Genome.Breed"/> генома матери и отца (пол наследуется без YY), стадия Baby,
/// поколение матери + 1, рядом с матерью. Компонент <see cref="Pregnant"/> снимается. Жёсткий потолок
/// численности страхует от взрыва популяции (бум-крах остаётся ниже потолка).
/// </summary>
public sealed class AnimalPregnancySystem : BaseSystem
{
private readonly EntityStore _store;
private readonly AnimalSet _animals;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly int _cellSize;
private readonly int _maxAnimals;
private readonly Random _rng;
private readonly ArchetypeQuery<Pregnant, AnimalOrganism, Transform2D> _query;
private readonly ArchetypeQuery<AnimalOrganism> _census;
private readonly List<Birth> _births = [];
public AnimalPregnancySystem(
EntityStore store,
AnimalSet animals,
GameClock clock,
float secondsPerDay,
int cellSize,
int maxAnimals,
int seed
)
{
_store = store;
_animals = animals;
_clock = clock;
_secondsPerDay = secondsPerDay;
_cellSize = cellSize;
_maxAnimals = maxAnimals;
_rng = new Random(seed);
_query = store.Query<Pregnant, AnimalOrganism, Transform2D>();
_census = store.Query<AnimalOrganism>();
}
protected override void OnUpdateGroup()
{
var days = _clock.DeltaTime / _secondsPerDay;
if (days <= 0f)
{
return;
}
_births.Clear();
foreach (var (pregnancies, organisms, transforms, entities) in _query.Chunks)
{
var p = pregnancies.Span;
var o = organisms.Span;
var t = transforms.Span;
for (var i = 0; i < p.Length; i++)
{
ref var preg = ref p[i];
preg.DueInDays -= days;
if (preg.DueInDays > 0f)
{
continue;
}
_births.Add(
new Birth
{
Mother = entities.EntityAt(i),
Species = o[i].Species,
MotherGenome = o[i].Genome,
FatherGenome = preg.FatherGenome,
Litter = preg.Litter,
Generation = o[i].Generation + 1,
Position = t[i].Position,
}
);
}
}
var count = _census.Count;
foreach (var birth in _births)
{
if (birth.Mother.HasComponent<Pregnant>())
{
birth.Mother.RemoveComponent<Pregnant>();
}
for (var k = 0; k < birth.Litter && count < _maxAnimals; k++)
{
var child = Genome.Breed(
birth.MotherGenome,
birth.FatherGenome,
_animals.GeneRegistry,
_rng
);
var offset =
new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize;
AnimalFactory.Create(
_store,
_animals,
birth.Species,
birth.Position + offset,
ageDays: 0f,
child,
birth.Generation,
_cellSize
);
count++;
}
}
}
private struct Birth
{
public Entity Mother;
public int Species;
public Genome MotherGenome;
public Genome FatherGenome;
public int Litter;
public int Generation;
public Vector2 Position;
}
}