Files
LittleSim/src/LittleSim/Sim/AnimalSystems.cs
T
Leonid PershinandClaude Opus 4.8 e105938a22 Wire health capacities to behaviour: filtration→immunity, sight/hearing→perception
The expanded capacities were mostly display-only; make two of them affect the
simulation (Moving→speed and Consciousness→intelligence gating already existed):

- Blood filtration → toxin/disease clearance: AdvanceHediffs takes an immunity
  scale (default 1); AnimalHealthSystem passes the BloodFiltration capacity, so
  damaged kidneys/liver clear Poisoned and diseases slower (RimWorld model).
  Ties the new capacities to the coevolution work — weak filtration makes toxic
  plants deadlier.
- Sight/hearing → perception radius: foraging, hunting and mate search scale by
  Sight; threat detection (flee) scales by max(Sight, Hearing) — you see or hear
  a predator. Injured eyes/ears shrink awareness.

Eating/Talking/Digestion remain indicators for now. Build + --check-content
clean. Not GUI-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:18:35 +03:00

1811 lines
73 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Id поведений-исполнителей (реестр действий ИИ). Дефы нужд (<see cref="NeedDef.Action"/>) ссылаются на
/// них; <see cref="AnimalDecisionSystem"/> выбирает действие, <see cref="AnimalActionSystem"/> исполняет.
/// Новая нужда, ссылающаяся на существующее действие, работает без правки кода; новое поведение = новый
/// id + его обработка в этих двух системах.
/// </summary>
public static class AnimalActions
{
public const string Wander = "Wander";
public const string Eat = "Eat";
public const string Drink = "Drink";
public const string Sleep = "Sleep";
public const string Mate = "Mate";
/// <summary>Охота: преследовать живую добычу и кусать (урон) либо есть труп — утоляет голод хищника.</summary>
public const string Hunt = "Hunt";
/// <summary>Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд).</summary>
public const string Flee = "Flee";
}
/// <summary>
/// Id событийных мыслей настроения (фаза A8). Системы-источники события вешают мысль с этим id (через
/// <see cref="Content.ThoughtSet"/>); её сила/длительность/метка — данные (thoughts.json). Новая мысль на
/// новое событие = код-триггер + деф; правка чисел существующей — только JSON.
/// </summary>
public static class AnimalThoughts
{
public const string GaveBirth = "GaveBirth";
public const string Mated = "Mated";
public const string QuenchedThirst = "QuenchedThirst";
}
/// <summary>
/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из <see cref="NeedSet"/>
/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в
/// <see cref="AnimalNeedsSystem"/> по <see cref="NeedDef"/>, восполняются исполнением действия.
/// </summary>
public struct AnimalNeeds : IComponent
{
/// <summary>Значения нужд [0,1] по индексам <see cref="NeedSet"/>.</summary>
public float[] Values;
/// <summary>Накоплено игровых дней истощения (летальная нужда на нуле); порог — смерть (A3).</summary>
public float Starve;
}
/// <summary>
/// «Мозг» зверя: выбранное действие (id поведения), его цель и таймер до пересмотра. Решение принимает
/// <see cref="AnimalDecisionSystem"/> (движковый <see cref="UtilityAi{TContext}"/>), исполняет
/// <see cref="AnimalActionSystem"/>.
/// </summary>
public struct AnimalBrain : IComponent
{
/// <summary>Id текущего действия (см. <see cref="AnimalActions"/>).</summary>
public string Action;
/// <summary>Куда идти (позиция растения/воды/партнёра/точки блуждания).</summary>
public Vector2 Target;
/// <summary>Id растения-цели для <see cref="AnimalActions.Eat"/>; -1 — нет.</summary>
public int TargetPlant;
/// <summary>Id партнёра для <see cref="AnimalActions.Mate"/>; -1 — нет.</summary>
public int TargetMate;
/// <summary>Id цели для <see cref="AnimalActions.Hunt"/> — живой добычи или трупа; -1 — нет.</summary>
public int TargetPrey;
/// <summary>Цель — труп (падаль), а не живая добыча: при достижении едим, а не атакуем.</summary>
public bool TargetIsCorpse;
/// <summary>Секунды до следующего пересмотра решения.</summary>
public float DecideIn;
}
/// <summary>
/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды) с гейтом интеллектом
/// (фаза A7): нужда, чей <see cref="NeedDef.MinBrain"/> выше эффективного интеллекта особи
/// (brainSize × Consciousness), в выбор не входит.
/// </summary>
public readonly struct AnimalContext(float[] values, NeedSet needs, float intelligence)
{
private readonly float[] _values = values;
private readonly NeedSet _needs = needs;
private readonly float _intelligence = intelligence;
/// <summary>
/// Значение нужды по индексу. Если её порог мозга выше интеллекта особи — нужда недоступна и
/// возвращается её «не-срочный» край (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы
/// соображение дало ~0 и действие не выбралось. Индекс вне диапазона → 0.
/// </summary>
public float Get(int index)
{
if (index >= _values.Length)
{
return 0f;
}
if (_needs[index].MinBrain > _intelligence)
{
return _needs.IsDrive(index) ? 0f : 1f;
}
return _values[index];
}
}
/// <summary>
/// Падение/рост нужд со временем (игровые дни через <see cref="GameClock"/>): deplete-нужды убывают
/// (× ген обмена веществ), drive-нужды растут под своим хедифом (гон) и спадают вне его. Летальные
/// нужды на нуле копят счётчик истощения. Полностью управляется данными <see cref="NeedSet"/>.
/// </summary>
public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay, NeedSet needs)
: QuerySystem<AnimalNeeds, AnimalOrganism, Health>
{
// Прибавка к обмену веществ при устойчивости к яду 1.0 (цена детоксикации, коэволюция C2).
private const float ToleranceMetabolicCost = 0.5f;
protected override void OnUpdate()
{
var days = clock.DeltaTime / secondsPerDay;
if (days <= 0f)
{
return;
}
foreach (var (needsChunk, organisms, healths, _) in Query.Chunks)
{
var n = needsChunk.Span;
var o = organisms.Span;
var h = healths.Span;
for (var i = 0; i < n.Length; i++)
{
var values = n[i].Values;
if (values is null || values.Length < needs.Count)
{
continue; // защита от старого/пустого состояния
}
// Цена устойчивости к яду (коэволюция C2): детоксикация метаболически затратна — чем выше
// toxinTolerance, тем быстрее расходуются нужды (зверь голоднее). Без этой платы устойчивость
// ушла бы к максимуму у всех и гонка вооружений встала бы. Множитель к обмену веществ.
var metabolism =
MathF.Max(0.1f, o[i].Traits.Metabolism)
* (
1f + ToleranceMetabolicCost * Math.Clamp(o[i].Traits.ToxinTolerance, 0f, 1f)
);
var lethalEmpty = false;
for (var k = 0; k < needs.Count; k++)
{
var def = needs[k];
if (needs.IsDrive(k))
{
var rising = def.RisesUnder is null || h[i].State.Has(def.RisesUnder);
var delta = (rising ? def.RisePerDay : -def.DecayPerDay) * days;
values[k] = Math.Clamp(values[k] + delta, 0f, 1f);
}
else
{
values[k] = Math.Clamp(
values[k] - def.DecayPerDay * metabolism * days,
0f,
1f
);
if (def.Lethal && values[k] <= 0f)
{
lethalEmpty = true;
}
}
}
ref var starve = ref n[i].Starve;
starve = lethalEmpty ? starve + days : MathF.Max(0f, starve - days * 2f);
}
}
}
}
/// <summary>
/// Гон (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut, вне сезона снимает. Рост влечения
/// под этим хедифом считает <see cref="AnimalNeedsSystem"/> (нужда Mating с <c>risesUnder=Rut</c>) —
/// сезонность и влечение разнесены: эта система отвечает только за состояние.
/// </summary>
public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDef rut)
: QuerySystem<AnimalGrowth, AnimalOrganism, Health>
{
protected override void OnUpdate()
{
var season = (int)climate.Season;
foreach (var (growths, organisms, healths, _) in Query.Chunks)
{
var g = growths.Span;
var o = organisms.Span;
var h = healths.Span;
for (var i = 0; i < g.Length; i++)
{
var inRut =
AnimalFactory.IsAdult(animals[o[i].Species], g[i].Stage)
&& season == o[i].Traits.BreedingSeason;
if (inRut)
{
h[i].State.Add(rut);
}
else
{
h[i].State.Remove(rut.DefName);
}
}
}
}
}
/// <summary>
/// Слой ВЫБОРА: раз в <see cref="Interval"/> секунд каждый зверь выбирает действие движковым
/// <see cref="UtilityAi{TContext}"/>, построенным ИЗ ДАННЫХ <see cref="NeedSet"/> (по соображению на
/// нужду), и находит цель действия (еда по диете, вода, партнёр). Решение пишется в
/// <see cref="AnimalBrain"/>; исполняет <see cref="AnimalActionSystem"/>.
/// </summary>
public sealed class AnimalDecisionSystem : BaseSystem
{
private const float Interval = 0.6f;
private const float MateMoodFloor = 0.35f; // ниже — стресс/истощение, зверь не спаривается (A8)
private readonly UtilityAi<AnimalContext> _brain;
private readonly GameClock _clock;
private readonly AnimalSet _animalSet;
private readonly NeedSet _needs;
private readonly int _cellSize;
private readonly Vector2[] _shore;
private readonly Random _rng;
private readonly ArchetypeQuery<
AnimalNeeds,
AnimalBrain,
AnimalOrganism,
AnimalGrowth,
Transform2D
> _animals;
private readonly ArchetypeQuery<Transform2D, PlantGrowth, PlantOrganism> _plants;
private readonly ArchetypeQuery<Transform2D, Corpse> _corpses;
public AnimalDecisionSystem(
EntityStore store,
GameClock clock,
AnimalSet animals,
NeedSet needs,
int cellSize,
Vector2[] shore,
int seed
)
{
_brain = BuildBrain(needs);
_clock = clock;
_animalSet = animals;
_needs = needs;
_cellSize = cellSize;
_shore = shore;
_rng = new Random(seed);
_animals = store.Query<
AnimalNeeds,
AnimalBrain,
AnimalOrganism,
AnimalGrowth,
Transform2D
>();
_plants = store.Query<Transform2D, PlantGrowth, PlantOrganism>();
_corpses = store.Query<Transform2D, Corpse>();
}
// Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже
// значение, тем привлекательнее ((1-v)^3). Drive: чем выше влечение, тем привлекательнее (v).
private static UtilityAi<AnimalContext> BuildBrain(NeedSet needs)
{
var actions = new List<UtilityAction<AnimalContext>>(needs.Count + 1);
for (var i = 0; i < needs.Count; i++)
{
var index = i;
var def = needs[i];
var consideration = needs.IsDrive(i)
? new Consideration<AnimalContext>(def.DefName, c => c.Get(index))
: new Consideration<AnimalContext>(
def.DefName,
c => c.Get(index),
curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f)
);
actions.Add(new UtilityAction<AnimalContext>(def.Action, consideration));
}
actions.Add(
new UtilityAction<AnimalContext>(
AnimalActions.Wander,
new Consideration<AnimalContext>("idle", _ => 0.12f)
)
);
return new UtilityAi<AnimalContext>(actions.ToArray());
}
protected override void OnUpdateGroup()
{
var delta = _clock.DeltaTime;
foreach (
var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks
)
{
var n = needsChunk.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++)
{
ref var brain = ref b[i];
brain.DecideIn -= delta;
if (brain.DecideIn > 0f)
{
continue;
}
brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан
var pos = t[i].Position;
var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage);
var self = entities.EntityAt(i);
var health = self.GetComponent<Health>().State;
// Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7):
// больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы.
var intelligence = AnimalFactory.Intelligence(o[i].Traits, health);
// Радиус восприятия по способностям: зрение ищет корм/партнёра/добычу, угрозу замечаем
// зрением ИЛИ слухом (повреждённые глаза/уши сужают восприятие).
var baseRadius = VisionRadius(o[i].Species, o[i].Traits.Vision);
var sight = health?.Capacity(AnimalCapacities.Sight) ?? 1f;
var hearing = health?.Capacity(AnimalCapacities.Hearing) ?? 1f;
var seeRadius = baseRadius * MathF.Max(0.2f, sight);
var senseRadius = baseRadius * MathF.Max(0.2f, MathF.Max(sight, hearing));
// Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию.
var mood = self.GetComponent<Mood>();
// Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее
// любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo).
if (TryFindThreat(pos, senseRadius, o[i].Species, o[i].Traits, out var threatPos))
{
brain.Action = AnimalActions.Flee;
brain.TargetPlant = -1;
brain.TargetPrey = -1;
var away = pos - threatPos;
var len = away.Length();
brain.Target =
len > 0.001f
? pos + away / len * (senseRadius + _cellSize)
: pos + new Vector2(_cellSize, 0f);
continue;
}
var (eatsPlants, eatsMeat) = AnimalFactory.Diet(
o[i].Traits,
_animalSet[o[i].Species].Def
);
var name =
_brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name
?? AnimalActions.Wander;
switch (name)
{
// Хищник/всеядное голодает → ищет труп (падаль) или живую добычу и охотится.
case AnimalActions.Eat
when eatsMeat
&& TryFindKill(
pos,
seeRadius,
o[i].Species,
o[i].Traits,
self.Id,
out var preyId,
out var preyPos,
out var preyIsCorpse
):
brain.Action = AnimalActions.Hunt;
brain.TargetPlant = -1;
brain.TargetPrey = preyId;
brain.TargetIsCorpse = preyIsCorpse;
brain.Target = preyPos;
break;
case AnimalActions.Eat
when eatsPlants
&& TryFindForage(
pos,
seeRadius,
intelligence,
o[i].Traits.ToxinTolerance,
out var plant,
out var pp
):
brain.Action = AnimalActions.Eat;
brain.TargetPlant = plant;
brain.Target = pp;
break;
case AnimalActions.Drink when TryFindShore(pos, out var water):
brain.Action = AnimalActions.Drink;
brain.TargetPlant = -1;
brain.Target = water;
break;
case AnimalActions.Sleep:
brain.Action = AnimalActions.Sleep;
brain.TargetPlant = -1;
break;
case AnimalActions.Mate
when adult
&& (!mood.Active || mood.Value >= MateMoodFloor)
&& TryFindMate(
pos,
o[i].IsMale,
seeRadius,
out var mateId,
out var matePos
):
brain.Action = AnimalActions.Mate;
brain.TargetPlant = -1;
brain.TargetMate = mateId;
brain.Target = matePos;
break;
default:
brain.Action = AnimalActions.Wander;
brain.TargetPlant = -1;
var angle = _rng.NextSingle() * MathF.Tau;
brain.Target =
pos
+ new Vector2(MathF.Cos(angle), MathF.Sin(angle))
* (_cellSize * (4f + _rng.NextSingle() * 6f));
break;
}
}
}
}
// Радиус восприятия особи: базовая дальность вида (данные) × ген зрения × размер клетки.
private float VisionRadius(int species, float vision) =>
MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize;
// Ближайший хищник-угроза в радиусе восприятия (для бегства жертвы). Угроза = другой вид, который
// ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма.
private bool TryFindThreat(
Vector2 from,
float radius,
int selfSpecies,
in AnimalPhenotype selfTraits,
out Vector2 threatPos
)
{
var bestSq = radius * radius;
threatPos = default;
var found = false;
var (_, selfEatsMeat) = AnimalFactory.Diet(selfTraits, _animalSet[selfSpecies].Def);
var selfBody = selfTraits.BodySize;
foreach (var (_, _, organisms, _, transforms, _) in _animals.Chunks)
{
var oo = organisms.Span;
var tt = transforms.Span;
for (var i = 0; i < tt.Length; i++)
{
if (
oo[i].Species == selfSpecies
|| !IsThreatTo(selfEatsMeat, selfBody, oo[i].Species, oo[i].Traits)
)
{
continue;
}
var sq = Vector2.DistanceSquared(from, tt[i].Position);
if (sq < bestSq)
{
bestSq = sq;
threatPos = tt[i].Position;
found = true;
}
}
}
return found;
}
// Является ли вид-кандидат угрозой особи: он ест мясо и не мельче её. Чистая жертва (не мясоед)
// бежит от любого хищника не мельче себя; сам хищник — лишь от заметно более крупного мясоеда.
private bool IsThreatTo(
bool selfEatsMeat,
float selfBody,
int otherSpecies,
in AnimalPhenotype otherTraits
)
{
var (_, otherEatsMeat) = AnimalFactory.Diet(otherTraits, _animalSet[otherSpecies].Def);
if (!otherEatsMeat)
{
return false;
}
var otherBody = otherTraits.BodySize;
return selfEatsMeat ? otherBody > selfBody * 1.1f : otherBody >= selfBody * 0.9f;
}
// Ближайшая «мясная» цель: труп (падаль, приоритет — даровая еда без риска) или живая добыча.
// Возвращает id, позицию и флаг трупа. Добыча — другой вид не крупнее охотника (см. IsPreyFor).
private bool TryFindKill(
Vector2 from,
float radius,
int selfSpecies,
in AnimalPhenotype selfTraits,
int selfId,
out int targetId,
out Vector2 position,
out bool isCorpse
)
{
targetId = -1;
position = default;
isCorpse = false;
// 1) Падаль с остатком мяса.
var bestCorpseSq = radius * radius;
var corpseId = -1;
var corpsePos = default(Vector2);
foreach (var (transforms, corpses, entities) in _corpses.Chunks)
{
var tt = transforms.Span;
var cc = corpses.Span;
for (var i = 0; i < tt.Length; i++)
{
if (cc[i].Meat <= 0f)
{
continue;
}
var sq = Vector2.DistanceSquared(from, tt[i].Position);
var id = entities.EntityAt(i).Id;
if (sq < bestCorpseSq || (sq == bestCorpseSq && id < corpseId))
{
bestCorpseSq = sq;
corpseId = id;
corpsePos = tt[i].Position;
}
}
}
// 2) Живая добыча.
var bestPreySq = radius * radius;
var preyId = -1;
var preyPos = default(Vector2);
var selfBody = selfTraits.BodySize;
foreach (var (_, _, organisms, _, transforms, entities) in _animals.Chunks)
{
var oo = organisms.Span;
var tt = transforms.Span;
for (var i = 0; i < tt.Length; i++)
{
var id = entities.EntityAt(i).Id;
if (oo[i].Species == selfSpecies || id == selfId)
{
continue;
}
if (!IsPreyFor(selfBody, oo[i].Traits))
{
continue;
}
var sq = Vector2.DistanceSquared(from, tt[i].Position);
if (sq < bestPreySq || (sq == bestPreySq && id < preyId))
{
bestPreySq = sq;
preyId = id;
preyPos = tt[i].Position;
}
}
}
// Предпочитаем падаль, если она не намного дальше живой добычи (даровая еда, без риска).
if (corpseId >= 0 && (preyId < 0 || bestCorpseSq <= bestPreySq * 2f))
{
targetId = corpseId;
position = corpsePos;
isCorpse = true;
return true;
}
if (preyId >= 0)
{
targetId = preyId;
position = preyPos;
isCorpse = false;
return true;
}
return false;
}
// Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника
// (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих.
private static bool IsPreyFor(float hunterBody, in AnimalPhenotype otherTraits) =>
otherTraits.BodySize <= hunterBody * 1.25f;
// Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм).
// Порог интеллекта, с которого травоядное РАЗБИРАЕТ корм (избегает ядовитого/невкусного). Ниже —
// рефлекторное выедание ближайшего растения (тир мозга 4, как сон/мысли; больной зверь с упавшим
// сознанием падает ниже и снова жрёт что попало — последствие гейтинга A7).
private const float ForageSmartMinBrain = 0.4f;
// Вес, с которым воспринимаемый яд (с поправкой на устойчивость) отталкивает от растения, и штраф за
// дальность — чтобы умный зверь предпочитал близкий и безопасный корм, а не бежал через всю карту.
private const float ToxinAvoidWeight = 1.2f;
private const float ForageDistanceWeight = 0.5f;
// Выбор корма: умный (избегает яда/невкусного) если интеллект ≥ порога, иначе рефлекс — ближайшее.
private bool TryFindForage(
Vector2 from,
float radius,
float intelligence,
float toxinTolerance,
out int plantId,
out Vector2 position
) =>
intelligence >= ForageSmartMinBrain
? TryFindBestPlant(from, radius, toxinTolerance, out plantId, out position)
: TryFindPlant(from, radius, out plantId, out position);
// Рефлекс: ближайшее растение в радиусе (ничья — меньший id, детерминизм).
private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position)
{
var bestSq = radius * radius;
plantId = -1;
position = default;
foreach (var (transforms, _, _, entities) in _plants.Chunks)
{
var t = transforms.Span;
for (var i = 0; i < t.Length; i++)
{
var sq = Vector2.DistanceSquared(from, t[i].Position);
var id = entities.EntityAt(i).Id;
if (sq < bestSq || (sq == bestSq && id < plantId))
{
bestSq = sq;
plantId = id;
position = t[i].Position;
}
}
}
return plantId >= 0;
}
// Умный выбор: среди растений в радиусе максимизируем привлекательность = вкусность −
// воспринимаемый_яд − штраф_за_дальность. Воспринимаемый яд = toxicity × (1 − устойчивость): чем
// выше устойчивость зверя, тем меньше его отпугивает токсичный корм. Если всё в округе невкусно/
// ядовито, голод всё равно заставит выбрать наименее плохой (берётся максимум, даже отрицательный).
private bool TryFindBestPlant(
Vector2 from,
float radius,
float toxinTolerance,
out int plantId,
out Vector2 position
)
{
var radiusSq = radius * radius;
var tol = Math.Clamp(toxinTolerance, 0f, 1f);
var bestScore = float.NegativeInfinity;
plantId = -1;
position = default;
foreach (var (transforms, _, organisms, entities) in _plants.Chunks)
{
var t = transforms.Span;
var o = organisms.Span;
for (var i = 0; i < t.Length; i++)
{
var sq = Vector2.DistanceSquared(from, t[i].Position);
if (sq > radiusSq)
{
continue;
}
ref readonly var tr = ref o[i].Traits;
var perceivedToxin = tr.Toxicity * (1f - tol);
var score =
tr.Palatability
- ToxinAvoidWeight * perceivedToxin
- ForageDistanceWeight * (radius > 0f ? MathF.Sqrt(sq) / radius : 0f);
var id = entities.EntityAt(i).Id;
if (score > bestScore || (score == bestScore && id < plantId))
{
bestScore = score;
plantId = id;
position = t[i].Position;
}
}
}
return plantId >= 0;
}
// Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2).
private bool TryFindShore(Vector2 from, out Vector2 position)
{
position = default;
if (_shore.Length == 0)
{
return false;
}
var bestSq = float.MaxValue;
foreach (var cell in _shore)
{
var sq = Vector2.DistanceSquared(from, cell);
if (sq < bestSq)
{
bestSq = sq;
position = cell;
}
}
return true;
}
// Ближайший подходящий партнёр в радиусе зрения: взрослый, противоположного пола, с влечением > 0.5.
private bool TryFindMate(
Vector2 from,
bool selfMale,
float radius,
out int mateId,
out Vector2 position
)
{
var bestSq = radius * radius;
mateId = -1;
position = default;
var mateNeed = -1;
foreach (var (needsChunk, _, organisms, growths, transforms, entities) in _animals.Chunks)
{
var nn = needsChunk.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
|| !AnimalFactory.IsAdult(_animalSet[oo[i].Species], gg[i].Stage)
)
{
continue; // тот же пол / не взрослый (self отсеивается по полу)
}
if (mateNeed < 0)
{
mateNeed = MateNeedIndex(nn[i].Values); // влечение — самый высокий drive-кандидат
}
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;
}
// Влечение партнёра отсекается в исполнении (контакт), а для выбора достаточно пола/взрослости —
// упрощённый поиск (полный учёт влечения партнёра придёт с половым отбором). Возвращает 0 (заглушка).
private static int MateNeedIndex(float[] _) => 0;
}
/// <summary>
/// Слой ИСПОЛНЕНИЯ: ведёт зверя к цели (скорость = базовая × ген moveSpeed) и при достижении исполняет
/// действие по его id — ест растение (выедает; трава до нуля исчезает → ёмкость среды), пьёт у воды,
/// спит, спаривается. Восполняемую нужду находит по действию через <see cref="NeedSet"/>. Структурные
/// изменения (гибель растений, беременность) применяются после прохода.
/// </summary>
public sealed class AnimalActionSystem : BaseSystem
{
private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает
private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась»
private const float FleeSpeedMult = 1.2f; // страх подгоняет жертву чуть быстрее обычного шага
private readonly EntityStore _store;
private readonly PlantSet _plants;
private readonly AnimalSet _animalSet;
private readonly NeedSet _needs;
private readonly ThoughtSet _thoughts;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly int _cellSize;
private readonly RectF _bounds;
private readonly HediffDef? _bleeding;
private readonly HediffDef? _poisoned;
private readonly Random _rng;
private readonly ArchetypeQuery<
AnimalBrain,
AnimalNeeds,
AnimalOrganism,
Transform2D,
Health
> _query;
private readonly List<Entity> _eaten = [];
private readonly List<Entity> _consumed = []; // трупы, доеденные до нуля
private readonly List<Entity> _kills = []; // добыча, забитая в этом проходе (смерть после итерации)
private readonly List<(Entity Self, Entity Partner)> _matings = [];
public AnimalActionSystem(
EntityStore store,
PlantSet plants,
AnimalSet animals,
NeedSet needs,
ThoughtSet thoughts,
GameClock clock,
float secondsPerDay,
int cellSize,
RectF bounds,
HediffDef? bleeding,
HediffDef? poisoned,
int seed
)
{
_store = store;
_plants = plants;
_animalSet = animals;
_needs = needs;
_thoughts = thoughts;
_clock = clock;
_secondsPerDay = secondsPerDay;
_cellSize = cellSize;
_bounds = bounds;
_bleeding = bleeding;
_poisoned = poisoned;
_rng = new Random(seed);
_query = store.Query<AnimalBrain, AnimalNeeds, AnimalOrganism, Transform2D, Health>();
}
protected override void OnUpdateGroup()
{
var seconds = _clock.DeltaTime;
if (seconds <= 0f)
{
return;
}
var days = seconds / _secondsPerDay;
_eaten.Clear();
_consumed.Clear();
_kills.Clear();
_matings.Clear();
foreach (
var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks
)
{
var b = brains.Span;
var n = needsChunk.Span;
var o = organisms.Span;
var t = transforms.Span;
var hh = healths.Span;
for (var i = 0; i < b.Length; i++)
{
ref var brain = ref b[i];
var values = n[i].Values;
ref var pos = ref t[i].Position;
var def = _animalSet[o[i].Species].Def;
// Способность Moving (падает от ран/болезней) — множитель к скорости: больные медленнее.
var moving = hh[i].State.Capacity(AnimalCapacities.Moving);
var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed) * moving;
switch (brain.Action)
{
case AnimalActions.Sleep:
Refill(values, AnimalActions.Sleep, days);
break;
case AnimalActions.Eat:
if (MoveTo(ref pos, brain.Target, speed * seconds))
{
Refill(values, AnimalActions.Eat, days);
Graze(brain.TargetPlant, days, def.ForageBiteDays);
// Коэволюция: защита растения бьёт по поедателю (яд/шипы).
ApplyPlantDefense(
brain.TargetPlant,
hh[i].State,
o[i].Traits.ToxinTolerance,
days
);
}
break;
case AnimalActions.Drink:
if (MoveTo(ref pos, brain.Target, speed * seconds))
{
// Утоление сильной жажды — событийная мысль настроения (A8); уровень берём
// ДО восполнения, чтобы радость была от облегчения, а не от каждого глотка.
var thirst = _needs.NeedForAction(AnimalActions.Drink);
var parched =
thirst >= 0
&& thirst < values.Length
&& values[thirst] < ThirstReliefBelow;
Refill(values, AnimalActions.Drink, days);
if (parched)
{
entities
.EntityAt(i)
.GetComponent<Mood>()
.Add(_thoughts.Get(AnimalThoughts.QuenchedThirst));
}
}
break;
case AnimalActions.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;
case AnimalActions.Hunt:
Hunt(ref brain, ref pos, values, def, speed * seconds, days);
break;
case AnimalActions.Flee:
MoveTo(ref pos, brain.Target, speed * FleeSpeedMult * seconds);
break;
default: // Wander
MoveTo(ref pos, brain.Target, speed * seconds);
break;
}
pos.X = Math.Clamp(pos.X, _bounds.Left, _bounds.Right);
pos.Y = Math.Clamp(pos.Y, _bounds.Top, _bounds.Bottom);
}
}
foreach (var plant in _eaten)
{
plant.DeleteEntity();
}
foreach (var corpse in _consumed)
{
if (!corpse.IsNull)
{
corpse.DeleteEntity(); // труп доеден до нуля
}
}
foreach (var prey in _kills)
{
AnimalFactory.Die(_store, _animalSet, prey, _cellSize); // забитая добыча → труп
}
ApplyMatings();
}
// Охота/падальщество: ведёт к цели и при контакте либо ест труп (утоляет голод, расходует мясо),
// либо кусает живую добычу — урон части тела, острая кровопотеря, рана-кровотечение (первый
// травматический урон). Добитая добыча помечается на смерть (труп оставит общий путь Die).
private void Hunt(
ref AnimalBrain brain,
ref Vector2 pos,
float[] values,
AnimalDef def,
float step,
float days
)
{
if (
brain.TargetPrey < 0
|| !_store.TryGetEntityById(brain.TargetPrey, out var target)
|| target.IsNull
|| !target.HasComponent<Transform2D>()
)
{
return; // цель исчезла — действие пересмотрят на следующем решении
}
var targetPos = target.GetComponent<Transform2D>().Position;
var range = def.AttackRangeCells * _cellSize;
var inRange = Vector2.DistanceSquared(pos, targetPos) <= range * range;
if (brain.TargetIsCorpse)
{
if (!target.HasComponent<Corpse>())
{
return;
}
if (!inRange)
{
MoveTo(ref pos, targetPos, step);
return;
}
ref var corpse = ref target.GetComponent<Corpse>();
Refill(values, AnimalActions.Eat, days);
corpse.Meat -= def.ForageBiteDays * days; // расход мяса той же «силой укуса», что и выедание
if (corpse.Meat <= 0f && !_consumed.Contains(target))
{
_consumed.Add(target);
}
return;
}
// Живая добыча.
if (!target.HasComponent<Health>() || !target.HasComponent<AnimalOrganism>())
{
return;
}
if (!inRange)
{
MoveTo(ref pos, targetPos, step);
return;
}
var preyHealth = target.GetComponent<Health>().State;
if (preyHealth is null)
{
return;
}
preyHealth.ApplyInjury(
def.AttackDamage * days,
def.AttackBloodLoss * days,
_bleeding,
def.AttackBleed * days,
_rng
);
if (
preyHealth.BloodLevel <= 0f
|| preyHealth.Capacity(AnimalCapacities.Consciousness) <= 0.01f
)
{
if (!_kills.Contains(target))
{
_kills.Add(target); // добита — смерть после прохода (структурное изменение)
}
}
}
// Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay.
private void Refill(float[] values, string action, float days)
{
var index = _needs.NeedForAction(action);
if (index < 0 || values is null || index >= values.Length)
{
return;
}
values[index] = Math.Clamp(values[index] + _needs[index].FeedPerDay * days, 0f, 1f);
}
// Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие).
private static bool MoveTo(ref Vector2 pos, Vector2 target, float step)
{
var delta = target - pos;
var dist = delta.Length();
if (dist <= step || dist < 0.001f)
{
pos = dist < 0.001f ? pos : target;
return true;
}
pos += delta / dist * step;
return false;
}
// Выедание: убавляет рост растения; траву (без ствола), выеденную до нуля, помечает на гибель.
private void Graze(int plantId, float days, float bitePerDay)
{
if (
plantId < 0
|| !_store.TryGetEntityById(plantId, out var plant)
|| plant.IsNull
|| !plant.HasComponent<PlantGrowth>()
)
{
return;
}
ref var grow = ref plant.GetComponent<PlantGrowth>();
grow.AgeDays = MathF.Max(0f, grow.AgeDays - bitePerDay * days);
var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f;
if (isGrass && grow.AgeDays <= GrazeKillAge && !_eaten.Contains(plant))
{
_eaten.Add(plant);
}
}
// Доза яда за день укуса при токсичности 1 и нулевой устойчивости (масштабируется тяжести Poisoned).
private const float PoisonDosePerDay = 1.6f;
// Порог шипов, выше которого укус о растение травмирует, и масштабы урона/кровопотери от шипов.
private const float ThornThreshold = 0.2f;
private const float ThornPartDamage = 4f;
private const float ThornBloodLoss = 0.02f;
// Коэволюция: при поедании растение «отвечает» — токсичность отравляет (тем сильнее, чем ниже
// устойчивость зверя к яду), шипы наносят лёгкую травму. Доза яда копится в хедифе Poisoned; если
// зверь ест яд быстрее, чем выводит, тяжесть дойдёт до летальной. Детерминированно (общий _rng).
private void ApplyPlantDefense(
int plantId,
HealthState? health,
float toxinTolerance,
float days
)
{
if (
health is null
|| plantId < 0
|| !_store.TryGetEntityById(plantId, out var plant)
|| plant.IsNull
|| !plant.HasComponent<PlantOrganism>()
)
{
return;
}
ref readonly var traits = ref plant.GetComponent<PlantOrganism>().Traits;
var poisoned = false;
if (_poisoned is not null && traits.Toxicity > 0f)
{
var dose = traits.Toxicity * (1f - Math.Clamp(toxinTolerance, 0f, 1f));
if (dose > 0f)
{
health.Intensify(_poisoned, dose * PoisonDosePerDay * days);
poisoned = true;
}
}
if (traits.Thorns > ThornThreshold)
{
// ApplyInjury сам пересчитывает способности (учтёт и свежий яд).
health.ApplyInjury(
traits.Thorns * ThornPartDamage * days,
traits.Thorns * ThornBloodLoss * days,
_bleeding,
traits.Thorns * 0.15f * days,
_rng
);
}
else if (poisoned)
{
health.RecomputeCapacities(); // отравление меняет capacity-моды — применить сразу
}
}
// Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление —
// после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются.
private void ApplyMatings()
{
var mateNeed = _needs.NeedForAction(AnimalActions.Mate);
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)),
}
);
}
ResetMating(a, mateNeed);
ResetMating(b, mateNeed);
// Спаривание — положительная событийная мысль обоим партнёрам (A8).
var mated = _thoughts.Get(AnimalThoughts.Mated);
a.GetComponent<Mood>().Add(mated);
b.GetComponent<Mood>().Add(mated);
}
}
private static void ResetMating(Entity entity, int mateNeed)
{
if (mateNeed < 0 || !entity.HasComponent<AnimalNeeds>())
{
return;
}
var values = entity.GetComponent<AnimalNeeds>().Values;
if (values is not null && mateNeed < values.Length)
{
values[mateNeed] = 0f;
}
}
}
/// <summary>
/// Настроение (фаза A8): для особей тира 4+ (мозг ≥ порога) считает настроение из непрерывного фона
/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей.
/// Мысли угасают со временем. У примитивных (тир &lt; 4) настроения нет (Active=false, мысли сбрасываются).
/// Гейт интеллектом — тем же порогом тира 4, что и высшие нужды (сон/секс). Низкое настроение влияет на
/// поведение (стресс не даёт спариваться — см. <see cref="AnimalDecisionSystem"/>) и видно в консоли/инспекторе.
/// </summary>
public sealed class AnimalMoodSystem(GameClock clock, float secondsPerDay, NeedSet needs)
: QuerySystem<Mood, AnimalNeeds, AnimalOrganism, Health>
{
/// <summary>Порог размера мозга (тир 4), с которого у особи появляется настроение.</summary>
public const float MoodMinBrain = 0.40f;
private const float NeutralBase = 0.35f; // настроение при полном истощении нужд (без боли/мыслей)
private const float NeedsWeight = 0.55f; // вклад средней удовлетворённости убывающих нужд
private const float PainWeight = 0.4f; // боль из здоровья тянет настроение вниз
protected override void OnUpdate()
{
var days = clock.DeltaTime / secondsPerDay;
if (days <= 0f)
{
return;
}
foreach (var (moods, needsChunk, organisms, healths, _) in Query.Chunks)
{
var m = moods.Span;
var n = needsChunk.Span;
var o = organisms.Span;
var h = healths.Span;
for (var i = 0; i < m.Length; i++)
{
ref var mood = ref m[i];
if (o[i].Traits.BrainSize < MoodMinBrain)
{
mood.Active = false; // тир ниже зверя — настроения нет
mood.Value = 0.5f;
mood.Thoughts?.Clear();
continue;
}
mood.Active = true;
// 1) Затухание событийных мыслей + сумма их сдвигов.
var thoughtSum = 0f;
var list = mood.Thoughts;
if (list is not null)
{
for (var k = list.Count - 1; k >= 0; k--)
{
var t = list[k];
t.RemainingDays -= days;
if (t.RemainingDays <= 0f)
{
list.RemoveAt(k);
continue;
}
list[k] = t;
thoughtSum += t.Def.MoodOffset;
}
}
// 2) Непрерывный фон: средняя удовлетворённость убывающих нужд минус боль из здоровья.
var wellbeing = AvgDeplete(n[i].Values);
var pain = h[i].State?.Pain ?? 0f;
mood.Value = Math.Clamp(
NeutralBase + NeedsWeight * wellbeing - PainWeight * pain + thoughtSum,
0f,
1f
);
}
}
}
// Средняя удовлетворённость убывающих нужд (голод/жажда/усталость) [0,1]; нет нужд → 1 (нейтрально).
private float AvgDeplete(float[] values)
{
if (values is null)
{
return 1f;
}
var sum = 0f;
var count = 0;
for (var k = 0; k < needs.Count && k < values.Length; k++)
{
if (needs.IsDeplete(k))
{
sum += values[k];
count++;
}
}
return count > 0 ? sum / count : 1f;
}
}
/// <summary>
/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой
/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только
/// <see cref="Sprite"/> — граница sim/presentation цела.
/// </summary>
public sealed class AnimalAppearanceSystem(NeedSet needs)
: QuerySystem<AnimalNeeds, AnimalOrganism, Sprite>
{
private const float MinBrightness = 0.5f;
protected override void OnUpdate()
{
foreach (var (needsChunk, organisms, sprites, _) in Query.Chunks)
{
var n = needsChunk.Span;
var o = organisms.Span;
var s = sprites.Span;
for (var i = 0; i < n.Length; i++)
{
var values = n[i].Values;
var worst = 1f;
if (values is not null)
{
for (var k = 0; k < needs.Count && k < values.Length; k++)
{
if (needs.IsDeplete(k))
{
worst = MathF.Min(worst, values[k]);
}
}
}
var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(worst, 0f, 1f);
s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness;
}
}
}
}
/// <summary>
/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→
/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер.
/// </summary>
public sealed class AnimalGrowthSystem(
GameClock clock,
float secondsPerDay,
AnimalSet animals,
int cellSize
) : QuerySystem<AnimalGrowth, AnimalOrganism, Transform2D, Sprite>
{
protected override void OnUpdate()
{
var days = clock.DeltaTime / secondsPerDay;
if (days <= 0f)
{
return;
}
foreach (var (growths, organisms, transforms, sprites, _) in Query.Chunks)
{
var g = growths.Span;
var o = organisms.Span;
var t = transforms.Span;
var s = sprites.Span;
for (var i = 0; i < g.Length; i++)
{
ref var grow = ref g[i];
grow.AgeDays += days;
ref readonly var org = ref o[i];
var sp = animals[org.Species];
var stage = AnimalFactory.StageAt(
grow.AgeDays,
org.Traits.MaturityAge,
org.Traits.Lifespan,
sp.Stages
);
if (stage == grow.Stage)
{
continue;
}
grow.Stage = stage;
var region = AnimalFactory.RegionFor(sp, stage, org.IsMale);
ref var sprite = ref s[i];
sprite.Region = region;
sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f);
t[i].Scale = new Vector2(
AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize)
);
}
}
}
}
/// <summary>
/// Смертность (фаза A3/A5): гибель от старости (возраст превысил продолжительность жизни) или истощения
/// (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. Фаза A5: вместо деспавна
/// оставляет <see cref="Corpse"/> (через <see cref="AnimalFactory.CreateCorpse"/>), затем удаляет особь.
/// </summary>
public sealed class AnimalMortalitySystem : BaseSystem
{
private const float LethalStarveDays = 3f;
private readonly EntityStore _store;
private readonly AnimalSet _animals;
private readonly int _cellSize;
private readonly ArchetypeQuery<AnimalGrowth, AnimalOrganism, AnimalNeeds> _query;
private readonly List<Entity> _deaths = [];
public AnimalMortalitySystem(EntityStore store, AnimalSet animals, int cellSize)
{
_store = store;
_animals = animals;
_cellSize = cellSize;
_query = store.Query<AnimalGrowth, AnimalOrganism, AnimalNeeds>();
}
protected override void OnUpdateGroup()
{
_deaths.Clear();
foreach (var (growths, organisms, needsChunk, entities) in _query.Chunks)
{
var g = growths.Span;
var o = organisms.Span;
var n = needsChunk.Span;
for (var i = 0; i < g.Length; i++)
{
if (g[i].AgeDays > o[i].Traits.Lifespan || n[i].Starve >= LethalStarveDays)
{
_deaths.Add(entities.EntityAt(i));
}
}
}
foreach (var dead in _deaths)
{
AnimalFactory.Die(_store, _animals, dead, _cellSize);
}
}
}
/// <summary>
/// Разложение трупа (фаза A5): копит дни гниения (ускоряется в тепле, замедляется на морозе),
/// переключает стадии Fresh→Rotting→Skeleton по порогам и тинтит спрайт; по достижении предела труп
/// исчезает. Скелет рисуется бледным тинтом падали (отдельного спрайта пока нет — см. docs).
/// </summary>
public sealed class CorpseSystem : BaseSystem
{
private const float RottingDay = 2f;
private const float SkeletonDay = 6f;
private const float GoneDay = 14f;
private static readonly Color FreshTint = new(176, 148, 128);
private static readonly Color RottingTint = new(120, 118, 98);
private static readonly Color SkeletonTint = new(228, 226, 214);
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly Climate _climate;
private readonly ArchetypeQuery<Corpse, Sprite> _query;
private readonly List<Entity> _gone = [];
public CorpseSystem(EntityStore store, GameClock clock, float secondsPerDay, Climate climate)
{
_clock = clock;
_secondsPerDay = secondsPerDay;
_climate = climate;
_query = store.Query<Corpse, Sprite>();
}
protected override void OnUpdateGroup()
{
var days = _clock.DeltaTime / _secondsPerDay;
if (days <= 0f)
{
return;
}
// Тепло ускоряет гниение, около нуля и ниже — резко замедляет (заморозка).
var factor = Math.Clamp((_climate.Temperature + 5f) / 20f, 0.1f, 2.5f);
var advance = days * factor;
_gone.Clear();
foreach (var (corpses, sprites, entities) in _query.Chunks)
{
var c = corpses.Span;
var s = sprites.Span;
for (var i = 0; i < c.Length; i++)
{
ref var corpse = ref c[i];
corpse.RotDays += advance;
if (corpse.RotDays >= GoneDay)
{
_gone.Add(entities.EntityAt(i));
continue;
}
var stage =
corpse.RotDays >= SkeletonDay ? 2
: corpse.RotDays >= RottingDay ? 1
: 0;
if (stage != corpse.Stage)
{
corpse.Stage = stage;
s[i].Color =
stage == 2 ? SkeletonTint
: stage == 1 ? RottingTint
: FreshTint;
}
}
}
foreach (var entity in _gone)
{
entity.DeleteEntity();
}
}
}
/// <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 NeedSet _needs;
private readonly ThoughtSet _thoughts;
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,
NeedSet needs,
ThoughtSet thoughts,
GameClock clock,
float secondsPerDay,
int cellSize,
int maxAnimals,
int seed
)
{
_store = store;
_animals = animals;
_needs = needs;
_thoughts = thoughts;
_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>();
}
// Роды — сильная положительная мысль матери (A8).
if (birth.Mother.HasComponent<Mood>())
{
birth.Mother.GetComponent<Mood>().Add(_thoughts.Get(AnimalThoughts.GaveBirth));
}
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,
_needs,
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;
}
}
/// <summary>
/// Здоровье и болезни (фаза A6): медленный тик (~игровой час). Фоновое заражение, прогресс болезней
/// (гонка тяжесть↔иммунитет — выздоровление или смерть), пересчёт способностей с учётом хедифов. Смерть
/// по здоровью (летальная болезнь / кровь на нуле / потеря сознания) оставляет труп. Первый реальный
/// источник урона — оживляет каркас A5 (больные звери теряют Moving → медленнее).
/// </summary>
public sealed class AnimalHealthSystem : BaseSystem
{
private const float TickDays = 1f / 24f; // ~игровой час
private const float BloodRecoveryPerDay = 0.25f; // восстановление крови в покое (нет кровотечений)
private readonly EntityStore _store;
private readonly AnimalSet _animals;
private readonly int _cellSize;
private readonly GameClock _clock;
private readonly float _secondsPerDay;
private readonly HediffDef[] _ambient;
private readonly Random _rng;
private readonly ArchetypeQuery<Health, AnimalOrganism> _query;
private readonly List<Entity> _deaths = [];
private float _accum;
public AnimalHealthSystem(
EntityStore store,
AnimalSet animals,
int cellSize,
GameClock clock,
float secondsPerDay,
HediffDef[] ambient,
int seed
)
{
_store = store;
_animals = animals;
_cellSize = cellSize;
_clock = clock;
_secondsPerDay = secondsPerDay;
_ambient = ambient;
_rng = new Random(seed);
_query = store.Query<Health, AnimalOrganism>();
}
protected override void OnUpdateGroup()
{
_accum += _clock.DeltaTime / _secondsPerDay;
if (_accum < TickDays)
{
return;
}
var days = _accum;
_accum = 0f;
_deaths.Clear();
foreach (var (healths, organisms, entities) in _query.Chunks)
{
var h = healths.Span;
for (var i = 0; i < h.Length; i++)
{
var state = h[i].State;
if (state is null)
{
continue;
}
foreach (var disease in _ambient)
{
if (
!state.Has(disease.DefName)
&& _rng.NextSingle() < disease.AmbientPerDay * days
)
{
state.Add(disease);
}
}
// Фильтрация крови (почки/печень) ускоряет вывод токсинов и набор иммунитета: повреждённый
// орган → медленнее выздоровление от яда/болезни (для тела без неё Capacity вернёт 1).
var lethal = state.AdvanceHediffs(
days,
state.Capacity(AnimalCapacities.BloodFiltration)
);
// Кровопотеря от ран (кровотечения), иначе — постепенное восстановление крови в покое.
var bleed = 0f;
foreach (var hd in state.Hediffs)
{
bleed += hd.Def.BloodLossPerDay * hd.Severity;
}
if (bleed > 0f)
{
state.BloodLevel = Math.Clamp(state.BloodLevel - bleed * days, 0f, 1f);
}
else if (state.BloodLevel < 1f)
{
state.BloodLevel = Math.Clamp(
state.BloodLevel + BloodRecoveryPerDay * days,
0f,
1f
);
}
state.RecomputeCapacities();
if (
lethal
|| state.BloodLevel <= 0f
|| state.Capacity(AnimalCapacities.Consciousness) <= 0.01f
)
{
_deaths.Add(entities.EntityAt(i));
}
}
}
foreach (var dead in _deaths)
{
AnimalFactory.Die(_store, _animals, dead, _cellSize);
}
}
}