diff --git a/Mods/Core/Defs/Animals/Animals.json b/Mods/Core/Defs/Animals/Animals.json index d899015..0b10d67 100644 --- a/Mods/Core/Defs/Animals/Animals.json +++ b/Mods/Core/Defs/Animals/Animals.json @@ -1,8 +1,9 @@ { "type": "Animal", - // Виды-животные (фаза A1): подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов - // (см. genes.json). Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов. - // Пол, стадии роста, размножение, здоровье и поведение приходят в фазах A2+. + // Виды-животные: подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов (см. Defs/Genes/). + // Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов. Диета задаётся ГЕНАМИ + // (herbivory/carnivory/omnivory): олень — травоядный, волк — хищник, кабан — всеядный. Хищники несут + // боевые параметры (attack*) и охотятся на добычу не крупнее себя; жертвы убегают (см. AnimalSystems). "defs": [ { "defName": "Deer", @@ -28,7 +29,75 @@ "GeneLifespan": 360, "GeneBreedingSeason": 2, "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 } } ] diff --git a/Mods/Core/Defs/Genes/Animal.json b/Mods/Core/Defs/Genes/Animal.json index ce91513..452e3ef 100644 --- a/Mods/Core/Defs/Genes/Animal.json +++ b/Mods/Core/Defs/Genes/Animal.json @@ -31,6 +31,21 @@ "default": 90, "min": 1, "max": 400, "tags": ["animal", "lifecycle"], "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. Эффекта нет — пол читается из // самих аллелей (наличие Y), не из выраженного значения. mutationChance 0 (X не мутирует в Y). // Генерация особи-основателя задаёт пол явно (XX или XY), чтобы не возник невозможный YY. diff --git a/Mods/Core/Defs/Hediffs/Hediffs.json b/Mods/Core/Defs/Hediffs/Hediffs.json index 5505d67..00b001e 100644 --- a/Mods/Core/Defs/Hediffs/Hediffs.json +++ b/Mods/Core/Defs/Hediffs/Hediffs.json @@ -12,6 +12,16 @@ "initialSeverity": 0.05, "severityPerDay": 0.25, "immunityPerDay": 0.32, "lethalSeverity": 1.0, "pain": 0.3, "ambientPerDay": 0.03, "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 } } ] } diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index d4cc4ee..5f1abda 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -63,6 +63,7 @@ "cap.sight": "sight", "hediff.rut": "rut", "hediff.fever": "fever", + "hediff.bleeding": "bleeding", "inspect.hint": "LMB — select · RMB/Esc — clear", "hud.paused": "PAUSED", "menu.title": "LittleSim", diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json index bd45d20..0c6d660 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -63,6 +63,7 @@ "cap.sight": "зрение", "hediff.rut": "гон", "hediff.fever": "лихорадка", + "hediff.bleeding": "кровотечение", "inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять", "hud.paused": "ПАУЗА", "menu.title": "LittleSim", diff --git a/docs/животные.md b/docs/животные.md index 8acaca9..3393585 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -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` (выследить → преследовать → атака → **урон через здоровье A5** → добить → съесть труп). Это **первый «активный» (травмирующий) источник урона** для системы здоровья и diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 66e010a..436545e 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -285,6 +285,18 @@ public sealed class AnimalDef : PawnDef /// Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки. public float ForageBiteDays { get; init; } = 40f; + + /// Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт). + public float AttackRangeCells { get; init; } = 1.2f; + + /// Урон части тела добычи за игровой день укусов (0 — вид не атакует, не хищник по бою). + public float AttackDamage { get; init; } + + /// Прирост тяжести раны-кровотечения у добычи за игровой день укусов. + public float AttackBleed { get; init; } + + /// Острая кровопотеря добычи за игровой день укусов (доля объёма крови) — основной путь к смерти. + public float AttackBloodLoss { get; init; } } /// @@ -397,6 +409,12 @@ public sealed class HediffDef : Def /// Шанс подхватить фоном за день (0 — не фоновая болезнь). public float AmbientPerDay { get; init; } + /// + /// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую + /// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья). + /// + public float BloodLossPerDay { get; init; } + /// Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity). public Dictionary CapMods { get; init; } = new(); } diff --git a/src/LittleSim/Program.cs b/src/LittleSim/Program.cs index 2e8d078..350680d 100644 --- a/src/LittleSim/Program.cs +++ b/src/LittleSim/Program.cs @@ -1,6 +1,7 @@ using LittleSim.Content; using LittleSim.Net; using LittleSim.Scenes; +using LittleSim.Sim; using Microsoft.Xna.Framework; using MrGameEng.Genetics; using MrGameEng.Host; @@ -20,6 +21,36 @@ if (args.Contains("--check-content")) + $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits " + $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})" ); + + // Диета животных из генов (предатор-кластер): для каждого вида строим центральный геном из его + // баз, считаем признаки формулами и выводим рацион (травоядное/хищник/всеядное) — проверка без + // GPU, что хищники заданы и роль выводится из генов herbivory/carnivory/omnivory. + foreach (var def in content.Defs.All()) + { + var entries = new List(); + 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; } catch (Exception error) diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index b47bb86..700ae0b 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -259,6 +259,7 @@ public sealed class WorldScene : Scene _config.Seed + 0x5EED ) ); + var bleeding = content.Defs.TryGet("Bleeding", out var bl) ? bl : null; UpdateSystems.Add( new AnimalActionSystem( Store, @@ -268,7 +269,10 @@ public sealed class WorldScene : Scene thoughts, Context.Clock, SecondsPerDay, - _bounds + CellSize, + _bounds, + bleeding, + _config.Seed + 0x1B17 ) ); UpdateSystems.Add(new AnimalAppearanceSystem(_needs)); @@ -952,6 +956,11 @@ public sealed class WorldScene : Scene "animal [seed] — sample an animal genome and show its gene-driven traits", (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( "popstats", "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): средние ключевых признаков живой популяции и размах поколений — // видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид. private void RunPopStats(DevConsole console, string[] args) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 06c2279..6eb5596 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -110,6 +110,54 @@ public static class AnimalFactory ); } + /// Порог выраженности признака диеты, с которого вид реально ест данный корм. + public const float DietThreshold = 0.5f; + + /// + /// Рацион особи из ГЕНОВ: ест растения, если max(herbivory, omnivory) ≥ порога; ест мясо + /// (охота/падаль), если max(carnivory, omnivory) ≥ порога — всеядность (omnivory) включает оба + /// источника, специалисты задаются высоким herbivory ЛИБО carnivory. Если у вида нет генов диеты + /// (все три ≈ 0) — откат на список (легаси/модерский fallback). + /// + 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; + } + + /// Гибель особи: оставляет труп (мясо ∝ размер тела) и удаляет сущность. Общий путь смерти. + public static void Die(EntityStore store, AnimalSet animals, Entity entity, int cellSize) + { + if (entity.IsNull || !entity.HasComponent()) + { + return; + } + + ref readonly var org = ref entity.GetComponent(); + var position = entity.GetComponent().Position; + CreateCorpse(store, animals, org.Species, position, org.Traits.BodySize, cellSize); + entity.DeleteEntity(); + } + /// /// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья). /// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект, diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 6f56161..9fd739e 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -39,6 +39,15 @@ public struct AnimalPhenotype /// Возраст созревания (игровых дней) — порог стадии Adult. public float MaturityAge; + /// Травоядность [0..1] — способность/тяга питаться растениями. + public float Herbivory; + + /// Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль). + public float Carnivory; + + /// Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо). + public float Omnivory; + /// Продолжительность жизни (игровых дней). public float Lifespan; @@ -66,6 +75,9 @@ public struct AnimalPhenotype Insulation = T("insulation"), FurHue = T("furHue"), MaturityAge = T("maturityAge"), + Herbivory = T("herbivory"), + Carnivory = T("carnivory"), + Omnivory = T("omnivory"), Lifespan = T("lifespan"), BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)), GestationDays = T("gestationDays"), @@ -316,6 +328,89 @@ public sealed class HealthState return false; } + + /// + /// Травма (укус хищника): повреждает часть тела (выбор по весу попадания coverage), вызывает + /// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности. + /// Первый травматический источник урона — оживляет каркас здоровья A5. + /// + 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; + } + + /// + /// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет) + /// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений. + /// + 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) }); + } } /// Здоровье особи: managed-состояние со списком хедифов (см. ). diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 5cf52ed..c51183f 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -22,6 +22,12 @@ public static class AnimalActions public const string Drink = "Drink"; public const string Sleep = "Sleep"; public const string Mate = "Mate"; + + /// Охота: преследовать живую добычу и кусать (урон) либо есть труп — утоляет голод хищника. + public const string Hunt = "Hunt"; + + /// Бегство: уходить от ближайшего хищника (реакция жертвы, важнее прочих нужд). + public const string Flee = "Flee"; } /// @@ -69,6 +75,12 @@ public struct AnimalBrain : IComponent /// Id партнёра для ; -1 — нет. public int TargetMate; + /// Id цели для — живой добычи или трупа; -1 — нет. + public int TargetPrey; + + /// Цель — труп (падаль), а не живая добыча: при достижении едим, а не атакуем. + public bool TargetIsCorpse; + /// Секунды до следующего пересмотра решения. public float DecideIn; } @@ -226,6 +238,7 @@ public sealed class AnimalDecisionSystem : BaseSystem Transform2D > _animals; private readonly ArchetypeQuery _plants; + private readonly ArchetypeQuery _corpses; public AnimalDecisionSystem( EntityStore store, @@ -252,6 +265,7 @@ public sealed class AnimalDecisionSystem : BaseSystem Transform2D >(); _plants = store.Query(); + _corpses = store.Query(); } // Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже @@ -316,15 +330,54 @@ public sealed class AnimalDecisionSystem : BaseSystem ); // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. var mood = self.GetComponent(); + + // Бегство (предатор-кластер): жертва, заметившая рядом хищника, спасается — это важнее + // любых нужд (инстинкт выживания). Сам хищник от добычи не бежит (см. 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 = _brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name ?? AnimalActions.Wander; switch (name) { + // Хищник/всеядное голодает → ищет труп (падаль) или живую добычу и охотится. case AnimalActions.Eat - when EatsPlants(o[i].Species) - && TryFindPlant(pos, radius, out var plant, out var pp): + when eatsMeat + && 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.TargetPlant = plant; brain.Target = pp; @@ -371,21 +424,167 @@ public sealed class AnimalDecisionSystem : BaseSystem private float VisionRadius(int species, float vision) => 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; - foreach (var food in diet) + 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) { - 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; } + // Подходит ли особь в добычу охотнику данного размера: важно лишь, что она не крупнее охотника + // (на превосходящих не нападаем). Вид/мясоедство добычи не важны — хищник ест и травоядных, и прочих. + private static bool IsPreyFor(float hunterBody, in AnimalPhenotype otherTraits) => + otherTraits.BodySize <= hunterBody * 1.25f; + // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). 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 ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась» + private const float FleeSpeedMult = 1.2f; // страх подгоняет жертву чуть быстрее обычного шага private readonly EntityStore _store; private readonly PlantSet _plants; @@ -504,7 +704,10 @@ public sealed class AnimalActionSystem : BaseSystem 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 Random _rng; private readonly ArchetypeQuery< AnimalBrain, AnimalNeeds, @@ -513,6 +716,8 @@ public sealed class AnimalActionSystem : BaseSystem Health > _query; private readonly List _eaten = []; + private readonly List _consumed = []; // трупы, доеденные до нуля + private readonly List _kills = []; // добыча, забитая в этом проходе (смерть после итерации) private readonly List<(Entity Self, Entity Partner)> _matings = []; public AnimalActionSystem( @@ -523,7 +728,10 @@ public sealed class AnimalActionSystem : BaseSystem ThoughtSet thoughts, GameClock clock, float secondsPerDay, - RectF bounds + int cellSize, + RectF bounds, + HediffDef? bleeding, + int seed ) { _store = store; @@ -533,7 +741,10 @@ public sealed class AnimalActionSystem : BaseSystem _thoughts = thoughts; _clock = clock; _secondsPerDay = secondsPerDay; + _cellSize = cellSize; _bounds = bounds; + _bleeding = bleeding; + _rng = new Random(seed); _query = store.Query(); } @@ -547,6 +758,8 @@ public sealed class AnimalActionSystem : BaseSystem var days = seconds / _secondsPerDay; _eaten.Clear(); + _consumed.Clear(); + _kills.Clear(); _matings.Clear(); foreach ( @@ -622,6 +835,14 @@ public sealed class AnimalActionSystem : BaseSystem 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; @@ -637,9 +858,109 @@ public sealed class AnimalActionSystem : BaseSystem 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() + ) + { + return; // цель исчезла — действие пересмотрят на следующем решении + } + + var targetPos = target.GetComponent().Position; + var range = def.AttackRangeCells * _cellSize; + var inRange = Vector2.DistanceSquared(pos, targetPos) <= range * range; + + if (brain.TargetIsCorpse) + { + if (!target.HasComponent()) + { + return; + } + + if (!inRange) + { + MoveTo(ref pos, targetPos, step); + return; + } + + ref var corpse = ref target.GetComponent(); + Refill(values, AnimalActions.Eat, days); + corpse.Meat -= def.ForageBiteDays * days; // расход мяса той же «силой укуса», что и выедание + if (corpse.Meat <= 0f && !_consumed.Contains(target)) + { + _consumed.Add(target); + } + + return; + } + + // Живая добыча. + if (!target.HasComponent() || !target.HasComponent()) + { + return; + } + + if (!inRange) + { + MoveTo(ref pos, targetPos, step); + return; + } + + var preyHealth = target.GetComponent().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) { @@ -985,22 +1306,7 @@ public sealed class AnimalMortalitySystem : BaseSystem foreach (var dead in _deaths) { - if (dead.IsNull || !dead.HasComponent()) - { - continue; - } - - ref readonly var org = ref dead.GetComponent(); - var position = dead.GetComponent().Position; - AnimalFactory.CreateCorpse( - _store, - _animals, - org.Species, - position, - org.Traits.BodySize, - _cellSize - ); - dead.DeleteEntity(); + AnimalFactory.Die(_store, _animals, dead, _cellSize); } } } @@ -1228,6 +1534,7 @@ public sealed class AnimalPregnancySystem : BaseSystem 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; @@ -1295,6 +1602,26 @@ public sealed class AnimalHealthSystem : BaseSystem } 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(); if ( lethal @@ -1309,22 +1636,7 @@ public sealed class AnimalHealthSystem : BaseSystem foreach (var dead in _deaths) { - if (dead.IsNull || !dead.HasComponent()) - { - continue; - } - - ref readonly var org = ref dead.GetComponent(); - var position = dead.GetComponent().Position; - AnimalFactory.CreateCorpse( - _store, - _animals, - org.Species, - position, - org.Traits.BodySize, - _cellSize - ); - dead.DeleteEntity(); + AnimalFactory.Die(_store, _animals, dead, _cellSize); } } }