Implement diet genes for animals, introducing GeneHerbivory, GeneCarnivory, and GeneOmnivory to define dietary behaviors. Add new animal types: Wolf (carnivore) and WildBoar (omnivore), with associated attributes and hunting mechanics. Enhance animal behavior systems to include hunting and fleeing dynamics based on genetic traits. Update documentation to reflect these changes and ensure localization for new terms. Clean build with no content errors.

This commit is contained in:
Leonid Pershin
2026-06-14 13:14:36 +03:00
parent aae76e9083
commit 97d83606ce
12 changed files with 717 additions and 46 deletions
+73 -4
View File
@@ -1,8 +1,9 @@
{ {
"type": "Animal", "type": "Animal",
// Виды-животные (фаза A1): подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов // Виды-животные: подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов (см. Defs/Genes/).
// (см. genes.json). Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов. // Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов. Диета задаётся ГЕНАМИ
// Пол, стадии роста, размножение, здоровье и поведение приходят в фазах A2+. // (herbivory/carnivory/omnivory): олень — травоядный, волк — хищник, кабан — всеядный. Хищники несут
// боевые параметры (attack*) и охотятся на добычу не крупнее себя; жертвы убегают (см. AnimalSystems).
"defs": [ "defs": [
{ {
"defName": "Deer", "defName": "Deer",
@@ -28,7 +29,75 @@
"GeneLifespan": 360, "GeneLifespan": 360,
"GeneBreedingSeason": 2, "GeneBreedingSeason": 2,
"GeneGestationDays": 30, "GeneGestationDays": 30,
"GeneLitterSize": 1 "GeneLitterSize": 1,
"GeneHerbivory": 1.0
}
},
{
"defName": "Wolf",
"label": "pawn.wolf",
"kind": "animal",
"texture": "things/pawn/animal/wolf_timber/Wolf_Timber_east",
"maleTexture": "things/pawn/animal/wolf_timber/Wolf_Timber3_east",
"body": "Quadruped",
"diet": ["meat"],
"spawnPer1000Cells": 0.8,
"baseSpeed": 32,
"visionCells": 18,
"attackRangeCells": 1.3,
"attackDamage": 80,
"attackBleed": 3.0,
"attackBloodLoss": 5.0,
"genome": {
"GeneMaxBodySize": 1.6,
"GeneMetabolism": 1.1,
"GeneMoveSpeed": 1.4,
"GeneBloodVolume": 1.3,
"GeneVision": 1.5,
"GeneBrainSize": 0.5,
"GeneInsulation": 0.6,
"GeneFurColor": 0.7,
"GeneMaturityAge": 70,
"GeneLifespan": 240,
"GeneBreedingSeason": 3,
"GeneGestationDays": 35,
"GeneLitterSize": 4,
"GeneCarnivory": 1.0
}
},
{
"defName": "WildBoar",
"label": "pawn.boar",
"kind": "animal",
"texture": "things/pawn/animal/wildboar/WildBoar_east",
"body": "Quadruped",
"diet": ["plant", "meat"],
"spawnPer1000Cells": 1.0,
"baseSpeed": 26,
"visionCells": 13,
"attackRangeCells": 1.2,
"attackDamage": 45,
"attackBleed": 1.8,
"attackBloodLoss": 3.0,
"genome": {
"GeneMaxBodySize": 1.3,
"GeneMetabolism": 1.2,
"GeneMoveSpeed": 1.0,
"GeneBloodVolume": 1.2,
"GeneVision": 1.0,
"GeneBrainSize": 0.42,
"GeneInsulation": 0.5,
"GeneFurColor": 0.85,
"GeneMaturityAge": 80,
"GeneLifespan": 280,
"GeneBreedingSeason": 0,
"GeneGestationDays": 28,
"GeneLitterSize": 5,
"GeneHerbivory": 0.4,
"GeneCarnivory": 0.2,
"GeneOmnivory": 0.85
} }
} }
] ]
+15
View File
@@ -31,6 +31,21 @@
"default": 90, "min": 1, "max": 400, "tags": ["animal", "lifecycle"], "default": 90, "min": 1, "max": 400, "tags": ["animal", "lifecycle"],
"effects": { "maturityAge": "value" } }, "effects": { "maturityAge": "value" } },
// --- Диета как ГЕНЫ (хищничество/травоядство/всеядство) ---
// Каждый ген выражает свой признак [0..1]; рацион выводится порогами: ест растения, если
// max(herbivory, omnivory) ≥ порога; ест мясо (охота/падаль), если max(carnivory, omnivory) ≥ порога.
// Поэтому всеядность — отдельный ген, который включает ОБА источника пищи (генералист), а
// специалисты задаются высоким herbivory ЛИБО carnivory. См. AnimalFactory.Diet.
{ "defName": "GeneHerbivory", "parent": "BaseNumericGene", "label": "gene.herbivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "herbivory": "value" } },
{ "defName": "GeneCarnivory", "parent": "BaseNumericGene", "label": "gene.carnivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "carnivory": "value" } },
{ "defName": "GeneOmnivory", "parent": "BaseNumericGene", "label": "gene.omnivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "omnivory": "value" } },
// Пол как локус (фаза A3): аллели {X=0, Y=1}; самка XX, самец XY. Эффекта нет — пол читается из // Пол как локус (фаза A3): аллели {X=0, Y=1}; самка XX, самец XY. Эффекта нет — пол читается из
// самих аллелей (наличие Y), не из выраженного значения. mutationChance 0 (X не мутирует в Y). // самих аллелей (наличие Y), не из выраженного значения. mutationChance 0 (X не мутирует в Y).
// Генерация особи-основателя задаёт пол явно (XX или XY), чтобы не возник невозможный YY. // Генерация особи-основателя задаёт пол явно (XX или XY), чтобы не возник невозможный YY.
+10
View File
@@ -12,6 +12,16 @@
"initialSeverity": 0.05, "severityPerDay": 0.25, "immunityPerDay": 0.32, "initialSeverity": 0.05, "severityPerDay": 0.25, "immunityPerDay": 0.32,
"lethalSeverity": 1.0, "pain": 0.3, "ambientPerDay": 0.03, "lethalSeverity": 1.0, "pain": 0.3, "ambientPerDay": 0.03,
"capMods": { "Moving": 0.6, "Consciousness": 0.85, "Digestion": 0.8 } "capMods": { "Moving": 0.6, "Consciousness": 0.85, "Digestion": 0.8 }
},
// Рана/кровотечение (предатор-кластер): первый травматический урон. Не прогрессирует сама
// (severityPerDay 0), но immunityPerDay её «сворачивает» (рана заживает ~за 2 дня). Пока активна —
// теряется кровь (bloodLossPerDay × тяжесть); кровь на нуле = смерть. Боль и капмоды от тяжёлых ран.
// Каждый укус усиливает тяжесть и сбрасывает заживление (см. HealthState.Intensify).
{
"defName": "Bleeding", "label": "hediff.bleeding",
"initialSeverity": 0.3, "immunityPerDay": 0.5, "bloodLossPerDay": 0.8,
"pain": 0.25, "capMods": { "Moving": 0.92, "Consciousness": 0.95 }
} }
] ]
} }
+1
View File
@@ -63,6 +63,7 @@
"cap.sight": "sight", "cap.sight": "sight",
"hediff.rut": "rut", "hediff.rut": "rut",
"hediff.fever": "fever", "hediff.fever": "fever",
"hediff.bleeding": "bleeding",
"inspect.hint": "LMB — select · RMB/Esc — clear", "inspect.hint": "LMB — select · RMB/Esc — clear",
"hud.paused": "PAUSED", "hud.paused": "PAUSED",
"menu.title": "LittleSim", "menu.title": "LittleSim",
+1
View File
@@ -63,6 +63,7 @@
"cap.sight": "зрение", "cap.sight": "зрение",
"hediff.rut": "гон", "hediff.rut": "гон",
"hediff.fever": "лихорадка", "hediff.fever": "лихорадка",
"hediff.bleeding": "кровотечение",
"inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять", "inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
"menu.title": "LittleSim", "menu.title": "LittleSim",
+18
View File
@@ -529,6 +529,24 @@ CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип = к
То, ради чего вся глубина: превращает «зоопарк» в живую экосистему. Каждая фича опирается То, ради чего вся глубина: превращает «зоопарк» в живую экосистему. Каждая фича опирается
на уже построенные системы. на уже построенные системы.
> **ГОТОВО — хищный кластер (часть 1): диета-гены + охота + урон + бегство.** Диета теперь
> ДАННЫЕ-через-ГЕНЫ: `GeneHerbivory`/`GeneCarnivory`/`GeneOmnivory` → признаки
> `herbivory/carnivory/omnivory`; рацион выводит `AnimalFactory.Diet` (ест растения, если
> max(herb,omni) ≥ 0.5; ест мясо, если max(carn,omni) ≥ 0.5 — всеядность включает оба; откат
> на `AnimalDef.Diet` у видов без генов диеты). Новые виды: **Wolf** (хищник, охотится) и
> **WildBoar** (всеядный); у оленя добавлен `GeneHerbivory`. Действие `Hunt`
> (`AnimalDecisionSystem` ищет ближайший труп-падаль ИЛИ живую добычу не крупнее себя;
> `AnimalActionSystem.Hunt` ведёт к цели и при контакте кусает или ест труп). **Первый
> травматический урон**: `HealthState.ApplyInjury` (урон части тела по весу попадания + острая
> кровопотеря + хедиф `Bleeding`); `Bleeding` (`bloodLossPerDay`) точит кровь в
> `AnimalHealthSystem` (+восстановление крови в покое), кровь на нуле → смерть → труп → падаль.
> Боевые параметры — данные на `AnimalDef` (`attackDamage/attackBleed/attackBloodLoss/
> attackRangeCells`). **Бегство**: `AnimalActions.Flee` — жертва, заметившая хищника не мельче
> себя в радиусе восприятия, убегает (приоритет над нуждами); сам хищник бежит лишь от заметно
> более крупного мясоеда. Общий путь смерти — `AnimalFactory.Die`. Консоль: `diet [species]`;
> `--check-content` печатает роль каждого вида. **Не покрыто (горизонт):** падальщики как
> отдельная роль, удар по настроению от страха, снижение страха стадом, спрайт скелета.
- **Хищный кластер.** Хищничество (волк→олень): диета = живая добыча; действие `Hunt` - **Хищный кластер.** Хищничество (волк→олень): диета = живая добыча; действие `Hunt`
(выследить → преследовать → атака → **урон через здоровье A5** → добить → съесть труп). (выследить → преследовать → атака → **урон через здоровье A5** → добить → съесть труп).
Это **первый «активный» (травмирующий) источник урона** для системы здоровья и Это **первый «активный» (травмирующий) источник урона** для системы здоровья и
+18
View File
@@ -285,6 +285,18 @@ public sealed class AnimalDef : PawnDef
/// <summary>Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки.</summary> /// <summary>Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки.</summary>
public float ForageBiteDays { get; init; } = 40f; public float ForageBiteDays { get; init; } = 40f;
/// <summary>Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт).</summary>
public float AttackRangeCells { get; init; } = 1.2f;
/// <summary>Урон части тела добычи за игровой день укусов (0 — вид не атакует, не хищник по бою).</summary>
public float AttackDamage { get; init; }
/// <summary>Прирост тяжести раны-кровотечения у добычи за игровой день укусов.</summary>
public float AttackBleed { get; init; }
/// <summary>Острая кровопотеря добычи за игровой день укусов (доля объёма крови) — основной путь к смерти.</summary>
public float AttackBloodLoss { get; init; }
} }
/// <summary> /// <summary>
@@ -397,6 +409,12 @@ public sealed class HediffDef : Def
/// <summary>Шанс подхватить фоном за день (0 — не фоновая болезнь).</summary> /// <summary>Шанс подхватить фоном за день (0 — не фоновая болезнь).</summary>
public float AmbientPerDay { get; init; } public float AmbientPerDay { get; init; }
/// <summary>
/// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую
/// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья).
/// </summary>
public float BloodLossPerDay { get; init; }
/// <summary>Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity).</summary> /// <summary>Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity).</summary>
public Dictionary<string, float> CapMods { get; init; } = new(); public Dictionary<string, float> CapMods { get; init; } = new();
} }
+31
View File
@@ -1,6 +1,7 @@
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Net; using LittleSim.Net;
using LittleSim.Scenes; using LittleSim.Scenes;
using LittleSim.Sim;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Genetics; using MrGameEng.Genetics;
using MrGameEng.Host; using MrGameEng.Host;
@@ -20,6 +21,36 @@ if (args.Contains("--check-content"))
+ $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits " + $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits "
+ $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})" + $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})"
); );
// Диета животных из генов (предатор-кластер): для каждого вида строим центральный геном из его
// баз, считаем признаки формулами и выводим рацион (травоядное/хищник/всеядное) — проверка без
// GPU, что хищники заданы и роль выводится из генов herbivory/carnivory/omnivory.
foreach (var def in content.Defs.All<AnimalDef>())
{
var entries = new List<GenomeTemplate.Entry>();
foreach (var (geneId, baseValue) in def.Genome)
{
if (registry.TryGetValue(geneId, out var gene))
{
var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread;
entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread));
}
}
var genome = new GenomeTemplate(entries).Generate(new Random(7));
var t = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, registry));
var (eatsPlants, eatsMeat) = AnimalFactory.Diet(t, def);
var role =
eatsMeat ? (eatsPlants ? "omnivore" : "carnivore")
: eatsPlants ? "herbivore"
: "none";
var hunt = eatsMeat && def.AttackDamage > 0f ? $", attack {def.AttackDamage:0}/d" : "";
Console.WriteLine(
$" {def.DefName}: {role} (herb {t.Herbivory:0.##}/carn {t.Carnivory:0.##}/omni {t.Omnivory:0.##}),"
+ $" spawn {def.SpawnPer1000Cells:0.##}/1000{hunt}"
);
}
return; return;
} }
catch (Exception error) catch (Exception error)
+54 -1
View File
@@ -259,6 +259,7 @@ public sealed class WorldScene : Scene
_config.Seed + 0x5EED _config.Seed + 0x5EED
) )
); );
var bleeding = content.Defs.TryGet<HediffDef>("Bleeding", out var bl) ? bl : null;
UpdateSystems.Add( UpdateSystems.Add(
new AnimalActionSystem( new AnimalActionSystem(
Store, Store,
@@ -268,7 +269,10 @@ public sealed class WorldScene : Scene
thoughts, thoughts,
Context.Clock, Context.Clock,
SecondsPerDay, SecondsPerDay,
_bounds CellSize,
_bounds,
bleeding,
_config.Seed + 0x1B17
) )
); );
UpdateSystems.Add(new AnimalAppearanceSystem(_needs)); UpdateSystems.Add(new AnimalAppearanceSystem(_needs));
@@ -952,6 +956,11 @@ public sealed class WorldScene : Scene
"animal <species> [seed] — sample an animal genome and show its gene-driven traits", "animal <species> [seed] — sample an animal genome and show its gene-driven traits",
(c, args) => RunAnimalDemo(c, content, args) (c, args) => RunAnimalDemo(c, content, args)
); );
console.Register(
"diet",
"diet [species] — gene-derived diet (herbivory/carnivory/omnivory), role and hunting stats",
(c, args) => RunDiet(c, content, args)
);
console.Register( console.Register(
"popstats", "popstats",
"popstats [species] — live population trait means and generation span (selection drift)", "popstats [species] — live population trait means and generation span (selection drift)",
@@ -1119,6 +1128,50 @@ public sealed class WorldScene : Scene
); );
} }
// Диета из генов (предатор-кластер): по каждому виду печатает herbivory/carnivory/omnivory,
// выведенный рацион (ест растения/мясо), роль и боевые параметры — проверка, что хищники заданы.
private void RunDiet(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
for (var s = 0; s < _animals.Count; s++)
{
var sp = _animals[s];
if (
args.Length > 0
&& !string.Equals(sp.Def.DefName, args[0], StringComparison.OrdinalIgnoreCase)
)
{
continue;
}
var genome = _animals.GenerateGenome(s, new Random(0x01E7 + s));
var traits = AnimalPhenotype.FromTraits(
Phenotype.Compute(genome, _animals.GeneRegistry)
);
var (eatsPlants, eatsMeat) = AnimalFactory.Diet(traits, sp.Def);
var role =
eatsMeat ? (eatsPlants ? "omnivore" : "carnivore")
: eatsPlants ? "herbivore"
: "none";
console.WriteLine(
$"{sp.Def.DefName}: {role} — herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / "
+ $"omni {traits.Omnivory:0.##} -> plants={eatsPlants} meat={eatsMeat}, spawn {sp.Def.SpawnPer1000Cells:0.##}/1000"
);
if (eatsMeat && sp.Def.AttackDamage > 0f)
{
console.WriteLine(
$" hunt: dmg {sp.Def.AttackDamage:0}/d, bleed {sp.Def.AttackBleed:0.#}/d, "
+ $"bloodloss {sp.Def.AttackBloodLoss:0.#}/d, range {sp.Def.AttackRangeCells:0.#} cells"
);
}
}
if (args.Length == 0)
{
console.WriteLine($"species: {string.Join(", ", species)}");
}
}
// Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений — // Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений —
// видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид. // видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид.
private void RunPopStats(DevConsole console, string[] args) private void RunPopStats(DevConsole console, string[] args)
+48
View File
@@ -110,6 +110,54 @@ public static class AnimalFactory
); );
} }
/// <summary>Порог выраженности признака диеты, с которого вид реально ест данный корм.</summary>
public const float DietThreshold = 0.5f;
/// <summary>
/// Рацион особи из ГЕНОВ: ест растения, если max(herbivory, omnivory) ≥ порога; ест мясо
/// (охота/падаль), если max(carnivory, omnivory) ≥ порога — всеядность (omnivory) включает оба
/// источника, специалисты задаются высоким herbivory ЛИБО carnivory. Если у вида нет генов диеты
/// (все три ≈ 0) — откат на список <see cref="AnimalDef.Diet"/> (легаси/модерский fallback).
/// </summary>
public static (bool EatsPlants, bool EatsMeat) Diet(in AnimalPhenotype traits, AnimalDef def)
{
if (traits.Herbivory <= 0f && traits.Carnivory <= 0f && traits.Omnivory <= 0f)
{
return (DietContains(def, "plant"), DietContains(def, "meat"));
}
var eatsPlants = MathF.Max(traits.Herbivory, traits.Omnivory) >= DietThreshold;
var eatsMeat = MathF.Max(traits.Carnivory, traits.Omnivory) >= DietThreshold;
return (eatsPlants, eatsMeat);
}
private static bool DietContains(AnimalDef def, string food)
{
foreach (var f in def.Diet)
{
if (string.Equals(f, food, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>Гибель особи: оставляет труп (мясо ∝ размер тела) и удаляет сущность. Общий путь смерти.</summary>
public static void Die(EntityStore store, AnimalSet animals, Entity entity, int cellSize)
{
if (entity.IsNull || !entity.HasComponent<AnimalOrganism>())
{
return;
}
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var position = entity.GetComponent<Transform2D>().Position;
CreateCorpse(store, animals, org.Species, position, org.Traits.BodySize, cellSize);
entity.DeleteEntity();
}
/// <summary> /// <summary>
/// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья). /// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья).
/// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект, /// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект,
+95
View File
@@ -39,6 +39,15 @@ public struct AnimalPhenotype
/// <summary>Возраст созревания (игровых дней) — порог стадии Adult.</summary> /// <summary>Возраст созревания (игровых дней) — порог стадии Adult.</summary>
public float MaturityAge; public float MaturityAge;
/// <summary>Травоядность [0..1] — способность/тяга питаться растениями.</summary>
public float Herbivory;
/// <summary>Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль).</summary>
public float Carnivory;
/// <summary>Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо).</summary>
public float Omnivory;
/// <summary>Продолжительность жизни (игровых дней).</summary> /// <summary>Продолжительность жизни (игровых дней).</summary>
public float Lifespan; public float Lifespan;
@@ -66,6 +75,9 @@ public struct AnimalPhenotype
Insulation = T("insulation"), Insulation = T("insulation"),
FurHue = T("furHue"), FurHue = T("furHue"),
MaturityAge = T("maturityAge"), MaturityAge = T("maturityAge"),
Herbivory = T("herbivory"),
Carnivory = T("carnivory"),
Omnivory = T("omnivory"),
Lifespan = T("lifespan"), Lifespan = T("lifespan"),
BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)), BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)),
GestationDays = T("gestationDays"), GestationDays = T("gestationDays"),
@@ -316,6 +328,89 @@ public sealed class HealthState
return false; return false;
} }
/// <summary>
/// Травма (укус хищника): повреждает часть тела (выбор по весу попадания <c>coverage</c>), вызывает
/// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности.
/// Первый травматический источник урона — оживляет каркас здоровья A5.
/// </summary>
public void ApplyInjury(
float partDamage,
float bloodLoss,
HediffDef? bleeding,
float bleedSeverity,
Random rng
)
{
if (Parts.Length > 0 && partDamage > 0f)
{
var idx = PickPart(rng);
var part = Parts[idx];
part.Hp = MathF.Max(0f, part.Hp - partDamage);
Parts[idx] = part;
}
BloodLevel = Math.Clamp(BloodLevel - bloodLoss, 0f, 1f);
if (bleeding is not null && bleedSeverity > 0f)
{
Intensify(bleeding, bleedSeverity);
}
RecomputeCapacities();
}
// Выбор задетой части по весу попадания (coverage); детерминируется переданным rng.
private int PickPart(Random rng)
{
var total = 0f;
foreach (var p in Parts)
{
total += p.Def?.Coverage ?? 0f;
}
if (total <= 0f)
{
return rng.Next(Parts.Length);
}
var roll = (float)rng.NextDouble() * total;
for (var i = 0; i < Parts.Length; i++)
{
if (Parts[i].Def is null)
{
continue;
}
roll -= Parts[i].Def.Coverage;
if (roll <= 0f)
{
return i;
}
}
return Parts.Length - 1;
}
/// <summary>
/// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет)
/// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений.
/// </summary>
public void Intensify(HediffDef def, float addSeverity)
{
for (var i = 0; i < _hediffs.Count; i++)
{
if (_hediffs[i].Def.DefName == def.DefName)
{
var h = _hediffs[i];
h.Severity = Math.Clamp(h.Severity + addSeverity, 0f, 1f);
h.Immunity = 0f; // свежая рана — заживление с нуля
_hediffs[i] = h;
return;
}
}
_hediffs.Add(new Hediff { Def = def, Severity = Math.Clamp(addSeverity, 0f, 1f) });
}
} }
/// <summary>Здоровье особи: managed-состояние со списком хедифов (см. <see cref="HealthState"/>).</summary> /// <summary>Здоровье особи: managed-состояние со списком хедифов (см. <see cref="HealthState"/>).</summary>
+353 -41
View File
@@ -22,6 +22,12 @@ public static class AnimalActions
public const string Drink = "Drink"; public const string Drink = "Drink";
public const string Sleep = "Sleep"; public const string Sleep = "Sleep";
public const string Mate = "Mate"; public const string Mate = "Mate";
/// <summary>Охота: преследовать живую добычу и кусать (урон) либо есть труп — утоляет голод хищника.</summary>
public const string Hunt = "Hunt";
/// <summary>Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд).</summary>
public const string Flee = "Flee";
} }
/// <summary> /// <summary>
@@ -69,6 +75,12 @@ public struct AnimalBrain : IComponent
/// <summary>Id партнёра для <see cref="AnimalActions.Mate"/>; -1 — нет.</summary> /// <summary>Id партнёра для <see cref="AnimalActions.Mate"/>; -1 — нет.</summary>
public int TargetMate; public int TargetMate;
/// <summary>Id цели для <see cref="AnimalActions.Hunt"/> — живой добычи или трупа; -1 — нет.</summary>
public int TargetPrey;
/// <summary>Цель — труп (падаль), а не живая добыча: при достижении едим, а не атакуем.</summary>
public bool TargetIsCorpse;
/// <summary>Секунды до следующего пересмотра решения.</summary> /// <summary>Секунды до следующего пересмотра решения.</summary>
public float DecideIn; public float DecideIn;
} }
@@ -226,6 +238,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
Transform2D Transform2D
> _animals; > _animals;
private readonly ArchetypeQuery<Transform2D, PlantGrowth> _plants; private readonly ArchetypeQuery<Transform2D, PlantGrowth> _plants;
private readonly ArchetypeQuery<Transform2D, Corpse> _corpses;
public AnimalDecisionSystem( public AnimalDecisionSystem(
EntityStore store, EntityStore store,
@@ -252,6 +265,7 @@ public sealed class AnimalDecisionSystem : BaseSystem
Transform2D Transform2D
>(); >();
_plants = store.Query<Transform2D, PlantGrowth>(); _plants = store.Query<Transform2D, PlantGrowth>();
_corpses = store.Query<Transform2D, Corpse>();
} }
// Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже // Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже
@@ -316,15 +330,54 @@ public sealed class AnimalDecisionSystem : BaseSystem
); );
// Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию.
var mood = self.GetComponent<Mood>(); var mood = self.GetComponent<Mood>();
// Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее
// любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. IsThreatTo).
if (TryFindThreat(pos, radius, 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 * (radius + _cellSize)
: pos + new Vector2(_cellSize, 0f);
continue;
}
var (eatsPlants, eatsMeat) = AnimalFactory.Diet(
o[i].Traits,
_animalSet[o[i].Species].Def
);
var name = var name =
_brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name _brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name
?? AnimalActions.Wander; ?? AnimalActions.Wander;
switch (name) switch (name)
{ {
// Хищник/всеядное голодает → ищет труп (падаль) или живую добычу и охотится.
case AnimalActions.Eat case AnimalActions.Eat
when EatsPlants(o[i].Species) when eatsMeat
&& TryFindPlant(pos, radius, out var plant, out var pp): && TryFindKill(
pos,
radius,
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 && TryFindPlant(pos, radius, out var plant, out var pp):
brain.Action = AnimalActions.Eat; brain.Action = AnimalActions.Eat;
brain.TargetPlant = plant; brain.TargetPlant = plant;
brain.Target = pp; brain.Target = pp;
@@ -371,21 +424,167 @@ public sealed class AnimalDecisionSystem : BaseSystem
private float VisionRadius(int species, float vision) => private float VisionRadius(int species, float vision) =>
MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize; MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize;
// Ест ли вид растения (диета данными) — гейт поиска корма; хищники (meat) появятся на горизонте. // Ближайший хищник-угроза в радиусе восприятия (для бегства жертвы). Угроза = другой вид, который
private bool EatsPlants(int species) // ест мясо и достаточно крупный (см. IsThreatTo). O(n) на особь — как поиск корма.
private bool TryFindThreat(
Vector2 from,
float radius,
int selfSpecies,
in AnimalPhenotype selfTraits,
out Vector2 threatPos
)
{ {
var diet = _animalSet[species].Def.Diet; var bestSq = radius * radius;
foreach (var food in diet) threatPos = default;
var found = false;
var (_, selfEatsMeat) = AnimalFactory.Diet(selfTraits, _animalSet[selfSpecies].Def);
var selfBody = selfTraits.BodySize;
foreach (var (_, _, organisms, _, transforms, _) in _animals.Chunks)
{ {
if (string.Equals(food, "plant", StringComparison.Ordinal)) var oo = organisms.Span;
var tt = transforms.Span;
for (var i = 0; i < tt.Length; i++)
{ {
return true; 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; return false;
} }
// Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника
// (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих.
private static bool IsPreyFor(float hunterBody, in AnimalPhenotype otherTraits) =>
otherTraits.BodySize <= hunterBody * 1.25f;
// Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм).
private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position) private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position)
{ {
@@ -496,6 +695,7 @@ public sealed class AnimalActionSystem : BaseSystem
{ {
private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает
private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась» private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась»
private const float FleeSpeedMult = 1.2f; // страх подгоняет жертву чуть быстрее обычного шага
private readonly EntityStore _store; private readonly EntityStore _store;
private readonly PlantSet _plants; private readonly PlantSet _plants;
@@ -504,7 +704,10 @@ public sealed class AnimalActionSystem : BaseSystem
private readonly ThoughtSet _thoughts; private readonly ThoughtSet _thoughts;
private readonly GameClock _clock; private readonly GameClock _clock;
private readonly float _secondsPerDay; private readonly float _secondsPerDay;
private readonly int _cellSize;
private readonly RectF _bounds; private readonly RectF _bounds;
private readonly HediffDef? _bleeding;
private readonly Random _rng;
private readonly ArchetypeQuery< private readonly ArchetypeQuery<
AnimalBrain, AnimalBrain,
AnimalNeeds, AnimalNeeds,
@@ -513,6 +716,8 @@ public sealed class AnimalActionSystem : BaseSystem
Health Health
> _query; > _query;
private readonly List<Entity> _eaten = []; private readonly List<Entity> _eaten = [];
private readonly List<Entity> _consumed = []; // трупы, доеденные до нуля
private readonly List<Entity> _kills = []; // добыча, забитая в этом проходе (смерть после итерации)
private readonly List<(Entity Self, Entity Partner)> _matings = []; private readonly List<(Entity Self, Entity Partner)> _matings = [];
public AnimalActionSystem( public AnimalActionSystem(
@@ -523,7 +728,10 @@ public sealed class AnimalActionSystem : BaseSystem
ThoughtSet thoughts, ThoughtSet thoughts,
GameClock clock, GameClock clock,
float secondsPerDay, float secondsPerDay,
RectF bounds int cellSize,
RectF bounds,
HediffDef? bleeding,
int seed
) )
{ {
_store = store; _store = store;
@@ -533,7 +741,10 @@ public sealed class AnimalActionSystem : BaseSystem
_thoughts = thoughts; _thoughts = thoughts;
_clock = clock; _clock = clock;
_secondsPerDay = secondsPerDay; _secondsPerDay = secondsPerDay;
_cellSize = cellSize;
_bounds = bounds; _bounds = bounds;
_bleeding = bleeding;
_rng = new Random(seed);
_query = store.Query<AnimalBrain, AnimalNeeds, AnimalOrganism, Transform2D, Health>(); _query = store.Query<AnimalBrain, AnimalNeeds, AnimalOrganism, Transform2D, Health>();
} }
@@ -547,6 +758,8 @@ public sealed class AnimalActionSystem : BaseSystem
var days = seconds / _secondsPerDay; var days = seconds / _secondsPerDay;
_eaten.Clear(); _eaten.Clear();
_consumed.Clear();
_kills.Clear();
_matings.Clear(); _matings.Clear();
foreach ( foreach (
@@ -622,6 +835,14 @@ public sealed class AnimalActionSystem : BaseSystem
break; 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 default: // Wander
MoveTo(ref pos, brain.Target, speed * seconds); MoveTo(ref pos, brain.Target, speed * seconds);
break; break;
@@ -637,9 +858,109 @@ public sealed class AnimalActionSystem : BaseSystem
plant.DeleteEntity(); plant.DeleteEntity();
} }
foreach (var corpse in _consumed)
{
if (!corpse.IsNull)
{
corpse.DeleteEntity(); // труп доеден до нуля
}
}
foreach (var prey in _kills)
{
AnimalFactory.Die(_store, _animalSet, prey, _cellSize); // забитая добыча → труп
}
ApplyMatings(); 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. // Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay.
private void Refill(float[] values, string action, float days) private void Refill(float[] values, string action, float days)
{ {
@@ -985,22 +1306,7 @@ public sealed class AnimalMortalitySystem : BaseSystem
foreach (var dead in _deaths) foreach (var dead in _deaths)
{ {
if (dead.IsNull || !dead.HasComponent<AnimalOrganism>()) AnimalFactory.Die(_store, _animals, dead, _cellSize);
{
continue;
}
ref readonly var org = ref dead.GetComponent<AnimalOrganism>();
var position = dead.GetComponent<Transform2D>().Position;
AnimalFactory.CreateCorpse(
_store,
_animals,
org.Species,
position,
org.Traits.BodySize,
_cellSize
);
dead.DeleteEntity();
} }
} }
} }
@@ -1228,6 +1534,7 @@ public sealed class AnimalPregnancySystem : BaseSystem
public sealed class AnimalHealthSystem : BaseSystem public sealed class AnimalHealthSystem : BaseSystem
{ {
private const float TickDays = 1f / 24f; // ~игровой час private const float TickDays = 1f / 24f; // ~игровой час
private const float BloodRecoveryPerDay = 0.25f; // восстановление крови в покое (нет кровотечений)
private readonly EntityStore _store; private readonly EntityStore _store;
private readonly AnimalSet _animals; private readonly AnimalSet _animals;
@@ -1295,6 +1602,26 @@ public sealed class AnimalHealthSystem : BaseSystem
} }
var lethal = state.AdvanceHediffs(days); var lethal = state.AdvanceHediffs(days);
// Кровопотеря от ран (кровотечения), иначе — постепенное восстановление крови в покое.
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(); state.RecomputeCapacities();
if ( if (
lethal lethal
@@ -1309,22 +1636,7 @@ public sealed class AnimalHealthSystem : BaseSystem
foreach (var dead in _deaths) foreach (var dead in _deaths)
{ {
if (dead.IsNull || !dead.HasComponent<AnimalOrganism>()) AnimalFactory.Die(_store, _animals, dead, _cellSize);
{
continue;
}
ref readonly var org = ref dead.GetComponent<AnimalOrganism>();
var position = dead.GetComponent<Transform2D>().Position;
AnimalFactory.CreateCorpse(
_store,
_animals,
org.Species,
position,
org.Traits.BodySize,
_cellSize
);
dead.DeleteEntity();
} }
} }
} }