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>
1811 lines
73 KiB
C#
1811 lines
73 KiB
C#
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+ (мозг ≥ порога) считает настроение из непрерывного фона
|
||
/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей.
|
||
/// Мысли угасают со временем. У примитивных (тир < 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);
|
||
}
|
||
}
|
||
}
|