From 516e263c777de8cd40e49d859e7797890640d87e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:03:39 +0300 Subject: [PATCH 01/17] =?UTF-8?q?docs:=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20=D0=B6?= =?UTF-8?q?=D0=B8=D0=B2=D0=BE=D1=82=D0=BD=D1=8B=D1=85=20(=D0=B7=D0=BE?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный план зоологии на генетическом фундаменте: 7 столпов (гены, жизненный цикл, размножение, здоровье по модели RimWorld, мозг/интеллект, нужды, настроение), моддинг данными, фазы A1-A8 и экологический горизонт. Co-Authored-By: Claude Opus 4.8 --- docs/животные.md | 466 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 docs/животные.md diff --git a/docs/животные.md b/docs/животные.md new file mode 100644 index 0000000..9162caa --- /dev/null +++ b/docs/животные.md @@ -0,0 +1,466 @@ +# Животные (зоология) + +> Статус: **A1 реализована**, остальное по плану. Это живой план — правится по мере +> реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). +> +> **A1 (готово):** тип дефа `Animal` (`AnimalDef : PawnDef`) + `animals.json` (олень); +> 8 животных генов в `genes.json`; `AnimalPhenotype`/`AnimalOrganism`; `AnimalSet` +> (шаблон генома вида) + `AnimalFactory`; детерминированный спавн стада оленей по суше в +> `WorldScene` с рендером и тинтом меха по гену; консольная команда `animal `. +> Проверено: `--check-content` (7 типов дефов, 28 генов). Визуальная проверка (олени в мире, +> команда `animal Deer`) — вручную: «Новый мир» → консоль. + +## Видение + +Животные — следующая большая система после генов. Главный принцип: животное живёт по +**тем же законам, что и растение** — гены → признаки (фенотип) → поведение систем, — +но разворачивает их в полноценную зоологию: двуполое размножение с вынашиванием, +здоровье по модели RimWorld (части тела, органы, кровь, болезни), мозг и интеллект, +нужды и настроение. Цель прежняя: **эмерджентность** (динамика популяций, отбор, +пищевые цепи складываются из правил, а не скриптуются) и **наблюдаемость** (всё видно +в мире, HUD, инспекторе, консоли). + +Первое показательное животное — **олень** (богатый набор текстур: ♂/♀/детёныш/труп). + +## Опора на существующий фундамент + +Ничего из перечисленного не строим заново — переиспользуем: + +- организм-агностичный `GeneDef`, управляемый `Genome` (диплоидные аллели), + `Genome.Breed(мать, отец, mutationChance)`, `Phenotype.Compute`, `GenomeTemplate` + (шаблон вида, ср. `PlantSet.BuildTemplate`); +- формульный движок и regex-группы (`gsum('Gene.*')`) для производных признаков; +- движковый `UtilityAi`, `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` (деф + текущий hp), `List` (severity, привязка к части, +иммунитет), кэш `Capacities`, `Pain`, `BloodLevel`, `Dead`. + +> Контейнер растёт по фазам: **рождается в A4** с одним лишь `List` (под гон-хедиф +> `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 0–20, Juvenile 20–90, 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 `** — среднее и разброс ключевых признаков популяции + + счётчик поколений. Без неё отбор «есть, но невидим». Появляется, как только в мире + есть популяция (≈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 `. +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. From 5e61491960a2ec78ea88efe6e4c107b37fd18284 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:03:51 +0300 Subject: [PATCH 02/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A1:=20=D1=84=D1=83=D0=BD=D0=B4=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=20=E2=80=94=20AnimalDef,=20=D0=B3=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B7=D0=B2=D0=B5=D1=80=D1=8F,=20=D1=81=D0=BF=D0=B0?= =?UTF-8?q?=D0=B2=D0=BD=20=D0=BE=D0=BB=D0=B5=D0=BD=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый тип дефа Animal (AnimalDef : PawnDef) + animals.json (Deer). 8 организм-агностичных животных генов в genes.json. AnimalPhenotype/AnimalOrganism, AnimalSet (шаблон генома вида) и AnimalFactory — зеркала растительных PlantSet/PlantFactory. Детерминированный спавн стада оленей по суше в WorldScene с рендером и тинтом меха по гену; консольная команда 'animal '. Проверено: сборка чистая, --check-content (7 типов дефов, 28 генов). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 26 +++++++ Mods/Core/Defs/genes.json | 26 +++++++ src/LittleSim/Content/AnimalSet.cs | 97 +++++++++++++++++++++++++ src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 52 +++++++++++++- src/LittleSim/Scenes/WorldScene.cs | 103 +++++++++++++++++++++++++++ src/LittleSim/Sim/AnimalFactory.cs | 62 ++++++++++++++++ src/LittleSim/Sim/AnimalOrganism.cs | 79 ++++++++++++++++++++ 8 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 Mods/Core/Defs/animals.json create mode 100644 src/LittleSim/Content/AnimalSet.cs create mode 100644 src/LittleSim/Sim/AnimalFactory.cs create mode 100644 src/LittleSim/Sim/AnimalOrganism.cs diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json new file mode 100644 index 0000000..f7a8a4d --- /dev/null +++ b/Mods/Core/Defs/animals.json @@ -0,0 +1,26 @@ +{ + "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", + "genome": { + "bodySize": 1.5, + "metabolism": 1.0, + "moveSpeed": 1.2, + "bloodVolume": 1.4, + "vision": 1.3, + "brainSize": 0.45, + "insulation": 0.55, + "furColor": 0.45, + "lifespan": 360, + "spread": 0.08 + } + } + ] +} diff --git a/Mods/Core/Defs/genes.json b/Mods/Core/Defs/genes.json index 5610511..cc17691 100644 --- a/Mods/Core/Defs/genes.json +++ b/Mods/Core/Defs/genes.json @@ -74,6 +74,32 @@ "default": 0, "min": 0, "max": 1, "spread": 0, "mutationChance": 0, "tags": ["derived"], "effects": { "hardiness": "gsum('Gene.*Tolerance')" } }, + // --- Животные (фаза A1): организм-агностичные гены зверя (их несёт геном животного) --- + { "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, "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" } }, + // Дискретный ген морфы: вариант 0 доминирует, 1 (рецессивный) виден только в гомозиготе. { "defName": "GeneMorph", "kind": "Discrete", "label": "gene.morph", "variants": 2, "variantWeights": [0.82, 0.18], "mutationChance": 0.05, "tags": ["morphology"], diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs new file mode 100644 index 0000000..f9f66aa --- /dev/null +++ b/src/LittleSim/Content/AnimalSet.cs @@ -0,0 +1,97 @@ +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Content; + +/// +/// Готовая (resolved) таблица животных для сцены: по каждому — загруженный +/// регион атласа и набор генов вида () для генерации особей. Строится +/// один раз при загрузке сцены (нужны атлас и устройство), как . Системы и +/// фабрика индексируются в неё без словарей. Фаза A1 — один спрайт на вид; стадии/пол придут позже. +/// +public sealed class AnimalSet +{ + /// Вид животного: его деф, спрайт и набор генов для генерации особи. + public sealed class Species + { + /// Исходный деф животного. + public required AnimalDef Def { get; init; } + + /// Спрайт вида (фаза A1 — единый; стадии/пол позже). + public required Texture2DRegion Region { get; init; } + + /// Набор генов вида: базовые значения для генерации особи. + public required GenomeTemplate Template { get; init; } + } + + private readonly Species[] _species; + private readonly Dictionary _index = new(); + + /// Общий реестр генов (по id) для скрещивания и вычисления признаков животных. + public IReadOnlyDictionary GeneRegistry { get; } + + /// Строит таблицу из всех (не-abstract) мода. + public AnimalSet(GameContent content, ModAtlases atlases, GraphicsDevice device) + { + var genes = content + .Defs.All() + .ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal); + GeneRegistry = genes; + + var defs = content.Defs.All().ToArray(); + _species = new Species[defs.Length]; + for (var i = 0; i < defs.Length; i++) + { + var def = defs[i]; + _species[i] = new Species + { + Def = def, + Region = atlases.GetRegion(device, def.Texture), + Template = BuildTemplate(def.Genome, genes), + }; + _index[def] = i; + } + } + + /// Число видов в таблице. + public int Count => _species.Length; + + /// Вид по индексу. + public Species this[int index] => _species[index]; + + /// Индекс вида по дефу. + public int IndexOf(AnimalDef def) => _index[def]; + + // Переводит базовый геном вида (числа из animals.json) в набор генов: каждое число — центр + // аллелей соответствующего общего GeneDef. Зеркало PlantSet.BuildTemplate. + private static GenomeTemplate BuildTemplate( + AnimalGenomeDef g, + IReadOnlyDictionary genes + ) + { + GeneDef Gene(string id) => + genes.TryGetValue(id, out var def) + ? def + : throw new KeyNotFoundException( + $"Gene def '{id}' not found — is Mods/Core/Defs/genes.json loaded?" + ); + + GenomeTemplate.Entry Numeric(string id, float baseValue) => + new(Gene(id), baseValue, g.Spread); + + return new GenomeTemplate( + [ + Numeric("GeneMaxBodySize", g.BodySize), + Numeric("GeneMetabolism", g.Metabolism), + Numeric("GeneMoveSpeed", g.MoveSpeed), + Numeric("GeneBloodVolume", g.BloodVolume), + Numeric("GeneVision", g.Vision), + new GenomeTemplate.Entry(Gene("GeneBrainSize"), g.BrainSize, 0.03f), + Numeric("GeneInsulation", g.Insulation), + new GenomeTemplate.Entry(Gene("GeneFurColor"), g.FurColor, 0.06f), + Numeric("GeneLifespan", g.Lifespan), + ] + ); + } +} diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 07fdee2..7643072 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -63,6 +63,7 @@ public sealed class GameContent defs.RegisterType("Product"); defs.RegisterType("Plant"); defs.RegisterType("Pawn"); + defs.RegisterType("Animal"); defs.RegisterType("WorldPreset"); // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 81b2442..9842294 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -191,7 +191,7 @@ public sealed class WorldPresetDef : Def } /// Существо (Defs/pawns.json): текстура, размер и вид (житель/животное). -public sealed class PawnDef : Def +public class PawnDef : Def { /// Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне. public const string KindBeing = "being"; @@ -208,3 +208,53 @@ public sealed class PawnDef : Def /// Вид: или . public string Kind { get; init; } = ""; } + +/// +/// Базовый геном вида-животного (вложенный объект ): центры аллелей +/// животных генов (см. genes.json). Особь при спавне получает аллели рядом с этими базами +/// (разброс — ), а фенотип считается формулами генов над аллелями. Зеркало +/// растительного , только для зверя. +/// +public sealed class AnimalGenomeDef +{ + /// Размер тела в клетках — масштаб спрайта и (позже) HP/расхода энергии. + public float BodySize { get; init; } = 1f; + + /// Скорость обмена веществ — множитель расхода нужд (голод/жажда). + public float Metabolism { get; init; } = 1f; + + /// Скорость передвижения (множитель). + public float MoveSpeed { get; init; } = 1f; + + /// Объём крови (множитель) — масштаб скорости кровопотери в системе здоровья. + public float BloodVolume { get; init; } = 1f; + + /// Дальность зрения (множитель радиуса поиска корма/воды/партнёра). + public float Vision { get; init; } = 1f; + + /// Размер мозга (0..1) — основа интеллекта (гейт нужд/действий). + public float BrainSize { get; init; } = 0.45f; + + /// Теплоизоляция мехом (0..1) — терморегуляция. + public float Insulation { get; init; } = 0.5f; + + /// Оттенок меха (0..1) — сдвиг тинта спрайта (как leafHue у растений). + public float FurColor { get; init; } = 0.5f; + + /// Продолжительность жизни (игровых дней) до смерти от старости. + public float Lifespan { get; init; } = 160f; + + /// Доля разброса аллелей вокруг базы при генерации особи. + public float Spread { get; init; } = 0.08f; +} + +/// +/// Вид-животное (Defs/animals.json): подтип (текстура/размер/вид) плюс базовый +/// геном вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе +/// дефа — существующие 9 животных- остаются валидными. +/// +public sealed class AnimalDef : PawnDef +{ + /// Базовый геном вида: центры аллелей животных генов. + public AnimalGenomeDef Genome { get; init; } = new(); +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 5b56a1b..238cfb8 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -65,6 +65,7 @@ public sealed class WorldScene : Scene private readonly List _speedRefreshers = []; private PlantSet _plants = null!; + private AnimalSet _animals = null!; private float[] _cellFertility = []; private bool[] _cellLand = []; private bool[] _cellOccluderBase = []; // горы (статично из террейна) @@ -109,6 +110,7 @@ public sealed class WorldScene : Scene // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва. _plants = new PlantSet(content, atlases, device); + _animals = new AnimalSet(content, atlases, device); var loadingPlants = _save?.Plants is { Count: > 0 }; BuildTerrain( content, @@ -122,6 +124,10 @@ public sealed class WorldScene : Scene RestorePlants(content); } + // Животные (фаза A1): детерминированный начальный спавн по суше. Пока без сейва/симуляции — + // регенерируются из сида и для нового, и для загруженного мира. + SpawnAnimals(content); + // Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира, // а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже). var lighting = this.UseLighting( @@ -454,6 +460,64 @@ public sealed class WorldScene : Scene } } + // Детерминированный начальный спавн оленей по клеткам суши (фаза A1): количество — лёгкое стадо, + // масштаб по площади суши; геном каждой особи генерируется из шаблона вида (разброс + аллели). + private void SpawnAnimals(GameContent content) + { + if (_animals.Count == 0 || !content.Defs.TryGet("Deer", out var deerDef)) + { + return; + } + + var species = _animals.IndexOf(deerDef); + var template = _animals[species].Template; + var random = new Random(_config.Seed + 0x5EED); + + var landCells = 0; + for (var i = 0; i < _cellLand.Length; i++) + { + if (_cellLand[i]) + { + landCells++; + } + } + + if (landCells == 0) + { + return; + } + + var count = Math.Clamp(landCells / 400, 6, 40); + 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, + species, + new Vector2(px, py), + template.Generate(random), + generation: 0, + CellSize + ); + placed++; + } + + Log.Info($"Spawned {placed} deer"); + } + // Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями. private bool[] BuildOccluders() { @@ -552,6 +616,11 @@ public sealed class WorldScene : Scene "plant [seed] — sample a species genome and show its gene-driven traits/products", (c, args) => RunPlantDemo(c, content, args) ); + console.Register( + "animal", + "animal [seed] — sample an animal genome and show its gene-driven traits", + (c, args) => RunAnimalDemo(c, content, args) + ); console.Register( "menu", "menu — return to the main menu", @@ -659,6 +728,40 @@ 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 [seed] e.g. animal Deer"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(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[index].Template.Generate(new Random(seed)); + var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry)); + + console.WriteLine($"{def.DefName} (seed {seed}):"); + console.WriteLine( + $" body {traits.BodySize:0.##} cells, 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.##}" + ); + } + private static string ProductLabel(GameContent content, string productDefName) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs new file mode 100644 index 0000000..ea7d312 --- /dev/null +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -0,0 +1,62 @@ +using Friflo.Engine.ECS; +using LittleSim.Content; +using LittleSim.Scenes; +using Microsoft.Xna.Framework; +using MrGameEng.Genetics; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// +/// Единая точка создания сущности-животного: спрайт вида, признаки из генома формулами генов, тинт по +/// оттенку меха и компонент-организм. Переиспользуется начальным спавном, размножением и загрузкой — +/// чтобы все животные собирались одинаково. Зеркало . +/// +public static class AnimalFactory +{ + // Якоря оттенка меха: тёплый рыжевато-песочный → холодный серо-голубой (по гену furColor). + private static readonly Color FurWarm = new(255, 226, 188); + private static readonly Color FurCool = new(206, 222, 235); + + /// Создаёт животное вида с заданным геномом и поколением. + public static Entity Create( + EntityStore store, + AnimalSet animals, + int species, + Vector2 position, + Genome genome, + int generation, + int cellSize + ) + { + var sp = animals[species]; + var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, animals.GeneRegistry)); + + var sprite = new Sprite(sp.Region, GameLayers.Beings); + sprite.CenterOrigin(); + sprite.Color = Tint(traits); + + // Размер спрайта задаёт ген размера тела; деф-fallback — на случай отсутствия гена. + var sizeCells = traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells; + + return store.CreateEntity( + new Transform2D(position, scale: new Vector2(cellSize * sizeCells / sp.Region.Width)), + sprite, + new AnimalOrganism + { + Genome = genome, + Traits = traits, + Species = species, + Generation = generation, + } + ); + } + + // Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры — + // субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений). + private static Color Tint(in AnimalPhenotype traits) + { + var fur = Color.Lerp(FurWarm, FurCool, Math.Clamp(traits.FurHue, 0f, 1f)); + return Color.Lerp(Color.White, fur, 0.5f); + } +} diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs new file mode 100644 index 0000000..80b3387 --- /dev/null +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using Friflo.Engine.ECS; +using MrGameEng.Genetics; + +namespace LittleSim.Sim; + +/// +/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. genes.json): +/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало +/// . Имена полей соответствуют именам признаков в эффектах генов. +/// +public struct AnimalPhenotype +{ + /// Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии). + public float BodySize; + + /// Скорость обмена веществ — множитель расхода нужд. + public float Metabolism; + + /// Скорость передвижения (множитель). + public float MoveSpeed; + + /// Объём крови (множитель) — масштаб скорости кровопотери. + public float BloodVolume; + + /// Дальность зрения (множитель радиуса поиска). + public float Vision; + + /// Размер мозга (0..1) — основа интеллекта. + public float BrainSize; + + /// Теплоизоляция мехом (0..1). + public float Insulation; + + /// Оттенок меха (0..1) — сдвиг тинта спрайта. + public float FurHue; + + /// Продолжительность жизни (игровых дней). + public float Lifespan; + + /// Собирает фенотип из карты признаков, посчитанной . + public static AnimalPhenotype FromTraits(IReadOnlyDictionary 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"), + Lifespan = T("lifespan"), + }; + } +} + +/// +/// Компонент-организм животного: управляемый (для размножения, сейва и +/// пересчёта), выраженный фенотип , индекс вида в +/// и номер поколения (для popstats и видимого дрейфа). Геном — ссылочный объект, поэтому +/// компонент несёт ссылку, а не копию (как у растений). Зеркало . +/// +public struct AnimalOrganism : IComponent +{ + /// Геном особи (аллели по генам); источник истины для признаков и размножения. + public Genome Genome; + + /// Выраженные признаки — то, что читают системы. + public AnimalPhenotype Traits; + + /// Индекс вида в . + public int Species; + + /// Номер поколения: max(родителей) + 1; у основателей 0. + public int Generation; +} From e35e6206559263fd8de3b2e715133e9942d04c60 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:13:50 +0300 Subject: [PATCH 03/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A2:=20=D0=BD=D1=83=D0=B6=D0=B4=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=98=D0=98=20(=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20+=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnimalNeeds (голод/жажда/отдых) падают по гену метаболизма. Два слоя ИИ: AnimalDecisionSystem (движковый UtilityAi выбирает действие и ищет ближайшую еду/воду) и AnimalActionSystem (движение к цели со скоростью по гену + утоление: выедание растений с гибелью выеденной травы, питьё у кромки воды, сон). AnimalAppearanceSystem тускнеет с острой нуждой. Кромка воды считается из террейна; команда popstats для наблюдаемости дрейфа генов. Сборка чистая. Контур ёмкости среды частичный — смерть от голода придёт в A3. Co-Authored-By: Claude Opus 4.8 --- docs/животные.md | 12 +- src/LittleSim/Scenes/WorldScene.cs | 108 +++++++ src/LittleSim/Sim/AnimalFactory.cs | 21 +- src/LittleSim/Sim/AnimalSystems.cs | 443 +++++++++++++++++++++++++++++ 4 files changed, 576 insertions(+), 8 deletions(-) create mode 100644 src/LittleSim/Sim/AnimalSystems.cs diff --git a/docs/животные.md b/docs/животные.md index 9162caa..244fc7a 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,14 +1,20 @@ # Животные (зоология) -> Статус: **A1 реализована**, остальное по плану. Это живой план — правится по мере +> Статус: **A1–A2 реализованы**, остальное по плану. Это живой план — правится по мере > реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). > > **A1 (готово):** тип дефа `Animal` (`AnimalDef : PawnDef`) + `animals.json` (олень); > 8 животных генов в `genes.json`; `AnimalPhenotype`/`AnimalOrganism`; `AnimalSet` > (шаблон генома вида) + `AnimalFactory`; детерминированный спавн стада оленей по суше в > `WorldScene` с рендером и тинтом меха по гену; консольная команда `animal `. -> Проверено: `--check-content` (7 типов дефов, 28 генов). Визуальная проверка (олени в мире, -> команда `animal Deer`) — вручную: «Новый мир» → консоль. +> +> **A2 (готово):** нужды `AnimalNeeds` (голод/жажда/отдых) с падением по гену метаболизма; +> два слоя ИИ — `AnimalDecisionSystem` (движковый `UtilityAi`, выбор + поиск ближайшей +> еды/воды) и `AnimalActionSystem` (движение к цели + утоление: выедание растений, питьё у +> кромки воды, сон); `AnimalAppearanceSystem` (яркость по острой нужде); кромка воды из +> террейна; команда `popstats [species]` (средние признаков + размах поколений). Контур +> ёмкости среды частичный (трава выедается, но смерть от голода — в A3). Сборка чистая; +> визуальная проверка (олени ищут корм/воду, пасутся) — вручную: «Новый мир». ## Видение diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 238cfb8..0811b73 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -209,6 +209,18 @@ public sealed class WorldScene : Scene _config.Seed ) ); + // Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет + // нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой. + var shore = ComputeShore(); + UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay)); + UpdateSystems.Add( + new AnimalDecisionSystem(Store, Context.Clock, CellSize, shore, _config.Seed + 0x5EED) + ); + UpdateSystems.Add( + new AnimalActionSystem(Store, _plants, Context.Clock, SecondsPerDay, _bounds) + ); + UpdateSystems.Add(new AnimalAppearanceSystem()); + UpdateSystems.Add( new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) ); @@ -518,6 +530,35 @@ public sealed class WorldScene : Scene Log.Info($"Spawned {placed} deer"); } + // Кромка воды: центры клеток суши, граничащих с водой — куда звери ходят пить (вода непроходима). + private Vector2[] ComputeShore() + { + var shore = new List(); + 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() { @@ -621,6 +662,11 @@ public sealed class WorldScene : Scene "animal [seed] — sample an animal genome and show its gene-driven traits", (c, args) => RunAnimalDemo(c, content, args) ); + console.Register( + "popstats", + "popstats [species] — live population trait means and generation span (selection drift)", + (c, args) => RunPopStats(c, args) + ); console.Register( "menu", "menu — return to the main menu", @@ -762,6 +808,68 @@ public sealed class WorldScene : Scene ); } + // Наблюдаемость отбора (фаза 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() + .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"); + } + } + private static string ProductLabel(GameContent content, string productDefName) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index ea7d312..06b1d3e 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -34,7 +34,7 @@ public static class AnimalFactory var sprite = new Sprite(sp.Region, GameLayers.Beings); sprite.CenterOrigin(); - sprite.Color = Tint(traits); + sprite.Color = FurTint(traits); // Размер спрайта задаёт ген размера тела; деф-fallback — на случай отсутствия гена. var sizeCells = traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells; @@ -48,13 +48,24 @@ public static class AnimalFactory Traits = traits, Species = species, Generation = generation, - } + }, + // Нужды стартуют полными; «мозг» решает действие сразу (DecideIn=0). + new AnimalNeeds + { + Hunger = 1f, + Thirst = 1f, + Rest = 1f, + }, + new AnimalBrain { Action = AnimalAction.Wander, TargetPlant = -1 } ); } - // Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры — - // субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений). - private static Color Tint(in AnimalPhenotype traits) + /// + /// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры — + /// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений). + /// Публичен, чтобы домножал его на яркость по нуждам. + /// + 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); diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs new file mode 100644 index 0000000..6bc5a16 --- /dev/null +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -0,0 +1,443 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using LittleSim.Content; +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace LittleSim.Sim; + +/// Что животное делает прямо сейчас — результат utility-выбора (фаза A2). +public enum AnimalAction +{ + /// Бродит по миру (поведение по умолчанию, когда нужды удовлетворены). + Wander, + + /// Идёт к растению-цели и ест его (утоляет голод). + Eat, + + /// Идёт к воде и пьёт (утоляет жажду). + Drink, + + /// Стоит и спит (восстанавливает отдых). + Sleep, +} + +/// +/// Простые нужды зверя (фаза A2), 1 — удовлетворена, 0 — критично. Падают со временем +/// (), восполняются исполнением действия (). +/// Гейтинг набора нужд интеллектом придёт в A7; секс/размножение — в A4. +/// +public struct AnimalNeeds : IComponent +{ + /// Сытость [0,1]. + public float Hunger; + + /// Утолённость жажды [0,1]. + public float Thirst; + + /// Отдых/бодрость [0,1]. + public float Rest; + + /// Наименьшая (самая острая) нужда — для выбора и внешнего вида. + public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest)); +} + +/// +/// «Мозг» зверя: выбранное действие, его цель и таймер до пересмотра решения. Решение принимает +/// (движковый ), исполняет +/// . Зеркало , но с целью и набором действий. +/// +public struct AnimalBrain : IComponent +{ + /// Текущее действие. + public AnimalAction Action; + + /// Куда идти (позиция растения/воды/точки блуждания). + public Vector2 Target; + + /// Id растения-цели для ; -1 — нет. + public int TargetPlant; + + /// Секунды до следующего пересмотра решения. + public float DecideIn; +} + +/// Снимок состояния зверя для соображений utility-выбора. +public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest); + +/// +/// Падение нужд со временем (игровые дни через , с учётом паузы/скорости): +/// голод/жажда/отдых убывают со скоростью базовых темпов × ген обмена веществ. Восполнение — +/// в . Смерти от голода ещё нет (придёт в A3). +/// +public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay) + : QuerySystem +{ + // Темпы убывания за игровой день при метаболизме 1: жажда быстрее голода, отдых — за ~сутки. + private const float HungerPerDay = 0.55f; + private const float ThirstPerDay = 0.9f; + private const float RestPerDay = 0.7f; + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (needs, organisms, _) in Query.Chunks) + { + var n = needs.Span; + var o = organisms.Span; + for (var i = 0; i < n.Length; i++) + { + var metabolism = MathF.Max(0.1f, o[i].Traits.Metabolism); + ref var need = ref n[i]; + need.Hunger = Math.Clamp(need.Hunger - HungerPerDay * metabolism * days, 0f, 1f); + need.Thirst = Math.Clamp(need.Thirst - ThirstPerDay * metabolism * days, 0f, 1f); + need.Rest = Math.Clamp(need.Rest - RestPerDay * metabolism * days, 0f, 1f); + } + } + } +} + +/// +/// Слой ВЫБОРА (фаза A2): раз в секунд каждый зверь выбирает действие движковым +/// по нуждам и находит цель — ближайшее растение в радиусе зрения +/// (еда) или ближайшую кромку воды (питьё). Чистое решение пишется в ; +/// исполняет его . Сид от мира → выбор детерминирован. +/// +public sealed class AnimalDecisionSystem : BaseSystem +{ + private const float Interval = 0.6f; + + // Базовый радиус зрения в клетках (масштабируется геном vision). + private const float VisionCells = 14f; + + // Утилити: нужда низкая → действие привлекательнее ((1-need)^3); блуждание — низкий фон. + private readonly UtilityAi _brain = new( + new UtilityAction( + "eat", + new Consideration( + "hungry", + c => c.Hunger, + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ) + ), + new UtilityAction( + "drink", + new Consideration( + "thirsty", + c => c.Thirst, + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ) + ), + new UtilityAction( + "sleep", + new Consideration( + "tired", + c => c.Rest, + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ) + ), + new UtilityAction( + "wander", + new Consideration("idle", _ => 0.12f) + ) + ); + + private readonly GameClock _clock; + private readonly int _cellSize; + private readonly Vector2[] _shore; + private readonly Random _rng; + private readonly ArchetypeQuery _animals; + private readonly ArchetypeQuery _plants; + + public AnimalDecisionSystem( + EntityStore store, + GameClock clock, + int cellSize, + Vector2[] shore, + int seed + ) + { + _clock = clock; + _cellSize = cellSize; + _shore = shore; + _rng = new Random(seed); + _animals = store.Query(); + _plants = store.Query(); + } + + protected override void OnUpdateGroup() + { + var delta = _clock.DeltaTime; + foreach (var (needs, brains, organisms, transforms, _) in _animals.Chunks) + { + var n = needs.Span; + var b = brains.Span; + var o = organisms.Span; + var t = transforms.Span; + for (var i = 0; i < b.Length; i++) + { + ref var brain = ref b[i]; + brain.DecideIn -= delta; + if (brain.DecideIn > 0f) + { + continue; + } + + brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан + var pos = t[i].Position; + var name = _brain + .Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest)) + ?.Name; + + switch (name) + { + case "eat" when TryFindPlant(pos, o[i].Traits.Vision, out var plant, out var pp): + brain.Action = AnimalAction.Eat; + brain.TargetPlant = plant; + brain.Target = pp; + break; + case "drink" when TryFindShore(pos, out var water): + brain.Action = AnimalAction.Drink; + brain.TargetPlant = -1; + brain.Target = water; + break; + case "sleep": + brain.Action = AnimalAction.Sleep; + brain.TargetPlant = -1; + break; + default: + brain.Action = AnimalAction.Wander; + brain.TargetPlant = -1; + var angle = _rng.NextSingle() * MathF.Tau; + brain.Target = + pos + + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) + * (_cellSize * (4f + _rng.NextSingle() * 6f)); + break; + } + } + } + } + + // Ближайшее растение в радиусе зрения (квадрат расстояния); ничьи — по меньшему id (детерминизм). + private bool TryFindPlant(Vector2 from, float vision, out int plantId, out Vector2 position) + { + var radius = VisionCells * MathF.Max(0.2f, vision) * _cellSize; + var bestSq = radius * radius; + plantId = -1; + position = default; + foreach (var (transforms, _, entities) in _plants.Chunks) + { + var t = transforms.Span; + for (var i = 0; i < t.Length; i++) + { + var p = t[i].Position; + var sq = Vector2.DistanceSquared(from, p); + var id = entities.EntityAt(i).Id; + if (sq < bestSq || (sq == bestSq && id < plantId)) + { + bestSq = sq; + plantId = id; + position = p; + } + } + } + + return plantId >= 0; + } + + // Ближайшая кромка воды (без лимита по зрению — звери «помнят» водопои; упрощение фазы A2). + private bool TryFindShore(Vector2 from, out Vector2 position) + { + position = default; + if (_shore.Length == 0) + { + return false; + } + + var bestSq = float.MaxValue; + foreach (var cell in _shore) + { + var sq = Vector2.DistanceSquared(from, cell); + if (sq < bestSq) + { + bestSq = sq; + position = cell; + } + } + + return true; + } +} + +/// +/// Слой ИСПОЛНЕНИЯ (фаза A2): двигает зверя к цели со скоростью базовый_темп × ген moveSpeed и при +/// достижении исполняет действие — ест растение (убавляет его рост; трава, выеденная до нуля, исчезает → +/// контур ёмкости среды), пьёт у воды, спит на месте. Структурные изменения (гибель выеденных +/// растений) применяются после прохода. Зеркало связки движение+нужды жителей. +/// +public sealed class AnimalActionSystem : BaseSystem +{ + private const float BaseSpeed = 28f; // мировых единиц в секунду при moveSpeed 1 + private const float EatFeedPerDay = 4f; + private const float DrinkFeedPerDay = 8f; + private const float SleepRestPerDay = 3f; + private const float GrazePerDay = 40f; // на сколько игровых дней роста убавляется выеденное растение + private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает + + private readonly EntityStore _store; + private readonly PlantSet _plants; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly RectF _bounds; + private readonly ArchetypeQuery _query; + private readonly List _eaten = []; + + public AnimalActionSystem( + EntityStore store, + PlantSet plants, + GameClock clock, + float secondsPerDay, + RectF bounds + ) + { + _store = store; + _plants = plants; + _clock = clock; + _secondsPerDay = secondsPerDay; + _bounds = bounds; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var seconds = _clock.DeltaTime; + if (seconds <= 0f) + { + return; + } + + var days = seconds / _secondsPerDay; + _eaten.Clear(); + + foreach (var (brains, needs, organisms, transforms, _) in _query.Chunks) + { + var b = brains.Span; + var n = needs.Span; + var o = organisms.Span; + var t = transforms.Span; + for (var i = 0; i < b.Length; i++) + { + ref var brain = ref b[i]; + ref var need = ref n[i]; + ref var pos = ref t[i].Position; + var speed = BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed); + + switch (brain.Action) + { + case AnimalAction.Sleep: + need.Rest = Math.Clamp(need.Rest + SleepRestPerDay * days, 0f, 1f); + break; + + case AnimalAction.Eat: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + need.Hunger = Math.Clamp(need.Hunger + EatFeedPerDay * days, 0f, 1f); + Graze(brain.TargetPlant, days); + } + + break; + + case AnimalAction.Drink: + if (MoveTo(ref pos, brain.Target, speed * seconds)) + { + need.Thirst = Math.Clamp(need.Thirst + DrinkFeedPerDay * days, 0f, 1f); + } + + break; + + default: // Wander + MoveTo(ref pos, brain.Target, speed * seconds); + break; + } + + pos.X = Math.Clamp(pos.X, _bounds.Left, _bounds.Right); + pos.Y = Math.Clamp(pos.Y, _bounds.Top, _bounds.Bottom); + } + } + + foreach (var plant in _eaten) + { + plant.DeleteEntity(); + } + } + + // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). + private static bool MoveTo(ref Vector2 pos, Vector2 target, float step) + { + var delta = target - pos; + var dist = delta.Length(); + if (dist <= step || dist < 0.001f) + { + pos = dist < 0.001f ? pos : target; + return true; + } + + pos += delta / dist * step; + return false; + } + + // Выедание: убавляет рост растения; траву (без ствола), выеденную до нуля, помечает на гибель. + private void Graze(int plantId, float days) + { + if ( + plantId < 0 + || !_store.TryGetEntityById(plantId, out var plant) + || plant.IsNull + || !plant.HasComponent() + ) + { + return; + } + + ref var grow = ref plant.GetComponent(); + grow.AgeDays = MathF.Max(0f, grow.AgeDays - GrazePerDay * days); + var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f; + if (isGrass && grow.AgeDays <= GrazeKillAge && !_eaten.Contains(plant)) + { + _eaten.Add(plant); + } + } +} + +/// +/// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой +/// нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только — +/// граница sim/presentation цела. Зеркало . +/// +public sealed class AnimalAppearanceSystem : QuerySystem +{ + private const float MinBrightness = 0.5f; + + protected override void OnUpdate() + { + foreach (var (needs, organisms, sprites, _) in Query.Chunks) + { + var n = needs.Span; + var o = organisms.Span; + var s = sprites.Span; + for (var i = 0; i < n.Length; i++) + { + var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(n[i].Worst(), 0f, 1f); + s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness; + } + } + } +} From 355ced493753503ba997b1fc426279834e10e0ab Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:20:44 +0300 Subject: [PATCH 04/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A3:=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB,=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B4=D0=B8=D0=B8=20=D0=B8=20=D0=BF=D0=BE=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Компонент AnimalGrowth (стадия + возраст). Ген пола GeneSex (локус XX/XY; генерация основателя задаёт пол явно через AnimalSet.GenerateGenome, исключая невозможный YY) и GeneMaturityAge. Стадии Baby->Juvenile->Adult->Senior с порогами из генов. AnimalGrowthSystem меняет спрайт (детёныш/самец/самка) и размер по стадии; AnimalMortalitySystem — смерть от старости и истощения (деспавн), замыкает контур ёмкости среды. Стадо спавнится разновозрастным. Сборка чистая, --check-content (30 генов, 29 признаков). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 3 + Mods/Core/Defs/genes.json | 9 +++ docs/животные.md | 10 ++- src/LittleSim/Content/AnimalSet.cs | 34 +++++++-- src/LittleSim/Content/GameDefs.cs | 9 +++ src/LittleSim/Scenes/WorldScene.cs | 16 +++-- src/LittleSim/Sim/AnimalFactory.cs | 66 ++++++++++++++++-- src/LittleSim/Sim/AnimalOrganism.cs | 22 ++++++ src/LittleSim/Sim/AnimalSystems.cs | 103 ++++++++++++++++++++++++++++ 9 files changed, 255 insertions(+), 17 deletions(-) diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index f7a8a4d..e7882a8 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -9,6 +9,8 @@ "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", "genome": { "bodySize": 1.5, "metabolism": 1.0, @@ -18,6 +20,7 @@ "brainSize": 0.45, "insulation": 0.55, "furColor": 0.45, + "maturityAge": 90, "lifespan": 360, "spread": 0.08 } diff --git a/Mods/Core/Defs/genes.json b/Mods/Core/Defs/genes.json index cc17691..e361b6b 100644 --- a/Mods/Core/Defs/genes.json +++ b/Mods/Core/Defs/genes.json @@ -99,6 +99,15 @@ { "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"] }, // Дискретный ген морфы: вариант 0 доминирует, 1 (рецессивный) виден только в гомозиготе. { "defName": "GeneMorph", "kind": "Discrete", "label": "gene.morph", diff --git a/docs/животные.md b/docs/животные.md index 244fc7a..891b25f 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,8 +1,16 @@ # Животные (зоология) -> Статус: **A1–A2 реализованы**, остальное по плану. Это живой план — правится по мере +> Статус: **A1–A3 реализованы**, остальное по плану. Это живой план — правится по мере > реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). > +> **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`; детерминированный спавн стада оленей по суше в diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs index f9f66aa..e07cd74 100644 --- a/src/LittleSim/Content/AnimalSet.cs +++ b/src/LittleSim/Content/AnimalSet.cs @@ -12,14 +12,20 @@ namespace LittleSim.Content; /// public sealed class AnimalSet { - /// Вид животного: его деф, спрайт и набор генов для генерации особи. + /// Вид животного: его деф, спрайты по полу/стадии и набор генов для генерации особи. public sealed class Species { /// Исходный деф животного. public required AnimalDef Def { get; init; } - /// Спрайт вида (фаза A1 — единый; стадии/пол позже). - public required Texture2DRegion Region { get; init; } + /// Спрайт взрослой самки (он же общий/fallback). + public required Texture2DRegion Female { get; init; } + + /// Спрайт взрослого самца (fallback — самка). + public required Texture2DRegion Male { get; init; } + + /// Спрайт детёныша/подростка (fallback — самка). + public required Texture2DRegion Baby { get; init; } /// Набор генов вида: базовые значения для генерации особи. public required GenomeTemplate Template { get; init; } @@ -44,10 +50,17 @@ public sealed class AnimalSet 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, - Region = atlases.GetRegion(device, def.Texture), + Female = female, + Male = string.IsNullOrEmpty(def.MaleTexture) + ? female + : atlases.GetRegion(device, def.MaleTexture), + Baby = string.IsNullOrEmpty(def.BabyTexture) + ? female + : atlases.GetRegion(device, def.BabyTexture), Template = BuildTemplate(def.Genome, genes), }; _index[def] = i; @@ -63,6 +76,18 @@ public sealed class AnimalSet /// Индекс вида по дефу. public int IndexOf(AnimalDef def) => _index[def]; + /// + /// Генерирует геном новой особи-основателя вида: аллели из шаблона + явно назначенный пол + /// (XX самка / XY самец, 50/50) — чтобы не возник невозможный YY (см. genes.json GeneSex). + /// Скрещивание (A4) наследует пол само (мать XX отдаёт только X). + /// + 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; + } + // Переводит базовый геном вида (числа из animals.json) в набор генов: каждое число — центр // аллелей соответствующего общего GeneDef. Зеркало PlantSet.BuildTemplate. private static GenomeTemplate BuildTemplate( @@ -90,6 +115,7 @@ public sealed class AnimalSet new GenomeTemplate.Entry(Gene("GeneBrainSize"), g.BrainSize, 0.03f), Numeric("GeneInsulation", g.Insulation), new GenomeTemplate.Entry(Gene("GeneFurColor"), g.FurColor, 0.06f), + Numeric("GeneMaturityAge", g.MaturityAge), Numeric("GeneLifespan", g.Lifespan), ] ); diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 9842294..1118b07 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -241,6 +241,9 @@ public sealed class AnimalGenomeDef /// Оттенок меха (0..1) — сдвиг тинта спрайта (как leafHue у растений). public float FurColor { get; init; } = 0.5f; + /// Возраст полового созревания (игровых дней) — порог стадии Adult. + public float MaturityAge { get; init; } = 90f; + /// Продолжительность жизни (игровых дней) до смерти от старости. public float Lifespan { get; init; } = 160f; @@ -255,6 +258,12 @@ public sealed class AnimalGenomeDef /// public sealed class AnimalDef : PawnDef { + /// Текстура взрослого самца (фаза A3); пусто — берётся (самка/общая). + public string MaleTexture { get; init; } = ""; + + /// Текстура детёныша/подростка (фаза A3); пусто — берётся . + public string BabyTexture { get; init; } = ""; + /// Базовый геном вида: центры аллелей животных генов. public AnimalGenomeDef Genome { get; init; } = new(); } diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 0811b73..ac61b13 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -220,6 +220,8 @@ public sealed class WorldScene : Scene new AnimalActionSystem(Store, _plants, Context.Clock, SecondsPerDay, _bounds) ); UpdateSystems.Add(new AnimalAppearanceSystem()); + UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize)); + UpdateSystems.Add(new AnimalMortalitySystem(Store)); UpdateSystems.Add( new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) @@ -482,7 +484,7 @@ public sealed class WorldScene : Scene } var species = _animals.IndexOf(deerDef); - var template = _animals[species].Template; + var maxAge = deerDef.Genome.Lifespan * 0.85f; // разновозрастное стадо, но не у самой смерти var random = new Random(_config.Seed + 0x5EED); var landCells = 0; @@ -520,7 +522,8 @@ public sealed class WorldScene : Scene _animals, species, new Vector2(px, py), - template.Generate(random), + ageDays: random.NextSingle() * maxAge, + _animals.GenerateGenome(species, random), generation: 0, CellSize ); @@ -794,13 +797,14 @@ public sealed class WorldScene : Scene var index = _animals.IndexOf(def); var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next(); - var genome = _animals[index].Template.Generate(new Random(seed)); + 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} (seed {seed}):"); + console.WriteLine($"{def.DefName} ({sex}, seed {seed}):"); console.WriteLine( - $" body {traits.BodySize:0.##} cells, lifespan {traits.Lifespan:0} d, " - + $"brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}" + $" 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.##}, " diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 06b1d3e..f419016 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -18,12 +18,22 @@ public static class AnimalFactory private static readonly Color FurWarm = new(255, 226, 188); private static readonly Color FurCool = new(206, 222, 235); - /// Создаёт животное вида с заданным геномом и поколением. + /// Стадии роста животного (фаза A3) по индексам, как в . + public const int StageBaby = 0; + public const int StageJuvenile = 1; + public const int StageAdult = 2; + public const int StageSenior = 3; + + // Масштаб спрайта по стадии (множитель размера тела): детёныш мельче, старик чуть усох. + private static readonly float[] StageScales = [0.45f, 0.7f, 1f, 0.92f]; + + /// Создаёт животное вида заданного возраста, генома и поколения. public static Entity Create( EntityStore store, AnimalSet animals, int species, Vector2 position, + float ageDays, Genome genome, int generation, int cellSize @@ -31,24 +41,26 @@ public static class AnimalFactory { 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); + var region = RegionFor(sp, stage, isMale); - var sprite = new Sprite(sp.Region, GameLayers.Beings); + var sprite = new Sprite(region, GameLayers.Beings); sprite.CenterOrigin(); sprite.Color = FurTint(traits); - // Размер спрайта задаёт ген размера тела; деф-fallback — на случай отсутствия гена. - var sizeCells = traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells; - return store.CreateEntity( - new Transform2D(position, scale: new Vector2(cellSize * sizeCells / sp.Region.Width)), + 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 }, // Нужды стартуют полными; «мозг» решает действие сразу (DecideIn=0). new AnimalNeeds { @@ -60,6 +72,48 @@ public static class AnimalFactory ); } + /// Пол из локуса GeneSex: наличие аллеля Y (значение ≈1) → самец; иначе самка (XX). + public static bool IsMaleGenome(Genome genome) => + genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f); + + /// Стадия по возрасту: Baby < 0.2·созревание, Juvenile < созревание, Adult < 0.8·жизнь, иначе Senior. + public static int StageAt(float ageDays, float maturity, float lifespan) + { + var babyEnd = MathF.Max(1f, maturity * 0.2f); + var juvEnd = MathF.Max(babyEnd + 1f, maturity); + var adultEnd = MathF.Max(juvEnd + 1f, lifespan * 0.8f); + if (ageDays < babyEnd) + { + return StageBaby; + } + + if (ageDays < juvEnd) + { + return StageJuvenile; + } + + return ageDays < adultEnd ? StageAdult : StageSenior; + } + + /// Спрайт по стадии и полу: детёныш/подросток — baby-текстура; взрослые — по полу. + public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) => + stage <= StageJuvenile ? sp.Baby + : isMale ? sp.Male + : sp.Female; + + /// Масштаб Transform2D для спрайта стадии (размер тела × множитель стадии / ширина региона). + 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) * StageScales[stage]; + return cellSize * sizeCells / region.Width; + } + /// /// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры — /// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений). diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 80b3387..2977571 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -35,6 +35,9 @@ public struct AnimalPhenotype /// Оттенок меха (0..1) — сдвиг тинта спрайта. public float FurHue; + /// Возраст созревания (игровых дней) — порог стадии Adult. + public float MaturityAge; + /// Продолжительность жизни (игровых дней). public float Lifespan; @@ -52,6 +55,7 @@ public struct AnimalPhenotype BrainSize = T("brainSize"), Insulation = T("insulation"), FurHue = T("furHue"), + MaturityAge = T("maturityAge"), Lifespan = T("lifespan"), }; } @@ -74,6 +78,24 @@ public struct AnimalOrganism : IComponent /// Индекс вида в . public int Species; + /// Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании. + public bool IsMale; + /// Номер поколения: max(родителей) + 1; у основателей 0. public int Generation; } + +/// +/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские +/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет +/// , читая пороги из генов (возраст созревания, продолжительность жизни). +/// Зеркало . +/// +public struct AnimalGrowth : IComponent +{ + /// Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior. + public int Stage; + + /// Накопленный возраст в игровых днях. + public float AgeDays; +} diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 6bc5a16..6f0ef68 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -40,6 +40,9 @@ public struct AnimalNeeds : IComponent /// Отдых/бодрость [0,1]. public float Rest; + /// Накоплено игровых дней истощения (голод/жажда на нуле); порог — смерть (A3). + public float Starve; + /// Наименьшая (самая острая) нужда — для выбора и внешнего вида. public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest)); } @@ -99,6 +102,12 @@ public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay) need.Hunger = Math.Clamp(need.Hunger - HungerPerDay * metabolism * days, 0f, 1f); need.Thirst = Math.Clamp(need.Thirst - ThirstPerDay * metabolism * days, 0f, 1f); need.Rest = Math.Clamp(need.Rest - RestPerDay * metabolism * days, 0f, 1f); + + // Истощение: голод/жажда на нуле копят счётчик смерти (A3), иначе он рассасывается вдвое быстрее. + need.Starve = + need.Hunger <= 0f || need.Thirst <= 0f + ? need.Starve + days + : MathF.Max(0f, need.Starve - days * 2f); } } } @@ -441,3 +450,97 @@ public sealed class AnimalAppearanceSystem : QuerySystem +/// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ +/// Senior) по порогам из генов (возраст созревания, продолжительность жизни) и при смене стадии меняет +/// спрайт (детёныш/самка/самец) и размер. Зеркало . +/// +public sealed class AnimalGrowthSystem( + GameClock clock, + float secondsPerDay, + AnimalSet animals, + int cellSize +) : QuerySystem +{ + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (growths, organisms, transforms, sprites, _) in Query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var t = transforms.Span; + var s = sprites.Span; + for (var i = 0; i < g.Length; i++) + { + ref var grow = ref g[i]; + grow.AgeDays += days; + ref readonly var org = ref o[i]; + var stage = AnimalFactory.StageAt( + grow.AgeDays, + org.Traits.MaturityAge, + org.Traits.Lifespan + ); + if (stage == grow.Stage) + { + continue; + } + + grow.Stage = stage; + var sp = animals[org.Species]; + var region = AnimalFactory.RegionFor(sp, stage, org.IsMale); + ref var sprite = ref s[i]; + sprite.Region = region; + sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); + t[i].Scale = new Vector2(AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize)); + } + } + } +} + +/// +/// Смертность (фаза A3): простая гибель с деспавном — от старости (возраст превысил продолжительность +/// жизни) или от истощения (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. +/// На A5 «апгрейдится» в смерть через здоровье и оставит труп вместо деспавна. +/// +public sealed class AnimalMortalitySystem : BaseSystem +{ + private const float LethalStarveDays = 3f; + + private readonly ArchetypeQuery _query; + private readonly List _deaths = []; + + public AnimalMortalitySystem(EntityStore store) + { + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + _deaths.Clear(); + foreach (var (growths, organisms, needs, entities) in _query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var n = needs.Span; + for (var i = 0; i < g.Length; i++) + { + if (g[i].AgeDays > o[i].Traits.Lifespan || n[i].Starve >= LethalStarveDays) + { + _deaths.Add(entities.EntityAt(i)); + } + } + } + + foreach (var entity in _deaths) + { + entity.DeleteEntity(); + } + } +} From 97521f81b8667a3bdee60a37e5a157581a1a62a8 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:32:18 +0300 Subject: [PATCH 05/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A4:=20=D1=80=D0=B0=D0=B7=D0=BC=D0=BD=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20(=D0=B3=D0=BE=D0=BD-=D1=85=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D1=84,=20=D0=B1=D0=B5=D1=80=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C,=20=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нужда Mating + лёгкий hediff-каркас (HediffDef, hediffs.json с Rut, Health/HealthState). Гены GeneBreedingSeason/GeneGestationDays/GeneLitterSize. AnimalRutSystem навешивает сезонный гон-хедиф взрослым и поднимает влечение (вне сезона зачатия нет). Действие Mate ищет партнёра противоположного пола и сближается; при контакте самка получает Pregnant (геном отца). AnimalPregnancySystem тикает срок и рожает помёт через Genome.Breed (пол наследуется без YY, поколение+1), с потолком численности. Сборка чистая, --check-content (8 типов дефов, 33 гена, 32 признака). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 3 + Mods/Core/Defs/genes.json | 11 + Mods/Core/Defs/hediffs.json | 8 + docs/животные.md | 9 +- src/LittleSim/Content/AnimalSet.cs | 3 + src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 16 ++ src/LittleSim/Scenes/WorldScene.cs | 19 ++ src/LittleSim/Sim/AnimalFactory.cs | 8 +- src/LittleSim/Sim/AnimalOrganism.cs | 88 +++++++ src/LittleSim/Sim/AnimalSystems.cs | 341 ++++++++++++++++++++++++++- 11 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 Mods/Core/Defs/hediffs.json diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index e7882a8..b7edc6d 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -22,6 +22,9 @@ "furColor": 0.45, "maturityAge": 90, "lifespan": 360, + "breedingSeason": 2, + "gestationDays": 30, + "litterSize": 1, "spread": 0.08 } } diff --git a/Mods/Core/Defs/genes.json b/Mods/Core/Defs/genes.json index e361b6b..c0a8f2f 100644 --- a/Mods/Core/Defs/genes.json +++ b/Mods/Core/Defs/genes.json @@ -109,6 +109,17 @@ { "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" } }, + // Дискретный ген морфы: вариант 0 доминирует, 1 (рецессивный) виден только в гомозиготе. { "defName": "GeneMorph", "kind": "Discrete", "label": "gene.morph", "variants": 2, "variantWeights": [0.82, 0.18], "mutationChance": 0.05, "tags": ["morphology"], diff --git a/Mods/Core/Defs/hediffs.json b/Mods/Core/Defs/hediffs.json new file mode 100644 index 0000000..7f786db --- /dev/null +++ b/Mods/Core/Defs/hediffs.json @@ -0,0 +1,8 @@ +{ + "type": "Hediff", + // Хедифы — состояния организма (фаза A4: лёгкий каркас). Гон (Rut) навешивается сезонно на взрослых + // и поднимает половое влечение. Раны/болезни/возрастные эффекты со стадиями придут в A5/A6. + "defs": [ + { "defName": "Rut", "label": "hediff.rut" } + ] +} diff --git a/docs/животные.md b/docs/животные.md index 891b25f..15902a3 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,8 +1,15 @@ # Животные (зоология) -> Статус: **A1–A3 реализованы**, остальное по плану. Это живой план — правится по мере +> Статус: **A1–A4 реализованы**, остальное по плану. Это живой план — правится по мере > реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). > +> **A4 (готово):** нужда `Mating`; лёгкий hediff-каркас (`HediffDef`, `hediffs.json` с `Rut`, +> компонент `Health`/`HealthState`); гены `GeneBreedingSeason`/`GeneGestationDays`/`GeneLitterSize`; +> `AnimalRutSystem` (сезонный гон-хедиф поднимает влечение — вне сезона зачатия нет); действие +> `Mate` (поиск партнёра, сближение); `Pregnant` + `AnimalPregnancySystem` (вынашивание → роды +> через `Genome.Breed`, наследование пола без YY, поколение+1, потолок численности). Сборка +> чистая, `--check-content` (33 гена). +> > **A3 (готово):** компонент `AnimalGrowth` (стадия + возраст); ген пола `GeneSex` (XX/XY, > генерация основателя без YY через `AnimalSet.GenerateGenome`) и ген `GeneMaturityAge`; > стадии Baby→Juvenile→Adult→Senior с порогами из генов; `AnimalGrowthSystem` (смена diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs index e07cd74..f028eeb 100644 --- a/src/LittleSim/Content/AnimalSet.cs +++ b/src/LittleSim/Content/AnimalSet.cs @@ -117,6 +117,9 @@ public sealed class AnimalSet new GenomeTemplate.Entry(Gene("GeneFurColor"), g.FurColor, 0.06f), Numeric("GeneMaturityAge", g.MaturityAge), Numeric("GeneLifespan", g.Lifespan), + new GenomeTemplate.Entry(Gene("GeneBreedingSeason"), g.BreedingSeason, 0f), + Numeric("GeneGestationDays", g.GestationDays), + Numeric("GeneLitterSize", g.LitterSize), ] ); } diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 7643072..25e57fa 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -64,6 +64,7 @@ public sealed class GameContent defs.RegisterType("Plant"); defs.RegisterType("Pawn"); defs.RegisterType("Animal"); + defs.RegisterType("Hediff"); defs.RegisterType("WorldPreset"); // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 1118b07..a7340c7 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -247,6 +247,15 @@ public sealed class AnimalGenomeDef /// Продолжительность жизни (игровых дней) до смерти от старости. public float Lifespan { get; init; } = 160f; + /// Сезон гона (0 весна, 1 лето, 2 осень, 3 зима) — фиксирован по виду. + public float BreedingSeason { get; init; } = 2f; + + /// Срок вынашивания (игровых дней). + public float GestationDays { get; init; } = 30f; + + /// Размер помёта (число детёнышей за роды). + public float LitterSize { get; init; } = 1f; + /// Доля разброса аллелей вокруг базы при генерации особи. public float Spread { get; init; } = 0.08f; } @@ -267,3 +276,10 @@ public sealed class AnimalDef : PawnDef /// Базовый геном вида: центры аллелей животных генов. public AnimalGenomeDef Genome { get; init; } = new(); } + +/// +/// Состояние/хедиф организма (Defs/hediffs.json): гон, позже — раны, болезни, возрастные эффекты. +/// Фаза A4 — лёгкий каркас: пока идентичность + локализуемый ; стадии, +/// модификаторы способностей и иммунитет придут в A5/A6. +/// +public sealed class HediffDef : Def { } diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index ac61b13..0268584 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -213,6 +213,14 @@ public sealed class WorldScene : Scene // нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой. var shore = ComputeShore(); UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay)); + UpdateSystems.Add( + new AnimalRutSystem( + Context.Clock, + SecondsPerDay, + climate, + content.Defs.Get("Rut") + ) + ); UpdateSystems.Add( new AnimalDecisionSystem(Store, Context.Clock, CellSize, shore, _config.Seed + 0x5EED) ); @@ -222,6 +230,17 @@ public sealed class WorldScene : Scene UpdateSystems.Add(new AnimalAppearanceSystem()); UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize)); UpdateSystems.Add(new AnimalMortalitySystem(Store)); + UpdateSystems.Add( + new AnimalPregnancySystem( + Store, + _animals, + Context.Clock, + SecondsPerDay, + CellSize, + maxAnimals: 500, + _config.Seed + 0x4BED + ) + ); UpdateSystems.Add( new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index f419016..83017ea 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -68,7 +68,13 @@ public static class AnimalFactory Thirst = 1f, Rest = 1f, }, - new AnimalBrain { Action = AnimalAction.Wander, TargetPlant = -1 } + new AnimalBrain + { + Action = AnimalAction.Wander, + TargetPlant = -1, + TargetMate = -1, + }, + new Health { State = new HealthState() } ); } diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 2977571..56b4a03 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Friflo.Engine.ECS; +using LittleSim.Content; using MrGameEng.Genetics; namespace LittleSim.Sim; @@ -41,6 +42,15 @@ public struct AnimalPhenotype /// Продолжительность жизни (игровых дней). public float Lifespan; + /// Сезон гона (0 весна … 3 зима). + public int BreedingSeason; + + /// Срок вынашивания (игровых дней). + public float GestationDays; + + /// Размер помёта. + public float LitterSize; + /// Собирает фенотип из карты признаков, посчитанной . public static AnimalPhenotype FromTraits(IReadOnlyDictionary traits) { @@ -57,6 +67,9 @@ public struct AnimalPhenotype 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"), }; } } @@ -99,3 +112,78 @@ public struct AnimalGrowth : IComponent /// Накопленный возраст в игровых днях. public float AgeDays; } + +/// +/// Состояние здоровья особи (фаза A4 — лёгкий каркас): список активных хедифов. Managed-объект, как +/// (Friflo допускает ссылку в компоненте). В A5/A6 прирастёт частями тела, +/// кровью, capacities и иммунитетом — контейнер один, растёт по фазам. +/// +public sealed class HealthState +{ + private readonly List _hediffs = []; + + /// Активные хедифы (гон, позже — раны/болезни). + public IReadOnlyList Hediffs => _hediffs; + + /// Несёт ли особь хедиф с данным именем дефа. + public bool Has(string defName) + { + foreach (var h in _hediffs) + { + if (h.DefName == defName) + { + return true; + } + } + + return false; + } + + /// Навешивает хедиф (если ещё не навешан). + public void Add(HediffDef def) + { + if (!Has(def.DefName)) + { + _hediffs.Add(def); + } + } + + /// Снимает хедиф по имени дефа; возвращает, был ли он. + public bool Remove(string defName) + { + for (var i = 0; i < _hediffs.Count; i++) + { + if (_hediffs[i].DefName == defName) + { + _hediffs.RemoveAt(i); + return true; + } + } + + return false; + } +} + +/// Здоровье особи: managed-состояние со списком хедифов (см. ). +public struct Health : IComponent +{ + /// Состояние здоровья (хедифы; позже части тела/кровь). + public HealthState State; +} + +/// +/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток +/// срока вынашивания и размер помёта. По истечении срока +/// рожает детёнышей через и снимает компонент. +/// +public struct Pregnant : IComponent +{ + /// Геном отца на момент зачатия. + public Genome FatherGenome; + + /// Остаток срока вынашивания (игровых дней). + public float DueInDays; + + /// Сколько детёнышей родится. + public int Litter; +} diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 6f0ef68..53a5866 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -4,6 +4,7 @@ using LittleSim.Content; using Microsoft.Xna.Framework; using MrGameEng.AI; using MrGameEng.Core; +using MrGameEng.Genetics; using MrGameEng.Graphics; namespace LittleSim.Sim; @@ -22,6 +23,9 @@ public enum AnimalAction /// Стоит и спит (восстанавливает отдых). Sleep, + + /// Идёт к партнёру и спаривается (фаза A4) — у самки наступает беременность. + Mate, } /// @@ -43,7 +47,10 @@ public struct AnimalNeeds : IComponent /// Накоплено игровых дней истощения (голод/жажда на нуле); порог — смерть (A3). public float Starve; - /// Наименьшая (самая острая) нужда — для выбора и внешнего вида. + /// Половое влечение [0,1] (фаза A4): растёт под гоном (хедиф Rut), обнуляется спариванием. + public float Mating; + + /// Наименьшая (самая острая) витальная нужда — для выбора и внешнего вида (без влечения). public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest)); } @@ -63,12 +70,15 @@ public struct AnimalBrain : IComponent /// Id растения-цели для ; -1 — нет. public int TargetPlant; + /// Id партнёра для ; -1 — нет. + public int TargetMate; + /// Секунды до следующего пересмотра решения. public float DecideIn; } /// Снимок состояния зверя для соображений utility-выбора. -public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest); +public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest, float Mating); /// /// Падение нужд со временем (игровые дни через , с учётом паузы/скорости): @@ -152,6 +162,11 @@ public sealed class AnimalDecisionSystem : BaseSystem curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) ) ), + // Спаривание (A4): тем привлекательнее, чем выше влечение (растёт только под гоном). + new UtilityAction( + "mate", + new Consideration("lustful", c => c.Mating) + ), new UtilityAction( "wander", new Consideration("idle", _ => 0.12f) @@ -162,7 +177,13 @@ public sealed class AnimalDecisionSystem : BaseSystem private readonly int _cellSize; private readonly Vector2[] _shore; private readonly Random _rng; - private readonly ArchetypeQuery _animals; + private readonly ArchetypeQuery< + AnimalNeeds, + AnimalBrain, + AnimalOrganism, + AnimalGrowth, + Transform2D + > _animals; private readonly ArchetypeQuery _plants; public AnimalDecisionSystem( @@ -177,18 +198,19 @@ public sealed class AnimalDecisionSystem : BaseSystem _cellSize = cellSize; _shore = shore; _rng = new Random(seed); - _animals = store.Query(); + _animals = store.Query(); _plants = store.Query(); } protected override void OnUpdateGroup() { var delta = _clock.DeltaTime; - foreach (var (needs, brains, organisms, transforms, _) in _animals.Chunks) + foreach (var (needs, brains, organisms, growths, transforms, _) in _animals.Chunks) { var n = needs.Span; var b = brains.Span; var o = organisms.Span; + var gr = growths.Span; var t = transforms.Span; for (var i = 0; i < b.Length; i++) { @@ -201,8 +223,9 @@ public sealed class AnimalDecisionSystem : BaseSystem brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан var pos = t[i].Position; + var adult = gr[i].Stage >= AnimalFactory.StageAdult; var name = _brain - .Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest)) + .Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest, n[i].Mating)) ?.Name; switch (name) @@ -221,6 +244,20 @@ public sealed class AnimalDecisionSystem : BaseSystem brain.Action = AnimalAction.Sleep; brain.TargetPlant = -1; break; + case "mate" + when adult + && TryFindMate( + pos, + o[i].IsMale, + o[i].Traits.Vision, + out var mateId, + out var matePos + ): + brain.Action = AnimalAction.Mate; + brain.TargetPlant = -1; + brain.TargetMate = mateId; + brain.Target = matePos; + break; default: brain.Action = AnimalAction.Wander; brain.TargetPlant = -1; @@ -284,6 +321,49 @@ public sealed class AnimalDecisionSystem : BaseSystem return true; } + + // Ближайший подходящий партнёр в радиусе зрения: взрослый, противоположного пола, с влечением > 0.5. + private bool TryFindMate( + Vector2 from, + bool selfMale, + float vision, + out int mateId, + out Vector2 position + ) + { + var radius = VisionCells * MathF.Max(0.2f, vision) * _cellSize; + var bestSq = radius * radius; + mateId = -1; + position = default; + foreach (var (needs, _, organisms, growths, transforms, entities) in _animals.Chunks) + { + var nn = needs.Span; + var oo = organisms.Span; + var gg = growths.Span; + var tt = transforms.Span; + for (var i = 0; i < tt.Length; i++) + { + if ( + oo[i].IsMale == selfMale + || gg[i].Stage < AnimalFactory.StageAdult + || nn[i].Mating < 0.5f + ) + { + continue; // тот же пол / не взрослый / без влечения (self отсеивается по полу) + } + + var sq = Vector2.DistanceSquared(from, tt[i].Position); + if (sq > 0.01f && sq < bestSq) + { + bestSq = sq; + mateId = entities.EntityAt(i).Id; + position = tt[i].Position; + } + } + } + + return mateId >= 0; + } } /// @@ -308,6 +388,7 @@ public sealed class AnimalActionSystem : BaseSystem private readonly RectF _bounds; private readonly ArchetypeQuery _query; private readonly List _eaten = []; + private readonly List<(Entity Self, Entity Partner)> _matings = []; public AnimalActionSystem( EntityStore store, @@ -335,8 +416,9 @@ public sealed class AnimalActionSystem : BaseSystem var days = seconds / _secondsPerDay; _eaten.Clear(); + _matings.Clear(); - foreach (var (brains, needs, organisms, transforms, _) in _query.Chunks) + foreach (var (brains, needs, organisms, transforms, entities) in _query.Chunks) { var b = brains.Span; var n = needs.Span; @@ -372,6 +454,23 @@ public sealed class AnimalActionSystem : BaseSystem break; + case AnimalAction.Mate: + if ( + brain.TargetMate >= 0 + && _store.TryGetEntityById(brain.TargetMate, out var partner) + && !partner.IsNull + && partner.HasComponent() + ) + { + var partnerPos = partner.GetComponent().Position; + if (MoveTo(ref pos, partnerPos, speed * seconds)) + { + _matings.Add((entities.EntityAt(i), partner)); + } + } + + break; + default: // Wander MoveTo(ref pos, brain.Target, speed * seconds); break; @@ -386,6 +485,58 @@ public sealed class AnimalActionSystem : BaseSystem { plant.DeleteEntity(); } + + ApplyMatings(); + } + + // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление + // компонента — после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. + private void ApplyMatings() + { + foreach (var (a, b) in _matings) + { + if ( + a.IsNull + || b.IsNull + || !a.HasComponent() + || !b.HasComponent() + ) + { + continue; + } + + var aMale = a.GetComponent().IsMale; + var bMale = b.GetComponent().IsMale; + if (aMale == bMale) + { + continue; // один пол — не пара (страховка) + } + + var mother = aMale ? b : a; + var father = aMale ? a : b; + if (!mother.HasComponent()) + { + ref readonly var mom = ref mother.GetComponent(); + mother.AddComponent( + new Pregnant + { + FatherGenome = father.GetComponent().Genome, + DueInDays = MathF.Max(1f, mom.Traits.GestationDays), + Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize)), + } + ); + } + + if (a.HasComponent()) + { + a.GetComponent().Mating = 0f; + } + + if (b.HasComponent()) + { + b.GetComponent().Mating = 0f; + } + } } // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). @@ -544,3 +695,179 @@ public sealed class AnimalMortalitySystem : BaseSystem } } } + +/// +/// Гон и влечение (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut и поднимает половое +/// влечение (); вне сезона снимает хедиф, влечение спадает. Только под +/// гоном влечение переходит порог действия — вне сезона зачатия фактически нет. +/// +public sealed class AnimalRutSystem( + GameClock clock, + float secondsPerDay, + Climate climate, + HediffDef rut +) : QuerySystem +{ + private const float RutDrivePerDay = 1.2f; + private const float DecayPerDay = 1f; + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + var season = (int)climate.Season; + foreach (var (growths, organisms, needs, healths, _) in Query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var n = needs.Span; + var h = healths.Span; + for (var i = 0; i < g.Length; i++) + { + var inRut = + g[i].Stage >= AnimalFactory.StageAdult && season == o[i].Traits.BreedingSeason; + var state = h[i].State; + if (inRut) + { + state.Add(rut); + } + else + { + state.Remove(rut.DefName); + } + + ref var need = ref n[i]; + need.Mating = inRut + ? Math.Clamp(need.Mating + RutDrivePerDay * days, 0f, 1f) + : MathF.Max(0f, need.Mating - DecayPerDay * days); + } + } + } +} + +/// +/// Беременность и роды (фаза A4): тикает срок вынашивания; по его истечении рожает помёт — каждый +/// детёныш = генома матери и отца (пол наследуется без YY), стадия Baby, +/// поколение матери + 1, рядом с матерью. Компонент снимается. Жёсткий потолок +/// численности страхует от взрыва популяции (бум-крах остаётся ниже потолка). +/// +public sealed class AnimalPregnancySystem : BaseSystem +{ + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly int _cellSize; + private readonly int _maxAnimals; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly ArchetypeQuery _census; + private readonly List _births = []; + + public AnimalPregnancySystem( + EntityStore store, + AnimalSet animals, + GameClock clock, + float secondsPerDay, + int cellSize, + int maxAnimals, + int seed + ) + { + _store = store; + _animals = animals; + _clock = clock; + _secondsPerDay = secondsPerDay; + _cellSize = cellSize; + _maxAnimals = maxAnimals; + _rng = new Random(seed); + _query = store.Query(); + _census = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + _births.Clear(); + foreach (var (pregnancies, organisms, transforms, entities) in _query.Chunks) + { + var p = pregnancies.Span; + var o = organisms.Span; + var t = transforms.Span; + for (var i = 0; i < p.Length; i++) + { + ref var preg = ref p[i]; + preg.DueInDays -= days; + if (preg.DueInDays > 0f) + { + continue; + } + + _births.Add( + new Birth + { + Mother = entities.EntityAt(i), + Species = o[i].Species, + MotherGenome = o[i].Genome, + FatherGenome = preg.FatherGenome, + Litter = preg.Litter, + Generation = o[i].Generation + 1, + Position = t[i].Position, + } + ); + } + } + + var count = _census.Count; + foreach (var birth in _births) + { + if (birth.Mother.HasComponent()) + { + birth.Mother.RemoveComponent(); + } + + for (var k = 0; k < birth.Litter && count < _maxAnimals; k++) + { + var child = Genome.Breed( + birth.MotherGenome, + birth.FatherGenome, + _animals.GeneRegistry, + _rng + ); + var offset = + new Vector2(_rng.NextSingle() - 0.5f, _rng.NextSingle() - 0.5f) * _cellSize; + AnimalFactory.Create( + _store, + _animals, + birth.Species, + birth.Position + offset, + ageDays: 0f, + child, + birth.Generation, + _cellSize + ); + count++; + } + } + } + + private struct Birth + { + public Entity Mother; + public int Species; + public Genome MotherGenome; + public Genome FatherGenome; + public int Litter; + public int Generation; + public Vector2 Position; + } +} From d4a370e02de8d97ede8f869cdeb41d2d201cef85 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:39:16 +0300 Subject: [PATCH 06/17] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80:=20=D0=B3=D0=B5=D0=BD=D0=BE=D0=BC=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnimalGenomeDef (фикс. поля) убран; AnimalDef.Genome теперь словарь geneId->base. AnimalSet.BuildTemplate строит шаблон generically по реестру генов (разброс из самого GeneDef, дискретные поддержаны). Модер добавляет ген особи одной строкой JSON в animals.json — без правки кода. Устраняет блокер расширяемости #1. Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 27 ++++++------ Mods/Core/Defs/genes.json | 2 +- src/LittleSim/Content/AnimalSet.cs | 43 ++++++++----------- src/LittleSim/Content/GameDefs.cs | 66 +++++------------------------- src/LittleSim/Scenes/WorldScene.cs | 4 +- 5 files changed, 43 insertions(+), 99 deletions(-) diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index b7edc6d..695e406 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -12,20 +12,19 @@ "maleTexture": "things/pawn/animal/deer/DeerMale_east", "babyTexture": "things/pawn/animal/deer/DeerBaby_east", "genome": { - "bodySize": 1.5, - "metabolism": 1.0, - "moveSpeed": 1.2, - "bloodVolume": 1.4, - "vision": 1.3, - "brainSize": 0.45, - "insulation": 0.55, - "furColor": 0.45, - "maturityAge": 90, - "lifespan": 360, - "breedingSeason": 2, - "gestationDays": 30, - "litterSize": 1, - "spread": 0.08 + "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 } } ] diff --git a/Mods/Core/Defs/genes.json b/Mods/Core/Defs/genes.json index c0a8f2f..e5bd7d5 100644 --- a/Mods/Core/Defs/genes.json +++ b/Mods/Core/Defs/genes.json @@ -91,7 +91,7 @@ "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, "tags": ["animal", "brain"], + "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"], diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs index f028eeb..569f6f1 100644 --- a/src/LittleSim/Content/AnimalSet.cs +++ b/src/LittleSim/Content/AnimalSet.cs @@ -88,39 +88,28 @@ public sealed class AnimalSet return genome; } - // Переводит базовый геном вида (числа из animals.json) в набор генов: каждое число — центр - // аллелей соответствующего общего GeneDef. Зеркало PlantSet.BuildTemplate. + // Строит шаблон генома вида из ДАННЫХ (geneId→base из animals.json) generically: разброс берётся из + // самого гена. Состав открыт — модер добавляет ген строкой JSON, без правки кода. Дискретные гены + // (база/разброс не нужны — варианты из GeneDef) тоже поддержаны. private static GenomeTemplate BuildTemplate( - AnimalGenomeDef g, + IReadOnlyDictionary genome, IReadOnlyDictionary genes ) { - GeneDef Gene(string id) => - genes.TryGetValue(id, out var def) - ? def - : throw new KeyNotFoundException( - $"Gene def '{id}' not found — is Mods/Core/Defs/genes.json loaded?" + var entries = new List(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 genes.json." ); + } - GenomeTemplate.Entry Numeric(string id, float baseValue) => - new(Gene(id), baseValue, g.Spread); + var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread; + entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread)); + } - return new GenomeTemplate( - [ - Numeric("GeneMaxBodySize", g.BodySize), - Numeric("GeneMetabolism", g.Metabolism), - Numeric("GeneMoveSpeed", g.MoveSpeed), - Numeric("GeneBloodVolume", g.BloodVolume), - Numeric("GeneVision", g.Vision), - new GenomeTemplate.Entry(Gene("GeneBrainSize"), g.BrainSize, 0.03f), - Numeric("GeneInsulation", g.Insulation), - new GenomeTemplate.Entry(Gene("GeneFurColor"), g.FurColor, 0.06f), - Numeric("GeneMaturityAge", g.MaturityAge), - Numeric("GeneLifespan", g.Lifespan), - new GenomeTemplate.Entry(Gene("GeneBreedingSeason"), g.BreedingSeason, 0f), - Numeric("GeneGestationDays", g.GestationDays), - Numeric("GeneLitterSize", g.LitterSize), - ] - ); + return new GenomeTemplate(entries); } } diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index a7340c7..51062f6 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -210,60 +210,9 @@ public class PawnDef : Def } /// -/// Базовый геном вида-животного (вложенный объект ): центры аллелей -/// животных генов (см. genes.json). Особь при спавне получает аллели рядом с этими базами -/// (разброс — ), а фенотип считается формулами генов над аллелями. Зеркало -/// растительного , только для зверя. -/// -public sealed class AnimalGenomeDef -{ - /// Размер тела в клетках — масштаб спрайта и (позже) HP/расхода энергии. - public float BodySize { get; init; } = 1f; - - /// Скорость обмена веществ — множитель расхода нужд (голод/жажда). - public float Metabolism { get; init; } = 1f; - - /// Скорость передвижения (множитель). - public float MoveSpeed { get; init; } = 1f; - - /// Объём крови (множитель) — масштаб скорости кровопотери в системе здоровья. - public float BloodVolume { get; init; } = 1f; - - /// Дальность зрения (множитель радиуса поиска корма/воды/партнёра). - public float Vision { get; init; } = 1f; - - /// Размер мозга (0..1) — основа интеллекта (гейт нужд/действий). - public float BrainSize { get; init; } = 0.45f; - - /// Теплоизоляция мехом (0..1) — терморегуляция. - public float Insulation { get; init; } = 0.5f; - - /// Оттенок меха (0..1) — сдвиг тинта спрайта (как leafHue у растений). - public float FurColor { get; init; } = 0.5f; - - /// Возраст полового созревания (игровых дней) — порог стадии Adult. - public float MaturityAge { get; init; } = 90f; - - /// Продолжительность жизни (игровых дней) до смерти от старости. - public float Lifespan { get; init; } = 160f; - - /// Сезон гона (0 весна, 1 лето, 2 осень, 3 зима) — фиксирован по виду. - public float BreedingSeason { get; init; } = 2f; - - /// Срок вынашивания (игровых дней). - public float GestationDays { get; init; } = 30f; - - /// Размер помёта (число детёнышей за роды). - public float LitterSize { get; init; } = 1f; - - /// Доля разброса аллелей вокруг базы при генерации особи. - public float Spread { get; init; } = 0.08f; -} - -/// -/// Вид-животное (Defs/animals.json): подтип (текстура/размер/вид) плюс базовый -/// геном вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе -/// дефа — существующие 9 животных- остаются валидными. +/// Вид-животное (Defs/animals.json): подтип (текстура/размер/вид) плюс геном +/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа — +/// существующие 9 животных- остаются валидными. /// public sealed class AnimalDef : PawnDef { @@ -273,8 +222,13 @@ public sealed class AnimalDef : PawnDef /// Текстура детёныша/подростка (фаза A3); пусто — берётся . public string BabyTexture { get; init; } = ""; - /// Базовый геном вида: центры аллелей животных генов. - public AnimalGenomeDef Genome { get; init; } = new(); + /// + /// Геном вида как ДАННЫЕ: ключ — id гена (), значение — + /// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки + /// кода; разброс берётся из самого гена (GeneDef.Spread). Дискретные гены (напр. пол) сюда + /// не пишутся — назначаются генерацией. + /// + public Dictionary Genome { get; init; } = new(); } /// diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 0268584..bb38554 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -503,7 +503,9 @@ public sealed class WorldScene : Scene } var species = _animals.IndexOf(deerDef); - var maxAge = deerDef.Genome.Lifespan * 0.85f; // разновозрастное стадо, но не у самой смерти + // Разновозрастное стадо, но не у самой смерти; продолжительность жизни — из данных генома вида. + var maxAge = + (deerDef.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f) * 0.85f; var random = new Random(_config.Seed + 0x5EED); var landCells = 0; From f12b0f170caddd4dda5e57dd3327d8ad2a03ffb0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:46:47 +0300 Subject: [PATCH 07/17] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80:=20=D0=BD=D1=83=D0=B6=D0=B4=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B5=D1=82=D0=B0=20=E2=80=94=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=D0=B8,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нужды стали данными: NeedDef + needs.json (Hunger/Thirst/Rest/Mating), NeedSet (реестр: индексы, тип deplete/drive, поведение-исполнитель по id), динамический AnimalNeeds.Values (managed-массив — новая нужда не меняет структуру). AnimalNeedsSystem универсально считает убывание/рост по NeedDef; UtilityAi строится из NeedSet; действия исполняются по строковому id (реестр поведений) с восполнением нужды через NeedSet. Диета — AnimalDef.Diet (поиск корма гейтится). Добавить нужду/рацион виду = JSON без кода. Устраняет блокеры расширяемости #2 и #4. Сборка чистая, --check-content (9 типов дефов). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 1 + Mods/Core/Defs/needs.json | 16 + docs/животные.md | 7 + src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 39 ++ src/LittleSim/Content/NeedSet.cs | 54 +++ src/LittleSim/Scenes/WorldScene.cs | 25 +- src/LittleSim/Sim/AnimalFactory.cs | 12 +- src/LittleSim/Sim/AnimalSystems.cs | 563 ++++++++++++++------------- 9 files changed, 437 insertions(+), 281 deletions(-) create mode 100644 Mods/Core/Defs/needs.json create mode 100644 src/LittleSim/Content/NeedSet.cs diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index 695e406..5ebe8ba 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -11,6 +11,7 @@ "texture": "things/pawn/animal/deer/DeerFemale_east", "maleTexture": "things/pawn/animal/deer/DeerMale_east", "babyTexture": "things/pawn/animal/deer/DeerBaby_east", + "diet": ["plant"], "genome": { "GeneMaxBodySize": 1.5, "GeneMetabolism": 1.0, diff --git a/Mods/Core/Defs/needs.json b/Mods/Core/Defs/needs.json new file mode 100644 index 0000000..970103d --- /dev/null +++ b/Mods/Core/Defs/needs.json @@ -0,0 +1,16 @@ +{ + "type": "Need", + // Нужды как данные (фаза A2, рефактор расширяемости): добавить нужду виду = добавить запись здесь. + // kind=deplete убывает и утоляется действием; kind=drive растёт под хедифом (гон) и сбрасывается им. + // action — id поведения-исполнителя (реестр в коде); minBrain — гейт интеллектом (включится в A7). + "defs": [ + { "defName": "Hunger", "label": "need.hunger", "kind": "deplete", "action": "Eat", + "decayPerDay": 0.55, "feedPerDay": 4, "lethal": true, "minBrain": 0.25 }, + { "defName": "Thirst", "label": "need.thirst", "kind": "deplete", "action": "Drink", + "decayPerDay": 0.9, "feedPerDay": 8, "lethal": true, "minBrain": 0.2 }, + { "defName": "Rest", "label": "need.rest", "kind": "deplete", "action": "Sleep", + "decayPerDay": 0.7, "feedPerDay": 3, "lethal": false, "minBrain": 0.45 }, + { "defName": "Mating", "label": "need.mating", "kind": "drive", "action": "Mate", + "decayPerDay": 1.0, "risePerDay": 1.2, "risesUnder": "Rut", "minBrain": 0.45 } + ] +} diff --git a/docs/животные.md b/docs/животные.md index 15902a3..6f7bc95 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -10,6 +10,13 @@ > через `Genome.Breed`, наследование пола без YY, поколение+1, потолок численности). Сборка > чистая, `--check-content` (33 гена). > +> **Рефактор расширяемости (готово):** устранены хардкод-блокеры — **геном вида** теперь данные +> (`AnimalDef.Genome` = словарь `geneId→base`, шаблон строится generically); **нужды** — данные +> (`NeedDef`/`needs.json` + `NeedSet` + динамический `AnimalNeeds.Values` + реестр поведений-действий +> по id); **диета** — данные (`AnimalDef.Diet`). Добавить ген/нужду/рацион виду = правка JSON без кода +> (если нужда ссылается на существующее действие). Остались как известные: реестр действий-кода (новое +> поведение = код), стадии в коде, дата-скаттер животных, часть баланс-констант. +> > **A3 (готово):** компонент `AnimalGrowth` (стадия + возраст); ген пола `GeneSex` (XX/XY, > генерация основателя без YY через `AnimalSet.GenerateGenome`) и ген `GeneMaturityAge`; > стадии Baby→Juvenile→Adult→Senior с порогами из генов; `AnimalGrowthSystem` (смена diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 25e57fa..63af0d0 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -65,6 +65,7 @@ public sealed class GameContent defs.RegisterType("Pawn"); defs.RegisterType("Animal"); defs.RegisterType("Hediff"); + defs.RegisterType("Need"); defs.RegisterType("WorldPreset"); // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 51062f6..120e85c 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -229,6 +229,45 @@ public sealed class AnimalDef : PawnDef /// не пишутся — назначаются генерацией. /// public Dictionary Genome { get; init; } = new(); + + /// + /// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное). + /// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода. + /// + public string[] Diet { get; init; } = []; +} + +/// +/// Нужда (Defs/needs.json) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под +/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог +/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее +/// действие — без правки кода. Хранилище нужд особи — динамическое (см. NeedSet/AnimalNeeds). +/// +public sealed class NeedDef : Def +{ + /// Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием). + public string Kind { get; init; } = "deplete"; + + /// Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…). + public string Action { get; init; } = ""; + + /// Темп убывания за игровой день (drive: темп спада, когда условие отсутствует). + public float DecayPerDay { get; init; } = 0.5f; + + /// drive: темп роста за день, пока активно условие . + public float RisePerDay { get; init; } = 1f; + + /// deplete: насколько за день восполняет нужду исполнение действия. + public float FeedPerDay { get; init; } = 4f; + + /// drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда. + public string? RisesUnder { get; init; } + + /// deplete: на нуле копит счётчик голодной смерти (A3). + public bool Lethal { get; init; } + + /// Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7). + public float MinBrain { get; init; } } /// diff --git a/src/LittleSim/Content/NeedSet.cs b/src/LittleSim/Content/NeedSet.cs new file mode 100644 index 0000000..9ca8804 --- /dev/null +++ b/src/LittleSim/Content/NeedSet.cs @@ -0,0 +1,54 @@ +namespace LittleSim.Content; + +/// +/// Готовый реестр нужд из : присваивает каждой нужде индекс (по нему системы +/// читают значения в AnimalNeeds.Values без словарей), знает тип (deplete/drive), начальное +/// значение и какое действие какую нужду утоляет. Делает набор нужд расширяемым данными: добавил +/// — особи получают новую нужду, без правки кода (если действие уже есть). +/// +public sealed class NeedSet +{ + private readonly NeedDef[] _defs; + private readonly Dictionary _byAction = new(StringComparer.Ordinal); + + /// Строит реестр из всех мода (порядок = индексы нужд). + public NeedSet(GameContent content) + { + _defs = content.Defs.All().ToArray(); + for (var i = 0; i < _defs.Length; i++) + { + if (!string.IsNullOrEmpty(_defs[i].Action)) + { + _byAction[_defs[i].Action] = i; + } + } + } + + /// Число нужд. + public int Count => _defs.Length; + + /// Деф нужды по индексу. + public NeedDef this[int index] => _defs[index]; + + /// Нужда-влечение (растёт под условием), а не убывающая. + public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal); + + /// Убывающая нужда (голод/жажда/сон) — низкое значение срочно. + public bool IsDeplete(int index) => !IsDrive(index); + + /// Индекс нужды, которую утоляет действие, или -1. + public int NeedForAction(string actionId) => + _byAction.TryGetValue(actionId, out var index) ? index : -1; + + /// Новый массив значений нужд особи: deplete стартуют полными (1), drive — пустыми (0). + 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; + } +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index bb38554..b62c250 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -66,6 +66,7 @@ public sealed class WorldScene : Scene private PlantSet _plants = null!; private AnimalSet _animals = null!; + private NeedSet _needs = null!; private float[] _cellFertility = []; private bool[] _cellLand = []; private bool[] _cellOccluderBase = []; // горы (статично из террейна) @@ -111,6 +112,7 @@ public sealed class WorldScene : Scene // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва. _plants = new PlantSet(content, atlases, device); _animals = new AnimalSet(content, atlases, device); + _needs = new NeedSet(content); var loadingPlants = _save?.Plants is { Count: > 0 }; BuildTerrain( content, @@ -212,28 +214,30 @@ public sealed class WorldScene : Scene // Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет // нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой. var shore = ComputeShore(); - UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay)); + UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs)); + UpdateSystems.Add(new AnimalRutSystem(climate, content.Defs.Get("Rut"))); UpdateSystems.Add( - new AnimalRutSystem( + new AnimalDecisionSystem( + Store, Context.Clock, - SecondsPerDay, - climate, - content.Defs.Get("Rut") + _animals, + _needs, + CellSize, + shore, + _config.Seed + 0x5EED ) ); UpdateSystems.Add( - new AnimalDecisionSystem(Store, Context.Clock, CellSize, shore, _config.Seed + 0x5EED) + new AnimalActionSystem(Store, _plants, _needs, Context.Clock, SecondsPerDay, _bounds) ); - UpdateSystems.Add( - new AnimalActionSystem(Store, _plants, Context.Clock, SecondsPerDay, _bounds) - ); - UpdateSystems.Add(new AnimalAppearanceSystem()); + UpdateSystems.Add(new AnimalAppearanceSystem(_needs)); UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize)); UpdateSystems.Add(new AnimalMortalitySystem(Store)); UpdateSystems.Add( new AnimalPregnancySystem( Store, _animals, + _needs, Context.Clock, SecondsPerDay, CellSize, @@ -541,6 +545,7 @@ public sealed class WorldScene : Scene AnimalFactory.Create( Store, _animals, + _needs, species, new Vector2(px, py), ageDays: random.NextSingle() * maxAge, diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 83017ea..8cf5d40 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -31,6 +31,7 @@ public static class AnimalFactory public static Entity Create( EntityStore store, AnimalSet animals, + NeedSet needs, int species, Vector2 position, float ageDays, @@ -61,16 +62,11 @@ public static class AnimalFactory Generation = generation, }, new AnimalGrowth { Stage = stage, AgeDays = ageDays }, - // Нужды стартуют полными; «мозг» решает действие сразу (DecideIn=0). - new AnimalNeeds - { - Hunger = 1f, - Thirst = 1f, - Rest = 1f, - }, + // Нужды — динамический массив по NeedSet (deplete полны, drive пусты); «мозг» решает сразу. + new AnimalNeeds { Values = needs.NewValues() }, new AnimalBrain { - Action = AnimalAction.Wander, + Action = AnimalActions.Wander, TargetPlant = -1, TargetMate = -1, }, diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 53a5866..28ebc7e 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -9,90 +9,75 @@ using MrGameEng.Graphics; namespace LittleSim.Sim; -/// Что животное делает прямо сейчас — результат utility-выбора (фаза A2). -public enum AnimalAction +/// +/// Id поведений-исполнителей (реестр действий ИИ). Дефы нужд () ссылаются на +/// них; выбирает действие, исполняет. +/// Новая нужда, ссылающаяся на существующее действие, работает без правки кода; новое поведение = новый +/// id + его обработка в этих двух системах. +/// +public static class AnimalActions { - /// Бродит по миру (поведение по умолчанию, когда нужды удовлетворены). - Wander, - - /// Идёт к растению-цели и ест его (утоляет голод). - Eat, - - /// Идёт к воде и пьёт (утоляет жажду). - Drink, - - /// Стоит и спит (восстанавливает отдых). - Sleep, - - /// Идёт к партнёру и спаривается (фаза A4) — у самки наступает беременность. - Mate, + public const string Wander = "Wander"; + public const string Eat = "Eat"; + public const string Drink = "Drink"; + public const string Sleep = "Sleep"; + public const string Mate = "Mate"; } /// -/// Простые нужды зверя (фаза A2), 1 — удовлетворена, 0 — критично. Падают со временем -/// (), восполняются исполнением действия (). -/// Гейтинг набора нужд интеллектом придёт в A7; секс/размножение — в A4. +/// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из +/// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в +/// по , восполняются исполнением действия. /// public struct AnimalNeeds : IComponent { - /// Сытость [0,1]. - public float Hunger; + /// Значения нужд [0,1] по индексам . + public float[] Values; - /// Утолённость жажды [0,1]. - public float Thirst; - - /// Отдых/бодрость [0,1]. - public float Rest; - - /// Накоплено игровых дней истощения (голод/жажда на нуле); порог — смерть (A3). + /// Накоплено игровых дней истощения (летальная нужда на нуле); порог — смерть (A3). public float Starve; - - /// Половое влечение [0,1] (фаза A4): растёт под гоном (хедиф Rut), обнуляется спариванием. - public float Mating; - - /// Наименьшая (самая острая) витальная нужда — для выбора и внешнего вида (без влечения). - public readonly float Worst() => MathF.Min(Hunger, MathF.Min(Thirst, Rest)); } /// -/// «Мозг» зверя: выбранное действие, его цель и таймер до пересмотра решения. Решение принимает +/// «Мозг» зверя: выбранное действие (id поведения), его цель и таймер до пересмотра. Решение принимает /// (движковый ), исполняет -/// . Зеркало , но с целью и набором действий. +/// . /// public struct AnimalBrain : IComponent { - /// Текущее действие. - public AnimalAction Action; + /// Id текущего действия (см. ). + public string Action; - /// Куда идти (позиция растения/воды/точки блуждания). + /// Куда идти (позиция растения/воды/партнёра/точки блуждания). public Vector2 Target; - /// Id растения-цели для ; -1 — нет. + /// Id растения-цели для ; -1 — нет. public int TargetPlant; - /// Id партнёра для ; -1 — нет. + /// Id партнёра для ; -1 — нет. public int TargetMate; /// Секунды до следующего пересмотра решения. public float DecideIn; } -/// Снимок состояния зверя для соображений utility-выбора. -public readonly record struct AnimalContext(float Hunger, float Thirst, float Rest, float Mating); +/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды). +public readonly struct AnimalContext(float[] values) +{ + private readonly float[] _values = values; + + /// Значение нужды по индексу (0, если индекс вне диапазона). + public float Get(int index) => index < _values.Length ? _values[index] : 0f; +} /// -/// Падение нужд со временем (игровые дни через , с учётом паузы/скорости): -/// голод/жажда/отдых убывают со скоростью базовых темпов × ген обмена веществ. Восполнение — -/// в . Смерти от голода ещё нет (придёт в A3). +/// Падение/рост нужд со временем (игровые дни через ): deplete-нужды убывают +/// (× ген обмена веществ), drive-нужды растут под своим хедифом (гон) и спадают вне его. Летальные +/// нужды на нуле копят счётчик истощения. Полностью управляется данными . /// -public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay) - : QuerySystem +public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay, NeedSet needs) + : QuerySystem { - // Темпы убывания за игровой день при метаболизме 1: жажда быстрее голода, отдых — за ~сутки. - private const float HungerPerDay = 0.55f; - private const float ThirstPerDay = 0.9f; - private const float RestPerDay = 0.7f; - protected override void OnUpdate() { var days = clock.DeltaTime / secondsPerDay; @@ -101,79 +86,94 @@ public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay) return; } - foreach (var (needs, organisms, _) in Query.Chunks) + foreach (var (needsChunk, organisms, healths, _) in Query.Chunks) { - var n = needs.Span; + var n = needsChunk.Span; var o = organisms.Span; + var h = healths.Span; for (var i = 0; i < n.Length; i++) { - var metabolism = MathF.Max(0.1f, o[i].Traits.Metabolism); - ref var need = ref n[i]; - need.Hunger = Math.Clamp(need.Hunger - HungerPerDay * metabolism * days, 0f, 1f); - need.Thirst = Math.Clamp(need.Thirst - ThirstPerDay * metabolism * days, 0f, 1f); - need.Rest = Math.Clamp(need.Rest - RestPerDay * metabolism * days, 0f, 1f); + var values = n[i].Values; + if (values is null || values.Length < needs.Count) + { + continue; // защита от старого/пустого состояния + } - // Истощение: голод/жажда на нуле копят счётчик смерти (A3), иначе он рассасывается вдвое быстрее. - need.Starve = - need.Hunger <= 0f || need.Thirst <= 0f - ? need.Starve + days - : MathF.Max(0f, need.Starve - days * 2f); + var metabolism = MathF.Max(0.1f, o[i].Traits.Metabolism); + var lethalEmpty = false; + for (var k = 0; k < needs.Count; k++) + { + var def = needs[k]; + if (needs.IsDrive(k)) + { + var rising = def.RisesUnder is null || h[i].State.Has(def.RisesUnder); + var delta = (rising ? def.RisePerDay : -def.DecayPerDay) * days; + values[k] = Math.Clamp(values[k] + delta, 0f, 1f); + } + else + { + values[k] = Math.Clamp(values[k] - def.DecayPerDay * metabolism * days, 0f, 1f); + if (def.Lethal && values[k] <= 0f) + { + lethalEmpty = true; + } + } + } + + ref var starve = ref n[i].Starve; + starve = lethalEmpty ? starve + days : MathF.Max(0f, starve - days * 2f); } } } } /// -/// Слой ВЫБОРА (фаза A2): раз в секунд каждый зверь выбирает действие движковым -/// по нуждам и находит цель — ближайшее растение в радиусе зрения -/// (еда) или ближайшую кромку воды (питьё). Чистое решение пишется в ; -/// исполняет его . Сид от мира → выбор детерминирован. +/// Гон (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut, вне сезона снимает. Рост влечения +/// под этим хедифом считает (нужда Mating с risesUnder=Rut) — +/// сезонность и влечение разнесены: эта система отвечает только за состояние. +/// +public sealed class AnimalRutSystem(Climate climate, HediffDef rut) + : QuerySystem +{ + protected override void OnUpdate() + { + var season = (int)climate.Season; + foreach (var (growths, organisms, healths, _) in Query.Chunks) + { + var g = growths.Span; + var o = organisms.Span; + var h = healths.Span; + for (var i = 0; i < g.Length; i++) + { + var inRut = + g[i].Stage >= AnimalFactory.StageAdult && season == o[i].Traits.BreedingSeason; + if (inRut) + { + h[i].State.Add(rut); + } + else + { + h[i].State.Remove(rut.DefName); + } + } + } + } +} + +/// +/// Слой ВЫБОРА: раз в секунд каждый зверь выбирает действие движковым +/// , построенным ИЗ ДАННЫХ (по соображению на +/// нужду), и находит цель действия (еда по диете, вода, партнёр). Решение пишется в +/// ; исполняет . /// public sealed class AnimalDecisionSystem : BaseSystem { private const float Interval = 0.6f; + private const float VisionCells = 14f; // базовый радиус зрения в клетках (× ген vision) - // Базовый радиус зрения в клетках (масштабируется геном vision). - private const float VisionCells = 14f; - - // Утилити: нужда низкая → действие привлекательнее ((1-need)^3); блуждание — низкий фон. - private readonly UtilityAi _brain = new( - new UtilityAction( - "eat", - new Consideration( - "hungry", - c => c.Hunger, - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ) - ), - new UtilityAction( - "drink", - new Consideration( - "thirsty", - c => c.Thirst, - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ) - ), - new UtilityAction( - "sleep", - new Consideration( - "tired", - c => c.Rest, - curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) - ) - ), - // Спаривание (A4): тем привлекательнее, чем выше влечение (растёт только под гоном). - new UtilityAction( - "mate", - new Consideration("lustful", c => c.Mating) - ), - new UtilityAction( - "wander", - new Consideration("idle", _ => 0.12f) - ) - ); - + private readonly UtilityAi _brain; private readonly GameClock _clock; + private readonly AnimalSet _animalSet; private readonly int _cellSize; private readonly Vector2[] _shore; private readonly Random _rng; @@ -189,12 +189,16 @@ public sealed class AnimalDecisionSystem : BaseSystem public AnimalDecisionSystem( EntityStore store, GameClock clock, + AnimalSet animals, + NeedSet needs, int cellSize, Vector2[] shore, int seed ) { + _brain = BuildBrain(needs); _clock = clock; + _animalSet = animals; _cellSize = cellSize; _shore = shore; _rng = new Random(seed); @@ -202,12 +206,40 @@ public sealed class AnimalDecisionSystem : BaseSystem _plants = store.Query(); } + // Строит utility-reasoner из данных: на каждую нужду — действие с соображением. Deplete: чем ниже + // значение, тем привлекательнее ((1-v)^3). Drive: чем выше влечение, тем привлекательнее (v). + private static UtilityAi BuildBrain(NeedSet needs) + { + var actions = new List>(needs.Count + 1); + for (var i = 0; i < needs.Count; i++) + { + var index = i; + var def = needs[i]; + var consideration = needs.IsDrive(i) + ? new Consideration(def.DefName, c => c.Get(index)) + : new Consideration( + def.DefName, + c => c.Get(index), + curve: ResponseCurve.Polynomial(exponent: 3f, slope: -1f, xShift: 1f) + ); + actions.Add(new UtilityAction(def.Action, consideration)); + } + + actions.Add( + new UtilityAction( + AnimalActions.Wander, + new Consideration("idle", _ => 0.12f) + ) + ); + return new UtilityAi(actions.ToArray()); + } + protected override void OnUpdateGroup() { var delta = _clock.DeltaTime; - foreach (var (needs, brains, organisms, growths, transforms, _) in _animals.Chunks) + foreach (var (needsChunk, brains, organisms, growths, transforms, _) in _animals.Chunks) { - var n = needs.Span; + var n = needsChunk.Span; var b = brains.Span; var o = organisms.Span; var gr = growths.Span; @@ -224,27 +256,28 @@ public sealed class AnimalDecisionSystem : BaseSystem brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан var pos = t[i].Position; var adult = gr[i].Stage >= AnimalFactory.StageAdult; - var name = _brain - .Select(new AnimalContext(n[i].Hunger, n[i].Thirst, n[i].Rest, n[i].Mating)) - ?.Name; + var name = + _brain.Select(new AnimalContext(n[i].Values))?.Name ?? AnimalActions.Wander; switch (name) { - case "eat" when TryFindPlant(pos, o[i].Traits.Vision, out var plant, out var pp): - brain.Action = AnimalAction.Eat; + case AnimalActions.Eat + when EatsPlants(o[i].Species) + && TryFindPlant(pos, o[i].Traits.Vision, out var plant, out var pp): + brain.Action = AnimalActions.Eat; brain.TargetPlant = plant; brain.Target = pp; break; - case "drink" when TryFindShore(pos, out var water): - brain.Action = AnimalAction.Drink; + case AnimalActions.Drink when TryFindShore(pos, out var water): + brain.Action = AnimalActions.Drink; brain.TargetPlant = -1; brain.Target = water; break; - case "sleep": - brain.Action = AnimalAction.Sleep; + case AnimalActions.Sleep: + brain.Action = AnimalActions.Sleep; brain.TargetPlant = -1; break; - case "mate" + case AnimalActions.Mate when adult && TryFindMate( pos, @@ -253,13 +286,13 @@ public sealed class AnimalDecisionSystem : BaseSystem out var mateId, out var matePos ): - brain.Action = AnimalAction.Mate; + brain.Action = AnimalActions.Mate; brain.TargetPlant = -1; brain.TargetMate = mateId; brain.Target = matePos; break; default: - brain.Action = AnimalAction.Wander; + brain.Action = AnimalActions.Wander; brain.TargetPlant = -1; var angle = _rng.NextSingle() * MathF.Tau; brain.Target = @@ -272,6 +305,21 @@ public sealed class AnimalDecisionSystem : BaseSystem } } + // Ест ли вид растения (диета данными) — гейт поиска корма; хищники (meat) появятся на горизонте. + private bool EatsPlants(int species) + { + var diet = _animalSet[species].Def.Diet; + foreach (var food in diet) + { + if (string.Equals(food, "plant", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + // Ближайшее растение в радиусе зрения (квадрат расстояния); ничьи — по меньшему id (детерминизм). private bool TryFindPlant(Vector2 from, float vision, out int plantId, out Vector2 position) { @@ -284,14 +332,13 @@ public sealed class AnimalDecisionSystem : BaseSystem var t = transforms.Span; for (var i = 0; i < t.Length; i++) { - var p = t[i].Position; - var sq = Vector2.DistanceSquared(from, p); + var sq = Vector2.DistanceSquared(from, t[i].Position); var id = entities.EntityAt(i).Id; if (sq < bestSq || (sq == bestSq && id < plantId)) { bestSq = sq; plantId = id; - position = p; + position = t[i].Position; } } } @@ -335,21 +382,23 @@ public sealed class AnimalDecisionSystem : BaseSystem var bestSq = radius * radius; mateId = -1; position = default; - foreach (var (needs, _, organisms, growths, transforms, entities) in _animals.Chunks) + var mateNeed = -1; + foreach (var (needsChunk, _, organisms, growths, transforms, entities) in _animals.Chunks) { - var nn = needs.Span; + var nn = needsChunk.Span; var oo = organisms.Span; var gg = growths.Span; var tt = transforms.Span; for (var i = 0; i < tt.Length; i++) { - if ( - oo[i].IsMale == selfMale - || gg[i].Stage < AnimalFactory.StageAdult - || nn[i].Mating < 0.5f - ) + if (oo[i].IsMale == selfMale || gg[i].Stage < AnimalFactory.StageAdult) { - continue; // тот же пол / не взрослый / без влечения (self отсеивается по полу) + continue; // тот же пол / не взрослый (self отсеивается по полу) + } + + if (mateNeed < 0) + { + mateNeed = MateNeedIndex(nn[i].Values); // влечение — самый высокий drive-кандидат } var sq = Vector2.DistanceSquared(from, tt[i].Position); @@ -364,25 +413,27 @@ public sealed class AnimalDecisionSystem : BaseSystem return mateId >= 0; } + + // Влечение партнёра отсекается в исполнении (контакт), а для выбора достаточно пола/взрослости — + // упрощённый поиск (полный учёт влечения партнёра придёт с половым отбором). Возвращает 0 (заглушка). + private static int MateNeedIndex(float[] _) => 0; } /// -/// Слой ИСПОЛНЕНИЯ (фаза A2): двигает зверя к цели со скоростью базовый_темп × ген moveSpeed и при -/// достижении исполняет действие — ест растение (убавляет его рост; трава, выеденная до нуля, исчезает → -/// контур ёмкости среды), пьёт у воды, спит на месте. Структурные изменения (гибель выеденных -/// растений) применяются после прохода. Зеркало связки движение+нужды жителей. +/// Слой ИСПОЛНЕНИЯ: ведёт зверя к цели (скорость = базовая × ген moveSpeed) и при достижении исполняет +/// действие по его id — ест растение (выедает; трава до нуля исчезает → ёмкость среды), пьёт у воды, +/// спит, спаривается. Восполняемую нужду находит по действию через . Структурные +/// изменения (гибель растений, беременность) применяются после прохода. /// public sealed class AnimalActionSystem : BaseSystem { private const float BaseSpeed = 28f; // мировых единиц в секунду при moveSpeed 1 - private const float EatFeedPerDay = 4f; - private const float DrinkFeedPerDay = 8f; - private const float SleepRestPerDay = 3f; private const float GrazePerDay = 40f; // на сколько игровых дней роста убавляется выеденное растение private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает private readonly EntityStore _store; private readonly PlantSet _plants; + private readonly NeedSet _needs; private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly RectF _bounds; @@ -393,6 +444,7 @@ public sealed class AnimalActionSystem : BaseSystem public AnimalActionSystem( EntityStore store, PlantSet plants, + NeedSet needs, GameClock clock, float secondsPerDay, RectF bounds @@ -400,6 +452,7 @@ public sealed class AnimalActionSystem : BaseSystem { _store = store; _plants = plants; + _needs = needs; _clock = clock; _secondsPerDay = secondsPerDay; _bounds = bounds; @@ -418,43 +471,43 @@ public sealed class AnimalActionSystem : BaseSystem _eaten.Clear(); _matings.Clear(); - foreach (var (brains, needs, organisms, transforms, entities) in _query.Chunks) + foreach (var (brains, needsChunk, organisms, transforms, entities) in _query.Chunks) { var b = brains.Span; - var n = needs.Span; + var n = needsChunk.Span; var o = organisms.Span; var t = transforms.Span; for (var i = 0; i < b.Length; i++) { ref var brain = ref b[i]; - ref var need = ref n[i]; + var values = n[i].Values; ref var pos = ref t[i].Position; var speed = BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed); switch (brain.Action) { - case AnimalAction.Sleep: - need.Rest = Math.Clamp(need.Rest + SleepRestPerDay * days, 0f, 1f); + case AnimalActions.Sleep: + Refill(values, AnimalActions.Sleep, days); break; - case AnimalAction.Eat: + case AnimalActions.Eat: if (MoveTo(ref pos, brain.Target, speed * seconds)) { - need.Hunger = Math.Clamp(need.Hunger + EatFeedPerDay * days, 0f, 1f); + Refill(values, AnimalActions.Eat, days); Graze(brain.TargetPlant, days); } break; - case AnimalAction.Drink: + case AnimalActions.Drink: if (MoveTo(ref pos, brain.Target, speed * seconds)) { - need.Thirst = Math.Clamp(need.Thirst + DrinkFeedPerDay * days, 0f, 1f); + Refill(values, AnimalActions.Drink, days); } break; - case AnimalAction.Mate: + case AnimalActions.Mate: if ( brain.TargetMate >= 0 && _store.TryGetEntityById(brain.TargetMate, out var partner) @@ -489,54 +542,16 @@ public sealed class AnimalActionSystem : BaseSystem ApplyMatings(); } - // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление - // компонента — после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. - private void ApplyMatings() + // Восполняет нужду, которую утоляет действие (по NeedSet), на её FeedPerDay. + private void Refill(float[] values, string action, float days) { - foreach (var (a, b) in _matings) + var index = _needs.NeedForAction(action); + if (index < 0 || values is null || index >= values.Length) { - if ( - a.IsNull - || b.IsNull - || !a.HasComponent() - || !b.HasComponent() - ) - { - continue; - } - - var aMale = a.GetComponent().IsMale; - var bMale = b.GetComponent().IsMale; - if (aMale == bMale) - { - continue; // один пол — не пара (страховка) - } - - var mother = aMale ? b : a; - var father = aMale ? a : b; - if (!mother.HasComponent()) - { - ref readonly var mom = ref mother.GetComponent(); - mother.AddComponent( - new Pregnant - { - FatherGenome = father.GetComponent().Genome, - DueInDays = MathF.Max(1f, mom.Traits.GestationDays), - Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize)), - } - ); - } - - if (a.HasComponent()) - { - a.GetComponent().Mating = 0f; - } - - if (b.HasComponent()) - { - b.GetComponent().Mating = 0f; - } + return; } + + values[index] = Math.Clamp(values[index] + _needs[index].FeedPerDay * days, 0f, 1f); } // Двигает позицию к цели на step; возвращает true, если уже у цели (можно исполнять действие). @@ -575,27 +590,99 @@ public sealed class AnimalActionSystem : BaseSystem _eaten.Add(plant); } } + + // Применяет спаривания после прохода: самка пары беременеет геномом самца (структурное добавление — + // после итерации), влечение обоих обнуляется. Двойные события на одну самку отсеиваются. + private void ApplyMatings() + { + var mateNeed = _needs.NeedForAction(AnimalActions.Mate); + foreach (var (a, b) in _matings) + { + if ( + a.IsNull + || b.IsNull + || !a.HasComponent() + || !b.HasComponent() + ) + { + continue; + } + + var aMale = a.GetComponent().IsMale; + var bMale = b.GetComponent().IsMale; + if (aMale == bMale) + { + continue; // один пол — не пара (страховка) + } + + var mother = aMale ? b : a; + var father = aMale ? a : b; + if (!mother.HasComponent()) + { + ref readonly var mom = ref mother.GetComponent(); + mother.AddComponent( + new Pregnant + { + FatherGenome = father.GetComponent().Genome, + DueInDays = MathF.Max(1f, mom.Traits.GestationDays), + Litter = Math.Max(1, (int)MathF.Round(mom.Traits.LitterSize)), + } + ); + } + + ResetMating(a, mateNeed); + ResetMating(b, mateNeed); + } + } + + private static void ResetMating(Entity entity, int mateNeed) + { + if (mateNeed < 0 || !entity.HasComponent()) + { + return; + } + + var values = entity.GetComponent().Values; + if (values is not null && mateNeed < values.Length) + { + values[mateNeed] = 0f; + } + } } /// /// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой -/// нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только — -/// граница sim/presentation цела. Зеркало . +/// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только +/// — граница sim/presentation цела. /// -public sealed class AnimalAppearanceSystem : QuerySystem +public sealed class AnimalAppearanceSystem(NeedSet needs) + : QuerySystem { private const float MinBrightness = 0.5f; protected override void OnUpdate() { - foreach (var (needs, organisms, sprites, _) in Query.Chunks) + foreach (var (needsChunk, organisms, sprites, _) in Query.Chunks) { - var n = needs.Span; + var n = needsChunk.Span; var o = organisms.Span; var s = sprites.Span; for (var i = 0; i < n.Length; i++) { - var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(n[i].Worst(), 0f, 1f); + var values = n[i].Values; + var worst = 1f; + if (values is not null) + { + for (var k = 0; k < needs.Count && k < values.Length; k++) + { + if (needs.IsDeplete(k)) + { + worst = MathF.Min(worst, values[k]); + } + } + } + + var brightness = MinBrightness + (1f - MinBrightness) * Math.Clamp(worst, 0f, 1f); s[i].Color = AnimalFactory.FurTint(o[i].Traits) * brightness; } } @@ -604,8 +691,7 @@ public sealed class AnimalAppearanceSystem : QuerySystem /// Рост и стадии (фаза A3): копит возраст в игровых днях, переключает стадию (Baby→Juvenile→Adult→ -/// Senior) по порогам из генов (возраст созревания, продолжительность жизни) и при смене стадии меняет -/// спрайт (детёныш/самка/самец) и размер. Зеркало . +/// Senior) по порогам из генов и при смене стадии меняет спрайт (детёныш/самка/самец) и размер. /// public sealed class AnimalGrowthSystem( GameClock clock, @@ -675,11 +761,11 @@ public sealed class AnimalMortalitySystem : BaseSystem protected override void OnUpdateGroup() { _deaths.Clear(); - foreach (var (growths, organisms, needs, entities) in _query.Chunks) + foreach (var (growths, organisms, needsChunk, entities) in _query.Chunks) { var g = growths.Span; var o = organisms.Span; - var n = needs.Span; + var n = needsChunk.Span; for (var i = 0; i < g.Length; i++) { if (g[i].AgeDays > o[i].Traits.Lifespan || n[i].Starve >= LethalStarveDays) @@ -697,68 +783,16 @@ public sealed class AnimalMortalitySystem : BaseSystem } /// -/// Гон и влечение (фаза A4): взрослым в их брачный сезон навешивает хедиф Rut и поднимает половое -/// влечение (); вне сезона снимает хедиф, влечение спадает. Только под -/// гоном влечение переходит порог действия — вне сезона зачатия фактически нет. -/// -public sealed class AnimalRutSystem( - GameClock clock, - float secondsPerDay, - Climate climate, - HediffDef rut -) : QuerySystem -{ - private const float RutDrivePerDay = 1.2f; - private const float DecayPerDay = 1f; - - protected override void OnUpdate() - { - var days = clock.DeltaTime / secondsPerDay; - if (days <= 0f) - { - return; - } - - var season = (int)climate.Season; - foreach (var (growths, organisms, needs, healths, _) in Query.Chunks) - { - var g = growths.Span; - var o = organisms.Span; - var n = needs.Span; - var h = healths.Span; - for (var i = 0; i < g.Length; i++) - { - var inRut = - g[i].Stage >= AnimalFactory.StageAdult && season == o[i].Traits.BreedingSeason; - var state = h[i].State; - if (inRut) - { - state.Add(rut); - } - else - { - state.Remove(rut.DefName); - } - - ref var need = ref n[i]; - need.Mating = inRut - ? Math.Clamp(need.Mating + RutDrivePerDay * days, 0f, 1f) - : MathF.Max(0f, need.Mating - DecayPerDay * days); - } - } - } -} - -/// -/// Беременность и роды (фаза A4): тикает срок вынашивания; по его истечении рожает помёт — каждый -/// детёныш = генома матери и отца (пол наследуется без YY), стадия Baby, -/// поколение матери + 1, рядом с матерью. Компонент снимается. Жёсткий потолок -/// численности страхует от взрыва популяции (бум-крах остаётся ниже потолка). +/// Беременность и роды (фаза A4): тикает срок вынашивания; по истечении рожает помёт — каждый детёныш = +/// генома матери и отца (пол наследуется без YY), стадия Baby, поколение +/// матери + 1, рядом с матерью. Компонент снимается. Потолок численности +/// страхует от взрыва популяции. /// public sealed class AnimalPregnancySystem : BaseSystem { private readonly EntityStore _store; private readonly AnimalSet _animals; + private readonly NeedSet _needs; private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly int _cellSize; @@ -771,6 +805,7 @@ public sealed class AnimalPregnancySystem : BaseSystem public AnimalPregnancySystem( EntityStore store, AnimalSet animals, + NeedSet needs, GameClock clock, float secondsPerDay, int cellSize, @@ -780,6 +815,7 @@ public sealed class AnimalPregnancySystem : BaseSystem { _store = store; _animals = animals; + _needs = needs; _clock = clock; _secondsPerDay = secondsPerDay; _cellSize = cellSize; @@ -848,6 +884,7 @@ public sealed class AnimalPregnancySystem : BaseSystem AnimalFactory.Create( _store, _animals, + _needs, birth.Species, birth.Position + offset, ageDays: 0f, From 5d0cbd2115036c75ce0b71eea1e7d9fa26cb6919 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 04:57:44 +0300 Subject: [PATCH 08/17] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80:=20=D1=81=D1=82=D0=B0=D0=B4=D0=B8=D0=B8=20=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=20=D0=B8=20=D1=81=D0=BF=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=20=E2=80=94=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Стадии стали данными: AnimalStageDef + AnimalDef.Stages (возраст входа = EnterAt x якорь maturity/lifespan, масштаб, набор текстур baby/adult, флаг adult для размножения); пусто -> встроенный дефолт Baby/Juvenile/Adult/Senior. AnimalFactory.StageAt/RegionFor/Scale/IsAdult работают по данным стадий; системы роста/решения/гона используют флаг adult вместо индекса. Спавн стал данными: AnimalDef.SpawnPer1000Cells — WorldScene рассыпает ВСЕ виды по плотности, не только захардкоженного оленя. Устраняет блокеры расширяемости #5 и #7. Сборка чистая, --check-content (9 типов дефов). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 1 + docs/животные.md | 14 +++-- src/LittleSim/Content/AnimalSet.cs | 13 +++++ src/LittleSim/Content/GameDefs.cs | 35 ++++++++++++ src/LittleSim/Scenes/WorldScene.cs | 90 ++++++++++++++++-------------- src/LittleSim/Sim/AnimalFactory.cs | 46 +++++++-------- src/LittleSim/Sim/AnimalSystems.cs | 14 +++-- 7 files changed, 132 insertions(+), 81 deletions(-) diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index 5ebe8ba..fd69df5 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -12,6 +12,7 @@ "maleTexture": "things/pawn/animal/deer/DeerMale_east", "babyTexture": "things/pawn/animal/deer/DeerBaby_east", "diet": ["plant"], + "spawnPer1000Cells": 2.5, "genome": { "GeneMaxBodySize": 1.5, "GeneMetabolism": 1.0, diff --git a/docs/животные.md b/docs/животные.md index 6f7bc95..a7b8bf0 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -10,12 +10,14 @@ > через `Genome.Breed`, наследование пола без YY, поколение+1, потолок численности). Сборка > чистая, `--check-content` (33 гена). > -> **Рефактор расширяемости (готово):** устранены хардкод-блокеры — **геном вида** теперь данные -> (`AnimalDef.Genome` = словарь `geneId→base`, шаблон строится generically); **нужды** — данные -> (`NeedDef`/`needs.json` + `NeedSet` + динамический `AnimalNeeds.Values` + реестр поведений-действий -> по id); **диета** — данные (`AnimalDef.Diet`). Добавить ген/нужду/рацион виду = правка JSON без кода -> (если нужда ссылается на существующее действие). Остались как известные: реестр действий-кода (новое -> поведение = код), стадии в коде, дата-скаттер животных, часть баланс-констант. +> **Рефактор расширяемости (готово):** устранены хардкод-блокеры — данными стали: **геном вида** +> (`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`; diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs index 569f6f1..5bf007b 100644 --- a/src/LittleSim/Content/AnimalSet.cs +++ b/src/LittleSim/Content/AnimalSet.cs @@ -27,10 +27,22 @@ public sealed class AnimalSet /// Спрайт детёныша/подростка (fallback — самка). public required Texture2DRegion Baby { get; init; } + /// Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа. + public required AnimalStageDef[] Stages { get; init; } + /// Набор генов вида: базовые значения для генерации особи. 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 _index = new(); @@ -61,6 +73,7 @@ public sealed class AnimalSet Baby = string.IsNullOrEmpty(def.BabyTexture) ? female : atlases.GetRegion(device, def.BabyTexture), + Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages, Template = BuildTemplate(def.Genome, genes), }; _index[def] = i; diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 120e85c..72c75bd 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -209,6 +209,33 @@ public class PawnDef : Def public string Kind { get; init; } = ""; } +/// +/// Стадия роста животного как ДАННЫЕ (вложенный объект ): возраст входа = +/// × якорь ( — созревание или продолжительность жизни), +/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа. +/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода. +/// +public sealed class AnimalStageDef +{ + /// Имя стадии (для тултипов/отладки). + public string Name { get; init; } = ""; + + /// Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни). + public float EnterAt { get; init; } + + /// Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни). + public string RelativeTo { get; init; } = "maturity"; + + /// Масштаб размера тела на этой стадии (× ген размера). + public float Scale { get; init; } = 1f; + + /// Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка). + public string Texture { get; init; } = "adult"; + + /// Взрослая стадия — способна размножаться. + public bool Adult { get; init; } +} + /// /// Вид-животное (Defs/animals.json): подтип (текстура/размер/вид) плюс геном /// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа — @@ -235,6 +262,14 @@ public sealed class AnimalDef : PawnDef /// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода. /// public string[] Diet { get; init; } = []; + + /// + /// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию. + /// + public AnimalStageDef[] Stages { get; init; } = []; + + /// Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам). + public float SpawnPer1000Cells { get; init; } } /// diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index b62c250..5e5b5e4 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -126,9 +126,9 @@ public sealed class WorldScene : Scene RestorePlants(content); } - // Животные (фаза A1): детерминированный начальный спавн по суше. Пока без сейва/симуляции — + // Животные: детерминированный начальный спавн по данным (плотность из дефов). Пока без сейва — // регенерируются из сида и для нового, и для загруженного мира. - SpawnAnimals(content); + SpawnAnimals(); // Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира, // а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже). @@ -215,7 +215,7 @@ public sealed class WorldScene : Scene // нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой. var shore = ComputeShore(); UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs)); - UpdateSystems.Add(new AnimalRutSystem(climate, content.Defs.Get("Rut"))); + UpdateSystems.Add(new AnimalRutSystem(_animals, climate, content.Defs.Get("Rut"))); UpdateSystems.Add( new AnimalDecisionSystem( Store, @@ -497,21 +497,10 @@ public sealed class WorldScene : Scene } } - // Детерминированный начальный спавн оленей по клеткам суши (фаза A1): количество — лёгкое стадо, - // масштаб по площади суши; геном каждой особи генерируется из шаблона вида (разброс + аллели). - private void SpawnAnimals(GameContent content) + // Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0 + // рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида. + private void SpawnAnimals() { - if (_animals.Count == 0 || !content.Defs.TryGet("Deer", out var deerDef)) - { - return; - } - - var species = _animals.IndexOf(deerDef); - // Разновозрастное стадо, но не у самой смерти; продолжительность жизни — из данных генома вида. - var maxAge = - (deerDef.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f) * 0.85f; - var random = new Random(_config.Seed + 0x5EED); - var landCells = 0; for (var i = 0; i < _cellLand.Length; i++) { @@ -521,42 +510,57 @@ public sealed class WorldScene : Scene } } - if (landCells == 0) + if (_animals.Count == 0 || landCells == 0) { return; } - var count = Math.Clamp(landCells / 400, 6, 40); - var placed = 0; - var attempts = 0; - while (placed < count && attempts < count * 50) + var random = new Random(_config.Seed + 0x5EED); + var spawned = 0; + for (var s = 0; s < _animals.Count; s++) { - attempts++; - var cell = random.Next(_cellLand.Length); - if (!_cellLand[cell]) + var def = _animals[s].Def; + if (def.SpawnPer1000Cells <= 0f) { - continue; + 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, - species, - new Vector2(px, py), - ageDays: random.NextSingle() * maxAge, - _animals.GenerateGenome(species, random), - generation: 0, - CellSize - ); - placed++; + 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 {placed} deer"); + Log.Info($"Spawned {spawned} animals ({_animals.Count} species)"); } // Кромка воды: центры клеток суши, граничащих с водой — куда звери ходят пить (вода непроходима). diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 8cf5d40..7d1ca87 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -18,15 +18,6 @@ public static class AnimalFactory private static readonly Color FurWarm = new(255, 226, 188); private static readonly Color FurCool = new(206, 222, 235); - /// Стадии роста животного (фаза A3) по индексам, как в . - public const int StageBaby = 0; - public const int StageJuvenile = 1; - public const int StageAdult = 2; - public const int StageSenior = 3; - - // Масштаб спрайта по стадии (множитель размера тела): детёныш мельче, старик чуть усох. - private static readonly float[] StageScales = [0.45f, 0.7f, 1f, 0.92f]; - /// Создаёт животное вида заданного возраста, генома и поколения. public static Entity Create( EntityStore store, @@ -43,7 +34,7 @@ public static class AnimalFactory 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); + var stage = StageAt(ageDays, traits.MaturityAge, traits.Lifespan, sp.Stages); var region = RegionFor(sp, stage, isMale); var sprite = new Sprite(region, GameLayers.Beings); @@ -78,31 +69,33 @@ public static class AnimalFactory public static bool IsMaleGenome(Genome genome) => genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f); - /// Стадия по возрасту: Baby < 0.2·созревание, Juvenile < созревание, Adult < 0.8·жизнь, иначе Senior. - public static int StageAt(float ageDays, float maturity, float lifespan) + /// Индекс стадии по возрасту: последняя стадия, чей возраст входа (EnterAt × якорь) ≤ возраст. + public static int StageAt(float ageDays, float maturity, float lifespan, AnimalStageDef[] stages) { - var babyEnd = MathF.Max(1f, maturity * 0.2f); - var juvEnd = MathF.Max(babyEnd + 1f, maturity); - var adultEnd = MathF.Max(juvEnd + 1f, lifespan * 0.8f); - if (ageDays < babyEnd) + var stage = 0; + for (var i = 1; i < stages.Length; i++) { - return StageBaby; + var anchor = string.Equals(stages[i].RelativeTo, "lifespan", StringComparison.Ordinal) + ? lifespan + : maturity; + if (ageDays >= stages[i].EnterAt * anchor) + { + stage = i; + } } - if (ageDays < juvEnd) - { - return StageJuvenile; - } - - return ageDays < adultEnd ? StageAdult : StageSenior; + return stage; } - /// Спрайт по стадии и полу: детёныш/подросток — baby-текстура; взрослые — по полу. + /// Спрайт стадии: набор "baby" — текстура детёныша; иначе по полу (самец/самка). public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) => - stage <= StageJuvenile ? sp.Baby + string.Equals(sp.Stages[stage].Texture, "baby", StringComparison.Ordinal) ? sp.Baby : isMale ? sp.Male : sp.Female; + /// Взрослая ли стадия (способна размножаться) — флаг из данных стадии. + public static bool IsAdult(AnimalSet.Species sp, int stage) => sp.Stages[stage].Adult; + /// Масштаб Transform2D для спрайта стадии (размер тела × множитель стадии / ширина региона). public static float Scale( AnimalSet.Species sp, @@ -112,7 +105,8 @@ public static class AnimalFactory int cellSize ) { - var sizeCells = (traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells) * StageScales[stage]; + var sizeCells = + (traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells) * sp.Stages[stage].Scale; return cellSize * sizeCells / region.Width; } diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 28ebc7e..b6200de 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -132,7 +132,7 @@ public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay, Need /// под этим хедифом считает (нужда Mating с risesUnder=Rut) — /// сезонность и влечение разнесены: эта система отвечает только за состояние. /// -public sealed class AnimalRutSystem(Climate climate, HediffDef rut) +public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDef rut) : QuerySystem { protected override void OnUpdate() @@ -146,7 +146,8 @@ public sealed class AnimalRutSystem(Climate climate, HediffDef rut) for (var i = 0; i < g.Length; i++) { var inRut = - g[i].Stage >= AnimalFactory.StageAdult && season == o[i].Traits.BreedingSeason; + AnimalFactory.IsAdult(animals[o[i].Species], g[i].Stage) + && season == o[i].Traits.BreedingSeason; if (inRut) { h[i].State.Add(rut); @@ -255,7 +256,7 @@ public sealed class AnimalDecisionSystem : BaseSystem brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан var pos = t[i].Position; - var adult = gr[i].Stage >= AnimalFactory.StageAdult; + var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); var name = _brain.Select(new AnimalContext(n[i].Values))?.Name ?? AnimalActions.Wander; @@ -391,7 +392,7 @@ public sealed class AnimalDecisionSystem : BaseSystem var tt = transforms.Span; for (var i = 0; i < tt.Length; i++) { - if (oo[i].IsMale == selfMale || gg[i].Stage < AnimalFactory.StageAdult) + if (oo[i].IsMale == selfMale || !AnimalFactory.IsAdult(_animalSet[oo[i].Species], gg[i].Stage)) { continue; // тот же пол / не взрослый (self отсеивается по полу) } @@ -719,10 +720,12 @@ public sealed class AnimalGrowthSystem( ref var grow = ref g[i]; grow.AgeDays += days; ref readonly var org = ref o[i]; + var sp = animals[org.Species]; var stage = AnimalFactory.StageAt( grow.AgeDays, org.Traits.MaturityAge, - org.Traits.Lifespan + org.Traits.Lifespan, + sp.Stages ); if (stage == grow.Stage) { @@ -730,7 +733,6 @@ public sealed class AnimalGrowthSystem( } grow.Stage = stage; - var sp = animals[org.Species]; var region = AnimalFactory.RegionFor(sp, stage, org.IsMale); ref var sprite = ref s[i]; sprite.Region = region; From cbc74c0794ec8b4502ab09e439d07d526e4aaa86 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 05:16:44 +0300 Subject: [PATCH 09/17] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80:=20=D0=B7=D1=80=D0=B5=D0=BD=D0=B8=D0=B5/=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=BE=D1=81=D1=82=D1=8C/=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B6=D0=B8=D0=B2=D0=BE=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=E2=80=94=20=D0=B8=D0=B7=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=84=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Завершает вынос баланса в данные: AnimalDecisionSystem.VisionRadius берёт дальность из AnimalDef.VisionCells (× ген), TryFindPlant/TryFindMate принимают готовый радиус; AnimalActionSystem берёт BaseSpeed и ForageBiteDays из дефа вида (через AnimalSet). Убраны хардкод-константы скорости/зрения/выедания. Сборка чистая, --check-content (9 типов дефов). Co-Authored-By: Claude Opus 4.8 --- src/LittleSim/Content/GameDefs.cs | 9 +++++++ src/LittleSim/Scenes/WorldScene.cs | 10 +++++++- src/LittleSim/Sim/AnimalSystems.cs | 38 ++++++++++++++---------------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 72c75bd..ffe45ff 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -270,6 +270,15 @@ public sealed class AnimalDef : PawnDef /// Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам). public float SpawnPer1000Cells { get; init; } + + /// Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1). + public float BaseSpeed { get; init; } = 28f; + + /// Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра. + public float VisionCells { get; init; } = 14f; + + /// Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки. + public float ForageBiteDays { get; init; } = 40f; } /// diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 5e5b5e4..b29c1e6 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -228,7 +228,15 @@ public sealed class WorldScene : Scene ) ); UpdateSystems.Add( - new AnimalActionSystem(Store, _plants, _needs, Context.Clock, SecondsPerDay, _bounds) + new AnimalActionSystem( + Store, + _plants, + _animals, + _needs, + Context.Clock, + SecondsPerDay, + _bounds + ) ); UpdateSystems.Add(new AnimalAppearanceSystem(_needs)); UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize)); diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index b6200de..858c0e4 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -170,7 +170,6 @@ public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDe public sealed class AnimalDecisionSystem : BaseSystem { private const float Interval = 0.6f; - private const float VisionCells = 14f; // базовый радиус зрения в клетках (× ген vision) private readonly UtilityAi _brain; private readonly GameClock _clock; @@ -257,6 +256,7 @@ public sealed class AnimalDecisionSystem : BaseSystem brain.DecideIn = Interval + _rng.NextSingle() * Interval; // джиттер — расфазировать скан var pos = t[i].Position; var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); + var radius = VisionRadius(o[i].Species, o[i].Traits.Vision); var name = _brain.Select(new AnimalContext(n[i].Values))?.Name ?? AnimalActions.Wander; @@ -264,7 +264,7 @@ public sealed class AnimalDecisionSystem : BaseSystem { case AnimalActions.Eat when EatsPlants(o[i].Species) - && TryFindPlant(pos, o[i].Traits.Vision, out var plant, out var pp): + && TryFindPlant(pos, radius, out var plant, out var pp): brain.Action = AnimalActions.Eat; brain.TargetPlant = plant; brain.Target = pp; @@ -280,13 +280,7 @@ public sealed class AnimalDecisionSystem : BaseSystem break; case AnimalActions.Mate when adult - && TryFindMate( - pos, - o[i].IsMale, - o[i].Traits.Vision, - out var mateId, - out var matePos - ): + && TryFindMate(pos, o[i].IsMale, radius, out var mateId, out var matePos): brain.Action = AnimalActions.Mate; brain.TargetPlant = -1; brain.TargetMate = mateId; @@ -306,6 +300,10 @@ public sealed class AnimalDecisionSystem : BaseSystem } } + // Радиус восприятия особи: базовая дальность вида (данные) × ген зрения × размер клетки. + private float VisionRadius(int species, float vision) => + MathF.Max(1f, _animalSet[species].Def.VisionCells) * MathF.Max(0.2f, vision) * _cellSize; + // Ест ли вид растения (диета данными) — гейт поиска корма; хищники (meat) появятся на горизонте. private bool EatsPlants(int species) { @@ -321,10 +319,9 @@ public sealed class AnimalDecisionSystem : BaseSystem return false; } - // Ближайшее растение в радиусе зрения (квадрат расстояния); ничьи — по меньшему id (детерминизм). - private bool TryFindPlant(Vector2 from, float vision, out int plantId, out Vector2 position) + // Ближайшее растение в радиусе (квадрат расстояния); ничьи — по меньшему id (детерминизм). + private bool TryFindPlant(Vector2 from, float radius, out int plantId, out Vector2 position) { - var radius = VisionCells * MathF.Max(0.2f, vision) * _cellSize; var bestSq = radius * radius; plantId = -1; position = default; @@ -374,12 +371,11 @@ public sealed class AnimalDecisionSystem : BaseSystem private bool TryFindMate( Vector2 from, bool selfMale, - float vision, + float radius, out int mateId, out Vector2 position ) { - var radius = VisionCells * MathF.Max(0.2f, vision) * _cellSize; var bestSq = radius * radius; mateId = -1; position = default; @@ -428,12 +424,11 @@ public sealed class AnimalDecisionSystem : BaseSystem /// public sealed class AnimalActionSystem : BaseSystem { - private const float BaseSpeed = 28f; // мировых единиц в секунду при moveSpeed 1 - private const float GrazePerDay = 40f; // на сколько игровых дней роста убавляется выеденное растение private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает private readonly EntityStore _store; private readonly PlantSet _plants; + private readonly AnimalSet _animalSet; private readonly NeedSet _needs; private readonly GameClock _clock; private readonly float _secondsPerDay; @@ -445,6 +440,7 @@ public sealed class AnimalActionSystem : BaseSystem public AnimalActionSystem( EntityStore store, PlantSet plants, + AnimalSet animals, NeedSet needs, GameClock clock, float secondsPerDay, @@ -453,6 +449,7 @@ public sealed class AnimalActionSystem : BaseSystem { _store = store; _plants = plants; + _animalSet = animals; _needs = needs; _clock = clock; _secondsPerDay = secondsPerDay; @@ -483,7 +480,8 @@ public sealed class AnimalActionSystem : BaseSystem ref var brain = ref b[i]; var values = n[i].Values; ref var pos = ref t[i].Position; - var speed = BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed); + var def = _animalSet[o[i].Species].Def; + var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed); switch (brain.Action) { @@ -495,7 +493,7 @@ public sealed class AnimalActionSystem : BaseSystem if (MoveTo(ref pos, brain.Target, speed * seconds)) { Refill(values, AnimalActions.Eat, days); - Graze(brain.TargetPlant, days); + Graze(brain.TargetPlant, days, def.ForageBiteDays); } break; @@ -571,7 +569,7 @@ public sealed class AnimalActionSystem : BaseSystem } // Выедание: убавляет рост растения; траву (без ствола), выеденную до нуля, помечает на гибель. - private void Graze(int plantId, float days) + private void Graze(int plantId, float days, float bitePerDay) { if ( plantId < 0 @@ -584,7 +582,7 @@ public sealed class AnimalActionSystem : BaseSystem } ref var grow = ref plant.GetComponent(); - grow.AgeDays = MathF.Max(0f, grow.AgeDays - GrazePerDay * days); + grow.AgeDays = MathF.Max(0f, grow.AgeDays - bitePerDay * days); var isGrass = _plants[grow.Species].Def.TrunkRadiusCells <= 0f; if (isGrass && grow.AgeDays <= GrazeKillAge && !_eaten.Contains(plant)) { From c90bf3246021c636b33219d303da24a488ba656a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 05:22:11 +0300 Subject: [PATCH 10/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A5=20(=D1=87=D0=B0=D1=81=D1=82=D1=8C=201):=20?= =?UTF-8?q?=D1=81=D0=BC=D0=B5=D1=80=D1=82=D1=8C=20=E2=86=92=20=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BF=20=E2=86=92=20=D1=80=D0=B0=D0=B7=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Компонент Corpse + CorpseSystem: после смерти животное оставляет труп (AnimalFactory.CreateCorpse, мясо ∝ размер тела) вместо деспавна; труп разлагается Fresh→Rotting→Skeleton→исчезает со скоростью от температуры, тинтуясь по стадии. AnimalDef.CorpseTexture + спрайт трупа в AnimalSet; смертность спавнит труп. Продукты мясо/кости/шкура заданы (разделка/падальщики — позже). Сборка чистая, --check-content (9 типов дефов). Части тела/кровь/capacities — часть 2 A5. Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 1 + docs/животные.md | 11 ++- src/LittleSim/Content/AnimalSet.cs | 6 ++ src/LittleSim/Content/GameDefs.cs | 3 + src/LittleSim/Scenes/WorldScene.cs | 3 +- src/LittleSim/Sim/AnimalFactory.cs | 32 +++++++++ src/LittleSim/Sim/AnimalOrganism.cs | 17 +++++ src/LittleSim/Sim/AnimalSystems.cs | 108 ++++++++++++++++++++++++++-- 8 files changed, 173 insertions(+), 8 deletions(-) diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index fd69df5..df6f5b0 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -11,6 +11,7 @@ "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", "diet": ["plant"], "spawnPer1000Cells": 2.5, "genome": { diff --git a/docs/животные.md b/docs/животные.md index a7b8bf0..e0d8236 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,7 +1,14 @@ # Животные (зоология) -> Статус: **A1–A4 реализованы**, остальное по плану. Это живой план — правится по мере -> реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). +> Статус: **A1–A4 + рефактор расширяемости + A5(труп) реализованы**, остальное по плану. Это живой +> план — правится по мере реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). +> +> **A5 часть 1 (готово): смерть → труп → разложение.** `Corpse`-компонент + `CorpseSystem` +> (Fresh→Rotting→Skeleton→исчезает, скорость от температуры, тинт по стадии); смерть теперь оставляет +> труп (`AnimalFactory.CreateCorpse`, мясо ∝ размер тела) вместо деспавна; `AnimalDef.CorpseTexture` + +> `corpseTexture` оленя (`Dessicated_*`, скелет — бледный тинт, своего спрайта пока нет). Продукты +> `ProductMeat/Bone/Leather` в `products.json` (разделка/падальщики — позже). Сборка/линт чистые. +> Осталось в A5: части тела (`BodyDef`), кровь, capacities (часть 2). > > **A4 (готово):** нужда `Mating`; лёгкий hediff-каркас (`HediffDef`, `hediffs.json` с `Rut`, > компонент `Health`/`HealthState`); гены `GeneBreedingSeason`/`GeneGestationDays`/`GeneLitterSize`; diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs index 5bf007b..a5a353a 100644 --- a/src/LittleSim/Content/AnimalSet.cs +++ b/src/LittleSim/Content/AnimalSet.cs @@ -27,6 +27,9 @@ public sealed class AnimalSet /// Спрайт детёныша/подростка (fallback — самка). public required Texture2DRegion Baby { get; init; } + /// Спрайт трупа (fallback — самка). + public required Texture2DRegion Corpse { get; init; } + /// Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа. public required AnimalStageDef[] Stages { get; init; } @@ -73,6 +76,9 @@ public sealed class AnimalSet 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, Template = BuildTemplate(def.Genome, genes), }; diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index ffe45ff..36e7da3 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -249,6 +249,9 @@ public sealed class AnimalDef : PawnDef /// Текстура детёныша/подростка (фаза A3); пусто — берётся . public string BabyTexture { get; init; } = ""; + /// Текстура трупа (фаза A5); пусто — берётся . + public string CorpseTexture { get; init; } = ""; + /// /// Геном вида как ДАННЫЕ: ключ — id гена (), значение — /// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index b29c1e6..f4e5b41 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -240,7 +240,8 @@ public sealed class WorldScene : Scene ); UpdateSystems.Add(new AnimalAppearanceSystem(_needs)); UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize)); - UpdateSystems.Add(new AnimalMortalitySystem(Store)); + UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize)); + UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate)); UpdateSystems.Add( new AnimalPregnancySystem( Store, diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 7d1ca87..9dd6258 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -65,6 +65,38 @@ public static class AnimalFactory ); } + /// Сколько мяса даёт труп на единицу размера тела (для будущей разделки/падальщиков). + public const float MeatPerBodySize = 25f; + + // Тинт свежего трупа (стадии гниения/скелета задаёт CorpseSystem). + private static readonly Color CorpseFreshTint = new(176, 148, 128); + + /// Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела). + public static Entity CreateCorpse( + EntityStore store, + AnimalSet.Species sp, + Vector2 position, + float bodySize, + int cellSize + ) + { + 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 + { + RotDays = 0f, + Stage = 0, + Meat = bodySize * MeatPerBodySize, + } + ); + } + /// Пол из локуса GeneSex: наличие аллеля Y (значение ≈1) → самец; иначе самка (XX). public static bool IsMaleGenome(Genome genome) => genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f); diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 56b4a03..061709f 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -171,6 +171,23 @@ public struct Health : IComponent public HealthState State; } +/// +/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям +/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела) +/// для будущей разделки/падальщиков. Управляется . +/// +public struct Corpse : IComponent +{ + /// Накоплено игровых дней разложения. + public float RotDays; + + /// Стадия: 0 свежий, 1 гниющий, 2 скелет. + public int Stage; + + /// Доступное мясо (∝ размер тела) — для разделки/падальщиков (фаза-горизонт). + public float Meat; +} + /// /// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток /// срока вынашивания и размер помёта. По истечении срока diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 858c0e4..be89838 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -742,19 +742,25 @@ public sealed class AnimalGrowthSystem( } /// -/// Смертность (фаза A3): простая гибель с деспавном — от старости (возраст превысил продолжительность -/// жизни) или от истощения (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. -/// На A5 «апгрейдится» в смерть через здоровье и оставит труп вместо деспавна. +/// Смертность (фаза A3/A5): гибель от старости (возраст превысил продолжительность жизни) или истощения +/// (счётчик голода/жажды превысил порог). Замыкает контур ёмкости среды. Фаза A5: вместо деспавна +/// оставляет (через ), затем удаляет особь. /// public sealed class AnimalMortalitySystem : BaseSystem { private const float LethalStarveDays = 3f; + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly int _cellSize; private readonly ArchetypeQuery _query; private readonly List _deaths = []; - public AnimalMortalitySystem(EntityStore store) + public AnimalMortalitySystem(EntityStore store, AnimalSet animals, int cellSize) { + _store = store; + _animals = animals; + _cellSize = cellSize; _query = store.Query(); } @@ -775,7 +781,99 @@ public sealed class AnimalMortalitySystem : BaseSystem } } - foreach (var entity in _deaths) + foreach (var dead in _deaths) + { + if (dead.IsNull || !dead.HasComponent()) + { + continue; + } + + ref readonly var org = ref dead.GetComponent(); + var position = dead.GetComponent().Position; + AnimalFactory.CreateCorpse( + _store, + _animals[org.Species], + position, + org.Traits.BodySize, + _cellSize + ); + dead.DeleteEntity(); + } + } +} + +/// +/// Разложение трупа (фаза A5): копит дни гниения (ускоряется в тепле, замедляется на морозе), +/// переключает стадии Fresh→Rotting→Skeleton по порогам и тинтит спрайт; по достижении предела труп +/// исчезает. Скелет рисуется бледным тинтом падали (отдельного спрайта пока нет — см. docs). +/// +public sealed class CorpseSystem : BaseSystem +{ + private const float RottingDay = 2f; + private const float SkeletonDay = 6f; + private const float GoneDay = 14f; + + private static readonly Color FreshTint = new(176, 148, 128); + private static readonly Color RottingTint = new(120, 118, 98); + private static readonly Color SkeletonTint = new(228, 226, 214); + + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly Climate _climate; + private readonly ArchetypeQuery _query; + private readonly List _gone = []; + + public CorpseSystem(EntityStore store, GameClock clock, float secondsPerDay, Climate climate) + { + _clock = clock; + _secondsPerDay = secondsPerDay; + _climate = climate; + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + var days = _clock.DeltaTime / _secondsPerDay; + if (days <= 0f) + { + return; + } + + // Тепло ускоряет гниение, около нуля и ниже — резко замедляет (заморозка). + var factor = Math.Clamp((_climate.Temperature + 5f) / 20f, 0.1f, 2.5f); + var advance = days * factor; + _gone.Clear(); + + foreach (var (corpses, sprites, entities) in _query.Chunks) + { + var c = corpses.Span; + var s = sprites.Span; + for (var i = 0; i < c.Length; i++) + { + ref var corpse = ref c[i]; + corpse.RotDays += advance; + if (corpse.RotDays >= GoneDay) + { + _gone.Add(entities.EntityAt(i)); + continue; + } + + var stage = + corpse.RotDays >= SkeletonDay ? 2 + : corpse.RotDays >= RottingDay ? 1 + : 0; + if (stage != corpse.Stage) + { + corpse.Stage = stage; + s[i].Color = + stage == 2 ? SkeletonTint + : stage == 1 ? RottingTint + : FreshTint; + } + } + } + + foreach (var entity in _gone) { entity.DeleteEntity(); } From 9db12273c9c32f8cd3028fe45ef591a0b3206339 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 05:36:34 +0300 Subject: [PATCH 11/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A5=20(=D1=87=D0=B0=D1=81=D1=82=D1=8C=202):=20?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D1=82=D0=B5=D0=BB=D0=B0,=20?= =?UTF-8?q?=D0=BA=D1=80=D0=BE=D0=B2=D1=8C,=20capacities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BodyDef/BodyPartDef + bodies.json (Quadruped — данные: иерархия органов, вклады в способности). AnimalDef.Body. HealthState расширен Parts/BloodLevel/Pain/Capacities; CapacityCalc обобщённо считает способности по зависимостям (кровь->сердце/лёгкие->сознание->движение/зрение), модовые проходят сырыми. Фабрика строит части (HP x размер тела) и начальные способности. Источников урона нет -> всё на полном HP, способности=1 (каркас под болезни/хищников). Команда body . Сборка чистая, --check-content (10 типов дефов). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/animals.json | 1 + Mods/Core/Defs/bodies.json | 34 +++++++++++++ docs/животные.md | 8 +++- src/LittleSim/Content/AnimalSet.cs | 4 ++ src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 39 +++++++++++++++ src/LittleSim/Scenes/WorldScene.cs | 47 ++++++++++++++++++ src/LittleSim/Sim/AnimalFactory.cs | 2 +- src/LittleSim/Sim/AnimalOrganism.cs | 69 ++++++++++++++++++++++++-- src/LittleSim/Sim/CapacityCalc.cs | 72 ++++++++++++++++++++++++++++ 10 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 Mods/Core/Defs/bodies.json create mode 100644 src/LittleSim/Sim/CapacityCalc.cs diff --git a/Mods/Core/Defs/animals.json b/Mods/Core/Defs/animals.json index df6f5b0..d899015 100644 --- a/Mods/Core/Defs/animals.json +++ b/Mods/Core/Defs/animals.json @@ -12,6 +12,7 @@ "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": { diff --git a/Mods/Core/Defs/bodies.json b/Mods/Core/Defs/bodies.json new file mode 100644 index 0000000..88c1e06 --- /dev/null +++ b/Mods/Core/Defs/bodies.json @@ -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 } } + ] + } + ] +} diff --git a/docs/животные.md b/docs/животные.md index e0d8236..3c5ded9 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -8,7 +8,13 @@ > труп (`AnimalFactory.CreateCorpse`, мясо ∝ размер тела) вместо деспавна; `AnimalDef.CorpseTexture` + > `corpseTexture` оленя (`Dessicated_*`, скелет — бледный тинт, своего спрайта пока нет). Продукты > `ProductMeat/Bone/Leather` в `products.json` (разделка/падальщики — позже). Сборка/линт чистые. -> Осталось в A5: части тела (`BodyDef`), кровь, capacities (часть 2). +> **A5 часть 2 (готово): части тела, кровь, capacities.** `BodyDef`/`BodyPartDef` + `bodies.json` +> (`Quadruped` — данные: иерархия органов, вклады в способности); `AnimalDef.Body`; `HealthState` +> расширен `Parts`/`BloodLevel`/`Pain`/`Capacities`; `CapacityCalc` (обобщённый расчёт по зависимостям +> кровь→сердце/лёгкие→сознание→движение/зрение, модовые способности — сырыми); фабрика строит части +> (HP × размер тела) и считает способности. Источников урона ещё нет → всё на полном HP, способности = +> 1 (каркас под болезни A6/хищников). Команда `body `. Сборка/линт чистые (10 типов дефов). +> Осталось в A5: смерть через здоровье (vital-часть/кровь) + истощение как hediff — когда появятся раны. > > **A4 (готово):** нужда `Mating`; лёгкий hediff-каркас (`HediffDef`, `hediffs.json` с `Rut`, > компонент `Health`/`HealthState`); гены `GeneBreedingSeason`/`GeneGestationDays`/`GeneLitterSize`; diff --git a/src/LittleSim/Content/AnimalSet.cs b/src/LittleSim/Content/AnimalSet.cs index a5a353a..6382510 100644 --- a/src/LittleSim/Content/AnimalSet.cs +++ b/src/LittleSim/Content/AnimalSet.cs @@ -33,6 +33,9 @@ public sealed class AnimalSet /// Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа. public required AnimalStageDef[] Stages { get; init; } + /// Анатомия вида (части тела) или null, если вид без тела. + public required BodyDef? Body { get; init; } + /// Набор генов вида: базовые значения для генерации особи. public required GenomeTemplate Template { get; init; } } @@ -80,6 +83,7 @@ public sealed class AnimalSet ? female : atlases.GetRegion(device, def.CorpseTexture), Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages, + Body = content.Defs.TryGet(def.Body, out var body) ? body : null, Template = BuildTemplate(def.Genome, genes), }; _index[def] = i; diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index 63af0d0..ee594b3 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -66,6 +66,7 @@ public sealed class GameContent defs.RegisterType("Animal"); defs.RegisterType("Hediff"); defs.RegisterType("Need"); + defs.RegisterType("Body"); defs.RegisterType("WorldPreset"); // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 36e7da3..7f359eb 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -252,6 +252,9 @@ public sealed class AnimalDef : PawnDef /// Текстура трупа (фаза A5); пусто — берётся . public string CorpseTexture { get; init; } = ""; + /// Имя — анатомия вида (части тела/органы); пусто — без частей тела. + public string Body { get; init; } = ""; + /// /// Геном вида как ДАННЫЕ: ключ — id гена (), значение — /// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки @@ -317,6 +320,42 @@ public sealed class NeedDef : Def public float MinBrain { get; init; } } +/// +/// Часть тела/орган (вложенный объект ) как ДАННЫЕ: иерархия (родитель), +/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в +/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода. +/// +public sealed class BodyPartDef +{ + /// Имя части (уникально в теле); на него ссылается детей. + public string Name { get; init; } = ""; + + /// Имя родительской части ("" — корневая, напр. торс). + public string Parent { get; init; } = ""; + + /// Вес попадания (доля тела) — для будущего выбора задетой части. + public float Coverage { get; init; } = 0.1f; + + /// Базовый максимум HP (× размер тела при создании особи). + public float MaxHp { get; init; } = 10f; + + /// Жизненно важная: уничтожение → смерть. + public bool Vital { get; init; } + + /// Вклады в способности: capacity id (см. AnimalCapacities) → доля (сумма по телу ≈ 1). + public Dictionary Capacities { get; init; } = new(); +} + +/// +/// Анатомия вида (Defs/bodies.json): дерево частей тела/органов. Используется системой здоровья (фаза +/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities). +/// +public sealed class BodyDef : Def +{ + /// Части тела/органы вида. + public BodyPartDef[] Parts { get; init; } = []; +} + /// /// Состояние/хедиф организма (Defs/hediffs.json): гон, позже — раны, болезни, возрастные эффекты. /// Фаза A4 — лёгкий каркас: пока идентичность + локализуемый ; стадии, diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index f4e5b41..9a25dfa 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -709,6 +709,11 @@ public sealed class WorldScene : Scene "popstats [species] — live population trait means and generation span (selection drift)", (c, args) => RunPopStats(c, args) ); + console.Register( + "body", + "body — dump a species body parts and a healthy individual's capacities", + (c, args) => RunBodyDemo(c, content, args) + ); console.Register( "menu", "menu — return to the main menu", @@ -913,6 +918,48 @@ public sealed class WorldScene : Scene } } + // Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи. + private void RunBodyDemo(DevConsole console, GameContent content, string[] args) + { + var species = content.Defs.NamesOf("Animal"); + if (args.Length == 0) + { + console.WriteLine("usage: body e.g. body Deer"); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(args[0], out var def)) + { + console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}"); + return; + } + + if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet(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.##}") + ) + ); + } + private static string ProductLabel(GameContent content, string productDefName) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 9dd6258..b8e8383 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -61,7 +61,7 @@ public static class AnimalFactory TargetPlant = -1, TargetMate = -1, }, - new Health { State = new HealthState() } + new Health { State = HealthState.Create(sp.Body, traits.BodySize) } ); } diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 061709f..244da8d 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -113,10 +113,27 @@ public struct AnimalGrowth : IComponent public float AgeDays; } +/// Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру). +public struct PartInstance +{ + /// Деф части тела (анатомия вида). + public Content.BodyPartDef Def; + + /// Текущий HP. + public float Hp; + + /// Максимальный HP (база × размер тела). + public float MaxHp; + + /// Доля сохранности [0,1]. + public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f; +} + /// -/// Состояние здоровья особи (фаза A4 — лёгкий каркас): список активных хедифов. Managed-объект, как -/// (Friflo допускает ссылку в компоненте). В A5/A6 прирастёт частями тела, -/// кровью, capacities и иммунитетом — контейнер один, растёт по фазам. +/// Состояние здоровья особи (фаза A4–A5): список активных хедифов + части тела, уровень крови и +/// вычисленные способности (capacities). Managed-объект, как (Friflo допускает +/// ссылку в компоненте). Источников урона пока нет — части на полном HP, способности = 1; каркас под +/// болезни (A6) и хищников (горизонт). Растёт по фазам — контейнер один. /// public sealed class HealthState { @@ -125,6 +142,52 @@ public sealed class HealthState /// Активные хедифы (гон, позже — раны/болезни). public IReadOnlyList Hediffs => _hediffs; + /// Части тела особи (из анатомии вида); пусто — у вида нет тела. + public PartInstance[] Parts { get; private set; } = []; + + /// Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны). + public float BloodLevel { get; set; } = 1f; + + /// Боль [0,1] — снижает сознание (когда появятся раны). + public float Pain { get; set; } + + /// Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле). + public bool Dead { get; set; } + + /// Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1. + public Dictionary Capacities { get; private set; } = new(); + + /// Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий. + public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f; + + /// Пересчитывает способности из текущих частей/крови/боли. + public void RecomputeCapacities() => + Capacities = CapacityCalc.Compute(Parts, BloodLevel, Pain); + + /// Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности. + 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; + } + /// Несёт ли особь хедиф с данным именем дефа. public bool Has(string defName) { diff --git a/src/LittleSim/Sim/CapacityCalc.cs b/src/LittleSim/Sim/CapacityCalc.cs new file mode 100644 index 0000000..87a1ed9 --- /dev/null +++ b/src/LittleSim/Sim/CapacityCalc.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; + +namespace LittleSim.Sim; + +/// Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела. +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"; +} + +/// +/// Обобщённый расчёт способностей (capacities) из частей тела и крови (фаза A5). Сырой вклад способности +/// = Σ(доля части × доля её HP). Затем известные способности домножаются по зависимостям (кровь → +/// сердце/лёгкие → сознание → движение/зрение), как в модели RimWorld; неизвестные (модовые) проходят +/// сырыми. У здоровой особи (полное HP, кровь 1, боль 0) все способности = 1. +/// +public static class CapacityCalc +{ + /// Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1). + public static Dictionary Compute(PartInstance[] parts, float blood, float pain) + { + var raw = new Dictionary(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(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; + } +} From 9f8ff93591fec556afecdf9c937fe55edc3b6459 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 05:48:05 +0300 Subject: [PATCH 12/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A6:=20=D0=B1=D0=BE=D0=BB=D0=B5=D0=B7=D0=BD=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=B8=D0=BC=D0=BC=D1=83=D0=BD=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HediffDef расширен (initialSeverity/severityPerDay/immunityPerDay/lethalSeverity/pain/ambientPerDay/capMods); хедифы стали инстансами (Hediff: тяжесть+иммунитет). AnimalHealthSystem (медленный тик ~час): фоновое заражение, гонка тяжесть<->иммунитет (выздоровление при иммунитете 1 / смерть при летальной тяжести), пересчёт способностей с болью и capMods; смерть по здоровью оставляет труп. Первый реальный источник урона: Moving подключён к скорости (больные медленнее). Болезнь Fever; команда infect для теста. Сборка чистая, --check-content (10 типов дефов). Co-Authored-By: Claude Opus 4.8 --- Mods/Core/Defs/hediffs.json | 11 ++- docs/животные.md | 7 ++ src/LittleSim/Content/GameDefs.cs | 31 ++++++- src/LittleSim/Scenes/WorldScene.cs | 39 +++++++++ src/LittleSim/Sim/AnimalOrganism.cs | 103 +++++++++++++++++++---- src/LittleSim/Sim/AnimalSystems.cs | 123 +++++++++++++++++++++++++++- 6 files changed, 291 insertions(+), 23 deletions(-) diff --git a/Mods/Core/Defs/hediffs.json b/Mods/Core/Defs/hediffs.json index 7f786db..5505d67 100644 --- a/Mods/Core/Defs/hediffs.json +++ b/Mods/Core/Defs/hediffs.json @@ -3,6 +3,15 @@ // Хедифы — состояния организма (фаза A4: лёгкий каркас). Гон (Rut) навешивается сезонно на взрослых // и поднимает половое влечение. Раны/болезни/возрастные эффекты со стадиями придут в A5/A6. "defs": [ - { "defName": "Rut", "label": "hediff.rut" } + { "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 } + } ] } diff --git a/docs/животные.md b/docs/животные.md index 3c5ded9..eb65ca0 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -16,6 +16,13 @@ > 1 (каркас под болезни A6/хищников). Команда `body `. Сборка/линт чистые (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` (сезонный гон-хедиф поднимает влечение — вне сезона зачатия нет); действие diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 7f359eb..951a922 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -357,8 +357,31 @@ public sealed class BodyDef : Def } /// -/// Состояние/хедиф организма (Defs/hediffs.json): гон, позже — раны, болезни, возрастные эффекты. -/// Фаза A4 — лёгкий каркас: пока идентичность + локализуемый ; стадии, -/// модификаторы способностей и иммунитет придут в A5/A6. +/// Состояние/хедиф организма (Defs/hediffs.json): гон, болезни, позже раны/возрастные эффекты. Фаза A6: +/// болезнь прогрессирует (), параллельно растёт иммунитет +/// () — гонка: иммунитет→1 раньше = выздоровление, severity→ +/// раньше = смерть. Снижает способности () и даёт боль. /// -public sealed class HediffDef : Def { } +public sealed class HediffDef : Def +{ + /// Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05). + public float InitialSeverity { get; init; } = 1f; + + /// Прирост тяжести за игровой день (0 — статичный хедиф вроде гона). + public float SeverityPerDay { get; init; } + + /// Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам). + public float ImmunityPerDay { get; init; } + + /// Тяжесть, при которой наступает смерть (0 — не летально). + public float LethalSeverity { get; init; } + + /// Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение. + public float Pain { get; init; } + + /// Шанс подхватить фоном за день (0 — не фоновая болезнь). + public float AmbientPerDay { get; init; } + + /// Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity). + public Dictionary CapMods { get; init; } = new(); +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 9a25dfa..b7850e9 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -216,6 +216,17 @@ public sealed class WorldScene : Scene var shore = ComputeShore(); UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs)); UpdateSystems.Add(new AnimalRutSystem(_animals, climate, content.Defs.Get("Rut"))); + UpdateSystems.Add( + new AnimalHealthSystem( + Store, + _animals, + CellSize, + Context.Clock, + SecondsPerDay, + content.Defs.All().Where(h => h.AmbientPerDay > 0f).ToArray(), + _config.Seed + 0x3EAD + ) + ); UpdateSystems.Add( new AnimalDecisionSystem( Store, @@ -714,6 +725,11 @@ public sealed class WorldScene : Scene "body — 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( "menu", "menu — return to the main menu", @@ -960,6 +976,29 @@ public sealed class WorldScene : Scene ); } + // Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным. + private void RunInfect(DevConsole console, GameContent content, string[] args) + { + var name = args.Length > 0 ? args[0] : "Fever"; + if (!content.Defs.TryGet(name, out var def)) + { + console.WriteLine($"no hediff '{name}'"); + return; + } + + var count = 0; + Store + .Query() + .ForEachEntity( + (ref Health h, Entity _) => + { + h.State?.Add(def); + count++; + } + ); + console.WriteLine($"infected {count} animals with {def.DefName}"); + } + private static string ProductLabel(GameContent content, string productDefName) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 244da8d..53523d2 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -129,18 +129,31 @@ public struct PartInstance public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f; } +/// Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6). +public struct Hediff +{ + /// Деф хедифа (гон/болезнь/…). + public HediffDef Def; + + /// Текущая тяжесть. + public float Severity; + + /// Набранный иммунитет [0,1]; при 1 — выздоровление. + public float Immunity; +} + /// -/// Состояние здоровья особи (фаза A4–A5): список активных хедифов + части тела, уровень крови и -/// вычисленные способности (capacities). Managed-объект, как (Friflo допускает -/// ссылку в компоненте). Источников урона пока нет — части на полном HP, способности = 1; каркас под -/// болезни (A6) и хищников (горизонт). Растёт по фазам — контейнер один. +/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные +/// способности (capacities). Managed-объект, как (Friflo допускает ссылку в +/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по +/// фазам — контейнер один. /// public sealed class HealthState { - private readonly List _hediffs = []; + private readonly List _hediffs = []; - /// Активные хедифы (гон, позже — раны/болезни). - public IReadOnlyList Hediffs => _hediffs; + /// Активные хедифы (гон, болезни). + public IReadOnlyList Hediffs => _hediffs; /// Части тела особи (из анатомии вида); пусто — у вида нет тела. public PartInstance[] Parts { get; private set; } = []; @@ -160,9 +173,71 @@ public sealed class HealthState /// Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий. public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f; - /// Пересчитывает способности из текущих частей/крови/боли. - public void RecomputeCapacities() => - Capacities = CapacityCalc.Compute(Parts, BloodLevel, Pain); + /// + /// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы + /// способностей от хедифов (лерп 1→factor по тяжести). Вызывать после изменения частей/крови/хедифов. + /// + 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; + } + + /// + /// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие + /// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести. + /// + 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; + } /// Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности. public static HealthState Create(Content.BodyDef? body, float bodySize) @@ -193,7 +268,7 @@ public sealed class HealthState { foreach (var h in _hediffs) { - if (h.DefName == defName) + if (h.Def.DefName == defName) { return true; } @@ -202,12 +277,12 @@ public sealed class HealthState return false; } - /// Навешивает хедиф (если ещё не навешан). + /// Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа. public void Add(HediffDef def) { if (!Has(def.DefName)) { - _hediffs.Add(def); + _hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity }); } } @@ -216,7 +291,7 @@ public sealed class HealthState { for (var i = 0; i < _hediffs.Count; i++) { - if (_hediffs[i].DefName == defName) + if (_hediffs[i].Def.DefName == defName) { _hediffs.RemoveAt(i); return true; diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index be89838..df7d48e 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -433,7 +433,13 @@ public sealed class AnimalActionSystem : BaseSystem private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly RectF _bounds; - private readonly ArchetypeQuery _query; + private readonly ArchetypeQuery< + AnimalBrain, + AnimalNeeds, + AnimalOrganism, + Transform2D, + Health + > _query; private readonly List _eaten = []; private readonly List<(Entity Self, Entity Partner)> _matings = []; @@ -454,7 +460,7 @@ public sealed class AnimalActionSystem : BaseSystem _clock = clock; _secondsPerDay = secondsPerDay; _bounds = bounds; - _query = store.Query(); + _query = store.Query(); } protected override void OnUpdateGroup() @@ -469,19 +475,22 @@ public sealed class AnimalActionSystem : BaseSystem _eaten.Clear(); _matings.Clear(); - foreach (var (brains, needsChunk, organisms, transforms, entities) in _query.Chunks) + foreach (var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks) { var b = brains.Span; var n = needsChunk.Span; var o = organisms.Span; var t = transforms.Span; + var hh = healths.Span; for (var i = 0; i < b.Length; i++) { ref var brain = ref b[i]; var values = n[i].Values; ref var pos = ref t[i].Position; var def = _animalSet[o[i].Species].Def; - var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed); + // Способность Moving (падает от ран/болезней) — множитель к скорости: больные медленнее. + var moving = hh[i].State.Capacity(AnimalCapacities.Moving); + var speed = def.BaseSpeed * MathF.Max(0.2f, o[i].Traits.MoveSpeed) * moving; switch (brain.Action) { @@ -1006,3 +1015,109 @@ public sealed class AnimalPregnancySystem : BaseSystem public Vector2 Position; } } + +/// +/// Здоровье и болезни (фаза A6): медленный тик (~игровой час). Фоновое заражение, прогресс болезней +/// (гонка тяжесть↔иммунитет — выздоровление или смерть), пересчёт способностей с учётом хедифов. Смерть +/// по здоровью (летальная болезнь / кровь на нуле / потеря сознания) оставляет труп. Первый реальный +/// источник урона — оживляет каркас A5 (больные звери теряют Moving → медленнее). +/// +public sealed class AnimalHealthSystem : BaseSystem +{ + private const float TickDays = 1f / 24f; // ~игровой час + + private readonly EntityStore _store; + private readonly AnimalSet _animals; + private readonly int _cellSize; + private readonly GameClock _clock; + private readonly float _secondsPerDay; + private readonly HediffDef[] _ambient; + private readonly Random _rng; + private readonly ArchetypeQuery _query; + private readonly List _deaths = []; + private float _accum; + + public AnimalHealthSystem( + EntityStore store, + AnimalSet animals, + int cellSize, + GameClock clock, + float secondsPerDay, + HediffDef[] ambient, + int seed + ) + { + _store = store; + _animals = animals; + _cellSize = cellSize; + _clock = clock; + _secondsPerDay = secondsPerDay; + _ambient = ambient; + _rng = new Random(seed); + _query = store.Query(); + } + + protected override void OnUpdateGroup() + { + _accum += _clock.DeltaTime / _secondsPerDay; + if (_accum < TickDays) + { + return; + } + + var days = _accum; + _accum = 0f; + _deaths.Clear(); + + foreach (var (healths, organisms, entities) in _query.Chunks) + { + var h = healths.Span; + for (var i = 0; i < h.Length; i++) + { + var state = h[i].State; + if (state is null) + { + continue; + } + + foreach (var disease in _ambient) + { + if (!state.Has(disease.DefName) && _rng.NextSingle() < disease.AmbientPerDay * days) + { + state.Add(disease); + } + } + + var lethal = state.AdvanceHediffs(days); + state.RecomputeCapacities(); + if ( + lethal + || state.BloodLevel <= 0f + || state.Capacity(AnimalCapacities.Consciousness) <= 0.01f + ) + { + _deaths.Add(entities.EntityAt(i)); + } + } + } + + foreach (var dead in _deaths) + { + if (dead.IsNull || !dead.HasComponent()) + { + continue; + } + + ref readonly var org = ref dead.GetComponent(); + var position = dead.GetComponent().Position; + AnimalFactory.CreateCorpse( + _store, + _animals[org.Species], + position, + org.Traits.BodySize, + _cellSize + ); + dead.DeleteEntity(); + } + } +} From 26bf83db64979ff221feb9d2e6efc5a15f83c4a0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 05:59:55 +0300 Subject: [PATCH 13/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A7:=20=D0=BC=D0=BE=D0=B7=D0=B3=20=E2=86=92=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D0=BB=D0=BB=D0=B5=D0=BA=D1=82=20?= =?UTF-8?q?=E2=86=92=20=D0=B3=D0=B5=D0=B9=D1=82=20=D0=BD=D1=83=D0=B6=D0=B4?= =?UTF-8?q?/=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Эффективный интеллект = brainSize (ген) × Consciousness (capacity из здоровья), AnimalFactory.Intelligence. AnimalContext гейтит выбор ИИ: нужда с minBrain выше интеллекта особи в выбор не входит (AnimalDecisionSystem читает Health поэлементно). Пороги в needs.json — тиры мозга (голод 0.0, жажда 0.25, сон/секс 0.40; у оленя мозг ~0.45). Обратная связь от повреждения мозга: болезнь/боль/кровопотеря роняют сознание → интеллект падает → первыми отключаются сон/спаривание. Команда intel [consciousness]. Сборка + --check-content чистые. Co-Authored-By: Claude Opus 4.8 (1M context) --- Mods/Core/Defs/needs.json | 14 +++-- docs/животные.md | 10 +++- src/LittleSim/Scenes/WorldScene.cs | 86 +++++++++++++++++++++++++++--- src/LittleSim/Sim/AnimalFactory.cs | 26 ++++++++- src/LittleSim/Sim/AnimalSystems.cs | 86 +++++++++++++++++++++++++----- 5 files changed, 194 insertions(+), 28 deletions(-) diff --git a/Mods/Core/Defs/needs.json b/Mods/Core/Defs/needs.json index 970103d..870ea45 100644 --- a/Mods/Core/Defs/needs.json +++ b/Mods/Core/Defs/needs.json @@ -2,15 +2,19 @@ "type": "Need", // Нужды как данные (фаза A2, рефактор расширяемости): добавить нужду виду = добавить запись здесь. // kind=deplete убывает и утоляется действием; kind=drive растёт под хедифом (гон) и сбрасывается им. - // action — id поведения-исполнителя (реестр в коде); minBrain — гейт интеллектом (включится в A7). + // 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.25 }, + "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.2 }, + "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.45 }, + "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.45 } + "decayPerDay": 1.0, "risePerDay": 1.2, "risesUnder": "Rut", "minBrain": 0.40 } ] } diff --git a/docs/животные.md b/docs/животные.md index eb65ca0..0aa753d 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,8 +1,16 @@ # Животные (зоология) -> Статус: **A1–A4 + рефактор расширяемости + A5(труп) реализованы**, остальное по плану. Это живой +> Статус: **A1–A7 + рефактор расширяемости реализованы**, остальное (A8) по плану. Это живой > план — правится по мере реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). > +> **A7 (готово): мозг → интеллект → гейт нужд/действий.** Эффективный интеллект = `brainSize` (ген) × +> `Consciousness` (capacity из здоровья) — `AnimalFactory.Intelligence`. `AnimalContext` гейтит выбор ИИ: +> нужда с `minBrain` выше интеллекта особи в выбор не входит (`AnimalDecisionSystem` читает `Health` +> поэлементно). Пороги в `needs.json` — тиры мозга (голод 0.0, жажда 0.25, сон/секс 0.40; у оленя мозг +> ~0.45 → здоров весь набор). **Обратная связь от повреждения мозга**: болезнь/боль/кровопотеря роняют +> сознание → эффективный интеллект падает → первыми отключаются сон/спаривание (лихорадящий олень +> сводится к еде/питью). Команда `intel [consciousness]`. Сборка + `--check-content` чистые. +> > **A5 часть 1 (готово): смерть → труп → разложение.** `Corpse`-компонент + `CorpseSystem` > (Fresh→Rotting→Skeleton→исчезает, скорость от температуры, тинт по стадии); смерть теперь оставляет > труп (`AnimalFactory.CreateCorpse`, мясо ∝ размер тела) вместо деспавна; `AnimalDef.CorpseTexture` + diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index b7850e9..19939f7 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -215,7 +215,9 @@ public sealed class WorldScene : Scene // нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой. var shore = ComputeShore(); UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs)); - UpdateSystems.Add(new AnimalRutSystem(_animals, climate, content.Defs.Get("Rut"))); + UpdateSystems.Add( + new AnimalRutSystem(_animals, climate, content.Defs.Get("Rut")) + ); UpdateSystems.Add( new AnimalHealthSystem( Store, @@ -547,7 +549,8 @@ public sealed class WorldScene : Scene 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; + (def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f) + * 0.85f; var placed = 0; var attempts = 0; while (placed < count && attempts < count * 50) @@ -730,6 +733,11 @@ public sealed class WorldScene : Scene "infect [hediff] — give every animal a disease (default Fever) to test the health system", (c, args) => RunInfect(c, content, args) ); + console.Register( + "intel", + "intel [consciousness] — effective intelligence (brain x consciousness) and which needs it gates", + (c, args) => RunIntel(c, content, args) + ); console.Register( "menu", "menu — return to the main menu", @@ -914,7 +922,10 @@ public sealed class WorldScene : Scene var name = _animals[s].Def.DefName; if ( count[s] == 0 - || (filter is not null && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase)) + || ( + filter is not null + && !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase) + ) ) { continue; @@ -960,10 +971,18 @@ public sealed class WorldScene : Scene 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 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); @@ -999,6 +1018,59 @@ public sealed class WorldScene : Scene 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 [consciousness 0..1] [seed] e.g. intel Deer 0.6" + ); + console.WriteLine($"species: {string.Join(", ", species)}"); + return; + } + + if (!content.Defs.TryGet(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(); + var gated = new List(); + 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) : "—")}"); + } + private static string ProductLabel(GameContent content, string productDefName) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index b8e8383..4c97e0d 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -42,7 +42,10 @@ public static class AnimalFactory sprite.Color = FurTint(traits); return store.CreateEntity( - new Transform2D(position, scale: new Vector2(Scale(sp, traits, stage, region, cellSize))), + new Transform2D( + position, + scale: new Vector2(Scale(sp, traits, stage, region, cellSize)) + ), sprite, new AnimalOrganism { @@ -97,12 +100,31 @@ public static class AnimalFactory ); } + /// + /// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья). + /// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект, + /// что гейтит набор доступных нужд/действий (см. ). Вид без частей тела + /// (сознание не вычисляется) считается полностью сознательным — чтобы не отключить ему всё. + /// + 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; + } + /// Пол из локуса GeneSex: наличие аллеля Y (значение ≈1) → самец; иначе самка (XX). public static bool IsMaleGenome(Genome genome) => genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f); /// Индекс стадии по возрасту: последняя стадия, чей возраст входа (EnterAt × якорь) ≤ возраст. - public static int StageAt(float ageDays, float maturity, float lifespan, AnimalStageDef[] stages) + public static int StageAt( + float ageDays, + float maturity, + float lifespan, + AnimalStageDef[] stages + ) { var stage = 0; for (var i = 1; i < stages.Length; i++) diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index df7d48e..9bdc71d 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -61,13 +61,36 @@ public struct AnimalBrain : IComponent public float DecideIn; } -/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды). -public readonly struct AnimalContext(float[] values) +/// +/// Снимок нужд зверя для соображений utility-выбора (доступ по индексу нужды) с гейтом интеллектом +/// (фаза A7): нужда, чей выше эффективного интеллекта особи +/// (brainSize × Consciousness), в выбор не входит. +/// +public readonly struct AnimalContext(float[] values, NeedSet needs, float intelligence) { private readonly float[] _values = values; + private readonly NeedSet _needs = needs; + private readonly float _intelligence = intelligence; - /// Значение нужды по индексу (0, если индекс вне диапазона). - public float Get(int index) => index < _values.Length ? _values[index] : 0f; + /// + /// Значение нужды по индексу. Если её порог мозга выше интеллекта особи — нужда недоступна и + /// возвращается её «не-срочный» край (deplete → 1 «сыта», drive → 0 «без влечения»), чтобы + /// соображение дало ~0 и действие не выбралось. Индекс вне диапазона → 0. + /// + public float Get(int index) + { + if (index >= _values.Length) + { + return 0f; + } + + if (_needs[index].MinBrain > _intelligence) + { + return _needs.IsDrive(index) ? 0f : 1f; + } + + return _values[index]; + } } /// @@ -112,7 +135,11 @@ public sealed class AnimalNeedsSystem(GameClock clock, float secondsPerDay, Need } else { - values[k] = Math.Clamp(values[k] - def.DecayPerDay * metabolism * days, 0f, 1f); + values[k] = Math.Clamp( + values[k] - def.DecayPerDay * metabolism * days, + 0f, + 1f + ); if (def.Lethal && values[k] <= 0f) { lethalEmpty = true; @@ -174,6 +201,7 @@ public sealed class AnimalDecisionSystem : BaseSystem private readonly UtilityAi _brain; private readonly GameClock _clock; private readonly AnimalSet _animalSet; + private readonly NeedSet _needs; private readonly int _cellSize; private readonly Vector2[] _shore; private readonly Random _rng; @@ -199,10 +227,17 @@ public sealed class AnimalDecisionSystem : BaseSystem _brain = BuildBrain(needs); _clock = clock; _animalSet = animals; + _needs = needs; _cellSize = cellSize; _shore = shore; _rng = new Random(seed); - _animals = store.Query(); + _animals = store.Query< + AnimalNeeds, + AnimalBrain, + AnimalOrganism, + AnimalGrowth, + Transform2D + >(); _plants = store.Query(); } @@ -237,7 +272,9 @@ public sealed class AnimalDecisionSystem : BaseSystem protected override void OnUpdateGroup() { var delta = _clock.DeltaTime; - foreach (var (needsChunk, brains, organisms, growths, transforms, _) in _animals.Chunks) + foreach ( + var (needsChunk, brains, organisms, growths, transforms, entities) in _animals.Chunks + ) { var n = needsChunk.Span; var b = brains.Span; @@ -257,8 +294,15 @@ public sealed class AnimalDecisionSystem : BaseSystem var pos = t[i].Position; var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); var radius = VisionRadius(o[i].Species, o[i].Traits.Vision); + // Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7): + // больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы. + var intelligence = AnimalFactory.Intelligence( + o[i].Traits, + entities.EntityAt(i).GetComponent().State + ); var name = - _brain.Select(new AnimalContext(n[i].Values))?.Name ?? AnimalActions.Wander; + _brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name + ?? AnimalActions.Wander; switch (name) { @@ -280,7 +324,13 @@ public sealed class AnimalDecisionSystem : BaseSystem break; case AnimalActions.Mate when adult - && TryFindMate(pos, o[i].IsMale, radius, out var mateId, out var matePos): + && TryFindMate( + pos, + o[i].IsMale, + radius, + out var mateId, + out var matePos + ): brain.Action = AnimalActions.Mate; brain.TargetPlant = -1; brain.TargetMate = mateId; @@ -388,7 +438,10 @@ public sealed class AnimalDecisionSystem : BaseSystem var tt = transforms.Span; for (var i = 0; i < tt.Length; i++) { - if (oo[i].IsMale == selfMale || !AnimalFactory.IsAdult(_animalSet[oo[i].Species], gg[i].Stage)) + if ( + oo[i].IsMale == selfMale + || !AnimalFactory.IsAdult(_animalSet[oo[i].Species], gg[i].Stage) + ) { continue; // тот же пол / не взрослый (self отсеивается по полу) } @@ -475,7 +528,9 @@ public sealed class AnimalActionSystem : BaseSystem _eaten.Clear(); _matings.Clear(); - foreach (var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks) + foreach ( + var (brains, needsChunk, organisms, transforms, healths, entities) in _query.Chunks + ) { var b = brains.Span; var n = needsChunk.Span; @@ -744,7 +799,9 @@ public sealed class AnimalGrowthSystem( ref var sprite = ref s[i]; sprite.Region = region; sprite.Origin = new Vector2(region.Width / 2f, region.Height / 2f); - t[i].Scale = new Vector2(AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize)); + t[i].Scale = new Vector2( + AnimalFactory.Scale(sp, org.Traits, stage, region, cellSize) + ); } } } @@ -1082,7 +1139,10 @@ public sealed class AnimalHealthSystem : BaseSystem foreach (var disease in _ambient) { - if (!state.Has(disease.DefName) && _rng.NextSingle() < disease.AmbientPerDay * days) + if ( + !state.Has(disease.DefName) + && _rng.NextSingle() < disease.AmbientPerDay * days + ) { state.Add(disease); } From 1b128318174a1a363bbfe38b069be2a7ecf2be71 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 06:13:46 +0300 Subject: [PATCH 14/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A8=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=201:=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=D1=8F=D0=B4=D1=80=D0=BE=20(ThoughtDef=20+=20Mood?= =?UTF-8?q?=20+=20MoodSystem)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тип дефа ThoughtDef + thoughts.json (роды/спаривание/жажда — сила и длительность данными). Компонент Mood (значение 0..1, активность по тиру мозга, список затухающих мыслей) на всех животных; AnimalMoodSystem (тир 4+: настроение из непрерывного фона — средняя удовлетворённость убывающих нужд минус боль — плюс сумма мыслей). Реестр ThoughtSet + id-константы AnimalThoughts; триггеры: роды (PregnancySystem), спаривание и утоление жажды (ActionSystem). Низкое настроение подавляет спаривание (DecisionSystem). Команда mood [species]. Локализация thought.* ru/en. Сборка + --check-content чистые (11 типов дефов). Осталось A8.2 — вкладки инспектора. Co-Authored-By: Claude Opus 4.8 (1M context) --- Mods/Core/Defs/thoughts.json | 13 +++ Mods/Core/Languages/en/ui.json | 3 + Mods/Core/Languages/ru/ui.json | 3 + docs/животные.md | 14 ++- src/LittleSim/Content/GameContent.cs | 1 + src/LittleSim/Content/GameDefs.cs | 15 +++ src/LittleSim/Content/ThoughtSet.cs | 22 ++++ src/LittleSim/Scenes/WorldScene.cs | 83 +++++++++++++++ src/LittleSim/Sim/AnimalFactory.cs | 9 +- src/LittleSim/Sim/AnimalOrganism.cs | 54 ++++++++++ src/LittleSim/Sim/AnimalSystems.cs | 147 ++++++++++++++++++++++++++- 11 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 Mods/Core/Defs/thoughts.json create mode 100644 src/LittleSim/Content/ThoughtSet.cs diff --git a/Mods/Core/Defs/thoughts.json b/Mods/Core/Defs/thoughts.json new file mode 100644 index 0000000..02a7bf0 --- /dev/null +++ b/Mods/Core/Defs/thoughts.json @@ -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 } + ] +} diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index 5c4464f..9ace6f9 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -123,6 +123,9 @@ "pawn.wolf": "wolf", "pawn.muffalo": "muffalo", "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.connecting": "connecting…", "net.connected": "connected", diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json index f576f95..9f4f785 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -123,6 +123,9 @@ "pawn.wolf": "волк", "pawn.muffalo": "муффало", "pawn.squirrel": "белка", + "thought.gaveBirth": "родила потомство", + "thought.mated": "спарилась", + "thought.quenchedThirst": "напилась вволю", "net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню", "net.connecting": "подключение…", "net.connected": "подключено", diff --git a/docs/животные.md b/docs/животные.md index 0aa753d..be941d5 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,7 +1,17 @@ # Животные (зоология) -> Статус: **A1–A7 + рефактор расширяемости реализованы**, остальное (A8) по плану. Это живой -> план — правится по мере реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). +> Статус: **A1–A7 + A8 часть 1 (ядро настроения) реализованы**, осталось A8 часть 2 (инспектор). Это +> живой план — правится по мере реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). +> +> **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` гейтит выбор ИИ: diff --git a/src/LittleSim/Content/GameContent.cs b/src/LittleSim/Content/GameContent.cs index ee594b3..90803d7 100644 --- a/src/LittleSim/Content/GameContent.cs +++ b/src/LittleSim/Content/GameContent.cs @@ -66,6 +66,7 @@ public sealed class GameContent defs.RegisterType("Animal"); defs.RegisterType("Hediff"); defs.RegisterType("Need"); + defs.RegisterType("Thought"); defs.RegisterType("Body"); defs.RegisterType("WorldPreset"); // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. diff --git a/src/LittleSim/Content/GameDefs.cs b/src/LittleSim/Content/GameDefs.cs index 951a922..772f554 100644 --- a/src/LittleSim/Content/GameDefs.cs +++ b/src/LittleSim/Content/GameDefs.cs @@ -320,6 +320,21 @@ public sealed class NeedDef : Def public float MinBrain { get; init; } } +/// +/// Мысль настроения (Defs/thoughts.json) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие +/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за +/// . Триггеры событий — код (реестр AnimalThoughts), а величины/метка — +/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки. +/// +public sealed class ThoughtDef : Def +{ + /// Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе). + public float MoodOffset { get; init; } + + /// Сколько игровых дней мысль живёт, прежде чем угаснуть. + public float DurationDays { get; init; } = 1f; +} + /// /// Часть тела/орган (вложенный объект ) как ДАННЫЕ: иерархия (родитель), /// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в diff --git a/src/LittleSim/Content/ThoughtSet.cs b/src/LittleSim/Content/ThoughtSet.cs new file mode 100644 index 0000000..0021b3d --- /dev/null +++ b/src/LittleSim/Content/ThoughtSet.cs @@ -0,0 +1,22 @@ +namespace LittleSim.Content; + +/// +/// Реестр мыслей настроения () с доступом по id (фаза A8): системы-источники +/// событий (роды/спаривание/жажда) берут нужную мысль по строковому id (см. AnimalThoughts) и +/// вешают её на особь. Метка/сила/длительность мысли — данные; модер правит их в thoughts.json. Зеркало +/// по духу. Удалил модер мысль из дефов — вернёт null (no-op). +/// +public sealed class ThoughtSet +{ + private readonly Dictionary _byId; + + /// Строит реестр из всех мода. + public ThoughtSet(GameContent content) => + _byId = content.Defs.All().ToDictionary(t => t.DefName, StringComparer.Ordinal); + + /// Число мыслей. + public int Count => _byId.Count; + + /// Деф мысли по id или null, если такой нет (мод убрал). + public ThoughtDef? Get(string id) => _byId.GetValueOrDefault(id); +} diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 19939f7..e172ce0 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -214,6 +214,7 @@ public sealed class WorldScene : Scene // Животные (фаза 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("Rut")) @@ -229,6 +230,8 @@ public sealed class WorldScene : Scene _config.Seed + 0x3EAD ) ); + // Настроение (A8) до выбора решения: стресс (низкое настроение) подавляет спаривание. + UpdateSystems.Add(new AnimalMoodSystem(Context.Clock, SecondsPerDay, _needs)); UpdateSystems.Add( new AnimalDecisionSystem( Store, @@ -246,6 +249,7 @@ public sealed class WorldScene : Scene _plants, _animals, _needs, + thoughts, Context.Clock, SecondsPerDay, _bounds @@ -260,6 +264,7 @@ public sealed class WorldScene : Scene Store, _animals, _needs, + thoughts, Context.Clock, SecondsPerDay, CellSize, @@ -738,6 +743,11 @@ public sealed class WorldScene : Scene "intel [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( "menu", "menu — return to the main menu", @@ -1071,6 +1081,79 @@ public sealed class WorldScene : Scene 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() + .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) => content.Defs.TryGet(productDefName, out var product) ? content.Languages.Get(product.Label) diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 4c97e0d..47d581a 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -64,7 +64,14 @@ public static class AnimalFactory TargetPlant = -1, TargetMate = -1, }, - new Health { State = HealthState.Create(sp.Body, traits.BodySize) } + new Health { State = HealthState.Create(sp.Body, traits.BodySize) }, + // Настроение (фаза A8): нейтральное на старте, активность по тиру мозга считает MoodSystem. + new Mood + { + Value = 0.5f, + Active = false, + Thoughts = [], + } ); } diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 53523d2..8f61071 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -326,6 +326,60 @@ public struct Corpse : IComponent public float Meat; } +/// Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания. +public struct ThoughtInstance +{ + /// Деф мысли (сдвиг настроения и длительность). + public Content.ThoughtDef Def; + + /// Сколько игровых дней мысль ещё действует. + public float RemainingDays; +} + +/// +/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается +/// из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у +/// тира 4+ ( по размеру мозга) — у примитивных настроения нет. Низкое настроение +/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка +/// (как AnimalNeeds.Values): события добавляют мысли через . +/// +public struct Mood : IComponent +{ + /// Текущее настроение [0,1]; у неактивного (тир < 4) держится нейтральным. + public float Value; + + /// Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются. + public bool Active; + + /// Активные событийные мысли (затухают со временем). + public List Thoughts; + + /// + /// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления + /// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды). + /// + 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 }); + } +} + /// /// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток /// срока вынашивания и размер помёта. По истечении срока diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 9bdc71d..1056f91 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -24,6 +24,18 @@ public static class AnimalActions public const string Mate = "Mate"; } +/// +/// Id событийных мыслей настроения (фаза A8). Системы-источники события вешают мысль с этим id (через +/// ); её сила/длительность/метка — данные (thoughts.json). Новая мысль на +/// новое событие = код-триггер + деф; правка чисел существующей — только JSON. +/// +public static class AnimalThoughts +{ + public const string GaveBirth = "GaveBirth"; + public const string Mated = "Mated"; + public const string QuenchedThirst = "QuenchedThirst"; +} + /// /// Нужды зверя как ДАННЫЕ (рефактор расширяемости): значения по индексам из /// (managed-массив — добавление нужды не меняет структуру) + счётчик истощения. Падают/растут в @@ -197,6 +209,7 @@ public sealed class AnimalRutSystem(AnimalSet animals, Climate climate, HediffDe public sealed class AnimalDecisionSystem : BaseSystem { private const float Interval = 0.6f; + private const float MateMoodFloor = 0.35f; // ниже — стресс/истощение, зверь не спаривается (A8) private readonly UtilityAi _brain; private readonly GameClock _clock; @@ -294,12 +307,15 @@ public sealed class AnimalDecisionSystem : BaseSystem var pos = t[i].Position; var adult = AnimalFactory.IsAdult(_animalSet[o[i].Species], gr[i].Stage); var radius = VisionRadius(o[i].Species, o[i].Traits.Vision); + var self = entities.EntityAt(i); // Эффективный интеллект (мозг × сознание) гейтит доступные нужды/действия (фаза A7): // больной/раненый зверь с упавшим сознанием теряет высшие нужды (сон/секс) → рефлексы. var intelligence = AnimalFactory.Intelligence( o[i].Traits, - entities.EntityAt(i).GetComponent().State + self.GetComponent().State ); + // Настроение (A8): стресс/истощение (низкое настроение) подавляет тягу к спариванию. + var mood = self.GetComponent(); var name = _brain.Select(new AnimalContext(n[i].Values, _needs, intelligence))?.Name ?? AnimalActions.Wander; @@ -324,6 +340,7 @@ public sealed class AnimalDecisionSystem : BaseSystem break; case AnimalActions.Mate when adult + && (!mood.Active || mood.Value >= MateMoodFloor) && TryFindMate( pos, o[i].IsMale, @@ -478,11 +495,13 @@ public sealed class AnimalDecisionSystem : BaseSystem public sealed class AnimalActionSystem : BaseSystem { private const float GrazeKillAge = 1f; // трава с возрастом ниже этого после выедания исчезает + private const float ThirstReliefBelow = 0.15f; // ниже этого жажда — питьё даёт мысль «напилась» private readonly EntityStore _store; private readonly PlantSet _plants; private readonly AnimalSet _animalSet; private readonly NeedSet _needs; + private readonly ThoughtSet _thoughts; private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly RectF _bounds; @@ -501,6 +520,7 @@ public sealed class AnimalActionSystem : BaseSystem PlantSet plants, AnimalSet animals, NeedSet needs, + ThoughtSet thoughts, GameClock clock, float secondsPerDay, RectF bounds @@ -510,6 +530,7 @@ public sealed class AnimalActionSystem : BaseSystem _plants = plants; _animalSet = animals; _needs = needs; + _thoughts = thoughts; _clock = clock; _secondsPerDay = secondsPerDay; _bounds = bounds; @@ -565,7 +586,21 @@ public sealed class AnimalActionSystem : BaseSystem case AnimalActions.Drink: if (MoveTo(ref pos, brain.Target, speed * seconds)) { + // Утоление сильной жажды — событийная мысль настроения (A8); уровень берём + // ДО восполнения, чтобы радость была от облегчения, а не от каждого глотка. + var thirst = _needs.NeedForAction(AnimalActions.Drink); + var parched = + thirst >= 0 + && thirst < values.Length + && values[thirst] < ThirstReliefBelow; Refill(values, AnimalActions.Drink, days); + if (parched) + { + entities + .EntityAt(i) + .GetComponent() + .Add(_thoughts.Get(AnimalThoughts.QuenchedThirst)); + } } break; @@ -695,6 +730,10 @@ public sealed class AnimalActionSystem : BaseSystem ResetMating(a, mateNeed); ResetMating(b, mateNeed); + // Спаривание — положительная событийная мысль обоим партнёрам (A8). + var mated = _thoughts.Get(AnimalThoughts.Mated); + a.GetComponent().Add(mated); + b.GetComponent().Add(mated); } } @@ -713,6 +752,103 @@ public sealed class AnimalActionSystem : BaseSystem } } +/// +/// Настроение (фаза A8): для особей тира 4+ (мозг ≥ порога) считает настроение из непрерывного фона +/// (удовлетворённость убывающих нужд минус боль из здоровья) и суммы затухающих событийных мыслей. +/// Мысли угасают со временем. У примитивных (тир < 4) настроения нет (Active=false, мысли сбрасываются). +/// Гейт интеллектом — тем же порогом тира 4, что и высшие нужды (сон/секс). Низкое настроение влияет на +/// поведение (стресс не даёт спариваться — см. ) и видно в консоли/инспекторе. +/// +public sealed class AnimalMoodSystem(GameClock clock, float secondsPerDay, NeedSet needs) + : QuerySystem +{ + /// Порог размера мозга (тир 4), с которого у особи появляется настроение. + public const float MoodMinBrain = 0.40f; + private const float NeutralBase = 0.35f; // настроение при полном истощении нужд (без боли/мыслей) + private const float NeedsWeight = 0.55f; // вклад средней удовлетворённости убывающих нужд + private const float PainWeight = 0.4f; // боль из здоровья тянет настроение вниз + + protected override void OnUpdate() + { + var days = clock.DeltaTime / secondsPerDay; + if (days <= 0f) + { + return; + } + + foreach (var (moods, needsChunk, organisms, healths, _) in Query.Chunks) + { + var m = moods.Span; + var n = needsChunk.Span; + var o = organisms.Span; + var h = healths.Span; + for (var i = 0; i < m.Length; i++) + { + ref var mood = ref m[i]; + if (o[i].Traits.BrainSize < MoodMinBrain) + { + mood.Active = false; // тир ниже зверя — настроения нет + mood.Value = 0.5f; + mood.Thoughts?.Clear(); + continue; + } + + mood.Active = true; + // 1) Затухание событийных мыслей + сумма их сдвигов. + var thoughtSum = 0f; + var list = mood.Thoughts; + if (list is not null) + { + for (var k = list.Count - 1; k >= 0; k--) + { + var t = list[k]; + t.RemainingDays -= days; + if (t.RemainingDays <= 0f) + { + list.RemoveAt(k); + continue; + } + + list[k] = t; + thoughtSum += t.Def.MoodOffset; + } + } + + // 2) Непрерывный фон: средняя удовлетворённость убывающих нужд минус боль из здоровья. + var wellbeing = AvgDeplete(n[i].Values); + var pain = h[i].State?.Pain ?? 0f; + mood.Value = Math.Clamp( + NeutralBase + NeedsWeight * wellbeing - PainWeight * pain + thoughtSum, + 0f, + 1f + ); + } + } + } + + // Средняя удовлетворённость убывающих нужд (голод/жажда/усталость) [0,1]; нет нужд → 1 (нейтрально). + private float AvgDeplete(float[] values) + { + if (values is null) + { + return 1f; + } + + var sum = 0f; + var count = 0; + for (var k = 0; k < needs.Count && k < values.Length; k++) + { + if (needs.IsDeplete(k)) + { + sum += values[k]; + count++; + } + } + + return count > 0 ? sum / count : 1f; + } +} + /// /// Презентация состояния нужд: голодный/уставший зверь тускнеет (яркость падает с самой острой /// убывающей нуждой), сохраняя оттенок меха из генов. Читает симуляцию, пишет только @@ -957,6 +1093,7 @@ public sealed class AnimalPregnancySystem : BaseSystem private readonly EntityStore _store; private readonly AnimalSet _animals; private readonly NeedSet _needs; + private readonly ThoughtSet _thoughts; private readonly GameClock _clock; private readonly float _secondsPerDay; private readonly int _cellSize; @@ -970,6 +1107,7 @@ public sealed class AnimalPregnancySystem : BaseSystem EntityStore store, AnimalSet animals, NeedSet needs, + ThoughtSet thoughts, GameClock clock, float secondsPerDay, int cellSize, @@ -980,6 +1118,7 @@ public sealed class AnimalPregnancySystem : BaseSystem _store = store; _animals = animals; _needs = needs; + _thoughts = thoughts; _clock = clock; _secondsPerDay = secondsPerDay; _cellSize = cellSize; @@ -1035,6 +1174,12 @@ public sealed class AnimalPregnancySystem : BaseSystem birth.Mother.RemoveComponent(); } + // Роды — сильная положительная мысль матери (A8). + if (birth.Mother.HasComponent()) + { + birth.Mother.GetComponent().Add(_thoughts.Get(AnimalThoughts.GaveBirth)); + } + for (var k = 0; k < birth.Litter && count < _maxAnimals; k++) { var child = Genome.Breed( From 003f235440790353212ff842ee246a2494ff21b5 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 06:19:23 +0300 Subject: [PATCH 15/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20A8=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=202:=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=81=D0=BF=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20=D0=B6?= =?UTF-8?q?=D0=B8=D0=B2=D0=BE=D1=82=D0=BD=D1=8B=D1=85=20(=D0=B2=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=BD=D1=83=D0=B6=D0=B4=D1=8B?= =?UTF-8?q?/=D0=B7=D0=B4=D0=BE=D1=80=D0=BE=D0=B2=D1=8C=D0=B5/=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InspectPanel осматривает и животных: вкладки Обзор (стадия/возраст/пол/поколение/беременность), Нужды, Здоровье (кровь/боль, способности, хедифы), Настроение (значение + мысли). Набор вкладок по типу выбранного. SelectionSystem пикает и животных (запрос расширен PlantGrowth → +AnimalOrganism). Локализация inspect.*/need.*/hediff.*/cap.*/animalstage.* ru+en. Базовый план зоологии A1-A8 закрыт. Сборка + --check-content чистые. Co-Authored-By: Claude Opus 4.8 (1M context) --- Mods/Core/Languages/en/ui.json | 32 ++++ Mods/Core/Languages/ru/ui.json | 32 ++++ docs/животные.md | 13 +- src/LittleSim/Scenes/WorldScene.cs | 11 +- src/LittleSim/Sim/Selection.cs | 68 +++++--- src/LittleSim/UI/InspectPanel.cs | 256 ++++++++++++++++++++++++++--- 6 files changed, 367 insertions(+), 45 deletions(-) diff --git a/Mods/Core/Languages/en/ui.json b/Mods/Core/Languages/en/ui.json index 9ace6f9..d4cc4ee 100644 --- a/Mods/Core/Languages/en/ui.json +++ b/Mods/Core/Languages/en/ui.json @@ -10,6 +10,9 @@ "inspect.tab.overview": "Overview", "inspect.tab.genes": "Genes", "inspect.tab.products": "Products", + "inspect.tab.needs": "Needs", + "inspect.tab.health": "Health", + "inspect.tab.mood": "Mood", "inspect.stage": "Stage: {0} ({1}/{2})", "inspect.growing": "Growth: {0:0}% to next stage", "inspect.mature": "Fully grown", @@ -31,6 +34,35 @@ "inspect.harvest": "Harvest: {0} ×{1:0.#}", "inspect.fruit": "Fruit: {0} ×{1:0.#} ({2}) · ripe {3:0.#}", "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", "hud.paused": "PAUSED", "menu.title": "LittleSim", diff --git a/Mods/Core/Languages/ru/ui.json b/Mods/Core/Languages/ru/ui.json index 9f4f785..bd45d20 100644 --- a/Mods/Core/Languages/ru/ui.json +++ b/Mods/Core/Languages/ru/ui.json @@ -10,6 +10,9 @@ "inspect.tab.overview": "Обзор", "inspect.tab.genes": "Гены", "inspect.tab.products": "Продукты", + "inspect.tab.needs": "Нужды", + "inspect.tab.health": "Здоровье", + "inspect.tab.mood": "Настроение", "inspect.stage": "Стадия: {0} ({1}/{2})", "inspect.growing": "Рост: {0:0}% до следующей стадии", "inspect.mature": "Полностью выросло", @@ -31,6 +34,35 @@ "inspect.harvest": "Сбор: {0} ×{1:0.#}", "inspect.fruit": "Плоды: {0} ×{1:0.#} ({2}) · зрелых {3:0.#}", "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 — снять", "hud.paused": "ПАУЗА", "menu.title": "LittleSim", diff --git a/docs/животные.md b/docs/животные.md index be941d5..d82e7f9 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,7 +1,16 @@ # Животные (зоология) -> Статус: **A1–A7 + A8 часть 1 (ядро настроения) реализованы**, осталось A8 часть 2 (инспектор). Это -> живой план — правится по мере реализации фаз A1–A8. Стиль и правила — как в [концепте](concept.md) и [модах](mods.md). +> Статус: **A1–A8 реализованы целиком** — базовый план зоологии закрыт; дальше экологический горизонт +> (хищники, опека, эпидемии, миграция) и бэклог реализма. Это живой план — стиль и правила как в +> [концепте](concept.md) и [модах](mods.md). +> +> **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, активность по diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index e172ce0..6a38477 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -163,7 +163,16 @@ public sealed class WorldScene : Scene atlases.GetRegion(device, "ui/overlays/selectionbracket"), 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); this.UseInspector(renderer); diff --git a/src/LittleSim/Sim/Selection.cs b/src/LittleSim/Sim/Selection.cs index 90d1689..46e4602 100644 --- a/src/LittleSim/Sim/Selection.cs +++ b/src/LittleSim/Sim/Selection.cs @@ -1,3 +1,4 @@ +using System; using Friflo.Engine.ECS; using Friflo.Engine.ECS.Systems; using Microsoft.Xna.Framework; @@ -74,41 +75,62 @@ public sealed class SelectionSystem( } } - // Ближайшее растение, чья ограничивающая окружность спрайта накрывает точку мира. Запрос - // ограничен PlantGrowth — так из пикинга выпадают спрайты рамки выделения и прочий не-контент. + // Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта + // накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают + // спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий. private bool TryPick(Vector2 world, out int id) { id = -1; - var bestDistance = float.MaxValue; + var best = float.MaxValue; foreach ( var (transforms, sprites, _, entities) in store .Query() .Chunks ) { - var t = transforms.Span; - var s = sprites.Span; - for (var i = 0; i < t.Length; i++) - { - if (s[i].Region is not { } region) - { - continue; - } + Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); + } - var (center, radius) = CullingMath.SpriteBoundingCircle( - in t[i], - region, - s[i].Origin - ); - var distance = Vector2.DistanceSquared(center, world); - if (distance <= radius * radius && distance < bestDistance) - { - bestDistance = distance; - id = entities.EntityAt(i).Id; - } - } + foreach ( + var (transforms, sprites, _, entities) in store + .Query() + .Chunks + ) + { + Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best); } return id >= 0; } + + // Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность. + private static void Scan( + Vector2 world, + Span transforms, + Span 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; + } + } + } } diff --git a/src/LittleSim/UI/InspectPanel.cs b/src/LittleSim/UI/InspectPanel.cs index be4673b..7d22afe 100644 --- a/src/LittleSim/UI/InspectPanel.cs +++ b/src/LittleSim/UI/InspectPanel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text; using Friflo.Engine.ECS; @@ -16,10 +17,11 @@ namespace LittleSim.UI; /// /// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта -/// () и боковая панель в левом нижнем углу с вкладками — «Обзор», -/// «Гены», «Продукты». Источник выделения — ; панель только читает компоненты -/// и обновляется каждый кадр (), оставаясь живой даже на паузе. Все строки идут -/// через (ключи inspect.*). +/// () и боковая панель в левом нижнем углу с вкладками. Набор вкладок +/// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Здоровье/Настроение». +/// Источник выделения — ; панель только читает компоненты и обновляется каждый +/// кадр (), оставаясь живой даже на паузе. Все строки идут через +/// (ключи inspect.*). /// internal sealed class InspectPanel { @@ -28,10 +30,18 @@ internal sealed class InspectPanel Overview, Genes, 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 PlantSet _plants; + private readonly AnimalSet _animals; + private readonly NeedSet _needs; private readonly GameContent _content; private readonly Climate _climate; private readonly Selection _selection; @@ -46,6 +56,8 @@ internal sealed class InspectPanel public InspectPanel( EntityStore store, PlantSet plants, + AnimalSet animals, + NeedSet needs, GameContent content, Climate climate, Selection selection, @@ -54,6 +66,8 @@ internal sealed class InspectPanel { _store = store; _plants = plants; + _animals = animals; + _needs = needs; _content = content; _climate = climate; _selection = selection; @@ -66,6 +80,9 @@ internal sealed class InspectPanel AddTab(tabBar, Tab.Overview, "inspect.tab.overview"); AddTab(tabBar, Tab.Genes, "inspect.tab.genes"); 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 { @@ -99,7 +116,7 @@ internal sealed class InspectPanel /// /// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает - /// рамку к объекту и пересобирает текст активной вкладки. Не-растения панель не показывает. + /// рамку к объекту и пересобирает текст активной вкладки. Осматриваются растения и животные. /// public void Refresh() { @@ -107,19 +124,19 @@ internal sealed class InspectPanel !_selection.HasSelection || !_store.TryGetEntityById(_selection.EntityId, out var entity) || entity.IsNull - || !entity.HasComponent() - || !entity.HasComponent() || !entity.HasComponent() || !entity.HasComponent() ) { - if (_selection.HasSelection) - { - _selection.Clear(); // сущность исчезла (растение погибло) - } + ClearSelection(); + return; + } - _panel.Visible = false; - _brackets.Hide(); + var isPlant = entity.HasComponent() && entity.HasComponent(); + var isAnimal = entity.HasComponent(); + if (!isPlant && !isAnimal) + { + ClearSelection(); // не контент (труп/прочее) — пока не осматривается return; } @@ -135,10 +152,29 @@ internal sealed class InspectPanel _brackets.Show(center, radius); } - UpdateText(entity); + if (isPlant) + { + UpdatePlant(entity); + } + else + { + UpdateAnimal(entity); + } + _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) { var button = new TextButton { Text = _content.Languages.Get(labelKey) }; @@ -147,7 +183,23 @@ internal sealed class InspectPanel _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; ref readonly var grow = ref entity.GetComponent(); @@ -157,10 +209,7 @@ internal sealed class InspectPanel var traits = org.Traits; _title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : ""); - foreach (var (tab, button) in _tabs) - { - button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted; - } + ShowTabs(PlantTabs); var text = new StringBuilder(); switch (_tab) @@ -179,6 +228,35 @@ internal sealed class InspectPanel _body.Text = text.ToString().TrimEnd(); } + private void UpdateAnimal(Entity entity) + { + var languages = _content.Languages; + ref readonly var org = ref entity.GetComponent(); + 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( StringBuilder text, 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(); + 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(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().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().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(); + 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) => stage < def.Stages.Count && def.Stages[stage].Label is { } key ? _content.Languages.Get(key) From 4b53eb898476df2117a863ddfcc7e7b3d6355ede Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 06:59:26 +0300 Subject: [PATCH 16/17] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5:=20=D1=81=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=E2=80=94=20=D1=81=D0=B5=D0=B9?= =?UTF-8?q?=D0=B2/=D0=BB=D0=BE=D0=B0=D0=B4=20=D0=BF=D0=BE=D0=BF=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=D1=86=D0=B8=D0=B8=20=D0=B8=20=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает сквозное требование плана (сейв/лоад данных каждой фазы). WorldSave получил Animals (AnimalSave: геном+пол, поколение, стадия/возраст, нужды, здоровье — кровь/HP частей/хедифы, настроение+мысли, беременность+геном отца) и Corpses (CorpseSave). SaveAnimals снимает популяцию (Health/Mood/Pregnant через entity); RestoreAnimals/RestoreAnimalState собирают особь фабрикой из генома + накладывают состояние (ref-правки до AddComponent(Pregnant); труп пере-тинтится через Stage=-1). Corpse получил Species; CreateCorpse принимает индекс вида + HealthState.AddHediff(def,severity,immunity). На загрузке стадо не пересыпается при наличии животных в сейве; старый/пустой сейв регенерируется из сида. Сборка + --check-content чистые. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/животные.md | 17 ++- src/LittleSim/App/WorldSave.cs | 117 ++++++++++++++ src/LittleSim/Scenes/WorldScene.cs | 228 +++++++++++++++++++++++++++- src/LittleSim/Sim/AnimalFactory.cs | 5 +- src/LittleSim/Sim/AnimalOrganism.cs | 19 +++ src/LittleSim/Sim/AnimalSystems.cs | 6 +- 6 files changed, 382 insertions(+), 10 deletions(-) diff --git a/docs/животные.md b/docs/животные.md index d82e7f9..8acaca9 100644 --- a/docs/животные.md +++ b/docs/животные.md @@ -1,8 +1,19 @@ # Животные (зоология) -> Статус: **A1–A8 реализованы целиком** — базовый план зоологии закрыт; дальше экологический горизонт -> (хищники, опека, эпидемии, миграция) и бэклог реализма. Это живой план — стиль и правила как в -> [концепте](concept.md) и [модах](mods.md). +> Статус: **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` теперь осматривает и животных (не только > растения): вкладки **Обзор** (стадия/возраст/пол/поколение/беременность), **Нужды** (значения нужд %), diff --git a/src/LittleSim/App/WorldSave.cs b/src/LittleSim/App/WorldSave.cs index 871c3f3..ce1b9e8 100644 --- a/src/LittleSim/App/WorldSave.cs +++ b/src/LittleSim/App/WorldSave.cs @@ -62,6 +62,117 @@ public sealed class PawnSave public float ChangeIn { get; set; } } +/// Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет. +public sealed class HediffSave +{ + /// Имя дефа хедифа (гон/болезнь). + public string DefName { get; set; } = ""; + + /// Текущая тяжесть. + public float Severity { get; set; } + + /// Набранный иммунитет [0,1]. + public float Immunity { get; set; } +} + +/// Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания. +public sealed class ThoughtSave +{ + /// Имя дефа мысли. + public string DefName { get; set; } = ""; + + /// Сколько игровых дней мысль ещё действует. + public float RemainingDays { get; set; } +} + +/// +/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и +/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и +/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние) +/// переживала сейв/лоад, а не пересыпалась заново из сида. +/// +public sealed class AnimalSave +{ + /// Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Номер поколения (для popstats/дрейфа). + public int Generation { get; set; } + + /// Текущая стадия роста. + public int Stage { get; set; } + + /// Накопленный возраст в игровых днях. + public float AgeDays { get; set; } + + /// Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex). + public Dictionary Genome { get; set; } = new(); + + /// Значения нужд по индексам NeedSet. + public float[] Needs { get; set; } = []; + + /// Накоплено дней истощения. + public float Starve { get; set; } + + /// Уровень крови [0,1]. + public float BloodLevel { get; set; } = 1f; + + /// HP частей тела по порядку анатомии вида (пусто — у вида нет тела). + public float[] PartHp { get; set; } = []; + + /// Активные хедифы (гон/болезни). + public List Hediffs { get; set; } = []; + + /// Текущее настроение [0,1]. + public float Mood { get; set; } + + /// Активно ли настроение (тир 4+). + public bool MoodActive { get; set; } + + /// Активные событийные мысли. + public List Thoughts { get; set; } = []; + + /// Беременна ли самка. + public bool Pregnant { get; set; } + + /// Геном отца (если беременна) — «слепок» на момент зачатия. + public Dictionary FatherGenome { get; set; } = new(); + + /// Остаток срока вынашивания (игровых дней). + public float DueInDays { get; set; } + + /// Размер помёта. + public int Litter { get; set; } +} + +/// Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо. +public sealed class CorpseSave +{ + /// Имя дефа вида — по нему берётся спрайт трупа. + public string Species { get; set; } = ""; + + /// Позиция X в мировых координатах. + public float X { get; set; } + + /// Позиция Y в мировых координатах. + public float Y { get; set; } + + /// Накоплено игровых дней разложения. + public float RotDays { get; set; } + + /// Размер тела (для масштаба спрайта). + public float BodySize { get; set; } + + /// Доступное мясо. + public float Meat { get; set; } +} + /// /// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), /// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он @@ -99,6 +210,12 @@ public sealed class WorldSave /// Снимок всех растений (геномы, позиции, возраст, стадия). public List Plants { get; set; } = []; + /// Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность). + public List Animals { get; set; } = []; + + /// Снимок трупов (вид, позиция, таймер/стадия разложения, мясо). + public List Corpses { get; set; } = []; + /// Конфиг мира для пересоздания сцены. public WorldConfig ToConfig() => new() diff --git a/src/LittleSim/Scenes/WorldScene.cs b/src/LittleSim/Scenes/WorldScene.cs index 6a38477..b47bb86 100644 --- a/src/LittleSim/Scenes/WorldScene.cs +++ b/src/LittleSim/Scenes/WorldScene.cs @@ -126,9 +126,16 @@ public sealed class WorldScene : Scene RestorePlants(content); } - // Животные: детерминированный начальный спавн по данным (плотность из дефов). Пока без сейва — - // регенерируются из сида и для нового, и для загруженного мира. - SpawnAnimals(); + // Животные: из сейва (сохранённая популяция — эволюция/состояние не теряются) либо + // детерминированный спавн из сида (новый мир или старый сейв без животных). + if (_save?.Animals is { Count: > 0 }) + { + RestoreAnimals(content); + } + else + { + SpawnAnimals(); + } // Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира, // а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже). @@ -501,11 +508,108 @@ public sealed class WorldScene : Scene } ); + SaveAnimals(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; } + // Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад + // (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами. + private void SaveAnimals(WorldSave save) + { + Store + .Query() + .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().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(); + 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(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() + .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) { var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома @@ -533,6 +637,122 @@ 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(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(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(); + 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(); + if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count) + { + needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики + } + + needs.Starve = saved.Starve; + entity.GetComponent().Stage = saved.Stage; + + ref var mood = ref entity.GetComponent(); + mood.Value = saved.Mood; + mood.Active = saved.MoodActive; + mood.Thoughts.Clear(); + foreach (var thought in saved.Thoughts) + { + if (content.Defs.TryGet(thought.DefName, out var tdef)) + { + mood.Thoughts.Add( + new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays } + ); + } + } + + if (entity.GetComponent().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(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() diff --git a/src/LittleSim/Sim/AnimalFactory.cs b/src/LittleSim/Sim/AnimalFactory.cs index 47d581a..06c2279 100644 --- a/src/LittleSim/Sim/AnimalFactory.cs +++ b/src/LittleSim/Sim/AnimalFactory.cs @@ -84,12 +84,14 @@ public static class AnimalFactory /// Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела). public static Entity CreateCorpse( EntityStore store, - AnimalSet.Species sp, + 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(); @@ -100,6 +102,7 @@ public static class AnimalFactory sprite, new Corpse { + Species = species, RotDays = 0f, Stage = 0, Meat = bodySize * MeatPerBodySize, diff --git a/src/LittleSim/Sim/AnimalOrganism.cs b/src/LittleSim/Sim/AnimalOrganism.cs index 8f61071..5409cfd 100644 --- a/src/LittleSim/Sim/AnimalOrganism.cs +++ b/src/LittleSim/Sim/AnimalOrganism.cs @@ -286,6 +286,22 @@ public sealed class HealthState } } + /// Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва). + public void AddHediff(HediffDef def, float severity, float immunity) + { + if (!Has(def.DefName)) + { + _hediffs.Add( + new Hediff + { + Def = def, + Severity = severity, + Immunity = immunity, + } + ); + } + } + /// Снимает хедиф по имени дефа; возвращает, был ли он. public bool Remove(string defName) { @@ -316,6 +332,9 @@ public struct Health : IComponent /// public struct Corpse : IComponent { + /// Индекс вида в (для спрайта, разделки, сейва). + public int Species; + /// Накоплено игровых дней разложения. public float RotDays; diff --git a/src/LittleSim/Sim/AnimalSystems.cs b/src/LittleSim/Sim/AnimalSystems.cs index 1056f91..5cf52ed 100644 --- a/src/LittleSim/Sim/AnimalSystems.cs +++ b/src/LittleSim/Sim/AnimalSystems.cs @@ -994,7 +994,8 @@ public sealed class AnimalMortalitySystem : BaseSystem var position = dead.GetComponent().Position; AnimalFactory.CreateCorpse( _store, - _animals[org.Species], + _animals, + org.Species, position, org.Traits.BodySize, _cellSize @@ -1317,7 +1318,8 @@ public sealed class AnimalHealthSystem : BaseSystem var position = dead.GetComponent().Position; AnimalFactory.CreateCorpse( _store, - _animals[org.Species], + _animals, + org.Species, position, org.Traits.BodySize, _cellSize From 77c6e84683e6cad6f176cb6596581cca230e6b17 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 07:01:38 +0300 Subject: [PATCH 17/17] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D1=8B=20(=D0=BC=D1=8F=D1=81=D0=BE,=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8,=20=D0=BA=D0=BE=D0=B6=D0=B0)=20=D0=B2=20prod?= =?UTF-8?q?ucts.json=20=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2?= =?UTF-8?q?=20worldpresets.json.=20=D0=A2=D0=B0=D0=BA=D0=B6=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=81=D0=BB=D0=BE=D0=B5=D0=B2=20=D0=B2=20?= =?UTF-8?q?GameLayers.cs.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B2=20eng?= =?UTF-8?q?ine.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mods/Core/Defs/products.json | 7 ++++++- Mods/Core/Defs/worldpresets.json | 6 +++--- src/LittleSim/Scenes/GameLayers.cs | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Mods/Core/Defs/products.json b/Mods/Core/Defs/products.json index eec749f..be8b667 100644 --- a/Mods/Core/Defs/products.json +++ b/Mods/Core/Defs/products.json @@ -8,6 +8,11 @@ { "defName": "ProductFiber", "label": "product.fiber", "kind": "material" }, { "defName": "ProductBerry", "label": "product.berry", "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" } ] } diff --git a/Mods/Core/Defs/worldpresets.json b/Mods/Core/Defs/worldpresets.json index 23a14e3..436608b 100644 --- a/Mods/Core/Defs/worldpresets.json +++ b/Mods/Core/Defs/worldpresets.json @@ -1,8 +1,8 @@ { "type": "WorldPreset", "defs": [ - { "defName": "Small", "label": "preset.small", "order": 0, "width": 80, "height": 50, "population": 40 }, - { "defName": "Medium", "label": "preset.medium", "order": 1, "width": 120, "height": 70, "population": 80 }, - { "defName": "Large", "label": "preset.large", "order": 2, "width": 180, "height": 110, "population": 140 } + { "defName": "Small", "label": "preset.small", "order": 0, "width": 128, "height": 80, "population": 60 }, + { "defName": "Medium", "label": "preset.medium", "order": 1, "width": 200, "height": 120, "population": 110 }, + { "defName": "Large", "label": "preset.large", "order": 2, "width": 300, "height": 180, "population": 180 } ] } diff --git a/src/LittleSim/Scenes/GameLayers.cs b/src/LittleSim/Scenes/GameLayers.cs index bc9fc05..ed2d938 100644 --- a/src/LittleSim/Scenes/GameLayers.cs +++ b/src/LittleSim/Scenes/GameLayers.cs @@ -16,7 +16,7 @@ public static class GameLayers /// Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта). public static LayerId Selection { get; private set; } - /// Регистрирует слои при первом вызове. + /// Регистрирует слои при первом вызове (порядок = порядок отрисовки). public static void EnsureRegistered(Renderer2D renderer) { if (_registered)