Plant–herbivore coevolution + RimWorld-style health capacities

Adds the first plant↔animal coupling beyond "herbivore eats plant": a toxin/
thorns ↔ tolerance arms race built on the existing gene + hediff engines, plus
the fuller RimWorld capacity set on the health scaffold.

Coevolution C1 — plant defense → poisons/injures the eater:
- Plant genes GeneToxicity/GeneThorns/GenePalatability (GenomeDef + PlantSet
  template + PlantPhenotype); animal gene GeneToxinTolerance.
- Hediff Poisoned (non-progressing; grows from eating via HealthState.Intensify,
  immunity heals it, overdose is lethal).
- AnimalActionSystem.ApplyPlantDefense on each bite: poison dose =
  toxicity × (1 − tolerance); thorns → ApplyInjury (reuses predator-cluster).
- Cost of defense: PlantGrowthSystem growth ×= DefenseGrowthFactor()
  (1 − 0.4·toxicity − 0.3·thorns) — without a cost defense would max out and
  coevolution would stall.
- Showcase: cactus thorns, poisonous mushroom; grass starts clean so toxicity
  can evolve on the staple. Deer/boar get small starting tolerance.

Coevolution C2 — discrimination + the other half of the arms race:
- Smart foraging (reuses A7 intelligence gating): herbivores with effective
  intelligence ≥ tier-4 weigh plants by palatability − perceived-toxin −
  distance; reflex grazers (or sick animals with dropped consciousness) eat the
  nearest plant and risk poison.
- Cost of tolerance: detox raises metabolism (need decay ×= 1 + 0.5·tolerance),
  closing the arms race so tolerance doesn't fix at 1.
- popstats reports mean toxinTolerance and a plants line (mean toxicity/thorns/
  palatability) to watch the drift.

Health capacities (RimWorld parity, user-requested):
- New capacities BloodFiltration/Hearing/Talking/Eating; CapacityCalc rewritten
  to emit only capacities the body declares (a destroyed organ still shows 0%)
  with the full dependency chain (blood → pumping/breathing/filtration →
  consciousness → moving/sight/hearing/talking/eating, digestion).
- Quadruped body gains kidneys + liver (filtration), ears (hearing), jaw/tongue
  (eating/talking). Inspector health tab lists all of a body's capacities.

Localization (ru/en) for new genes' defense line, Poisoned, and the new
capacity labels. Build + --check-content clean (11 def types, 40 genes,
39 traits). Not GUI-verified (headless can't render the world scene).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 14:13:51 +03:00
co-authored by Claude Opus 4.8
parent 97d83606ce
commit 8807045303
17 changed files with 400 additions and 25 deletions
+164 -5
View File
@@ -125,6 +125,9 @@ public readonly struct AnimalContext(float[] values, NeedSet needs, float intell
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;
@@ -146,7 +149,14 @@ public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay, Need
continue; // защита от старого/пустого состояния
}
var metabolism = MathF.Max(0.1f, o[i].Traits.Metabolism);
// Цена устойчивости к яду (коэволюция 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++)
{
@@ -237,7 +247,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
AnimalGrowth,
Transform2D
> _animals;
private readonly ArchetypeQuery<Transform2D, PlantGrowth> _plants;
private readonly ArchetypeQuery<Transform2D, PlantGrowth, PlantOrganism> _plants;
private readonly ArchetypeQuery<Transform2D, Corpse> _corpses;
public AnimalDecisionSystem(
@@ -264,7 +274,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
AnimalGrowth,
Transform2D
>();
_plants = store.Query<Transform2D, PlantGrowth>();
_plants = store.Query<Transform2D, PlantGrowth, PlantOrganism>();
_corpses = store.Query<Transform2D, Corpse>();
}
@@ -377,7 +387,15 @@ public sealed class AnimalDecisionSystem : BaseSystem
brain.Target = preyPos;
break;
case AnimalActions.Eat
when eatsPlants && TryFindPlant(pos, radius, out var plant, out var pp):
when eatsPlants
&& TryFindForage(
pos,
radius,
intelligence,
o[i].Traits.ToxinTolerance,
out var plant,
out var pp
):
brain.Action = AnimalActions.Eat;
brain.TargetPlant = plant;
brain.Target = pp;
@@ -586,12 +604,36 @@ public sealed class AnimalDecisionSystem : BaseSystem
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)
foreach (var (transforms, _, _, entities) in _plants.Chunks)
{
var t = transforms.Span;
for (var i = 0; i < t.Length; i++)
@@ -610,6 +652,54 @@ public sealed class AnimalDecisionSystem : BaseSystem
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)
{
@@ -707,6 +797,7 @@ public sealed class AnimalActionSystem : BaseSystem
private readonly int _cellSize;
private readonly RectF _bounds;
private readonly HediffDef? _bleeding;
private readonly HediffDef? _poisoned;
private readonly Random _rng;
private readonly ArchetypeQuery<
AnimalBrain,
@@ -731,6 +822,7 @@ public sealed class AnimalActionSystem : BaseSystem
int cellSize,
RectF bounds,
HediffDef? bleeding,
HediffDef? poisoned,
int seed
)
{
@@ -744,6 +836,7 @@ public sealed class AnimalActionSystem : BaseSystem
_cellSize = cellSize;
_bounds = bounds;
_bleeding = bleeding;
_poisoned = poisoned;
_rng = new Random(seed);
_query = store.Query<AnimalBrain, AnimalNeeds, AnimalOrganism, Transform2D, Health>();
}
@@ -792,6 +885,13 @@ public sealed class AnimalActionSystem : BaseSystem
{
Refill(values, AnimalActions.Eat, days);
Graze(brain.TargetPlant, days, def.ForageBiteDays);
// Коэволюция: защита растения бьёт по поедателю (яд/шипы).
ApplyPlantDefense(
brain.TargetPlant,
hh[i].State,
o[i].Traits.ToxinTolerance,
days
);
}
break;
@@ -1010,6 +1110,65 @@ public sealed class AnimalActionSystem : BaseSystem
}
}
// Доза яда за день укуса при токсичности 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()