Merge branch 'animals' into main: animal/zoology system (A1-A8 + serialization)

Brings the full animal foundation onto main and folds its new defs into the
Defs/ subfolder layout introduced on main:
- animal genes -> Defs/Genes/Animal.json (split out of genes.json)
- animals/bodies/hediffs/needs/thoughts -> own Defs/<Type>/ subfolders
- ProductMeat/Bone/Leather + bigger world presets auto-merged via rename detection
- PawnDef un-sealed (AnimalDef : PawnDef); def-location doc comments updated to subfolders
- engine pointer kept at main's newer 96e19c7 (only adds directional sun shadows)

Build + --check-content clean: 11 def types, 33 genes, 35 plants, 32 traits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-14 12:48:49 +03:00
co-authored by Claude Opus 4.8
25 changed files with 4348 additions and 51 deletions
+35
View File
@@ -0,0 +1,35 @@
{
"type": "Animal",
// Виды-животные (фаза A1): подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов
// (см. genes.json). Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов.
// Пол, стадии роста, размножение, здоровье и поведение приходят в фазах A2+.
"defs": [
{
"defName": "Deer",
"label": "pawn.deer",
"kind": "animal",
"texture": "things/pawn/animal/deer/DeerFemale_east",
"maleTexture": "things/pawn/animal/deer/DeerMale_east",
"babyTexture": "things/pawn/animal/deer/DeerBaby_east",
"corpseTexture": "things/pawn/animal/deer/Dessicated_DeerFemale_east",
"body": "Quadruped",
"diet": ["plant"],
"spawnPer1000Cells": 2.5,
"genome": {
"GeneMaxBodySize": 1.5,
"GeneMetabolism": 1.0,
"GeneMoveSpeed": 1.2,
"GeneBloodVolume": 1.4,
"GeneVision": 1.3,
"GeneBrainSize": 0.45,
"GeneInsulation": 0.55,
"GeneFurColor": 0.45,
"GeneMaturityAge": 90,
"GeneLifespan": 360,
"GeneBreedingSeason": 2,
"GeneGestationDays": 30,
"GeneLitterSize": 1
}
}
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"type": "Body",
// Анатомия видов (фаза A5): дерево частей/органов. Вклады в способности (capacities) по телу
// суммируются ≈1 у здоровой особи. maxHp умножается на размер тела особи при создании.
// Источников урона пока нет — части на полном HP, способности = 1; каркас под болезни/хищников.
"defs": [
{
"defName": "Quadruped",
"parts": [
{ "name": "torso", "coverage": 0.32, "maxHp": 40, "vital": true },
{ "name": "heart", "parent": "torso", "coverage": 0.02, "maxHp": 12, "vital": true,
"capacities": { "BloodPumping": 1.0 } },
{ "name": "lungLeft", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Breathing": 0.5 } },
{ "name": "lungRight", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Breathing": 0.5 } },
{ "name": "liver", "parent": "torso", "coverage": 0.03, "maxHp": 14, "vital": true },
{ "name": "stomach", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Digestion": 1.0 } },
{ "name": "head", "coverage": 0.1, "maxHp": 25 },
{ "name": "brain", "parent": "head", "coverage": 0.02, "maxHp": 12, "vital": true,
"capacities": { "Consciousness": 1.0 } },
{ "name": "eyeLeft", "parent": "head", "coverage": 0.015, "maxHp": 8,
"capacities": { "Sight": 0.5 } },
{ "name": "eyeRight", "parent": "head", "coverage": 0.015, "maxHp": 8,
"capacities": { "Sight": 0.5 } },
{ "name": "legFrontLeft", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legFrontRight", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legBackLeft", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legBackRight", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } }
]
}
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"type": "Gene",
// Гены животных: организм-агностичные гены зверя (их несёт геном животного). Тело/обмен/движение/
// чувства/мозг + пол (локус) и размножение. Признаки читают системы животных (рост, ИИ, здоровье).
"defs": [
{ "defName": "GeneMaxBodySize", "parent": "BaseNumericGene", "label": "gene.bodySize",
"default": 1.0, "min": 0.1, "max": 4.0, "tags": ["animal", "body"],
"effects": { "bodySize": "value" } },
{ "defName": "GeneMetabolism", "parent": "BaseNumericGene", "label": "gene.metabolism",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "metabolism"],
"effects": { "metabolism": "value" } },
{ "defName": "GeneMoveSpeed", "parent": "BaseNumericGene", "label": "gene.moveSpeed",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "movement"],
"effects": { "moveSpeed": "value" } },
{ "defName": "GeneBloodVolume", "parent": "BaseNumericGene", "label": "gene.bloodVolume",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "health"],
"effects": { "bloodVolume": "value" } },
{ "defName": "GeneVision", "parent": "BaseNumericGene", "label": "gene.vision",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "vision": "value" } },
{ "defName": "GeneBrainSize", "parent": "BaseNumericGene", "label": "gene.brainSize",
"default": 0.45, "min": 0.0, "max": 1.0, "spread": 0.03, "tags": ["animal", "brain"],
"effects": { "brainSize": "value" } },
{ "defName": "GeneInsulation", "parent": "BaseNumericGene", "label": "gene.insulation",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "temperature"],
"effects": { "insulation": "value" } },
{ "defName": "GeneFurColor", "parent": "BaseNumericGene", "label": "gene.furColor",
"default": 0.5, "min": 0.0, "max": 1.0, "spread": 0.06, "tags": ["animal", "morphology", "color"],
"effects": { "furHue": "value" } },
{ "defName": "GeneMaturityAge", "parent": "BaseNumericGene", "label": "gene.maturityAge",
"default": 90, "min": 1, "max": 400, "tags": ["animal", "lifecycle"],
"effects": { "maturityAge": "value" } },
// Пол как локус (фаза A3): аллели {X=0, Y=1}; самка XX, самец XY. Эффекта нет — пол читается из
// самих аллелей (наличие Y), не из выраженного значения. mutationChance 0 (X не мутирует в Y).
// Генерация особи-основателя задаёт пол явно (XX или XY), чтобы не возник невозможный YY.
{ "defName": "GeneSex", "kind": "Discrete", "label": "gene.sex",
"variants": 2, "variantWeights": [0.5, 0.5], "mutationChance": 0, "tags": ["animal", "sex"] },
// Размножение (фаза A4): сезон гона фиксирован по виду (0 весна … 3 зима), срок вынашивания и помёт.
{ "defName": "GeneBreedingSeason", "parent": "BaseNumericGene", "label": "gene.breedingSeason",
"default": 2, "min": 0, "max": 3, "spread": 0, "mutationChance": 0, "tags": ["animal", "reproduction"],
"effects": { "breedingSeason": "value" } },
{ "defName": "GeneGestationDays", "parent": "BaseNumericGene", "label": "gene.gestationDays",
"default": 30, "min": 1, "max": 200, "tags": ["animal", "reproduction"],
"effects": { "gestationDays": "value" } },
{ "defName": "GeneLitterSize", "parent": "BaseNumericGene", "label": "gene.litterSize",
"default": 1, "min": 1, "max": 12, "tags": ["animal", "reproduction"],
"effects": { "litterSize": "value" } }
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"type": "Hediff",
// Хедифы — состояния организма (фаза A4: лёгкий каркас). Гон (Rut) навешивается сезонно на взрослых
// и поднимает половое влечение. Раны/болезни/возрастные эффекты со стадиями придут в A5/A6.
"defs": [
{ "defName": "Rut", "label": "hediff.rut" },
// Болезнь (фаза A6): прогрессирует, параллельно растёт иммунитет (гонка). Обычно иммунитет
// успевает победить (~3 дня), но больной зверь медленнее и слабее; слабые/невезучие гибнут.
{
"defName": "Fever", "label": "hediff.fever",
"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 }
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"type": "Need",
// Нужды как данные (фаза A2, рефактор расширяемости): добавить нужду виду = добавить запись здесь.
// kind=deplete убывает и утоляется действием; kind=drive растёт под хедифом (гон) и сбрасывается им.
// action — id поведения-исполнителя (реестр в коде). minBrain — гейт интеллектом (активен с A7):
// нужда, чей minBrain выше эффективного интеллекта особи (brainSize × Consciousness), в выбор ИИ не
// входит. Пороги — тиры мозга: 0.0 голод-рефлекс, 0.25 жажда (низший позвоночный), 0.40 сон/секс
// (зверь). У оленя мозг ~0.45: здоров — весь набор; повреждение мозга/болезнь роняют сознание →
// эффективный интеллект падает → первыми отключаются сон/спаривание (обратная связь A7).
"defs": [
{ "defName": "Hunger", "label": "need.hunger", "kind": "deplete", "action": "Eat",
"decayPerDay": 0.55, "feedPerDay": 4, "lethal": true, "minBrain": 0.0 },
{ "defName": "Thirst", "label": "need.thirst", "kind": "deplete", "action": "Drink",
"decayPerDay": 0.9, "feedPerDay": 8, "lethal": true, "minBrain": 0.25 },
{ "defName": "Rest", "label": "need.rest", "kind": "deplete", "action": "Sleep",
"decayPerDay": 0.7, "feedPerDay": 3, "lethal": false, "minBrain": 0.40 },
{ "defName": "Mating", "label": "need.mating", "kind": "drive", "action": "Mate",
"decayPerDay": 1.0, "risePerDay": 1.2, "risesUnder": "Rut", "minBrain": 0.40 }
]
}
+6 -1
View File
@@ -8,6 +8,11 @@
{ "defName": "ProductFiber", "label": "product.fiber", "kind": "material" }, { "defName": "ProductFiber", "label": "product.fiber", "kind": "material" },
{ "defName": "ProductBerry", "label": "product.berry", "kind": "food" }, { "defName": "ProductBerry", "label": "product.berry", "kind": "food" },
{ "defName": "ProductAcorn", "label": "product.acorn", "kind": "food" }, { "defName": "ProductAcorn", "label": "product.acorn", "kind": "food" },
{ "defName": "ProductMushroom", "label": "product.mushroom", "kind": "food" } { "defName": "ProductMushroom", "label": "product.mushroom", "kind": "food" },
// Животные продукты (фаза A5): добываются из трупа (разделка/гниение). Инвентарь/добыча — позже.
{ "defName": "ProductMeat", "label": "product.meat", "kind": "food" },
{ "defName": "ProductBone", "label": "product.bone", "kind": "material" },
{ "defName": "ProductLeather", "label": "product.leather", "kind": "material" }
] ]
} }
+13
View File
@@ -0,0 +1,13 @@
{
"type": "Thought",
// Мысли настроения (фаза A8): событие вешает на зверя временный сдвиг настроения, затухающий за
// durationDays. Триггер события — код (реестр AnimalThoughts), сила/длительность/метка — данные.
// Настроение есть только у тира 4+ (мозг ≥ 0.40); непрерывный фон (голод/жажда/усталость/боль)
// считается отдельно, мысли — поверх него. Негативные событийные мысли (потеря детёныша) придут с
// родительской опекой (горизонт) — пока горе идёт через непрерывный фон.
"defs": [
{ "defName": "GaveBirth", "label": "thought.gaveBirth", "moodOffset": 0.25, "durationDays": 3 },
{ "defName": "Mated", "label": "thought.mated", "moodOffset": 0.15, "durationDays": 1.5 },
{ "defName": "QuenchedThirst", "label": "thought.quenchedThirst", "moodOffset": 0.1, "durationDays": 0.5 }
]
}
@@ -1,8 +1,8 @@
{ {
"type": "WorldPreset", "type": "WorldPreset",
"defs": [ "defs": [
{ "defName": "Small", "label": "preset.small", "order": 0, "width": 80, "height": 50, "population": 40 }, { "defName": "Small", "label": "preset.small", "order": 0, "width": 128, "height": 80, "population": 60 },
{ "defName": "Medium", "label": "preset.medium", "order": 1, "width": 120, "height": 70, "population": 80 }, { "defName": "Medium", "label": "preset.medium", "order": 1, "width": 200, "height": 120, "population": 110 },
{ "defName": "Large", "label": "preset.large", "order": 2, "width": 180, "height": 110, "population": 140 } { "defName": "Large", "label": "preset.large", "order": 2, "width": 300, "height": 180, "population": 180 }
] ]
} }
+35
View File
@@ -10,6 +10,9 @@
"inspect.tab.overview": "Overview", "inspect.tab.overview": "Overview",
"inspect.tab.genes": "Genes", "inspect.tab.genes": "Genes",
"inspect.tab.products": "Products", "inspect.tab.products": "Products",
"inspect.tab.needs": "Needs",
"inspect.tab.health": "Health",
"inspect.tab.mood": "Mood",
"inspect.stage": "Stage: {0} ({1}/{2})", "inspect.stage": "Stage: {0} ({1}/{2})",
"inspect.growing": "Growth: {0:0}% to next stage", "inspect.growing": "Growth: {0:0}% to next stage",
"inspect.mature": "Fully grown", "inspect.mature": "Fully grown",
@@ -31,6 +34,35 @@
"inspect.harvest": "Harvest: {0} ×{1:0.#}", "inspect.harvest": "Harvest: {0} ×{1:0.#}",
"inspect.fruit": "Fruit: {0} ×{1:0.#} ({2}) · ripe {3:0.#}", "inspect.fruit": "Fruit: {0} ×{1:0.#} ({2}) · ripe {3:0.#}",
"inspect.barren": "Bears no fruit", "inspect.barren": "Bears no fruit",
"inspect.sex.male": "male",
"inspect.sex.female": "female",
"inspect.animal.sex": "Sex: {0} · generation {1}",
"inspect.animal.pregnant": "Pregnant: {0:0.0} d to birth",
"inspect.needline": "{0}: {1:0}%",
"inspect.health.blood": "Blood: {0:0}% · pain: {1:0}%",
"inspect.health.caps": "— Capacities —",
"inspect.health.cap": "{0}: {1:0}%",
"inspect.health.hediffs": "— Conditions —",
"inspect.health.hediffline": "{0}: severity {1:0}%",
"inspect.health.none": "Healthy, no wounds or illness",
"inspect.mood.value": "Mood: {0:0}%",
"inspect.mood.none": "No mood (primitive brain)",
"inspect.mood.thoughts": "— Thoughts —",
"inspect.mood.thoughtline": "{0}: {1}",
"inspect.mood.calm": "Calm, nothing on its mind",
"animalstage.baby": "baby",
"animalstage.juvenile": "juvenile",
"animalstage.adult": "adult",
"animalstage.senior": "senior",
"need.hunger": "hunger",
"need.thirst": "thirst",
"need.rest": "rest",
"need.mating": "mating",
"cap.consciousness": "consciousness",
"cap.moving": "moving",
"cap.sight": "sight",
"hediff.rut": "rut",
"hediff.fever": "fever",
"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",
@@ -123,6 +155,9 @@
"pawn.wolf": "wolf", "pawn.wolf": "wolf",
"pawn.muffalo": "muffalo", "pawn.muffalo": "muffalo",
"pawn.squirrel": "squirrel", "pawn.squirrel": "squirrel",
"thought.gaveBirth": "gave birth",
"thought.mated": "mated",
"thought.quenchedThirst": "drank its fill",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu", "net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…", "net.connecting": "connecting…",
"net.connected": "connected", "net.connected": "connected",
+35
View File
@@ -10,6 +10,9 @@
"inspect.tab.overview": "Обзор", "inspect.tab.overview": "Обзор",
"inspect.tab.genes": "Гены", "inspect.tab.genes": "Гены",
"inspect.tab.products": "Продукты", "inspect.tab.products": "Продукты",
"inspect.tab.needs": "Нужды",
"inspect.tab.health": "Здоровье",
"inspect.tab.mood": "Настроение",
"inspect.stage": "Стадия: {0} ({1}/{2})", "inspect.stage": "Стадия: {0} ({1}/{2})",
"inspect.growing": "Рост: {0:0}% до следующей стадии", "inspect.growing": "Рост: {0:0}% до следующей стадии",
"inspect.mature": "Полностью выросло", "inspect.mature": "Полностью выросло",
@@ -31,6 +34,35 @@
"inspect.harvest": "Сбор: {0} ×{1:0.#}", "inspect.harvest": "Сбор: {0} ×{1:0.#}",
"inspect.fruit": "Плоды: {0} ×{1:0.#} ({2}) · зрелых {3:0.#}", "inspect.fruit": "Плоды: {0} ×{1:0.#} ({2}) · зрелых {3:0.#}",
"inspect.barren": "Не плодоносит", "inspect.barren": "Не плодоносит",
"inspect.sex.male": "самец",
"inspect.sex.female": "самка",
"inspect.animal.sex": "Пол: {0} · поколение {1}",
"inspect.animal.pregnant": "Беременна: {0:0.0} дн до родов",
"inspect.needline": "{0}: {1:0}%",
"inspect.health.blood": "Кровь: {0:0}% · боль: {1:0}%",
"inspect.health.caps": "— Способности —",
"inspect.health.cap": "{0}: {1:0}%",
"inspect.health.hediffs": "— Состояния —",
"inspect.health.hediffline": "{0}: тяжесть {1:0}%",
"inspect.health.none": "Здоров, ран и болезней нет",
"inspect.mood.value": "Настроение: {0:0}%",
"inspect.mood.none": "Настроения нет (примитивный мозг)",
"inspect.mood.thoughts": "— Мысли —",
"inspect.mood.thoughtline": "{0}: {1}",
"inspect.mood.calm": "Спокоен, особых мыслей нет",
"animalstage.baby": "детёныш",
"animalstage.juvenile": "подросток",
"animalstage.adult": "взрослый",
"animalstage.senior": "старый",
"need.hunger": "голод",
"need.thirst": "жажда",
"need.rest": "отдых",
"need.mating": "влечение",
"cap.consciousness": "сознание",
"cap.moving": "движение",
"cap.sight": "зрение",
"hediff.rut": "гон",
"hediff.fever": "лихорадка",
"inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять", "inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
"menu.title": "LittleSim", "menu.title": "LittleSim",
@@ -123,6 +155,9 @@
"pawn.wolf": "волк", "pawn.wolf": "волк",
"pawn.muffalo": "муффало", "pawn.muffalo": "муффало",
"pawn.squirrel": "белка", "pawn.squirrel": "белка",
"thought.gaveBirth": "родила потомство",
"thought.mated": "спарилась",
"thought.quenchedThirst": "напилась вволю",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню", "net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…", "net.connecting": "подключение…",
"net.connected": "подключено", "net.connected": "подключено",
+554
View File
@@ -0,0 +1,554 @@
# Животные (зоология)
> Статус: **A1–A8 реализованы целиком + сериализация животных** — базовый план зоологии и его сквозное
> требование сейв/лоада закрыты; дальше экологический горизонт (хищники, опека, эпидемии, миграция) и
> бэклог реализма. Это живой план — стиль и правила как в [концепте](concept.md) и [модах](mods.md).
>
> **Сериализация животных (готово): сейв/лоад популяции и трупов.** `WorldSave` получил `Animals`
> (`AnimalSave`: геном→признаки+пол, поколение, стадия/возраст, нужды+истощение, здоровье — кровь/HP
> частей/хедифы тяжесть+иммунитет, настроение+мысли, беременность+геном отца) и `Corpses` (`CorpseSave`:
> вид/позиция/таймер-стадия разложения/мясо). `SaveAnimals` снимает популяцию (Health/Mood/Pregnant — через
> entity, т.к. запрос ≤5 типов); `RestoreAnimals`/`RestoreAnimalState` собирают особь фабрикой из генома и
> накладывают динамическое состояние (ref-правки до структурного `AddComponent(Pregnant)`; труп
> пере-тинтится через `Stage=-1`). `Corpse` получил поле `Species` (для спрайта/разделки); `CreateCorpse`
> теперь принимает индекс вида. На загрузке стадо НЕ пересыпается, если в сейве есть животные; старый/пустой
> сейв — регенерируется из сида (не крашит). Эволюция/болезни/беременности переживают сейв/лоад. Сборка +
> `--check-content` чистые.
>
> **A8 часть 2 (готово): инспектор животных.** `InspectPanel` теперь осматривает и животных (не только
> растения): вкладки **Обзор** (стадия/возраст/пол/поколение/беременность), **Нужды** (значения нужд %),
> **Здоровье** (кровь/боль, способности Consciousness/Moving/Sight, активные хедифы с тяжестью),
> **Настроение** (значение % + список мыслей с их сдвигом). Набор вкладок зависит от типа выбранного
> (растение vs животное). `SelectionSystem` теперь пикает и животных (запрос расширен с `PlantGrowth` на
> `AnimalOrganism`). Локализация `inspect.*`/`need.*`/`hediff.*`/`cap.*`/`animalstage.*` (ru/en). Сборка +
> `--check-content` чистые.
>
> **A8 часть 1 (готово): настроение — ядро.** Тип дефа `ThoughtDef` + `thoughts.json` (мысли: роды/
> спаривание/утоление жажды — сила и длительность данными); компонент `Mood` (значение 0..1, активность по
> тиру мозга, список затухающих мыслей) на всех животных; `AnimalMoodSystem` (тир 4+: настроение из
> непрерывного фона — средняя удовлетворённость убывающих нужд минус боль из здоровья — плюс сумма мыслей;
> мысли угасают). Реестр `ThoughtSet` + id-константы `AnimalThoughts`; событийные триггеры: роды
> (`AnimalPregnancySystem`), спаривание и утоление сильной жажды (`AnimalActionSystem`). **Эффект на
> поведение**: низкое настроение (стресс/истощение) подавляет спаривание в `AnimalDecisionSystem`. Команда
> `mood [species]`. Сборка + `--check-content` чистые (11 типов дефов). Осталось A8.2: вкладки инспектора
> (needs/health/mood) — сейчас животные в инспекторе не осматриваются (он только растительный).
>
> **A7 (готово): мозг → интеллект → гейт нужд/действий.** Эффективный интеллект = `brainSize` (ген) ×
> `Consciousness` (capacity из здоровья) — `AnimalFactory.Intelligence`. `AnimalContext` гейтит выбор ИИ:
> нужда с `minBrain` выше интеллекта особи в выбор не входит (`AnimalDecisionSystem` читает `Health`
> поэлементно). Пороги в `needs.json` — тиры мозга (голод 0.0, жажда 0.25, сон/секс 0.40; у оленя мозг
> ~0.45 → здоров весь набор). **Обратная связь от повреждения мозга**: болезнь/боль/кровопотеря роняют
> сознание → эффективный интеллект падает → первыми отключаются сон/спаривание (лихорадящий олень
> сводится к еде/питью). Команда `intel <species> [consciousness]`. Сборка + `--check-content` чистые.
>
> **A5 часть 1 (готово): смерть → труп → разложение.** `Corpse`-компонент + `CorpseSystem`
> (Fresh→Rotting→Skeleton→исчезает, скорость от температуры, тинт по стадии); смерть теперь оставляет
> труп (`AnimalFactory.CreateCorpse`, мясо ∝ размер тела) вместо деспавна; `AnimalDef.CorpseTexture` +
> `corpseTexture` оленя (`Dessicated_*`, скелет — бледный тинт, своего спрайта пока нет). Продукты
> `ProductMeat/Bone/Leather` в `products.json` (разделка/падальщики — позже). Сборка/линт чистые.
> **A5 часть 2 (готово): части тела, кровь, capacities.** `BodyDef`/`BodyPartDef` + `bodies.json`
> (`Quadruped` — данные: иерархия органов, вклады в способности); `AnimalDef.Body`; `HealthState`
> расширен `Parts`/`BloodLevel`/`Pain`/`Capacities`; `CapacityCalc` (обобщённый расчёт по зависимостям
> кровь→сердце/лёгкие→сознание→движение/зрение, модовые способности — сырыми); фабрика строит части
> (HP × размер тела) и считает способности. Источников урона ещё нет → всё на полном HP, способности =
> 1 (каркас под болезни A6/хищников). Команда `body <species>`. Сборка/линт чистые (10 типов дефов).
> Осталось в A5: смерть через здоровье (vital-часть/кровь) + истощение как hediff — когда появятся раны.
>
> **A6 (готово): болезни и иммунитет.** `HediffDef` расширён (severity/immunity/lethal/pain/capMods/
> ambient); хедифы теперь инстансы (`Hediff` с тяжестью/иммунитетом). `AnimalHealthSystem` (медленный
> тик ~час): фоновое заражение, гонка тяжесть↔иммунитет (выздоровление или смерть), пересчёт способностей
> с учётом боли и capMods; смерть по здоровью (летальная болезнь/кровь/потеря сознания) → труп. **Первый
> реальный урон** — `Moving` подключён к скорости (больные звери медленнее). Болезнь `Fever` в
> `hediffs.json`; команда `infect [hediff]`. Сборка/линт чистые.
>
> **A4 (готово):** нужда `Mating`; лёгкий hediff-каркас (`HediffDef`, `hediffs.json` с `Rut`,
> компонент `Health`/`HealthState`); гены `GeneBreedingSeason`/`GeneGestationDays`/`GeneLitterSize`;
> `AnimalRutSystem` (сезонный гон-хедиф поднимает влечение — вне сезона зачатия нет); действие
> `Mate` (поиск партнёра, сближение); `Pregnant` + `AnimalPregnancySystem` (вынашивание → роды
> через `Genome.Breed`, наследование пола без YY, поколение+1, потолок численности). Сборка
> чистая, `--check-content` (33 гена).
>
> **Рефактор расширяемости (готово):** устранены хардкод-блокеры — данными стали: **геном вида**
> (`AnimalDef.Genome` = словарь `geneId→base`, шаблон строится generically); **нужды**
> (`NeedDef`/`needs.json` + `NeedSet` + динамический `AnimalNeeds.Values` + реестр поведений по id);
> **диета** (`AnimalDef.Diet`); **стадии роста** (`AnimalStageDef`/`AnimalDef.Stages` — возраст входа,
> масштаб, текстуры, флаг «взрослая»; пусто → встроенный дефолт); **спавн** (`AnimalDef.SpawnPer1000Cells`
> — все виды рассыпаются по суше из данных, не только олень). Добавить ген/нужду/рацион/стадию/спавн
> виду = правка JSON без кода. Остаются как известные: реестр действий-кода (новое *поведение* = код,
> сознательная граница), часть глобальных баланс-констант (скорость/выпас/потолок).
>
> **A3 (готово):** компонент `AnimalGrowth` (стадия + возраст); ген пола `GeneSex` (XX/XY,
> генерация основателя без YY через `AnimalSet.GenerateGenome`) и ген `GeneMaturityAge`;
> стадии Baby→Juvenile→Adult→Senior с порогами из генов; `AnimalGrowthSystem` (смена
> спрайта детёныш/самец/самка и размера по стадии) + `AnimalMortalitySystem` (смерть от
> старости и от истощения → деспавн, замыкает контур ёмкости среды). Спавн стада —
> разновозрастный; команда `animal` показывает пол/созревание. Сборка чистая, `--check-content`
> (30 генов).
>
> **A1 (готово):** тип дефа `Animal` (`AnimalDef : PawnDef`) + `animals.json` (олень);
> 8 животных генов в `genes.json`; `AnimalPhenotype`/`AnimalOrganism`; `AnimalSet`
> (шаблон генома вида) + `AnimalFactory`; детерминированный спавн стада оленей по суше в
> `WorldScene` с рендером и тинтом меха по гену; консольная команда `animal <species>`.
>
> **A2 (готово):** нужды `AnimalNeeds` (голод/жажда/отдых) с падением по гену метаболизма;
> два слоя ИИ — `AnimalDecisionSystem` (движковый `UtilityAi`, выбор + поиск ближайшей
> еды/воды) и `AnimalActionSystem` (движение к цели + утоление: выедание растений, питьё у
> кромки воды, сон); `AnimalAppearanceSystem` (яркость по острой нужде); кромка воды из
> террейна; команда `popstats [species]` (средние признаков + размах поколений). Контур
> ёмкости среды частичный (трава выедается, но смерть от голода — в A3). Сборка чистая;
> визуальная проверка (олени ищут корм/воду, пасутся) — вручную: «Новый мир».
## Видение
Животные — следующая большая система после генов. Главный принцип: животное живёт по
**тем же законам, что и растение** — гены → признаки (фенотип) → поведение систем, —
но разворачивает их в полноценную зоологию: двуполое размножение с вынашиванием,
здоровье по модели RimWorld (части тела, органы, кровь, болезни), мозг и интеллект,
нужды и настроение. Цель прежняя: **эмерджентность** (динамика популяций, отбор,
пищевые цепи складываются из правил, а не скриптуются) и **наблюдаемость** (всё видно
в мире, HUD, инспекторе, консоли).
Первое показательное животное — **олень** (богатый набор текстур: ♂/♀/детёныш/труп).
## Опора на существующий фундамент
Ничего из перечисленного не строим заново — переиспользуем:
- организм-агностичный `GeneDef`, управляемый `Genome` (диплоидные аллели),
`Genome.Breed(мать, отец, mutationChance)`, `Phenotype.Compute`, `GenomeTemplate`
(шаблон вида, ср. `PlantSet.BuildTemplate`);
- формульный движок и regex-группы (`gsum('Gene.*')`) для производных признаков;
- движковый `UtilityAi<TContext>`, `ResponseCurve`, `Suitability.Trapezoid`;
- день/ночь, сезоны, климат, террейн `water`, `ProductDef`;
- паттерн компонентов растений: `…Organism { Genome; Phenotype Traits }`,
`…Growth { species, stage, ageDays }`, `…Needs`, `…Brain`, `AppearanceSystem` (sim→`Sprite`).
- Friflo допускает managed-ссылку в `IComponent` (доказано `Genome`/`Tilemap`) —
поэтому `Health`/`Pregnant` несут managed-объекты.
## Принцип моддинга: всё расширяется данными
Главное требование — **система должна целиком модифицироваться через моды без правки
кода**. Для этого:
1. **Никаких C#-`enum` для видового контента.** Виды, части тела, органы, способности
(capacities), болезни, нужды, мысли — это **дефы со строковыми id**, интернируемые
в int при загрузке (ровно как уже сделано с `geneId`). Enum нельзя расширить модом —
строковый id можно.
2. **Формулы вместо констант** везде, где можно (как `effects` у генов): модер меняет
баланс формулой в дефе, а не пересборкой.
3. **Реестр поведенческих действий** (`actionId → исполнитель-код`). Деф нужды/ИИ
ссылается на `actionId`. Граница честная и в духе проекта (движок без сэмпла,
фичи демонстрируются игрой): **комбинировать готовые кирпичи — мод; принципиально
новое поведение — код + деф.**
4. **Patch-система (фаза G5)** уже позволяет модеру менять чужие дефы по regex
(например, «всем хищникам +агрессия»).
5. Любой деф переопределяется одноимённым из позднего мода; локализация — в `Languages`.
Итог: модер добавляет новый вид зверя, орган, болезнь, нужду или мысль одними JSON-файлами.
## Геном животного
Животные гены живут в `genes.json` рядом с растительными (один организм-агностичный
тип `Gene`). Предлагаемый набор (числовые, если не сказано иное):
| Группа | Гены |
|---|---|
| Тело/метаболизм | `GeneMaxBodySize`, `GeneMetabolism`, `GeneMoveSpeed`, `GeneBloodVolume`, `GeneVision`, `GeneInsulation` (мех/терморегуляция) |
| Жизненный цикл | `GeneMaturityAge`, `GeneLifespan` (есть), `GeneGestationDays`, `GeneLitterSize` |
| Органы | `GeneHeartEfficiency`, `GeneLungCapacity`, `GeneImmunityGain`, `GeneHealRate`, `GenePainTolerance` |
| Мозг | `GeneBrainSize` → признак `brainSize` (см. «Мозг и интеллект») |
| Поведение | `GeneAggression`, `GeneSociability`, `GeneActivityPattern` (дневной/ночной/сумеречный) |
| Среда | переиспользуем `GeneOptimalTemperature/Tolerance/Cold/HeatHardiness` — звери тоже мёрзнут/перегреваются через ту же трапецию |
| Морфология | `GeneFurColor` (тинт спрайта, как `leafHue`), `GeneSex` (дискретный, см. ниже) |
**Пол выводится из диплоидности бесплатно.** Локус с аллелями {X=0, Y=1}: самка = XX,
самец = XY. При `Genome.Breed` мать может отдать только X (у неё нет Y), отец — X/Y
поровну → потомок XX/XY ровно 50/50, **без спец-кода в скрещивании**. У гена пола
`mutationChance: 0` (как у `GeneFruitSeason`), чтобы X не мутировал в Y.
> Тонкость генерации (не скрещивания): обычный `Generate` дискретного гена выбрал бы
> два аллеля независимо и мог бы выдать невозможный **YY**. Поэтому пол особи-основателя
> назначается явно — XX или XY (по 50/50) — а дальнейшее наследование корректно само.
Признаки собираются в `AnimalPhenotype.FromTraits(traits)` — зеркало `PlantPhenotype`.
Организм: `AnimalOrganism { Genome; AnimalPhenotype Traits; int Generation }` (поколение =
max(род.) + 1, у основателей 0 — для `popstats` и видимого дрейфа).
## Жизненный цикл и стадии роста
Зеркало `PlantGrowth`: `AnimalGrowth { species, stage, ageDays }`. Стадии задаются
данными в дефе вида: `Embryo` (в утробе) → `Baby``Juvenile``Adult``Senior`,
пороги из генов (`MaturityAge`, `Lifespan`). `AnimalGrowthSystem` двигает стадию,
масштабирует `sizeCells`, меняет спрайт/набор текстур (Baby ↔ ♀/♂). Стадия `Senior`
повышает шанс возрастных hediff и смерти — стык со здоровьем.
> **Путь смерти по фазам.** Чтобы на A4 (размножение) популяция не взрывалась без
> убыли, простая **смерть по старости и от голода/жажды (деспавн)** появляется уже в A3.
> На A5 она «апгрейдится»: смерть идёт через здоровье (возрастные hediff, истощение, отказ
> органов) и оставляет **труп** вместо деспавна.
## Нужды и ИИ
Обобщаем `PawnNeeds` (одна энергия) → `AnimalNeeds`: голод, жажда, сон, секс (0..1).
- скорости падения из `GeneMetabolism` × размер (масштаб по Клайберу: ∝ size^0.75);
- **голод** — ест растения/продукты. **Поедание реально воздействует на сущность
растения** (важно для контура ёмкости среды): выедает накопленные плоды
(`Fruiting.RipeFruit`) и/или убавляет биомассу/стадию растения; перевыпас может убить
растение. **Диета** (`AnimalDef.diet`) — список съедобного: теги растений/`ProductDef`
(для будущих хищников — живая добыча/мясо/трупы), плюс способ (выедание плодов vs выпас);
- **жажда** — пьёт на клетках террейна `water`;
- **сон** — спит в неактивную фазу суток (`GeneActivityPattern`): дневные ночью и т.д.;
- **секс** — удовлетворяется размножением.
ИИ разбит на **два слоя — выбор и исполнение**:
1. **Выбор**`AnimalDecisionSystem` на движковом `UtilityAi`: из активных нужд выбирает
действие `FindFood / FindWater / Sleep / SeekMate / Wander(default)` (готовые
`ResponseCurve`) и пишет его + цель в `AnimalBrain { Action; Target; DecideIn }`.
Действия и нужды описаны дефами `NeedDef` (decay-формула, `minBrain`).
2. **Исполнение** — отдельный слой систем (реестр `actionId → исполнитель`, см. «Принцип
моддинга») доводит выбранное действие до результата по схеме **найти цель → дойти →
выполнить → удовлетворить нужду**:
- *Eat*: ближайшее съедобное (плод/биомасса) → подойти → съесть (убавить растение) → ↑ сытость;
- *Drink*: ближайшая клетка `water` → подойти → попить → ↓ жажда;
- *Sleep*: остановиться → спать → ↓ усталость;
- *Mate*: подходящий партнёр → сблизиться → акт → `Pregnant` + сброс `Mating`;
- *Wander*: движение по умолчанию.
Без этого слоя нужда только падает, но не исполняется.
**Передвижение и поиск цели.** Движение к цели — рулевое смещение к точке (реюз
`Transform2D` + `WanderSystem`/`SeparationSystem` для обхода препятствий: стволы деревьев,
другие звери), без полноценного A* на старте. Вода непроходима для сухопутных → `Drink`
целит клетку суши у кромки воды. «Ближайшее съедобное/вода/партнёр» — **пространственный
запрос по сетке/тайлмапу** с радиусом из `Vision`/`Sight` (не O(n²)); ничьи в «ближайшем»
разрешаются детерминированно (по id сущности).
> Порядок фаз: формальный **гейтинг нужд интеллектом — фаза A7**. В A2 у оленя просто
> полный набор нужд зверя (тир 4); поле `minBrain` уже пишем в дефы, но активируется оно
> на A7 (см. «Мозг и интеллект»).
## Размножение (млекопитающие)
Главное отличие от растений — двуполость и отложенное во времени вынашивание:
1. **Влечение и гон (через хедиф).** Нужда `Mating` есть **круглый год** на низком фоне.
В брачный сезон вида на взрослых вешается **хедиф `Rut` (гон)**, который поднимает
влечение (множитель на рост `Mating` / снижение порога действия). **Только под `Rut`
влечение переходит порог `SeekMate` → вне сезона зачатия фактически нет**, хотя нужда
формально существует. Хедиф снимается с концом сезона и **виден в инспекторе** (как
любой hediff). Это унифицирует сезонность с hediff-механизмом, а не городит спец-флаг.
2. **Спаривание → беременность.** Два взрослых разного пола сближаются (действие `Mate`) →
акт → у самки появляется `Pregnant : IComponent { Genome FatherGenome; float
ConceivedDay; float GestationDays; int LitterSize }` — managed-ссылка на геном отца
(оплодотворение = «слепок» отца). Беременность повышает метаболизм (голод) и снижает
`Moving`. (Можно реализовать и как hediff, но держим компонентом ради managed-ссылки
на геном отца.)
3. **Роды.** По достижении срока спавним `LitterSize` детёнышей, каждому
`Genome.Breed(мать, отец, mutationRate)`, стадия `Baby`, рядом с матерью. Лактация/
опека — опционально после.
Стратегии r/K выводятся из генов: мелкие (заяц) — большой помёт, короткая жизнь;
крупные (олень/медведь) — мало потомства, долгая жизнь.
**Решённые параметры (A4):**
- **Гон = хедиф `Rut`** (а не «нужда растёт в сезон»): навешивается в брачный сезон
`GeneBreedingSeason` (как `fruitSeason`, 0..3) — у **оленя осень** (→ роды весной к
обилию корма); у r-стратегов (заяц) — круглогодично/частый гон. Под `Rut` влечение
переходит порог действия; вне `Rut` зачатия нет.
- **Hediff-каркас рождается здесь (A4).** Гон — первый хедиф, поэтому в A4 появляется
лёгкий каркас: контейнер `Health`/`HealthState` со списком хедифов, `HediffDef` со
стадиями и модификаторами, навешивание/снятие по триггеру. A5/A6 расширяют его
частями тела, кровью, болезнями.
- **Триггер акта** — взрослые, разный пол, у обоих `Mating > 0.6`, рядом (1–2 клетки),
самка не беременна, оба не спят/не убегают.
- **Послеродовый кулдаун** — самка не зачинает снова до родов + восстановления;
самец восстанавливается быстро → **полигиния** (один самец кроет нескольких самок).
- **Выбор партнёра (половой отбор)** — НЕ в A4: на старте берётся ближайший подходящий;
предпочтение по размеру/здоровью/настроению добавим позже отдельной итерацией.
## Здоровье — полная модель RimWorld
### Данные (дефы)
- **`BodyDef`** — дерево частей. У части: `coverage` (доля тела → вес попадания),
`parent`, `maxHp` (масштаб от размера тела), флаги (`vital`, `solid`, `skinCovered`),
**вклады в capacities** `{ breathing: 0.5, consciousness: 1.0, moving: 0.25, … }`.
Пример (олень): `body → torso → (heart, lungs×2, liver, stomach, kidneys×2, spine),
head → (brain, eyes×2, ears×2, jaw), legs×4 → hooves`.
- **`HediffDef`** — *всё* есть hediff (рана, потеря части, болезнь, имплант, можно и
беременность): `stages` с порогами по severity; стадия задаёт `capMods`
(offset/factor/setMax по capacity), `painOffset/painFactor`, `bleedRate`, для болезней —
параметры иммунитета.
- **`CapacityDef`** — общий каталог способностей строковыми id: `Consciousness, Moving,
Manipulation, Sight, Hearing, Breathing, BloodPumping, BloodFiltration, Digestion,
Metabolism` (расширяемо модом). Это **каталог**, а не обязательный набор: вид имеет
лишь те способности, в которые вкладываются части его `BodyDef` (у копытного оленя нет
`Manipulation` — некому вкладываться).
### Компонент
`Health : IComponent { HealthState State }` — managed-класс (как `Genome`):
`List<PartInstance>` (деф + текущий hp), `List<Hediff>` (severity, привязка к части,
иммунитет), кэш `Capacities`, `Pain`, `BloodLevel`, `Dead`.
> Контейнер растёт по фазам: **рождается в A4** с одним лишь `List<Hediff>` (под гон-хедиф
> `Rut`); части тела, кровь, capacities и `Dead` добавляются в A5, болезни/иммунитет — A6.
### Расчёт capacities (обобщённая дата-модель)
Способности зависят друг от друга, считаем в порядке зависимостей:
1. `BloodLevel` (0..1) ← запас минус кровопотеря;
2. `BloodPumping` ← сердце × BloodLevel; `Breathing` ← лёгкие × BloodLevel;
`BloodFiltration` ← печень/почки;
3. `Consciousness` ← мозг × BloodPumping × Breathing × (1 болевой штраф);
4. `Moving` ← ноги/позвоночник × Consciousness × BloodPumping; `Sight` ← глаза ×
Consciousness; `Digestion` и пр. — домножаются на вышестоящие.
Формула одна и общая: `capacity = Σ(вклады работающих частей × partHp/maxHp) ×
вышестоящие capacity`. **Без хардкода на каждую способность** — новую способность
вводит деф. Каждая capacity — множитель на действия: `Moving`→скорость, `Sight`→радиус
поиска, `Digestion`→скорость насыщения, `Consciousness`→интеллект и сам факт сознания.
### Кровь — как hediff (реалистичная модель RW)
Кровопотеря = hediff `BloodLoss` (severity 0..1). Кровоточащие раны поднимают её через
`bleedRate`; без кровотечения медленно рассасывается; severity = 1 → смерть; влияет на
`BloodPumping`/`Consciousness` через `capMods`. `GeneBloodVolume` масштабирует скорость
роста severity (больше крови → медленнее теряет).
### Болезни и иммунитет (гонка)
severity болезни растёт за день; параллельно растёт иммунитет (0..1) со скоростью из
стата (зависит от `BloodFiltration`, отдыха, сытости, `GeneImmunityGain`). Иммунитет→1
раньше = выздоровел; severity→летальный порог раньше = смерть. Голодный/раненый зверь
борется с болезнью медленнее.
### Смерть
Триггеры: разрушена `vital`-часть (мозг/сердце), `Consciousness ≤ порога`,
`BloodLevel = 0`, летальная severity болезни, старость (возрастные hediff: катаракта,
больная спина, деменция, закупорка артерий + шанс смерти от старости). На смерти:
стоп-ИИ, переход в труп.
### Тик
Медленный «health tick» раз в игровой час + событийный пересчёт при уроне.
Детерминизм: привязка к игровому времени, не к FPS.
### Источники урона в v1
**Почти никаких.** Строим весь каркас (части, кровь, capacities, hediff-framework, условия
смерти), но не вводим *травмирующих* источников. На старте (A1–A2) смерть только **от
старости**.
Порядок источников урона по фазам: **истощение/обезвоживание** — уже в **A3** (простое
правило «нужда=0 дольше N дней → смерть», замыкает контур ёмкости среды; в A5
переоформляется в hediff истощения) → **температура** (гипотермия/тепловой удар, hediff на
той же трапеции, что у растений) → **болезни** (A6) → **хищничество** (горизонт).
## Труп и разложение
Труп — отдельная сущность с `Corpse { RotDays; Stage }`. Конвейер (скорость зависит от
температуры/сезона — реюз климата):
```
Fresh ──→ Rotting ──→ Skeleton ──→ (исчезает)
│ │ │
│ разделка │ разделка └─ остаётся ProductBone
└──────────┴─→ ProductMeat (кол-во ∝ GeneBodySize) + опц. ProductLeather
```
- свежий/гниющий можно **разделать** → мясо (объём от размера тела) + шкура;
спрайт `Dessicated_*` (для оленя уже есть) на стадии Rotting;
- **скелет** — финальная стадия разложения (как просили, скелет — часть процесса):
мясо уже не получить, остаются кости (`ProductBone`);
- после скелета труп **исчезает в ничто**. На будущее — возврат плодородия в почву
(ниточка к растениям, опционально).
- мясо/кости/падаль → кормовая база будущих хищников и падальщиков.
Новые продукты: `ProductMeat`, `ProductBone`, `ProductLeather`.
**Спрайт скелета — нужен настоящий (решено).** В проекте костяных/скелетных текстур
нет (проверено) → это **ассет-задача**: создать/найти спрайт скелета (желательно
универсальный + по-видовой при наличии), путь вида `things/pawn/animal/deer/Skeleton_*`.
До появления ассета стадию Skeleton можно временно рисовать обработкой `Dessicated_*`,
но финальная версия требует своего спрайта.
## Мозг и интеллект
`GeneBrainSize` → признак `brainSize`. **Эффективный интеллект** = `brainSize ×
Consciousness` — т.е. повреждение/болезнь мозга, боль, кровопотеря динамически снижают
интеллект (сильная связка со здоровьем).
Интеллект **гейтит набор нужд и действий**. У `NeedDef`/действия поле `minBrain`:
активный набор особи = те, у кого `minBrain ≤ brainSize`. **Без enum** — шкала
непрерывная, тиры ниже лишь ярлыки для авторов контента (числа порогов — контент,
правятся модом). `brainSize` нормирован 0..1.
| Тир | `minBrain` | Кто | Что открывается |
|---|---|---|---|
| 1 — рефлекс | 0.00 | губки, простейшие | пассивный голод, реактивность |
| 2 — примитив | ~0.10 | насекомые, черви | голод; реактивное поведение |
| 3 — низший позвоночный | ~0.25 | рыбы, рептилии, амфибии | +жажда; простое избегание |
| 4 — зверь | ~0.45 | млекопитающие, птицы (**олень**, заяц, волк) | +сон, секс, Flee, SeekMate; **настроение**; факультативно стадность/память |
| 5 — умный (на будущее) | ~0.75 | приматы, врановые, люди | +социальные нужды, игра, любопытство, память о ресурсах, планирование, обучение, сложное настроение/отношения |
Олень = **тир 4**. Настроение, сон и брачное поведение включаются с тира 4.
## Настроение
`Mood` (0..1) собирается **из двух источников** (решено):
1. **Непрерывные состояния → в настроение напрямую** по кривой `ResponseCurve`:
уровни голода, жажды, усталости, боли (из здоровья), температуры вне комфорта.
Это не дискретные мысли, а текущий вклад нужд/состояния.
2. **События → `ThoughtDef { moodOffset, durationDays, trigger }`** (модель thoughts
RimWorld, данными): «дала потомство», «спарилась», «напилась после долгой жажды»,
«потеряла детёныша» — временный сдвиг настроения с затуханием.
Настроение есть только с тира 4 (зверь) — гейт интеллектом; у примитивных его нет.
Влияет на поведение (низкое → паника/бегство, не спаривается) и видно в инспекторе.
## Ориентировочные числа для оленя (старт)
Шкала времени мира: **год = 60 игровых дней**, сезон = 15 дней (для справки растения
живут ~160 дней). Числа — стартовая точка, балансируются при реализации; это значения
по умолчанию генома вида (`GenomeTemplate`), вокруг которых идёт разброс и мутации.
| Параметр | Ген/поле | Значение | Заметка |
|---|---|---|---|
| Размер тела | `GeneMaxBodySize` | ♀ 1.4 / ♂ 1.6 sizeCells | как в текущих `DeerDoe`/`DeerBuck` |
| Продолжительность жизни | `GeneLifespan` | ~360 дн (~6 лет) | дольше растений, видно несколько поколений |
| Взросление (→Adult) | `GeneMaturityAge` | ~90 дн (~1.5 года) | Baby 020, Juvenile 2090, Adult 90~290, Senior 290+ |
| Срок беременности | `GeneGestationDays` | ~30 дн (2 сезона) | гон осенью → роды весной |
| Помёт | `GeneLitterSize` | 1 (изредка 2) | K-стратег |
| Сезон гона | `GeneBreedingSeason` | осень | у зайца — круглый год |
| Размер мозга | `GeneBrainSize` | ~0.45 (тир 4) | открывает сон/секс/настроение |
| Объём крови | `GeneBloodVolume` | ~1.0 (×размер) | масштабирует скорость кровопотери |
| Скорость | `GeneMoveSpeed` | ~1.1–1.3 | олень быстрый |
| Голод | `NeedDef` decay | полный голод за ~1.5 дн | по Клайберу ∝ size^0.75 |
| Жажда | `NeedDef` decay | полная жажда за ~0.75 дн | быстрее голода |
| Сон | `NeedDef` decay | суточный цикл | спит в неактивную фазу |
## Идеи для реализма (бэклог)
Опционально, по мере фаз — каждая ложится на дата-модель:
- **Гон/брачный сезон** — хедиф `Rut`, навешиваемый в сезон, поднимает влечение (реализовано в A4).
- **Терморегуляция мехом** — `GeneInsulation` сдвигает температурную трапецию;
новорождённые мёрзнут сильнее.
- **Метаболизм по Клайберу** — расход ∝ size^0.75 (крупные едят больше абсолютно,
меньше на кг).
- **Циркадные ритмы** — дневные/ночные/сумеречные виды (`GeneActivityPattern`).
- **Стадность** — `GeneSociability`: стадные держатся группой (реюз `SeparationSystem`
+ когезия) → эмерджентные стада.
- **Сезонный окрас** — арктические лиса/заяц/волк белеют зимой (текстуры `*_arctic`
есть) — сезонный свитч спрайта/тинта.
- **Половой диморфизм** — рога/размер у самцов (ген, проявляется при XY).
- **r/K-стратегии** — выводятся из `litterSize/gestation/lifespan`.
- **Инбридинг-депрессия** (продвинутое) — родственное скрещивание снижает fitness
через накопление рецессивных аллелей.
- **Память о ресурсах/территория** (для умных) — водопои, кормовые места.
- **Возврат нутриентов** — полное разложение трупа повышает плодородие клетки.
## Новые типы дефов (для модеров)
| Тип | Назначение |
|---|---|
| `AnimalDef : PawnDef` (подтип) | вид: геном-шаблон (ссылки на гены), `BodyDef`, диета, текстуры по стадиям/полу, размеры. Наследник `PawnDef` — существующие 9 животных-`PawnDef` остаются валидными; поведение на компонентах, не на типе дефа |
| `BodyDef` | дерево частей тела с coverage/parent/maxHp/флагами/вкладами в capacities |
| `HediffDef` | раны/болезни/состояния: стадии, capMods, боль, кровотечение, иммунитет |
| `CapacityDef` | именованная способность (строковый id) |
| `NeedDef` | нужда: формула расхода, `minBrain`, привязка к действию |
| `ThoughtDef` | мысль настроения: offset, длительность, условие-триггер |
| `ProductDef` (есть) | + `ProductMeat`, `ProductBone`, `ProductLeather` |
CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип = класс + `RegisterType` в
`GameContent.Load` (см. [mods.md](mods.md)).
## Отбор, экология и наблюдаемость
Смысл генетики — чтобы за поколениями популяция **реально адаптировалась к климату
сида** (дрейф признаков под отбором). Чтобы это было видно (столп «наблюдаемость»):
- **Команда `popstats <species>`** — среднее и разброс ключевых признаков популяции +
счётчик поколений. Без неё отбор «есть, но невидим». Появляется, как только в мире
есть популяция (≈A1–A2), расширяется по мере добавления признаков.
- **Контур ёмкости среды (Лотка–Вольтерра через корм).** Даже без хищников травоядные
могут **выесть растения → голод → крах популяции → восстановление флоры → новый рост**.
Это бесплатная эмерджентность уже на A2, **если поедание реально уменьшает растения**.
Явно проверяем этот контур на A2 (поедание убавляет плод/растение, голод растёт при
нехватке корма). **Важно:** полный «крах» популяции требует **смерти от голода** — она
появляется в **A3** (простое правило). На A2 контур наблюдается частично (звери голодают
и тормозят, но не вымирают), полностью замыкается с A3.
## Сквозные требования (каждая фаза)
- **Детерминизм.** Любая случайность (спавн, выбор партнёра, мутации, выбор части под
урон, шанс инфекции) — из seeded `Random`, выведенного из сида мира (+ id сущности),
никогда `Random.Shared`. Один сид = воспроизводимый мир. Ничьи в пространственных
запросах («ближайший корм/вода/партнёр») разрешаются по id сущности, не по порядку обхода.
- **Сериализация — чек-лист по каждой фазе.** Каждая фаза сразу делает сейв/лоад своих
данных: A1 геном (уже умеет, geneId→Allele) → A3 стадия/возраст/пол → A4 беременность
(срок + **геном отца**) + список хедифов (гон `Rut`) → A5 здоровье (части тела, кровь,
capacities) + труп (стадия/таймер разложения) → A8 настроение/мысли. Пустой/старый
сейв — регенерируется, не крашит.
- **Sim/presentation split.** Системы симуляции пишут только компоненты; спрайт/тинт
(пол, стадия, мех, гниль) ставит `AppearanceSystem`. Никаких draw/UI из симуляции.
## План реализации (фазы A1–A8)
По аналогии с фазами генов G1–G5. Каждая фаза — отдельный коммит, дев-команда для
проверки, проверка через `--check-content` (где можно), и пункты сквозных требований выше.
**Вкладки инспектора добавляются по мере появления данных:** needs@A2, health@A5, mood@A8.
1. **A1 — фундамент.** `AnimalDef` + животные гены + `AnimalPhenotype`/`AnimalOrganism`
+ per-species `GenomeTemplate` + спавн оленя в `WorldScene` + рендер + команда
`animal <species>`.
2. **A2 — нужды + ИИ (выбор + исполнение).** `AnimalNeeds` + `AnimalDecisionSystem`
(выбор) + **слой исполнения** (Eat/Drink/Sleep: найти→дойти→выполнить→удовлетворить) +
еда-растения / вода-террейн / сон по фазе суток + `AppearanceSystem`. **Проверить
контур ёмкости среды** (поедание убавляет растения → колебания). Команда `popstats`.
3. **A3 — жизненный цикл.** Стадии Baby→Adult→Senior, ген пола (XX/XY, генерация без
YY), спрайт по стадии/полу, **простая смерть по старости и от голода/жажды (деспавн)**
— последняя замыкает контур ёмкости среды.
4. **A4 — размножение.** Нужда `Mating` + **лёгкий hediff-каркас** + гон-хедиф `Rut`
(сезон) + исполнение `Mate` → `Pregnant` → роды через `Genome.Breed`.
5. **A5 — здоровье I.** `BodyDef`, части тела, кровь (hediff), capacities, **апгрейд
смерти**: через здоровье + **труп** с разложением до скелета (вместо деспавна A3).
6. **A6 — здоровье II.** Болезни/`HediffDef` с иммунитетом, боль.
7. **A7 — мозг/интеллект.** Ген→интеллект→гейт нужд/действий + обратная связь от
повреждения мозга.
8. **A8 — настроение.** `Mood` + `ThoughtDef` + вкладки инспектора (needs/health/mood —
`InspectPanel` уже умеет вкладки overview/genes/products).
## После A8 — экологический горизонт
То, ради чего вся глубина: превращает «зоопарк» в живую экосистему. Каждая фича опирается
на уже построенные системы.
- **Хищный кластер.** Хищничество (волк→олень): диета = живая добыча; действие `Hunt`
(выследить → преследовать → атака → **урон через здоровье A5** → добить → съесть труп).
Это **первый «активный» (травмирующий) источник урона** для системы здоровья и
полноценная Лотка–Вольтерра с хищником. Рядом: **падальщики** (едят трупы Fresh/Rotting)
и **угроза/бегство** (хищник в радиусе `Sight` → страх → действие `Flee`, удар по
настроению; стадо снижает страх).
- **Родительская опека / импринтинг.** Детёныш ходит за матерью, голод гасится близостью
к лактирующей матери до ювенильной стадии; мать ведёт/защищает. Реализм + детёныши не
мрут от голода в одиночку.
- **Эпидемии.** Болезни (A6) распространяются по контакту/плотности → плотностно-зависимый
регулятор популяции (вспышки-крахи). Исход решает иммунитет (`GeneImmunityGain`).
- **Миграция.** При локальном истощении корма / некомфортном климате — уход к лучшим
клеткам (концепт: «голод+неурожай→миграция»). Эмерджентно из нужд + пространственного
поиска с большим радиусом.
Сюда же тяготеют пункты бэклога: половой отбор (выбор партнёра), сезонный окрас,
инбридинг-депрессия, возврат нутриентов в почву.
## Открытые вопросы
- Конкретный список `ThoughtDef`-событий для оленя (набор и величины offset/длительности)
— детализируется при реализации A8.
- Ассет: создать/найти спрайт скелета (`things/pawn/animal/deer/Skeleton_*`) к фазе A5.
+117
View File
@@ -62,6 +62,117 @@ public sealed class PawnSave
public float ChangeIn { get; set; } public float ChangeIn { get; set; }
} }
/// <summary>Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет.</summary>
public sealed class HediffSave
{
/// <summary>Имя дефа хедифа (гон/болезнь).</summary>
public string DefName { get; set; } = "";
/// <summary>Текущая тяжесть.</summary>
public float Severity { get; set; }
/// <summary>Набранный иммунитет [0,1].</summary>
public float Immunity { get; set; }
}
/// <summary>Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания.</summary>
public sealed class ThoughtSave
{
/// <summary>Имя дефа мысли.</summary>
public string DefName { get; set; } = "";
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays { get; set; }
}
/// <summary>
/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и
/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и
/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние)
/// переживала сейв/лоад, а не пересыпалась заново из сида.
/// </summary>
public sealed class AnimalSave
{
/// <summary>Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Номер поколения (для popstats/дрейфа).</summary>
public int Generation { get; set; }
/// <summary>Текущая стадия роста.</summary>
public int Stage { get; set; }
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays { get; set; }
/// <summary>Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex).</summary>
public Dictionary<string, Allele> Genome { get; set; } = new();
/// <summary>Значения нужд по индексам NeedSet.</summary>
public float[] Needs { get; set; } = [];
/// <summary>Накоплено дней истощения.</summary>
public float Starve { get; set; }
/// <summary>Уровень крови [0,1].</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>HP частей тела по порядку анатомии вида (пусто — у вида нет тела).</summary>
public float[] PartHp { get; set; } = [];
/// <summary>Активные хедифы (гон/болезни).</summary>
public List<HediffSave> Hediffs { get; set; } = [];
/// <summary>Текущее настроение [0,1].</summary>
public float Mood { get; set; }
/// <summary>Активно ли настроение (тир 4+).</summary>
public bool MoodActive { get; set; }
/// <summary>Активные событийные мысли.</summary>
public List<ThoughtSave> Thoughts { get; set; } = [];
/// <summary>Беременна ли самка.</summary>
public bool Pregnant { get; set; }
/// <summary>Геном отца (если беременна) — «слепок» на момент зачатия.</summary>
public Dictionary<string, Allele> FatherGenome { get; set; } = new();
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays { get; set; }
/// <summary>Размер помёта.</summary>
public int Litter { get; set; }
}
/// <summary>Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо.</summary>
public sealed class CorpseSave
{
/// <summary>Имя дефа вида — по нему берётся спрайт трупа.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays { get; set; }
/// <summary>Размер тела (для масштаба спрайта).</summary>
public float BodySize { get; set; }
/// <summary>Доступное мясо.</summary>
public float Meat { get; set; }
}
/// <summary> /// <summary>
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), /// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он /// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
@@ -99,6 +210,12 @@ public sealed class WorldSave
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary> /// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
public List<PlantSave> Plants { get; set; } = []; public List<PlantSave> Plants { get; set; } = [];
/// <summary>Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность).</summary>
public List<AnimalSave> Animals { get; set; } = [];
/// <summary>Снимок трупов (вид, позиция, таймер/стадия разложения, мясо).</summary>
public List<CorpseSave> Corpses { get; set; } = [];
/// <summary>Конфиг мира для пересоздания сцены.</summary> /// <summary>Конфиг мира для пересоздания сцены.</summary>
public WorldConfig ToConfig() => public WorldConfig ToConfig() =>
new() new()
+138
View File
@@ -0,0 +1,138 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Content;
/// <summary>
/// Готовая (resolved) таблица животных для сцены: по каждому <see cref="AnimalDef"/> — загруженный
/// регион атласа и набор генов вида (<see cref="GenomeTemplate"/>) для генерации особей. Строится
/// один раз при загрузке сцены (нужны атлас и устройство), как <see cref="PlantSet"/>. Системы и
/// фабрика индексируются в неё без словарей. Фаза A1 — один спрайт на вид; стадии/пол придут позже.
/// </summary>
public sealed class AnimalSet
{
/// <summary>Вид животного: его деф, спрайты по полу/стадии и набор генов для генерации особи.</summary>
public sealed class Species
{
/// <summary>Исходный деф животного.</summary>
public required AnimalDef Def { get; init; }
/// <summary>Спрайт взрослой самки (он же общий/fallback).</summary>
public required Texture2DRegion Female { get; init; }
/// <summary>Спрайт взрослого самца (fallback — самка).</summary>
public required Texture2DRegion Male { get; init; }
/// <summary>Спрайт детёныша/подростка (fallback — самка).</summary>
public required Texture2DRegion Baby { get; init; }
/// <summary>Спрайт трупа (fallback — самка).</summary>
public required Texture2DRegion Corpse { get; init; }
/// <summary>Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа.</summary>
public required AnimalStageDef[] Stages { get; init; }
/// <summary>Анатомия вида (части тела) или null, если вид без тела.</summary>
public required BodyDef? Body { get; init; }
/// <summary>Набор генов вида: базовые значения для генерации особи.</summary>
public required GenomeTemplate Template { get; init; }
}
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
private static readonly AnimalStageDef[] DefaultStages =
[
new() { Name = "Baby", EnterAt = 0f, RelativeTo = "maturity", Scale = 0.45f, Texture = "baby" },
new() { Name = "Juvenile", EnterAt = 0.22f, RelativeTo = "maturity", Scale = 0.7f, Texture = "baby" },
new() { Name = "Adult", EnterAt = 1f, RelativeTo = "maturity", Scale = 1f, Texture = "adult", Adult = true },
new() { Name = "Senior", EnterAt = 0.8f, RelativeTo = "lifespan", Scale = 0.92f, Texture = "adult", Adult = true },
];
private readonly Species[] _species;
private readonly Dictionary<AnimalDef, int> _index = new();
/// <summary>Общий реестр генов (по id) для скрещивания и вычисления признаков животных.</summary>
public IReadOnlyDictionary<string, GeneDef> GeneRegistry { get; }
/// <summary>Строит таблицу из всех (не-abstract) <see cref="AnimalDef"/> мода.</summary>
public AnimalSet(GameContent content, ModAtlases atlases, GraphicsDevice device)
{
var genes = content
.Defs.All<GeneDef>()
.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
GeneRegistry = genes;
var defs = content.Defs.All<AnimalDef>().ToArray();
_species = new Species[defs.Length];
for (var i = 0; i < defs.Length; i++)
{
var def = defs[i];
var female = atlases.GetRegion(device, def.Texture);
_species[i] = new Species
{
Def = def,
Female = female,
Male = string.IsNullOrEmpty(def.MaleTexture)
? female
: atlases.GetRegion(device, def.MaleTexture),
Baby = string.IsNullOrEmpty(def.BabyTexture)
? female
: atlases.GetRegion(device, def.BabyTexture),
Corpse = string.IsNullOrEmpty(def.CorpseTexture)
? female
: atlases.GetRegion(device, def.CorpseTexture),
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null,
Template = BuildTemplate(def.Genome, genes),
};
_index[def] = i;
}
}
/// <summary>Число видов в таблице.</summary>
public int Count => _species.Length;
/// <summary>Вид по индексу.</summary>
public Species this[int index] => _species[index];
/// <summary>Индекс вида по дефу.</summary>
public int IndexOf(AnimalDef def) => _index[def];
/// <summary>
/// Генерирует геном новой особи-основателя вида: аллели из шаблона + явно назначенный пол
/// (XX самка / XY самец, 50/50) — чтобы не возник невозможный YY (см. Defs/Genes/Animal.json GeneSex).
/// Скрещивание (A4) наследует пол само (мать XX отдаёт только X).
/// </summary>
public Genome GenerateGenome(int species, Random random)
{
var genome = _species[species].Template.Generate(random);
genome["GeneSex"] = random.NextSingle() < 0.5f ? new Allele(0f, 0f) : new Allele(0f, 1f);
return genome;
}
// Строит шаблон генома вида из ДАННЫХ (geneId→base из Defs/Animals/) generically: разброс берётся из
// самого гена. Состав открыт — модер добавляет ген строкой JSON, без правки кода. Дискретные гены
// (база/разброс не нужны — варианты из GeneDef) тоже поддержаны.
private static GenomeTemplate BuildTemplate(
IReadOnlyDictionary<string, float> genome,
IReadOnlyDictionary<string, GeneDef> genes
)
{
var entries = new List<GenomeTemplate.Entry>(genome.Count);
foreach (var (geneId, baseValue) in genome)
{
if (!genes.TryGetValue(geneId, out var gene))
{
throw new KeyNotFoundException(
$"Animal genome references unknown gene '{geneId}' — check Defs/Genes/."
);
}
var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread;
entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread));
}
return new GenomeTemplate(entries);
}
}
+5
View File
@@ -63,6 +63,11 @@ public sealed class GameContent
defs.RegisterType<ProductDef>("Product"); defs.RegisterType<ProductDef>("Product");
defs.RegisterType<PlantDef>("Plant"); defs.RegisterType<PlantDef>("Plant");
defs.RegisterType<PawnDef>("Pawn"); defs.RegisterType<PawnDef>("Pawn");
defs.RegisterType<AnimalDef>("Animal");
defs.RegisterType<HediffDef>("Hediff");
defs.RegisterType<NeedDef>("Need");
defs.RegisterType<ThoughtDef>("Thought");
defs.RegisterType<BodyDef>("Body");
defs.RegisterType<WorldPresetDef>("WorldPreset"); defs.RegisterType<WorldPresetDef>("WorldPreset");
// Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
+194 -2
View File
@@ -190,8 +190,8 @@ public sealed class WorldPresetDef : Def
public int Order { get; init; } public int Order { get; init; }
} }
/// <summary>Существо (Defs/Pawns/): текстура, размер и вид (житель/животное).</summary> /// <summary>Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — <see cref="AnimalDef"/> наследует.</summary>
public sealed class PawnDef : Def public class PawnDef : Def
{ {
/// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary> /// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary>
public const string KindBeing = "being"; public const string KindBeing = "being";
@@ -208,3 +208,195 @@ public sealed class PawnDef : Def
/// <summary>Вид: <see cref="KindBeing"/> или <see cref="KindAnimal"/>.</summary> /// <summary>Вид: <see cref="KindBeing"/> или <see cref="KindAnimal"/>.</summary>
public string Kind { get; init; } = ""; public string Kind { get; init; } = "";
} }
/// <summary>
/// Стадия роста животного как ДАННЫЕ (вложенный объект <see cref="AnimalDef.Stages"/>): возраст входа =
/// <see cref="EnterAt"/> × якорь (<see cref="RelativeTo"/> — созревание или продолжительность жизни),
/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа.
/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода.
/// </summary>
public sealed class AnimalStageDef
{
/// <summary>Имя стадии (для тултипов/отладки).</summary>
public string Name { get; init; } = "";
/// <summary>Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни).</summary>
public float EnterAt { get; init; }
/// <summary>Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни).</summary>
public string RelativeTo { get; init; } = "maturity";
/// <summary>Масштаб размера тела на этой стадии (× ген размера).</summary>
public float Scale { get; init; } = 1f;
/// <summary>Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка).</summary>
public string Texture { get; init; } = "adult";
/// <summary>Взрослая стадия — способна размножаться.</summary>
public bool Adult { get; init; }
}
/// <summary>
/// Вид-животное (Defs/Animals/): подтип <see cref="PawnDef"/> (текстура/размер/вид) плюс геном
/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа —
/// существующие 9 животных-<see cref="PawnDef"/> остаются валидными.
/// </summary>
public sealed class AnimalDef : PawnDef
{
/// <summary>Текстура взрослого самца (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/> (самка/общая).</summary>
public string MaleTexture { get; init; } = "";
/// <summary>Текстура детёныша/подростка (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string BabyTexture { get; init; } = "";
/// <summary>Текстура трупа (фаза A5); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string CorpseTexture { get; init; } = "";
/// <summary>Имя <see cref="BodyDef"/> — анатомия вида (части тела/органы); пусто — без частей тела.</summary>
public string Body { get; init; } = "";
/// <summary>
/// Геном вида как ДАННЫЕ: ключ — id гена (<see cref="MrGameEng.Genetics.GeneDef"/>), значение —
/// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки
/// кода; разброс берётся из самого гена (<c>GeneDef.Spread</c>). Дискретные гены (напр. пол) сюда
/// не пишутся — назначаются генерацией.
/// </summary>
public Dictionary<string, float> Genome { get; init; } = new();
/// <summary>
/// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное).
/// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода.
/// </summary>
public string[] Diet { get; init; } = [];
/// <summary>
/// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию.
/// </summary>
public AnimalStageDef[] Stages { get; init; } = [];
/// <summary>Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам).</summary>
public float SpawnPer1000Cells { get; init; }
/// <summary>Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1).</summary>
public float BaseSpeed { get; init; } = 28f;
/// <summary>Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра.</summary>
public float VisionCells { get; init; } = 14f;
/// <summary>Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки.</summary>
public float ForageBiteDays { get; init; } = 40f;
}
/// <summary>
/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под
/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог
/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее
/// действие — без правки кода. Хранилище нужд особи — динамическое (см. <c>NeedSet</c>/<c>AnimalNeeds</c>).
/// </summary>
public sealed class NeedDef : Def
{
/// <summary>Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием).</summary>
public string Kind { get; init; } = "deplete";
/// <summary>Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…).</summary>
public string Action { get; init; } = "";
/// <summary>Темп убывания за игровой день (drive: темп спада, когда условие отсутствует).</summary>
public float DecayPerDay { get; init; } = 0.5f;
/// <summary>drive: темп роста за день, пока активно условие <see cref="RisesUnder"/>.</summary>
public float RisePerDay { get; init; } = 1f;
/// <summary>deplete: насколько за день восполняет нужду исполнение действия.</summary>
public float FeedPerDay { get; init; } = 4f;
/// <summary>drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда.</summary>
public string? RisesUnder { get; init; }
/// <summary>deplete: на нуле копит счётчик голодной смерти (A3).</summary>
public bool Lethal { get; init; }
/// <summary>Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7).</summary>
public float MinBrain { get; init; }
}
/// <summary>
/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие
/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за
/// <see cref="DurationDays"/>. Триггеры событий — код (реестр <c>AnimalThoughts</c>), а величины/метка —
/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки.
/// </summary>
public sealed class ThoughtDef : Def
{
/// <summary>Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе).</summary>
public float MoodOffset { get; init; }
/// <summary>Сколько игровых дней мысль живёт, прежде чем угаснуть.</summary>
public float DurationDays { get; init; } = 1f;
}
/// <summary>
/// Часть тела/орган (вложенный объект <see cref="BodyDef.Parts"/>) как ДАННЫЕ: иерархия (родитель),
/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в
/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода.
/// </summary>
public sealed class BodyPartDef
{
/// <summary>Имя части (уникально в теле); на него ссылается <see cref="Parent"/> детей.</summary>
public string Name { get; init; } = "";
/// <summary>Имя родительской части ("" — корневая, напр. торс).</summary>
public string Parent { get; init; } = "";
/// <summary>Вес попадания (доля тела) — для будущего выбора задетой части.</summary>
public float Coverage { get; init; } = 0.1f;
/// <summary>Базовый максимум HP (× размер тела при создании особи).</summary>
public float MaxHp { get; init; } = 10f;
/// <summary>Жизненно важная: уничтожение → смерть.</summary>
public bool Vital { get; init; }
/// <summary>Вклады в способности: capacity id (см. <c>AnimalCapacities</c>) → доля (сумма по телу ≈ 1).</summary>
public Dictionary<string, float> Capacities { get; init; } = new();
}
/// <summary>
/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза
/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities).
/// </summary>
public sealed class BodyDef : Def
{
/// <summary>Части тела/органы вида.</summary>
public BodyPartDef[] Parts { get; init; } = [];
}
/// <summary>
/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6:
/// болезнь прогрессирует (<see cref="SeverityPerDay"/>), параллельно растёт иммунитет
/// (<see cref="ImmunityPerDay"/>) — гонка: иммунитет→1 раньше = выздоровление, severity→
/// <see cref="LethalSeverity"/> раньше = смерть. Снижает способности (<see cref="CapMods"/>) и даёт боль.
/// </summary>
public sealed class HediffDef : Def
{
/// <summary>Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05).</summary>
public float InitialSeverity { get; init; } = 1f;
/// <summary>Прирост тяжести за игровой день (0 — статичный хедиф вроде гона).</summary>
public float SeverityPerDay { get; init; }
/// <summary>Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам).</summary>
public float ImmunityPerDay { get; init; }
/// <summary>Тяжесть, при которой наступает смерть (0 — не летально).</summary>
public float LethalSeverity { get; init; }
/// <summary>Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение.</summary>
public float Pain { get; init; }
/// <summary>Шанс подхватить фоном за день (0 — не фоновая болезнь).</summary>
public float AmbientPerDay { get; init; }
/// <summary>Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity).</summary>
public Dictionary<string, float> CapMods { get; init; } = new();
}
+54
View File
@@ -0,0 +1,54 @@
namespace LittleSim.Content;
/// <summary>
/// Готовый реестр нужд из <see cref="NeedDef"/>: присваивает каждой нужде индекс (по нему системы
/// читают значения в <c>AnimalNeeds.Values</c> без словарей), знает тип (deplete/drive), начальное
/// значение и какое действие какую нужду утоляет. Делает набор нужд расширяемым данными: добавил
/// <see cref="NeedDef"/> — особи получают новую нужду, без правки кода (если действие уже есть).
/// </summary>
public sealed class NeedSet
{
private readonly NeedDef[] _defs;
private readonly Dictionary<string, int> _byAction = new(StringComparer.Ordinal);
/// <summary>Строит реестр из всех <see cref="NeedDef"/> мода (порядок = индексы нужд).</summary>
public NeedSet(GameContent content)
{
_defs = content.Defs.All<NeedDef>().ToArray();
for (var i = 0; i < _defs.Length; i++)
{
if (!string.IsNullOrEmpty(_defs[i].Action))
{
_byAction[_defs[i].Action] = i;
}
}
}
/// <summary>Число нужд.</summary>
public int Count => _defs.Length;
/// <summary>Деф нужды по индексу.</summary>
public NeedDef this[int index] => _defs[index];
/// <summary>Нужда-влечение (растёт под условием), а не убывающая.</summary>
public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal);
/// <summary>Убывающая нужда (голод/жажда/сон) — низкое значение срочно.</summary>
public bool IsDeplete(int index) => !IsDrive(index);
/// <summary>Индекс нужды, которую утоляет действие, или -1.</summary>
public int NeedForAction(string actionId) =>
_byAction.TryGetValue(actionId, out var index) ? index : -1;
/// <summary>Новый массив значений нужд особи: deplete стартуют полными (1), drive — пустыми (0).</summary>
public float[] NewValues()
{
var values = new float[_defs.Length];
for (var i = 0; i < values.Length; i++)
{
values[i] = IsDrive(i) ? 0f : 1f;
}
return values;
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace LittleSim.Content;
/// <summary>
/// Реестр мыслей настроения (<see cref="ThoughtDef"/>) с доступом по id (фаза A8): системы-источники
/// событий (роды/спаривание/жажда) берут нужную мысль по строковому id (см. <c>AnimalThoughts</c>) и
/// вешают её на особь. Метка/сила/длительность мысли — данные; модер правит их в thoughts.json. Зеркало
/// <see cref="NeedSet"/> по духу. Удалил модер мысль из дефов — <see cref="Get"/> вернёт null (no-op).
/// </summary>
public sealed class ThoughtSet
{
private readonly Dictionary<string, ThoughtDef> _byId;
/// <summary>Строит реестр из всех <see cref="ThoughtDef"/> мода.</summary>
public ThoughtSet(GameContent content) =>
_byId = content.Defs.All<ThoughtDef>().ToDictionary(t => t.DefName, StringComparer.Ordinal);
/// <summary>Число мыслей.</summary>
public int Count => _byId.Count;
/// <summary>Деф мысли по id или null, если такой нет (мод убрал).</summary>
public ThoughtDef? Get(string id) => _byId.GetValueOrDefault(id);
}
+1 -1
View File
@@ -16,7 +16,7 @@ public static class GameLayers
/// <summary>Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).</summary> /// <summary>Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).</summary>
public static LayerId Selection { get; private set; } public static LayerId Selection { get; private set; }
/// <summary>Регистрирует слои при первом вызове.</summary> /// <summary>Регистрирует слои при первом вызове (порядок = порядок отрисовки).</summary>
public static void EnsureRegistered(Renderer2D renderer) public static void EnsureRegistered(Renderer2D renderer)
{ {
if (_registered) if (_registered)
+726 -2
View File
@@ -65,6 +65,8 @@ public sealed class WorldScene : Scene
private readonly List<Action> _speedRefreshers = []; private readonly List<Action> _speedRefreshers = [];
private PlantSet _plants = null!; private PlantSet _plants = null!;
private AnimalSet _animals = null!;
private NeedSet _needs = null!;
private float[] _cellFertility = []; private float[] _cellFertility = [];
private bool[] _cellLand = []; private bool[] _cellLand = [];
private bool[] _cellOccluderBase = []; // горы (статично из террейна) private bool[] _cellOccluderBase = []; // горы (статично из террейна)
@@ -109,6 +111,8 @@ public sealed class WorldScene : Scene
// Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва. // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
_plants = new PlantSet(content, atlases, device); _plants = new PlantSet(content, atlases, device);
_animals = new AnimalSet(content, atlases, device);
_needs = new NeedSet(content);
var loadingPlants = _save?.Plants is { Count: > 0 }; var loadingPlants = _save?.Plants is { Count: > 0 };
BuildTerrain( BuildTerrain(
content, content,
@@ -122,6 +126,17 @@ public sealed class WorldScene : Scene
RestorePlants(content); RestorePlants(content);
} }
// Животные: из сейва (сохранённая популяция — эволюция/состояние не теряются) либо
// детерминированный спавн из сида (новый мир или старый сейв без животных).
if (_save?.Animals is { Count: > 0 })
{
RestoreAnimals(content);
}
else
{
SpawnAnimals();
}
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира, // Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже). // а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
var lighting = this.UseLighting( var lighting = this.UseLighting(
@@ -155,7 +170,16 @@ public sealed class WorldScene : Scene
atlases.GetRegion(device, "ui/overlays/selectionbracket"), atlases.GetRegion(device, "ui/overlays/selectionbracket"),
GameLayers.Selection GameLayers.Selection
); );
_inspect = new InspectPanel(Store, _plants, content, climate, _selection, brackets); _inspect = new InspectPanel(
Store,
_plants,
_animals,
_needs,
content,
climate,
_selection,
brackets
);
desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root); desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root);
this.UseInspector(renderer); this.UseInspector(renderer);
@@ -203,6 +227,68 @@ public sealed class WorldScene : Scene
_config.Seed _config.Seed
) )
); );
// Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет
// нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой.
var shore = ComputeShore();
var thoughts = new ThoughtSet(content);
UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs));
UpdateSystems.Add(
new AnimalRutSystem(_animals, climate, content.Defs.Get<HediffDef>("Rut"))
);
UpdateSystems.Add(
new AnimalHealthSystem(
Store,
_animals,
CellSize,
Context.Clock,
SecondsPerDay,
content.Defs.All<HediffDef>().Where(h => h.AmbientPerDay > 0f).ToArray(),
_config.Seed + 0x3EAD
)
);
// Настроение (A8) до выбора решения: стресс (низкое настроение) подавляет спаривание.
UpdateSystems.Add(new AnimalMoodSystem(Context.Clock, SecondsPerDay, _needs));
UpdateSystems.Add(
new AnimalDecisionSystem(
Store,
Context.Clock,
_animals,
_needs,
CellSize,
shore,
_config.Seed + 0x5EED
)
);
UpdateSystems.Add(
new AnimalActionSystem(
Store,
_plants,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
_bounds
)
);
UpdateSystems.Add(new AnimalAppearanceSystem(_needs));
UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize));
UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize));
UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate));
UpdateSystems.Add(
new AnimalPregnancySystem(
Store,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
CellSize,
maxAnimals: 500,
_config.Seed + 0x4BED
)
);
UpdateSystems.Add( UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
); );
@@ -422,11 +508,108 @@ public sealed class WorldScene : Scene
} }
); );
SaveAnimals(save);
new SaveStore().Write(save); new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)"); Log.Info(
$"World '{_config.Name}' saved ({save.Plants.Count} plants, "
+ $"{save.Animals.Count} animals, {save.Corpses.Count} corpses)"
);
return _config.Name; return _config.Name;
} }
// Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад
// (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами.
private void SaveAnimals(WorldSave save)
{
Store
.Query<AnimalOrganism, AnimalGrowth, AnimalNeeds, Transform2D>()
.ForEachEntity(
(
ref AnimalOrganism org,
ref AnimalGrowth grow,
ref AnimalNeeds needs,
ref Transform2D transform,
Entity entity
) =>
{
var record = new AnimalSave
{
Species = _animals[org.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
Generation = org.Generation,
Stage = grow.Stage,
AgeDays = grow.AgeDays,
Genome = org.Genome.ToDictionary(),
Needs = needs.Values,
Starve = needs.Starve,
};
if (entity.GetComponent<Health>().State is { } health)
{
record.BloodLevel = health.BloodLevel;
record.PartHp = health.Parts.Select(p => p.Hp).ToArray();
foreach (var hediff in health.Hediffs)
{
record.Hediffs.Add(
new HediffSave
{
DefName = hediff.Def.DefName,
Severity = hediff.Severity,
Immunity = hediff.Immunity,
}
);
}
}
var mood = entity.GetComponent<Mood>();
record.Mood = mood.Value;
record.MoodActive = mood.Active;
if (mood.Thoughts is not null)
{
foreach (var thought in mood.Thoughts)
{
record.Thoughts.Add(
new ThoughtSave
{
DefName = thought.Def.DefName,
RemainingDays = thought.RemainingDays,
}
);
}
}
if (entity.TryGetComponent<Pregnant>(out var preg))
{
record.Pregnant = true;
record.FatherGenome = preg.FatherGenome.ToDictionary();
record.DueInDays = preg.DueInDays;
record.Litter = preg.Litter;
}
save.Animals.Add(record);
}
);
Store
.Query<Corpse, Transform2D>()
.ForEachEntity(
(ref Corpse corpse, ref Transform2D transform, Entity _) =>
save.Corpses.Add(
new CorpseSave
{
Species = _animals[corpse.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
RotDays = corpse.RotDays,
BodySize = corpse.Meat / AnimalFactory.MeatPerBodySize,
Meat = corpse.Meat,
}
)
);
}
private void RestorePlants(GameContent content) private void RestorePlants(GameContent content)
{ {
var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома
@@ -454,6 +637,218 @@ public sealed class WorldScene : Scene
} }
} }
// Восстанавливает популяцию животных и трупы из сейва: фабрика собирает особь из генома (геном
// несёт и пол), затем динамическое состояние (нужды/стадия/здоровье/настроение/беременность)
// накладывается поверх. Старый/битый сейв без генома — особь регенерируется из шаблона вида.
private void RestoreAnimals(GameContent content)
{
var fallback = new Random(_config.Seed);
foreach (var saved in _save!.Animals)
{
if (!content.Defs.TryGet<AnimalDef>(saved.Species, out var def))
{
continue; // вид пропал (мод убрали) — пропускаем особь
}
var index = _animals.IndexOf(def);
var genome = saved.Genome is { Count: > 0 }
? new Genome(saved.Genome)
: _animals.GenerateGenome(index, fallback);
var entity = AnimalFactory.Create(
Store,
_animals,
_needs,
index,
new Vector2(saved.X, saved.Y),
saved.AgeDays,
genome,
saved.Generation,
CellSize
);
RestoreAnimalState(entity, saved, content);
}
foreach (var saved in _save.Corpses)
{
if (!content.Defs.TryGet<AnimalDef>(saved.Species, out var def))
{
continue;
}
var corpse = AnimalFactory.CreateCorpse(
Store,
_animals,
_animals.IndexOf(def),
new Vector2(saved.X, saved.Y),
saved.BodySize,
CellSize
);
ref var data = ref corpse.GetComponent<Corpse>();
data.RotDays = saved.RotDays;
data.Stage = -1; // форсируем пере-тинт стадии в CorpseSystem на следующем тике
data.Meat = saved.Meat;
}
Log.Info(
$"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses from save"
);
}
// Накладывает на свежесозданную особь сохранённое динамическое состояние. Ref-компоненты правим до
// AddComponent(Pregnant) (структурное изменение инвалидирует ссылки); Health — managed, безопасен.
private void RestoreAnimalState(Entity entity, AnimalSave saved, GameContent content)
{
ref var needs = ref entity.GetComponent<AnimalNeeds>();
if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count)
{
needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики
}
needs.Starve = saved.Starve;
entity.GetComponent<AnimalGrowth>().Stage = saved.Stage;
ref var mood = ref entity.GetComponent<Mood>();
mood.Value = saved.Mood;
mood.Active = saved.MoodActive;
mood.Thoughts.Clear();
foreach (var thought in saved.Thoughts)
{
if (content.Defs.TryGet<ThoughtDef>(thought.DefName, out var tdef))
{
mood.Thoughts.Add(
new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays }
);
}
}
if (entity.GetComponent<Health>().State is { } health)
{
health.BloodLevel = saved.BloodLevel;
for (var i = 0; i < health.Parts.Length && i < saved.PartHp.Length; i++)
{
health.Parts[i].Hp = saved.PartHp[i];
}
foreach (var hediff in saved.Hediffs)
{
if (content.Defs.TryGet<HediffDef>(hediff.DefName, out var hdef))
{
health.AddHediff(hdef, hediff.Severity, hediff.Immunity);
}
}
health.RecomputeCapacities();
}
if (saved.Pregnant && saved.FatherGenome is { Count: > 0 })
{
entity.AddComponent(
new Pregnant
{
FatherGenome = new Genome(saved.FatherGenome),
DueInDays = saved.DueInDays,
Litter = saved.Litter,
}
);
}
}
// Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0
// рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида.
private void SpawnAnimals()
{
var landCells = 0;
for (var i = 0; i < _cellLand.Length; i++)
{
if (_cellLand[i])
{
landCells++;
}
}
if (_animals.Count == 0 || landCells == 0)
{
return;
}
var random = new Random(_config.Seed + 0x5EED);
var spawned = 0;
for (var s = 0; s < _animals.Count; s++)
{
var def = _animals[s].Def;
if (def.SpawnPer1000Cells <= 0f)
{
continue; // вид не спавнится сам (напр. появляется только в сейве/событием)
}
var count = Math.Max(1, (int)MathF.Round(landCells / 1000f * def.SpawnPer1000Cells));
var maxAge =
(def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f)
* 0.85f;
var placed = 0;
var attempts = 0;
while (placed < count && attempts < count * 50)
{
attempts++;
var cell = random.Next(_cellLand.Length);
if (!_cellLand[cell])
{
continue;
}
var cx = cell % _config.Width;
var cy = cell / _config.Width;
var px = (cx + 0.2f + random.NextSingle() * 0.6f) * CellSize;
var py = (cy + 0.2f + random.NextSingle() * 0.6f) * CellSize;
AnimalFactory.Create(
Store,
_animals,
_needs,
s,
new Vector2(px, py),
ageDays: random.NextSingle() * maxAge,
_animals.GenerateGenome(s, random),
generation: 0,
CellSize
);
placed++;
}
spawned += placed;
}
Log.Info($"Spawned {spawned} animals ({_animals.Count} species)");
}
// Кромка воды: центры клеток суши, граничащих с водой — куда звери ходят пить (вода непроходима).
private Vector2[] ComputeShore()
{
var shore = new List<Vector2>();
for (var y = 0; y < _config.Height; y++)
{
for (var x = 0; x < _config.Width; x++)
{
var cell = y * _config.Width + x;
if (!_cellLand[cell])
{
continue;
}
var nearWater =
(x > 0 && !_cellLand[cell - 1])
|| (x < _config.Width - 1 && !_cellLand[cell + 1])
|| (y > 0 && !_cellLand[cell - _config.Width])
|| (y < _config.Height - 1 && !_cellLand[cell + _config.Width]);
if (nearWater)
{
shore.Add(new Vector2((x + 0.5f) * CellSize, (y + 0.5f) * CellSize));
}
}
}
return shore.ToArray();
}
// Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями. // Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
private bool[] BuildOccluders() private bool[] BuildOccluders()
{ {
@@ -552,6 +947,36 @@ public sealed class WorldScene : Scene
"plant <species> [seed] — sample a species genome and show its gene-driven traits/products", "plant <species> [seed] — sample a species genome and show its gene-driven traits/products",
(c, args) => RunPlantDemo(c, content, args) (c, args) => RunPlantDemo(c, content, args)
); );
console.Register(
"animal",
"animal <species> [seed] — sample an animal genome and show its gene-driven traits",
(c, args) => RunAnimalDemo(c, content, args)
);
console.Register(
"popstats",
"popstats [species] — live population trait means and generation span (selection drift)",
(c, args) => RunPopStats(c, args)
);
console.Register(
"body",
"body <species> — dump a species body parts and a healthy individual's capacities",
(c, args) => RunBodyDemo(c, content, args)
);
console.Register(
"infect",
"infect [hediff] — give every animal a disease (default Fever) to test the health system",
(c, args) => RunInfect(c, content, args)
);
console.Register(
"intel",
"intel <species> [consciousness] — effective intelligence (brain x consciousness) and which needs it gates",
(c, args) => RunIntel(c, content, args)
);
console.Register(
"mood",
"mood [species] — live population mood (avg/min/max) and content/stressed counts",
(c, args) => RunMood(c, args)
);
console.Register( console.Register(
"menu", "menu",
"menu — return to the main menu", "menu — return to the main menu",
@@ -659,6 +1084,305 @@ public sealed class WorldScene : Scene
} }
} }
// Демонстрация животного (фаза A1): по виду берём его набор генов, генерируем особь и печатаем
// признаки, вычисленные формулами генов (тело/мозг/метаболизм/чувства/мех).
private void RunAnimalDemo(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine("usage: animal <species> [seed] e.g. animal Deer");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
var index = _animals.IndexOf(def);
var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next();
var genome = _animals.GenerateGenome(index, new Random(seed));
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry));
var sex = AnimalFactory.IsMaleGenome(genome) ? "male" : "female";
console.WriteLine($"{def.DefName} ({sex}, seed {seed}):");
console.WriteLine(
$" body {traits.BodySize:0.##} cells, maturity {traits.MaturityAge:0} d, "
+ $"lifespan {traits.Lifespan:0} d, brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}"
);
console.WriteLine(
$" move {traits.MoveSpeed:0.##}, vision {traits.Vision:0.##}, blood {traits.BloodVolume:0.##}, "
+ $"insulation {traits.Insulation:0.##}, furHue {traits.FurHue:0.##}"
);
}
// Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений —
// видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид.
private void RunPopStats(DevConsole console, string[] args)
{
if (_animals.Count == 0)
{
console.WriteLine("no animal species");
return;
}
var count = new int[_animals.Count];
var body = new double[_animals.Count];
var brain = new double[_animals.Count];
var life = new double[_animals.Count];
var move = new double[_animals.Count];
var maxGen = new int[_animals.Count];
Store
.Query<AnimalOrganism>()
.ForEachEntity(
(ref AnimalOrganism o, Entity _) =>
{
var s = o.Species;
count[s]++;
body[s] += o.Traits.BodySize;
brain[s] += o.Traits.BrainSize;
life[s] += o.Traits.Lifespan;
move[s] += o.Traits.MoveSpeed;
if (o.Generation > maxGen[s])
{
maxGen[s] = o.Generation;
}
}
);
var filter = args.Length > 0 ? args[0] : null;
var any = false;
for (var s = 0; s < _animals.Count; s++)
{
var name = _animals[s].Def.DefName;
if (
count[s] == 0
|| (
filter is not null
&& !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase)
)
)
{
continue;
}
any = true;
var c = count[s];
console.WriteLine(
$"{name}: n={c}, gen 0..{maxGen[s]}, body {body[s] / c:0.##}, "
+ $"brain {brain[s] / c:0.##}, lifespan {life[s] / c:0} d, move {move[s] / c:0.##}"
);
}
if (!any)
{
console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive");
}
}
// Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи.
private void RunBodyDemo(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine("usage: body <species> e.g. body Deer");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet<BodyDef>(def.Body, out var body))
{
console.WriteLine($"{def.DefName}: no body");
return;
}
console.WriteLine($"{def.DefName} body '{body.DefName}': {body.Parts.Length} parts");
foreach (var part in body.Parts)
{
var caps =
part.Capacities.Count > 0
? " ["
+ string.Join(
", ",
part.Capacities.Select(kv => $"{kv.Key} {kv.Value:0.##}")
)
+ "]"
: "";
console.WriteLine(
$" {part.Name}{(part.Vital ? " *vital" : "")} hp{part.MaxHp:0}{caps}"
);
}
var state = HealthState.Create(body, 1f);
console.WriteLine(
"capacities (healthy): "
+ string.Join(
", ",
state.Capacities.OrderBy(k => k.Key).Select(kv => $"{kv.Key} {kv.Value:0.##}")
)
);
}
// Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным.
private void RunInfect(DevConsole console, GameContent content, string[] args)
{
var name = args.Length > 0 ? args[0] : "Fever";
if (!content.Defs.TryGet<HediffDef>(name, out var def))
{
console.WriteLine($"no hediff '{name}'");
return;
}
var count = 0;
Store
.Query<Health>()
.ForEachEntity(
(ref Health h, Entity _) =>
{
h.State?.Add(def);
count++;
}
);
console.WriteLine($"infected {count} animals with {def.DefName}");
}
// Наблюдаемость интеллекта (фаза A7): мозг особи (ген) × сознание = эффективный интеллект, который
// гейтит набор нужд/действий. Показывает активные/отключённые нужды при заданном уровне сознания
// (по умолчанию 1 — здоров; меньше — имитация повреждения мозга/болезни/боли).
private void RunIntel(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine(
"usage: intel <species> [consciousness 0..1] [seed] e.g. intel Deer 0.6"
);
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
var consciousness =
args.Length > 1
? Math.Clamp(
float.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture),
0f,
1f
)
: 1f;
var seed = args.Length > 2 ? int.Parse(args[2]) : Random.Shared.Next();
var index = _animals.IndexOf(def);
var genome = _animals.GenerateGenome(index, new Random(seed));
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry));
var intelligence = traits.BrainSize * consciousness;
console.WriteLine(
$"{def.DefName} (seed {seed}): brain {traits.BrainSize:0.##} x consciousness {consciousness:0.##} "
+ $"= intelligence {intelligence:0.###}"
);
var active = new List<string>();
var gated = new List<string>();
for (var k = 0; k < _needs.Count; k++)
{
var need = _needs[k];
(need.MinBrain > intelligence ? gated : active).Add(
$"{need.DefName}({need.MinBrain:0.##})"
);
}
console.WriteLine($" active: {(active.Count > 0 ? string.Join(", ", active) : "")}");
console.WriteLine($" gated: {(gated.Count > 0 ? string.Join(", ", gated) : "")}");
}
// Наблюдаемость настроения (фаза A8): среднее/мин/макс настроения живой популяции по виду и счётчики
// довольных (>0.7) / угнетённых (<0.35). Считает только особей с активным настроением (тир 4+).
private void RunMood(DevConsole console, string[] args)
{
if (_animals.Count == 0)
{
console.WriteLine("no animal species");
return;
}
var count = new int[_animals.Count];
var sum = new double[_animals.Count];
var min = new float[_animals.Count];
var max = new float[_animals.Count];
var content_ = new int[_animals.Count];
var stressed = new int[_animals.Count];
Array.Fill(min, 1f);
Store
.Query<AnimalOrganism, Mood>()
.ForEachEntity(
(ref AnimalOrganism o, ref Mood m, Entity _) =>
{
if (!m.Active)
{
return; // тир ниже зверя — настроения нет
}
var s = o.Species;
count[s]++;
sum[s] += m.Value;
min[s] = MathF.Min(min[s], m.Value);
max[s] = MathF.Max(max[s], m.Value);
if (m.Value > 0.7f)
{
content_[s]++;
}
else if (m.Value < 0.35f)
{
stressed[s]++;
}
}
);
var filter = args.Length > 0 ? args[0] : null;
var any = false;
for (var s = 0; s < _animals.Count; s++)
{
var name = _animals[s].Def.DefName;
if (
count[s] == 0
|| (
filter is not null
&& !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase)
)
)
{
continue;
}
any = true;
console.WriteLine(
$"{name}: n={count[s]}, mood {sum[s] / count[s]:0.##} (min {min[s]:0.##}, max {max[s]:0.##}), "
+ $"content {content_[s]}, stressed {stressed[s]}"
);
}
if (!any)
{
console.WriteLine(filter is null ? "no animals with mood" : $"no '{filter}' with mood");
}
}
private static string ProductLabel(GameContent content, string productDefName) => private static string ProductLabel(GameContent content, string productDefName) =>
content.Defs.TryGet<ProductDef>(productDefName, out var product) content.Defs.TryGet<ProductDef>(productDefName, out var product)
? content.Languages.Get(product.Label) ? content.Languages.Get(product.Label)
+187
View File
@@ -0,0 +1,187 @@
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Единая точка создания сущности-животного: спрайт вида, признаки из генома формулами генов, тинт по
/// оттенку меха и компонент-организм. Переиспользуется начальным спавном, размножением и загрузкой —
/// чтобы все животные собирались одинаково. Зеркало <see cref="PlantFactory"/>.
/// </summary>
public static class AnimalFactory
{
// Якоря оттенка меха: тёплый рыжевато-песочный → холодный серо-голубой (по гену furColor).
private static readonly Color FurWarm = new(255, 226, 188);
private static readonly Color FurCool = new(206, 222, 235);
/// <summary>Создаёт животное вида <paramref name="species"/> заданного возраста, генома и поколения.</summary>
public static Entity Create(
EntityStore store,
AnimalSet animals,
NeedSet needs,
int species,
Vector2 position,
float ageDays,
Genome genome,
int generation,
int cellSize
)
{
var sp = animals[species];
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, animals.GeneRegistry));
var isMale = IsMaleGenome(genome);
var stage = StageAt(ageDays, traits.MaturityAge, traits.Lifespan, sp.Stages);
var region = RegionFor(sp, stage, isMale);
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = FurTint(traits);
return store.CreateEntity(
new Transform2D(
position,
scale: new Vector2(Scale(sp, traits, stage, region, cellSize))
),
sprite,
new AnimalOrganism
{
Genome = genome,
Traits = traits,
Species = species,
IsMale = isMale,
Generation = generation,
},
new AnimalGrowth { Stage = stage, AgeDays = ageDays },
// Нужды — динамический массив по NeedSet (deplete полны, drive пусты); «мозг» решает сразу.
new AnimalNeeds { Values = needs.NewValues() },
new AnimalBrain
{
Action = AnimalActions.Wander,
TargetPlant = -1,
TargetMate = -1,
},
new Health { State = HealthState.Create(sp.Body, traits.BodySize) },
// Настроение (фаза A8): нейтральное на старте, активность по тиру мозга считает MoodSystem.
new Mood
{
Value = 0.5f,
Active = false,
Thoughts = [],
}
);
}
/// <summary>Сколько мяса даёт труп на единицу размера тела (для будущей разделки/падальщиков).</summary>
public const float MeatPerBodySize = 25f;
// Тинт свежего трупа (стадии гниения/скелета задаёт CorpseSystem).
private static readonly Color CorpseFreshTint = new(176, 148, 128);
/// <summary>Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела).</summary>
public static Entity CreateCorpse(
EntityStore store,
AnimalSet animals,
int species,
Vector2 position,
float bodySize,
int cellSize
)
{
var sp = animals[species];
var region = sp.Corpse;
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = CorpseFreshTint;
var sizeCells = bodySize > 0f ? bodySize : sp.Def.SizeCells;
return store.CreateEntity(
new Transform2D(position, scale: new Vector2(cellSize * sizeCells / region.Width)),
sprite,
new Corpse
{
Species = species,
RotDays = 0f,
Stage = 0,
Meat = bodySize * MeatPerBodySize,
}
);
}
/// <summary>
/// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья).
/// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект,
/// что гейтит набор доступных нужд/действий (см. <see cref="AnimalContext"/>). Вид без частей тела
/// (сознание не вычисляется) считается полностью сознательным — чтобы не отключить ему всё.
/// </summary>
public static float Intelligence(in AnimalPhenotype traits, HealthState? health)
{
var consciousness = health is { Parts.Length: > 0 }
? health.Capacity(AnimalCapacities.Consciousness)
: 1f;
return traits.BrainSize * consciousness;
}
/// <summary>Пол из локуса GeneSex: наличие аллеля Y (значение ≈1) → самец; иначе самка (XX).</summary>
public static bool IsMaleGenome(Genome genome) =>
genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f);
/// <summary>Индекс стадии по возрасту: последняя стадия, чей возраст входа (EnterAt × якорь) ≤ возраст.</summary>
public static int StageAt(
float ageDays,
float maturity,
float lifespan,
AnimalStageDef[] stages
)
{
var stage = 0;
for (var i = 1; i < stages.Length; i++)
{
var anchor = string.Equals(stages[i].RelativeTo, "lifespan", StringComparison.Ordinal)
? lifespan
: maturity;
if (ageDays >= stages[i].EnterAt * anchor)
{
stage = i;
}
}
return stage;
}
/// <summary>Спрайт стадии: набор "baby" — текстура детёныша; иначе по полу (самец/самка).</summary>
public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) =>
string.Equals(sp.Stages[stage].Texture, "baby", StringComparison.Ordinal) ? sp.Baby
: isMale ? sp.Male
: sp.Female;
/// <summary>Взрослая ли стадия (способна размножаться) — флаг из данных стадии.</summary>
public static bool IsAdult(AnimalSet.Species sp, int stage) => sp.Stages[stage].Adult;
/// <summary>Масштаб Transform2D для спрайта стадии (размер тела × множитель стадии / ширина региона).</summary>
public static float Scale(
AnimalSet.Species sp,
in AnimalPhenotype traits,
int stage,
Texture2DRegion region,
int cellSize
)
{
var sizeCells =
(traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells) * sp.Stages[stage].Scale;
return cellSize * sizeCells / region.Width;
}
/// <summary>
/// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры —
/// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений).
/// Публичен, чтобы <see cref="AnimalAppearanceSystem"/> домножал его на яркость по нуждам.
/// </summary>
public static Color FurTint(in AnimalPhenotype traits)
{
var fur = Color.Lerp(FurWarm, FurCool, Math.Clamp(traits.FurHue, 0f, 1f));
return Color.Lerp(Color.White, fur, 0.5f);
}
}
+417
View File
@@ -0,0 +1,417 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.Content;
using MrGameEng.Genetics;
namespace LittleSim.Sim;
/// <summary>
/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. <c>Defs/Genes/</c>):
/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало
/// <see cref="PlantPhenotype"/>. Имена полей соответствуют именам признаков в эффектах генов.
/// </summary>
public struct AnimalPhenotype
{
/// <summary>Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии).</summary>
public float BodySize;
/// <summary>Скорость обмена веществ — множитель расхода нужд.</summary>
public float Metabolism;
/// <summary>Скорость передвижения (множитель).</summary>
public float MoveSpeed;
/// <summary>Объём крови (множитель) — масштаб скорости кровопотери.</summary>
public float BloodVolume;
/// <summary>Дальность зрения (множитель радиуса поиска).</summary>
public float Vision;
/// <summary>Размер мозга (0..1) — основа интеллекта.</summary>
public float BrainSize;
/// <summary>Теплоизоляция мехом (0..1).</summary>
public float Insulation;
/// <summary>Оттенок меха (0..1) — сдвиг тинта спрайта.</summary>
public float FurHue;
/// <summary>Возраст созревания (игровых дней) — порог стадии Adult.</summary>
public float MaturityAge;
/// <summary>Продолжительность жизни (игровых дней).</summary>
public float Lifespan;
/// <summary>Сезон гона (0 весна … 3 зима).</summary>
public int BreedingSeason;
/// <summary>Срок вынашивания (игровых дней).</summary>
public float GestationDays;
/// <summary>Размер помёта.</summary>
public float LitterSize;
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
float T(string name) => traits.GetValueOrDefault(name);
return new AnimalPhenotype
{
BodySize = T("bodySize"),
Metabolism = T("metabolism"),
MoveSpeed = T("moveSpeed"),
BloodVolume = T("bloodVolume"),
Vision = T("vision"),
BrainSize = T("brainSize"),
Insulation = T("insulation"),
FurHue = T("furHue"),
MaturityAge = T("maturityAge"),
Lifespan = T("lifespan"),
BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)),
GestationDays = T("gestationDays"),
LitterSize = T("litterSize"),
};
}
}
/// <summary>
/// Компонент-организм животного: управляемый <see cref="Genome"/> (для размножения, сейва и
/// пересчёта), выраженный фенотип <see cref="Traits"/>, индекс вида в <see cref="Content.AnimalSet"/>
/// и номер поколения (для <c>popstats</c> и видимого дрейфа). Геном — ссылочный объект, поэтому
/// компонент несёт ссылку, а не копию (как у растений). Зеркало <see cref="PlantOrganism"/>.
/// </summary>
public struct AnimalOrganism : IComponent
{
/// <summary>Геном особи (аллели по генам); источник истины для признаков и размножения.</summary>
public Genome Genome;
/// <summary>Выраженные признаки — то, что читают системы.</summary>
public AnimalPhenotype Traits;
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/>.</summary>
public int Species;
/// <summary>Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании.</summary>
public bool IsMale;
/// <summary>Номер поколения: max(родителей) + 1; у основателей 0.</summary>
public int Generation;
}
/// <summary>
/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские
/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет
/// <see cref="AnimalGrowthSystem"/>, читая пороги из генов (возраст созревания, продолжительность жизни).
/// Зеркало <see cref="PlantGrowth"/>.
/// </summary>
public struct AnimalGrowth : IComponent
{
/// <summary>Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior.</summary>
public int Stage;
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays;
}
/// <summary>Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру).</summary>
public struct PartInstance
{
/// <summary>Деф части тела (анатомия вида).</summary>
public Content.BodyPartDef Def;
/// <summary>Текущий HP.</summary>
public float Hp;
/// <summary>Максимальный HP (база × размер тела).</summary>
public float MaxHp;
/// <summary>Доля сохранности [0,1].</summary>
public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f;
}
/// <summary>Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6).</summary>
public struct Hediff
{
/// <summary>Деф хедифа (гон/болезнь/…).</summary>
public HediffDef Def;
/// <summary>Текущая тяжесть.</summary>
public float Severity;
/// <summary>Набранный иммунитет [0,1]; при 1 — выздоровление.</summary>
public float Immunity;
}
/// <summary>
/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные
/// способности (capacities). Managed-объект, как <see cref="Genome"/> (Friflo допускает ссылку в
/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по
/// фазам — контейнер один.
/// </summary>
public sealed class HealthState
{
private readonly List<Hediff> _hediffs = [];
/// <summary>Активные хедифы (гон, болезни).</summary>
public IReadOnlyList<Hediff> Hediffs => _hediffs;
/// <summary>Части тела особи (из анатомии вида); пусто — у вида нет тела.</summary>
public PartInstance[] Parts { get; private set; } = [];
/// <summary>Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны).</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>Боль [0,1] — снижает сознание (когда появятся раны).</summary>
public float Pain { get; set; }
/// <summary>Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле).</summary>
public bool Dead { get; set; }
/// <summary>Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1.</summary>
public Dictionary<string, float> Capacities { get; private set; } = new();
/// <summary>Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий.</summary>
public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f;
/// <summary>
/// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы
/// способностей от хедифов (лерп 1→factor по тяжести). Вызывать после изменения частей/крови/хедифов.
/// </summary>
public void RecomputeCapacities()
{
var pain = 0f;
foreach (var h in _hediffs)
{
pain += h.Def.Pain * h.Severity;
}
Pain = Math.Clamp(pain, 0f, 1f);
var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain);
foreach (var h in _hediffs)
{
if (h.Def.CapMods.Count == 0)
{
continue;
}
var t = Math.Clamp(h.Severity, 0f, 1f);
foreach (var (capacity, factor) in h.Def.CapMods)
{
caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t);
}
}
Capacities = caps;
}
/// <summary>
/// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие
/// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести.
/// </summary>
public bool AdvanceHediffs(float days)
{
var lethal = false;
for (var i = _hediffs.Count - 1; i >= 0; i--)
{
var h = _hediffs[i];
if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f)
{
continue; // статичный хедиф (гон) не прогрессирует
}
h.Severity += h.Def.SeverityPerDay * days;
h.Immunity += h.Def.ImmunityPerDay * days;
if (h.Immunity >= 1f)
{
_hediffs.RemoveAt(i); // иммунитет победил — выздоровление
continue;
}
if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity)
{
lethal = true;
}
_hediffs[i] = h;
}
return lethal;
}
/// <summary>Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности.</summary>
public static HealthState Create(Content.BodyDef? body, float bodySize)
{
var state = new HealthState();
if (body is { Parts.Length: > 0 })
{
var scale = MathF.Max(0.2f, bodySize);
state.Parts = new PartInstance[body.Parts.Length];
for (var i = 0; i < body.Parts.Length; i++)
{
var hp = body.Parts[i].MaxHp * scale;
state.Parts[i] = new PartInstance
{
Def = body.Parts[i],
Hp = hp,
MaxHp = hp,
};
}
}
state.RecomputeCapacities();
return state;
}
/// <summary>Несёт ли особь хедиф с данным именем дефа.</summary>
public bool Has(string defName)
{
foreach (var h in _hediffs)
{
if (h.Def.DefName == defName)
{
return true;
}
}
return false;
}
/// <summary>Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа.</summary>
public void Add(HediffDef def)
{
if (!Has(def.DefName))
{
_hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity });
}
}
/// <summary>Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва).</summary>
public void AddHediff(HediffDef def, float severity, float immunity)
{
if (!Has(def.DefName))
{
_hediffs.Add(
new Hediff
{
Def = def,
Severity = severity,
Immunity = immunity,
}
);
}
}
/// <summary>Снимает хедиф по имени дефа; возвращает, был ли он.</summary>
public bool Remove(string defName)
{
for (var i = 0; i < _hediffs.Count; i++)
{
if (_hediffs[i].Def.DefName == defName)
{
_hediffs.RemoveAt(i);
return true;
}
}
return false;
}
}
/// <summary>Здоровье особи: managed-состояние со списком хедифов (см. <see cref="HealthState"/>).</summary>
public struct Health : IComponent
{
/// <summary>Состояние здоровья (хедифы; позже части тела/кровь).</summary>
public HealthState State;
}
/// <summary>
/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям
/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела)
/// для будущей разделки/падальщиков. Управляется <see cref="Sim.CorpseSystem"/>.
/// </summary>
public struct Corpse : IComponent
{
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/> (для спрайта, разделки, сейва).</summary>
public int Species;
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays;
/// <summary>Стадия: 0 свежий, 1 гниющий, 2 скелет.</summary>
public int Stage;
/// <summary>Доступное мясо (∝ размер тела) — для разделки/падальщиков (фаза-горизонт).</summary>
public float Meat;
}
/// <summary>Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания.</summary>
public struct ThoughtInstance
{
/// <summary>Деф мысли (сдвиг настроения и длительность).</summary>
public Content.ThoughtDef Def;
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays;
}
/// <summary>
/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается
/// <see cref="Sim.AnimalMoodSystem"/> из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у
/// тира 4+ (<see cref="Active"/> по размеру мозга) — у примитивных настроения нет. Низкое настроение
/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка
/// (как <c>AnimalNeeds.Values</c>): события добавляют мысли через <see cref="Add"/>.
/// </summary>
public struct Mood : IComponent
{
/// <summary>Текущее настроение [0,1]; у неактивного (тир &lt; 4) держится нейтральным.</summary>
public float Value;
/// <summary>Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются.</summary>
public bool Active;
/// <summary>Активные событийные мысли (затухают со временем).</summary>
public List<ThoughtInstance> Thoughts;
/// <summary>
/// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления
/// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды).
/// </summary>
public readonly void Add(Content.ThoughtDef? def)
{
if (Thoughts is null || def is null)
{
return;
}
for (var i = 0; i < Thoughts.Count; i++)
{
if (Thoughts[i].Def == def)
{
var refreshed = Thoughts[i];
refreshed.RemainingDays = def.DurationDays;
Thoughts[i] = refreshed;
return;
}
}
Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays });
}
}
/// <summary>
/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток
/// срока вынашивания и размер помёта. По истечении срока <see cref="Sim.AnimalPregnancySystem"/>
/// рожает детёнышей через <see cref="Genome.Breed"/> и снимает компонент.
/// </summary>
public struct Pregnant : IComponent
{
/// <summary>Геном отца на момент зачатия.</summary>
public Genome FatherGenome;
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays;
/// <summary>Сколько детёнышей родится.</summary>
public int Litter;
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
using System.Collections.Generic;
namespace LittleSim.Sim;
/// <summary>Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела.</summary>
public static class AnimalCapacities
{
public const string Consciousness = "Consciousness";
public const string Moving = "Moving";
public const string Breathing = "Breathing";
public const string BloodPumping = "BloodPumping";
public const string Sight = "Sight";
public const string Digestion = "Digestion";
}
/// <summary>
/// Обобщённый расчёт способностей (capacities) из частей тела и крови (фаза A5). Сырой вклад способности
/// = Σ(доля части × доля её HP). Затем известные способности домножаются по зависимостям (кровь →
/// сердце/лёгкие → сознание → движение/зрение), как в модели RimWorld; неизвестные (модовые) проходят
/// сырыми. У здоровой особи (полное HP, кровь 1, боль 0) все способности = 1.
/// </summary>
public static class CapacityCalc
{
/// <summary>Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1).</summary>
public static Dictionary<string, float> Compute(PartInstance[] parts, float blood, float pain)
{
var raw = new Dictionary<string, float>(System.StringComparer.Ordinal);
foreach (var part in parts)
{
if (part.Def is null)
{
continue;
}
var fraction = part.Fraction;
foreach (var (capacity, contribution) in part.Def.Capacities)
{
raw[capacity] = raw.GetValueOrDefault(capacity) + contribution * fraction;
}
}
float Raw(string id) => raw.GetValueOrDefault(id);
static float Clamp01(float v) => v < 0f ? 0f : v > 1f ? 1f : v;
var bloodLevel = Clamp01(blood);
var bloodPumping = Raw(AnimalCapacities.BloodPumping) * bloodLevel;
var breathing = Raw(AnimalCapacities.Breathing) * bloodLevel;
var consciousness =
Raw(AnimalCapacities.Consciousness)
* Clamp01(bloodPumping)
* Clamp01(breathing)
* (1f - Clamp01(pain));
var result = new Dictionary<string, float>(System.StringComparer.Ordinal)
{
[AnimalCapacities.BloodPumping] = bloodPumping,
[AnimalCapacities.Breathing] = breathing,
[AnimalCapacities.Consciousness] = consciousness,
[AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * consciousness * Clamp01(bloodPumping),
[AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * consciousness,
[AnimalCapacities.Digestion] = Raw(AnimalCapacities.Digestion) * Clamp01(bloodPumping),
};
// Модовые способности, не охваченные зависимостями, — сырыми.
foreach (var (capacity, value) in raw)
{
result.TryAdd(capacity, value);
}
return result;
}
}
+45 -23
View File
@@ -1,3 +1,4 @@
using System;
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems; using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
@@ -74,41 +75,62 @@ public sealed class SelectionSystem(
} }
} }
// Ближайшее растение, чья ограничивающая окружность спрайта накрывает точку мира. Запрос // Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта
// ограничен PlantGrowth — так из пикинга выпадают спрайты рамки выделения и прочий не-контент. // накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают
// спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий.
private bool TryPick(Vector2 world, out int id) private bool TryPick(Vector2 world, out int id)
{ {
id = -1; id = -1;
var bestDistance = float.MaxValue; var best = float.MaxValue;
foreach ( foreach (
var (transforms, sprites, _, entities) in store var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, PlantGrowth>() .Query<Transform2D, Sprite, PlantGrowth>()
.Chunks .Chunks
) )
{ {
var t = transforms.Span; Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
var s = sprites.Span; }
for (var i = 0; i < t.Length; i++)
{
if (s[i].Region is not { } region)
{
continue;
}
var (center, radius) = CullingMath.SpriteBoundingCircle( foreach (
in t[i], var (transforms, sprites, _, entities) in store
region, .Query<Transform2D, Sprite, AnimalOrganism>()
s[i].Origin .Chunks
); )
var distance = Vector2.DistanceSquared(center, world); {
if (distance <= radius * radius && distance < bestDistance) Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
{
bestDistance = distance;
id = entities.EntityAt(i).Id;
}
}
} }
return id >= 0; return id >= 0;
} }
// Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность.
private static void Scan(
Vector2 world,
Span<Transform2D> transforms,
Span<Sprite> sprites,
ChunkEntities entities,
ref int id,
ref float best
)
{
for (var i = 0; i < transforms.Length; i++)
{
if (sprites[i].Region is not { } region)
{
continue;
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transforms[i],
region,
sprites[i].Origin
);
var distance = Vector2.DistanceSquared(center, world);
if (distance <= radius * radius && distance < best)
{
best = distance;
id = entities.EntityAt(i).Id;
}
}
}
} }
+237 -19
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
@@ -16,10 +17,11 @@ namespace LittleSim.UI;
/// <summary> /// <summary>
/// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта /// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками — «Обзор», /// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками. Набор вкладок
/// «Гены», «Продукты». Источник выделения — <see cref="Selection"/>; панель только читает компоненты /// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Здоровье/Настроение».
/// и обновляется каждый кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут /// Источник выделения — <see cref="Selection"/>; панель только читает компоненты и обновляется каждый
/// через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>). /// кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут через
/// <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary> /// </summary>
internal sealed class InspectPanel internal sealed class InspectPanel
{ {
@@ -28,10 +30,18 @@ internal sealed class InspectPanel
Overview, Overview,
Genes, Genes,
Products, Products,
Needs,
Health,
Mood,
} }
private static readonly Tab[] PlantTabs = [Tab.Overview, Tab.Genes, Tab.Products];
private static readonly Tab[] AnimalTabs = [Tab.Overview, Tab.Needs, Tab.Health, Tab.Mood];
private readonly EntityStore _store; private readonly EntityStore _store;
private readonly PlantSet _plants; private readonly PlantSet _plants;
private readonly AnimalSet _animals;
private readonly NeedSet _needs;
private readonly GameContent _content; private readonly GameContent _content;
private readonly Climate _climate; private readonly Climate _climate;
private readonly Selection _selection; private readonly Selection _selection;
@@ -46,6 +56,8 @@ internal sealed class InspectPanel
public InspectPanel( public InspectPanel(
EntityStore store, EntityStore store,
PlantSet plants, PlantSet plants,
AnimalSet animals,
NeedSet needs,
GameContent content, GameContent content,
Climate climate, Climate climate,
Selection selection, Selection selection,
@@ -54,6 +66,8 @@ internal sealed class InspectPanel
{ {
_store = store; _store = store;
_plants = plants; _plants = plants;
_animals = animals;
_needs = needs;
_content = content; _content = content;
_climate = climate; _climate = climate;
_selection = selection; _selection = selection;
@@ -66,6 +80,9 @@ internal sealed class InspectPanel
AddTab(tabBar, Tab.Overview, "inspect.tab.overview"); AddTab(tabBar, Tab.Overview, "inspect.tab.overview");
AddTab(tabBar, Tab.Genes, "inspect.tab.genes"); AddTab(tabBar, Tab.Genes, "inspect.tab.genes");
AddTab(tabBar, Tab.Products, "inspect.tab.products"); AddTab(tabBar, Tab.Products, "inspect.tab.products");
AddTab(tabBar, Tab.Needs, "inspect.tab.needs");
AddTab(tabBar, Tab.Health, "inspect.tab.health");
AddTab(tabBar, Tab.Mood, "inspect.tab.mood");
_panel = new VerticalStackPanel _panel = new VerticalStackPanel
{ {
@@ -99,7 +116,7 @@ internal sealed class InspectPanel
/// <summary> /// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает /// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку к объекту и пересобирает текст активной вкладки. Не-растения панель не показывает. /// рамку к объекту и пересобирает текст активной вкладки. Осматриваются растения и животные.
/// </summary> /// </summary>
public void Refresh() public void Refresh()
{ {
@@ -107,19 +124,19 @@ internal sealed class InspectPanel
!_selection.HasSelection !_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var entity) || !_store.TryGetEntityById(_selection.EntityId, out var entity)
|| entity.IsNull || entity.IsNull
|| !entity.HasComponent<PlantGrowth>()
|| !entity.HasComponent<PlantOrganism>()
|| !entity.HasComponent<Transform2D>() || !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>() || !entity.HasComponent<Sprite>()
) )
{ {
if (_selection.HasSelection) ClearSelection();
{ return;
_selection.Clear(); // сущность исчезла (растение погибло) }
}
_panel.Visible = false; var isPlant = entity.HasComponent<PlantGrowth>() && entity.HasComponent<PlantOrganism>();
_brackets.Hide(); var isAnimal = entity.HasComponent<AnimalOrganism>();
if (!isPlant && !isAnimal)
{
ClearSelection(); // не контент (труп/прочее) — пока не осматривается
return; return;
} }
@@ -135,10 +152,29 @@ internal sealed class InspectPanel
_brackets.Show(center, radius); _brackets.Show(center, radius);
} }
UpdateText(entity); if (isPlant)
{
UpdatePlant(entity);
}
else
{
UpdateAnimal(entity);
}
_panel.Visible = true; _panel.Visible = true;
} }
private void ClearSelection()
{
if (_selection.HasSelection)
{
_selection.Clear(); // сущность исчезла (растение/зверь погибли) или не осматривается
}
_panel.Visible = false;
_brackets.Hide();
}
private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey) private void AddTab(HorizontalStackPanel bar, Tab tab, string labelKey)
{ {
var button = new TextButton { Text = _content.Languages.Get(labelKey) }; var button = new TextButton { Text = _content.Languages.Get(labelKey) };
@@ -147,7 +183,23 @@ internal sealed class InspectPanel
_tabs.Add((tab, button)); _tabs.Add((tab, button));
} }
private void UpdateText(Entity entity) // Показывает только вкладки текущего типа, подсвечивает активную; если активная не из набора —
// откатывает на первую (Обзор) при смене типа выбранного объекта.
private void ShowTabs(Tab[] valid)
{
if (Array.IndexOf(valid, _tab) < 0)
{
_tab = valid[0];
}
foreach (var (tab, button) in _tabs)
{
button.Visible = Array.IndexOf(valid, tab) >= 0;
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
}
private void UpdatePlant(Entity entity)
{ {
var languages = _content.Languages; var languages = _content.Languages;
ref readonly var grow = ref entity.GetComponent<PlantGrowth>(); ref readonly var grow = ref entity.GetComponent<PlantGrowth>();
@@ -157,10 +209,7 @@ internal sealed class InspectPanel
var traits = org.Traits; var traits = org.Traits;
_title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : ""); _title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : "");
foreach (var (tab, button) in _tabs) ShowTabs(PlantTabs);
{
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
var text = new StringBuilder(); var text = new StringBuilder();
switch (_tab) switch (_tab)
@@ -179,6 +228,35 @@ internal sealed class InspectPanel
_body.Text = text.ToString().TrimEnd(); _body.Text = text.ToString().TrimEnd();
} }
private void UpdateAnimal(Entity entity)
{
var languages = _content.Languages;
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var sp = _animals[org.Species];
_title.Text = languages.Get(sp.Def.Label);
ShowTabs(AnimalTabs);
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildAnimalOverview(text, entity, org, sp);
break;
case Tab.Needs:
BuildAnimalNeeds(text, entity);
break;
case Tab.Health:
BuildAnimalHealth(text, entity);
break;
case Tab.Mood:
BuildAnimalMood(text, entity);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void BuildOverview( private void BuildOverview(
StringBuilder text, StringBuilder text,
Entity entity, Entity entity,
@@ -296,6 +374,146 @@ internal sealed class InspectPanel
} }
} }
// — Животное: Обзор — стадия, возраст, пол/поколение, беременность.
private void BuildAnimalOverview(
StringBuilder text,
Entity entity,
in AnimalOrganism org,
AnimalSet.Species sp
)
{
var languages = _content.Languages;
var grow = entity.GetComponent<AnimalGrowth>();
var stageName = languages.Get(
"animalstage." + sp.Stages[grow.Stage].Name.ToLowerInvariant()
);
text.AppendLine(
languages.Format("inspect.stage", stageName, grow.Stage + 1, sp.Stages.Length)
);
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, org.Traits.Lifespan));
var sex = languages.Get(org.IsMale ? "inspect.sex.male" : "inspect.sex.female");
text.AppendLine(languages.Format("inspect.animal.sex", sex, org.Generation));
if (entity.TryGetComponent<Pregnant>(out var preg))
{
text.AppendLine(languages.Format("inspect.animal.pregnant", preg.DueInDays));
}
}
// — Животное: Нужды — значение каждой нужды (голод/жажда/отдых/влечение) в процентах.
private void BuildAnimalNeeds(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var values = entity.GetComponent<AnimalNeeds>().Values;
if (values is null)
{
return;
}
for (var k = 0; k < _needs.Count && k < values.Length; k++)
{
text.AppendLine(
languages.Format(
"inspect.needline",
languages.Get(_needs[k].Label),
values[k] * 100f
)
);
}
}
// — Животное: Здоровье — кровь/боль, ключевые способности, активные хедифы (раны/болезни/гон).
private void BuildAnimalHealth(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var state = entity.GetComponent<Health>().State;
if (state is null)
{
text.AppendLine(languages.Get("inspect.health.none"));
return;
}
text.AppendLine(
languages.Format("inspect.health.blood", state.BloodLevel * 100f, state.Pain * 100f)
);
if (state.Parts.Length > 0)
{
text.AppendLine(languages.Get("inspect.health.caps"));
AppendCapacity(text, state, AnimalCapacities.Consciousness, "cap.consciousness");
AppendCapacity(text, state, AnimalCapacities.Moving, "cap.moving");
AppendCapacity(text, state, AnimalCapacities.Sight, "cap.sight");
}
if (state.Hediffs.Count > 0)
{
text.AppendLine(languages.Get("inspect.health.hediffs"));
foreach (var hediff in state.Hediffs)
{
text.AppendLine(
languages.Format(
"inspect.health.hediffline",
languages.Get(hediff.Def.Label),
hediff.Severity * 100f
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.health.none"));
}
}
private void AppendCapacity(
StringBuilder text,
HealthState state,
string capId,
string labelKey
)
{
var languages = _content.Languages;
text.AppendLine(
languages.Format(
"inspect.health.cap",
languages.Get(labelKey),
state.Capacity(capId) * 100f
)
);
}
// — Животное: Настроение — значение и активные событийные мысли с их сдвигом (тир 4+).
private void BuildAnimalMood(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var mood = entity.GetComponent<Mood>();
if (!mood.Active)
{
text.AppendLine(languages.Get("inspect.mood.none"));
return;
}
text.AppendLine(languages.Format("inspect.mood.value", mood.Value * 100f));
if (mood.Thoughts is { Count: > 0 })
{
text.AppendLine(languages.Get("inspect.mood.thoughts"));
foreach (var thought in mood.Thoughts)
{
var pct = thought.Def.MoodOffset * 100f;
var offset = (pct >= 0f ? "+" : "") + pct.ToString("0") + "%";
text.AppendLine(
languages.Format(
"inspect.mood.thoughtline",
languages.Get(thought.Def.Label),
offset
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.mood.calm"));
}
}
private string StageLabel(PlantDef def, int stage) => private string StageLabel(PlantDef def, int stage) =>
stage < def.Stages.Count && def.Stages[stage].Label is { } key stage < def.Stages.Count && def.Stages[stage].Label is { } key
? _content.Languages.Get(key) ? _content.Languages.Get(key)