Compare commits

..
Author SHA1 Message Date
Leonid Pershin 97d83606ce Implement diet genes for animals, introducing GeneHerbivory, GeneCarnivory, and GeneOmnivory to define dietary behaviors. Add new animal types: Wolf (carnivore) and WildBoar (omnivore), with associated attributes and hunting mechanics. Enhance animal behavior systems to include hunting and fleeing dynamics based on genetic traits. Update documentation to reflect these changes and ensure localization for new terms. Clean build with no content errors. 2026-06-14 13:14:36 +03:00
Leonid PershinandClaude Opus 4.8 aae76e9083 Merge branch 'animals' into main: animal/zoology system (A1-A8 + serialization)
Brings the full animal foundation onto main and folds its new defs into the
Defs/ subfolder layout introduced on main:
- animal genes -> Defs/Genes/Animal.json (split out of genes.json)
- animals/bodies/hediffs/needs/thoughts -> own Defs/<Type>/ subfolders
- ProductMeat/Bone/Leather + bigger world presets auto-merged via rename detection
- PawnDef un-sealed (AnimalDef : PawnDef); def-location doc comments updated to subfolders
- engine pointer kept at main's newer 96e19c7 (only adds directional sun shadows)

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:48:49 +03:00
Leonid Pershin c47f99bc29 Update engine subproject commit to 96e19c7. Remove obsolete gene, patch, pawn, plant, product, terrain, and world preset definitions from the Core mod. Enhance documentation to reflect new directory structure for Defs, improving clarity on file organization and localization paths. 2026-06-14 12:42:09 +03:00
Leonid Pershin 77c6e84683 Обновление данных: добавлены новые продукты (мясо, кости, кожа) в products.json и изменены размеры и население в worldpresets.json. Также обновлено описание метода регистрации слоев в GameLayers.cs. Обновлен статус подпроекта в engine. 2026-06-14 07:01:38 +03:00
Leonid PershinandClaude Opus 4.8 4b53eb8984 Животные: сериализация — сейв/лоад популяции и трупов
Закрывает сквозное требование плана (сейв/лоад данных каждой фазы). 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) <noreply@anthropic.com>
2026-06-14 06:59:26 +03:00
Leonid PershinandClaude Opus 4.8 003f235440 Животные A8 часть 2: инспектор животных (вкладки нужды/здоровье/настроение)
InspectPanel осматривает и животных: вкладки Обзор (стадия/возраст/пол/поколение/беременность), Нужды, Здоровье (кровь/боль, способности, хедифы), Настроение (значение + мысли). Набор вкладок по типу выбранного. SelectionSystem пикает и животных (запрос расширен PlantGrowth → +AnimalOrganism). Локализация inspect.*/need.*/hediff.*/cap.*/animalstage.* ru+en. Базовый план зоологии A1-A8 закрыт. Сборка + --check-content чистые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:19:23 +03:00
Leonid PershinandClaude Opus 4.8 1b12831817 Животные A8 часть 1: настроение — ядро (ThoughtDef + Mood + MoodSystem)
Тип дефа 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) <noreply@anthropic.com>
2026-06-14 06:13:46 +03:00
Leonid PershinandClaude Opus 4.8 26bf83db64 Животные A7: мозг → интеллект → гейт нужд/действий
Эффективный интеллект = brainSize (ген) × Consciousness (capacity из здоровья), AnimalFactory.Intelligence. AnimalContext гейтит выбор ИИ: нужда с minBrain выше интеллекта особи в выбор не входит (AnimalDecisionSystem читает Health поэлементно). Пороги в needs.json — тиры мозга (голод 0.0, жажда 0.25, сон/секс 0.40; у оленя мозг ~0.45). Обратная связь от повреждения мозга: болезнь/боль/кровопотеря роняют сознание → интеллект падает → первыми отключаются сон/спаривание. Команда intel <species> [consciousness]. Сборка + --check-content чистые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:00:11 +03:00
Leonid PershinandClaude Opus 4.8 9f8ff93591 Животные A6: болезни и иммунитет
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 <noreply@anthropic.com>
2026-06-14 05:48:05 +03:00
Leonid PershinandClaude Opus 4.8 9db12273c9 Животные A5 (часть 2): части тела, кровь, capacities
BodyDef/BodyPartDef + bodies.json (Quadruped — данные: иерархия органов, вклады в способности). AnimalDef.Body. HealthState расширен Parts/BloodLevel/Pain/Capacities; CapacityCalc обобщённо считает способности по зависимостям (кровь->сердце/лёгкие->сознание->движение/зрение), модовые проходят сырыми. Фабрика строит части (HP x размер тела) и начальные способности. Источников урона нет -> всё на полном HP, способности=1 (каркас под болезни/хищников). Команда body <species>.

Сборка чистая, --check-content (10 типов дефов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:36:34 +03:00
Leonid PershinandClaude Opus 4.8 c90bf32460 Животные A5 (часть 1): смерть → труп → разложение
Компонент Corpse + CorpseSystem: после смерти животное оставляет труп (AnimalFactory.CreateCorpse, мясо ∝ размер тела) вместо деспавна; труп разлагается Fresh→Rotting→Skeleton→исчезает со скоростью от температуры, тинтуясь по стадии. AnimalDef.CorpseTexture + спрайт трупа в AnimalSet; смертность спавнит труп. Продукты мясо/кости/шкура заданы (разделка/падальщики — позже).

Сборка чистая, --check-content (9 типов дефов). Части тела/кровь/capacities — часть 2 A5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:22:11 +03:00
Leonid PershinandClaude Opus 4.8 cbc74c0794 Рефактор: зрение/скорость/выедание животного — из дефа вида
Завершает вынос баланса в данные: AnimalDecisionSystem.VisionRadius берёт дальность из AnimalDef.VisionCells (× ген), TryFindPlant/TryFindMate принимают готовый радиус; AnimalActionSystem берёт BaseSpeed и ForageBiteDays из дефа вида (через AnimalSet). Убраны хардкод-константы скорости/зрения/выедания.

Сборка чистая, --check-content (9 типов дефов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:16:44 +03:00
Leonid PershinandClaude Opus 4.8 5d0cbd2115 Рефактор: стадии роста и спавн — данными
Стадии стали данными: 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 <noreply@anthropic.com>
2026-06-14 04:57:44 +03:00
Leonid PershinandClaude Opus 4.8 f12b0f170c Рефактор: нужды и диета — данными, а не кодом
Нужды стали данными: 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 <noreply@anthropic.com>
2026-06-14 04:46:47 +03:00
Leonid PershinandClaude Opus 4.8 d4a370e02d Рефактор: геном животного — данными, а не кодом
AnimalGenomeDef (фикс. поля) убран; AnimalDef.Genome теперь словарь geneId->base. AnimalSet.BuildTemplate строит шаблон generically по реестру генов (разброс из самого GeneDef, дискретные поддержаны). Модер добавляет ген особи одной строкой JSON в animals.json — без правки кода. Устраняет блокер расширяемости #1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:39:16 +03:00
Leonid PershinandClaude Opus 4.8 97521f81b8 Животные A4: размножение (гон-хедиф, беременность, роды)
Нужда 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 <noreply@anthropic.com>
2026-06-14 04:32:18 +03:00
Leonid PershinandClaude Opus 4.8 355ced4937 Животные A3: жизненный цикл, стадии и пол
Компонент 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 <noreply@anthropic.com>
2026-06-14 04:20:44 +03:00
Leonid PershinandClaude Opus 4.8 e35e620655 Животные A2: нужды и ИИ (выбор + исполнение)
AnimalNeeds (голод/жажда/отдых) падают по гену метаболизма. Два слоя ИИ: AnimalDecisionSystem (движковый UtilityAi выбирает действие и ищет ближайшую еду/воду) и AnimalActionSystem (движение к цели со скоростью по гену + утоление: выедание растений с гибелью выеденной травы, питьё у кромки воды, сон). AnimalAppearanceSystem тускнеет с острой нуждой. Кромка воды считается из террейна; команда popstats для наблюдаемости дрейфа генов.

Сборка чистая. Контур ёмкости среды частичный — смерть от голода придёт в A3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:13:50 +03:00
Leonid PershinandClaude Opus 4.8 5e61491960 Животные A1: фундамент — AnimalDef, гены зверя, спавн оленя
Новый тип дефа Animal (AnimalDef : PawnDef) + animals.json (Deer). 8 организм-агностичных животных генов в genes.json. AnimalPhenotype/AnimalOrganism, AnimalSet (шаблон генома вида) и AnimalFactory — зеркала растительных PlantSet/PlantFactory. Детерминированный спавн стада оленей по суше в WorldScene с рендером и тинтом меха по гену; консольная команда 'animal <species>'.

Проверено: сборка чистая, --check-content (7 типов дефов, 28 генов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:03:51 +03:00
Leonid PershinandClaude Opus 4.8 516e263c77 docs: дизайн системы животных (зоология)
Полный план зоологии на генетическом фундаменте: 7 столпов (гены, жизненный цикл, размножение, здоровье по модели RimWorld, мозг/интеллект, нужды, настроение), моддинг данными, фазы A1-A8 и экологический горизонт.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:03:39 +03:00
Leonid Pershin 9c9502414b Update plant definitions and localization. Adjusted GrassA sizeCells from 1.2 to 1.5 and added new plant types including BaseWarmTree, BaseColdTree, and various cacti. Enhanced terrain definitions with scatter probabilities for new plants. Updated product definitions to include fiber and mushrooms, and localized new plant and product names in English and Russian. Updated engine subproject commit to 08381703f79377a5bc48fc0745620f8815695b05-dirty. 2026-06-13 06:55:43 +03:00
Leonid Pershin b9bb7006be Enhance UI localization and settings functionality. Added new localization keys for inspection tabs and UI scale settings in both English and Russian. Updated GameSettings to include a UiScale property. Refactored scenes to utilize UseScaledUI for consistent UI scaling across various game scenes. Improved InspectPanel to support tabbed navigation for overview, genes, and products. Adjusted SettingsPanel to allow users to modify UI scale dynamically. 2026-06-13 06:24:53 +03:00
Leonid Pershin c60ae072e9 Update engine subproject commit to 08381703f79377a5bc48fc0745620f8815695b05 2026-06-13 05:21:30 +03:00
Leonid Pershin f46ae15dc6 Update engine subproject commit to f382fc98eadc4a489689e51e88748b616661a560-dirty; adjust plant genetics for optimal temperature and hardiness traits, enhance terrain scatter probabilities, and improve UI localization for connection status. Refactor multiplayer scene to implement exponential backoff for reconnections and streamline game initialization in web client. 2026-06-13 05:21:17 +03:00
Leonid PershinandClaude Fable 5 6b29a40b7f Bump engine: MrGameEng.Net robustness
Points the engine submodule at f382fc9 (pushed): heartbeat liveness +
idle-timeout drop, replication protocol-version byte, reassembled-message
size cap, and ReplicationClient.Clear() for reconnect. Enables the desktop
and web clients to auto-reconnect and the server to shed dead connections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:45:13 +03:00
Leonid Pershin 6f38d949ab Update engine subproject commit to d360093be1a9352fd2f531dde620a400c83a5d71 2026-06-13 04:34:39 +03:00
Leonid Pershin cc1841c2e2 Enhance plant genetics system with cold and heat hardiness traits. Updated genes.json to include GeneColdHardiness and GeneHeatHardiness, and modified plant definitions to incorporate these traits. Adjusted temperature tolerance calculations in PlantPhenotype and PlantLifecycleSystem to account for temperature stress. Improved documentation and added temperature stress management in the lifecycle system. 2026-06-13 04:34:03 +03:00
Leonid Pershin 4fda693994 Update engine subproject commit to d044cafad93177e0fbffb79872bde12afcf03f9c-dirty; remove outdated documentation files: растения.md and roadmap.md. 2026-06-13 04:27:35 +03:00
mrleo1nid d712afa518 Merge pull request 'Multiplayer: headless dedicated server, MrGameEng.Net, desktop and browser clients' (#1) from claude/fervent-shannon-8d884e into main
Reviewed-on: #1
2026-06-13 01:25:07 +00:00
76 changed files with 6618 additions and 489 deletions
+104
View File
@@ -0,0 +1,104 @@
{
"type": "Animal",
// Виды-животные: подтип Pawn (kind=animal) + базовый геном вида из общих Gene-дефов (см. Defs/Genes/).
// Особь при спавне получает аллели вокруг этих баз, фенотип — формулами генов. Диета задаётся ГЕНАМИ
// (herbivory/carnivory/omnivory): олень — травоядный, волк — хищник, кабан — всеядный. Хищники несут
// боевые параметры (attack*) и охотятся на добычу не крупнее себя; жертвы убегают (см. AnimalSystems).
"defs": [
{
"defName": "Deer",
"label": "pawn.deer",
"kind": "animal",
"texture": "things/pawn/animal/deer/DeerFemale_east",
"maleTexture": "things/pawn/animal/deer/DeerMale_east",
"babyTexture": "things/pawn/animal/deer/DeerBaby_east",
"corpseTexture": "things/pawn/animal/deer/Dessicated_DeerFemale_east",
"body": "Quadruped",
"diet": ["plant"],
"spawnPer1000Cells": 2.5,
"genome": {
"GeneMaxBodySize": 1.5,
"GeneMetabolism": 1.0,
"GeneMoveSpeed": 1.2,
"GeneBloodVolume": 1.4,
"GeneVision": 1.3,
"GeneBrainSize": 0.45,
"GeneInsulation": 0.55,
"GeneFurColor": 0.45,
"GeneMaturityAge": 90,
"GeneLifespan": 360,
"GeneBreedingSeason": 2,
"GeneGestationDays": 30,
"GeneLitterSize": 1,
"GeneHerbivory": 1.0
}
},
{
"defName": "Wolf",
"label": "pawn.wolf",
"kind": "animal",
"texture": "things/pawn/animal/wolf_timber/Wolf_Timber_east",
"maleTexture": "things/pawn/animal/wolf_timber/Wolf_Timber3_east",
"body": "Quadruped",
"diet": ["meat"],
"spawnPer1000Cells": 0.8,
"baseSpeed": 32,
"visionCells": 18,
"attackRangeCells": 1.3,
"attackDamage": 80,
"attackBleed": 3.0,
"attackBloodLoss": 5.0,
"genome": {
"GeneMaxBodySize": 1.6,
"GeneMetabolism": 1.1,
"GeneMoveSpeed": 1.4,
"GeneBloodVolume": 1.3,
"GeneVision": 1.5,
"GeneBrainSize": 0.5,
"GeneInsulation": 0.6,
"GeneFurColor": 0.7,
"GeneMaturityAge": 70,
"GeneLifespan": 240,
"GeneBreedingSeason": 3,
"GeneGestationDays": 35,
"GeneLitterSize": 4,
"GeneCarnivory": 1.0
}
},
{
"defName": "WildBoar",
"label": "pawn.boar",
"kind": "animal",
"texture": "things/pawn/animal/wildboar/WildBoar_east",
"body": "Quadruped",
"diet": ["plant", "meat"],
"spawnPer1000Cells": 1.0,
"baseSpeed": 26,
"visionCells": 13,
"attackRangeCells": 1.2,
"attackDamage": 45,
"attackBleed": 1.8,
"attackBloodLoss": 3.0,
"genome": {
"GeneMaxBodySize": 1.3,
"GeneMetabolism": 1.2,
"GeneMoveSpeed": 1.0,
"GeneBloodVolume": 1.2,
"GeneVision": 1.0,
"GeneBrainSize": 0.42,
"GeneInsulation": 0.5,
"GeneFurColor": 0.85,
"GeneMaturityAge": 80,
"GeneLifespan": 280,
"GeneBreedingSeason": 0,
"GeneGestationDays": 28,
"GeneLitterSize": 5,
"GeneHerbivory": 0.4,
"GeneCarnivory": 0.2,
"GeneOmnivory": 0.85
}
}
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"type": "Body",
// Анатомия видов (фаза A5): дерево частей/органов. Вклады в способности (capacities) по телу
// суммируются ≈1 у здоровой особи. maxHp умножается на размер тела особи при создании.
// Источников урона пока нет — части на полном HP, способности = 1; каркас под болезни/хищников.
"defs": [
{
"defName": "Quadruped",
"parts": [
{ "name": "torso", "coverage": 0.32, "maxHp": 40, "vital": true },
{ "name": "heart", "parent": "torso", "coverage": 0.02, "maxHp": 12, "vital": true,
"capacities": { "BloodPumping": 1.0 } },
{ "name": "lungLeft", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Breathing": 0.5 } },
{ "name": "lungRight", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Breathing": 0.5 } },
{ "name": "liver", "parent": "torso", "coverage": 0.03, "maxHp": 14, "vital": true },
{ "name": "stomach", "parent": "torso", "coverage": 0.03, "maxHp": 12,
"capacities": { "Digestion": 1.0 } },
{ "name": "head", "coverage": 0.1, "maxHp": 25 },
{ "name": "brain", "parent": "head", "coverage": 0.02, "maxHp": 12, "vital": true,
"capacities": { "Consciousness": 1.0 } },
{ "name": "eyeLeft", "parent": "head", "coverage": 0.015, "maxHp": 8,
"capacities": { "Sight": 0.5 } },
{ "name": "eyeRight", "parent": "head", "coverage": 0.015, "maxHp": 8,
"capacities": { "Sight": 0.5 } },
{ "name": "legFrontLeft", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legFrontRight", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legBackLeft", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } },
{ "name": "legBackRight", "coverage": 0.08, "maxHp": 15, "capacities": { "Moving": 0.25 } }
]
}
]
}
+66
View File
@@ -0,0 +1,66 @@
{
"type": "Gene",
// Гены животных: организм-агностичные гены зверя (их несёт геном животного). Тело/обмен/движение/
// чувства/мозг + пол (локус) и размножение. Признаки читают системы животных (рост, ИИ, здоровье).
"defs": [
{ "defName": "GeneMaxBodySize", "parent": "BaseNumericGene", "label": "gene.bodySize",
"default": 1.0, "min": 0.1, "max": 4.0, "tags": ["animal", "body"],
"effects": { "bodySize": "value" } },
{ "defName": "GeneMetabolism", "parent": "BaseNumericGene", "label": "gene.metabolism",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "metabolism"],
"effects": { "metabolism": "value" } },
{ "defName": "GeneMoveSpeed", "parent": "BaseNumericGene", "label": "gene.moveSpeed",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "movement"],
"effects": { "moveSpeed": "value" } },
{ "defName": "GeneBloodVolume", "parent": "BaseNumericGene", "label": "gene.bloodVolume",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "health"],
"effects": { "bloodVolume": "value" } },
{ "defName": "GeneVision", "parent": "BaseNumericGene", "label": "gene.vision",
"default": 1.0, "min": 0.2, "max": 3.0, "tags": ["animal", "senses"],
"effects": { "vision": "value" } },
{ "defName": "GeneBrainSize", "parent": "BaseNumericGene", "label": "gene.brainSize",
"default": 0.45, "min": 0.0, "max": 1.0, "spread": 0.03, "tags": ["animal", "brain"],
"effects": { "brainSize": "value" } },
{ "defName": "GeneInsulation", "parent": "BaseNumericGene", "label": "gene.insulation",
"default": 0.5, "min": 0.0, "max": 1.0, "tags": ["animal", "temperature"],
"effects": { "insulation": "value" } },
{ "defName": "GeneFurColor", "parent": "BaseNumericGene", "label": "gene.furColor",
"default": 0.5, "min": 0.0, "max": 1.0, "spread": 0.06, "tags": ["animal", "morphology", "color"],
"effects": { "furHue": "value" } },
{ "defName": "GeneMaturityAge", "parent": "BaseNumericGene", "label": "gene.maturityAge",
"default": 90, "min": 1, "max": 400, "tags": ["animal", "lifecycle"],
"effects": { "maturityAge": "value" } },
// --- Диета как ГЕНЫ (хищничество/травоядство/всеядство) ---
// Каждый ген выражает свой признак [0..1]; рацион выводится порогами: ест растения, если
// max(herbivory, omnivory) ≥ порога; ест мясо (охота/падаль), если max(carnivory, omnivory) ≥ порога.
// Поэтому всеядность — отдельный ген, который включает ОБА источника пищи (генералист), а
// специалисты задаются высоким herbivory ЛИБО carnivory. См. AnimalFactory.Diet.
{ "defName": "GeneHerbivory", "parent": "BaseNumericGene", "label": "gene.herbivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "herbivory": "value" } },
{ "defName": "GeneCarnivory", "parent": "BaseNumericGene", "label": "gene.carnivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "carnivory": "value" } },
{ "defName": "GeneOmnivory", "parent": "BaseNumericGene", "label": "gene.omnivory",
"default": 0.0, "min": 0.0, "max": 1.0, "tags": ["animal", "diet"],
"effects": { "omnivory": "value" } },
// Пол как локус (фаза A3): аллели {X=0, Y=1}; самка XX, самец XY. Эффекта нет — пол читается из
// самих аллелей (наличие Y), не из выраженного значения. mutationChance 0 (X не мутирует в Y).
// Генерация особи-основателя задаёт пол явно (XX или XY), чтобы не возник невозможный YY.
{ "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" } }
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"type": "Gene",
// Контент-гены (фаза G4+): плодоношение, добыча, цвет, производные и дискретные морфы.
"defs": [
{ "defName": "GeneFruitYield", "parent": "BaseNumericGene", "label": "gene.fruitYield",
"default": 0, "min": 0, "max": 12, "tags": ["fruiting"],
"effects": { "fruitYield": "value" } },
{ "defName": "GeneFruitSeason", "parent": "BaseNumericGene", "label": "gene.fruitSeason",
"default": 1, "min": 0, "max": 3, "spread": 0, "mutationChance": 0, "tags": ["fruiting"],
"effects": { "fruitSeason": "value" } },
{ "defName": "GeneHarvestAmount", "parent": "BaseNumericGene", "label": "gene.harvestAmount",
"default": 1, "min": 0, "max": 50, "tags": ["harvest"],
"effects": { "harvestAmount": "value" } },
{ "defName": "GeneLeafHue", "parent": "BaseNumericGene", "label": "gene.leafHue",
"default": 0.33, "min": 0, "max": 1, "spread": 0.04, "tags": ["morphology", "color"],
"effects": { "leafHue": "value" } },
// Производный ген: признак собирается группировкой по регэкспу — сумма всех генов-толерантностей
// (демонстрация gsom-функций фазы G5). Собственное значение гена не используется.
{ "defName": "GeneHardiness", "parent": "BaseNumericGene", "label": "gene.hardiness",
"default": 0, "min": 0, "max": 1, "spread": 0, "mutationChance": 0, "tags": ["derived"],
"effects": { "hardiness": "gsum('Gene.*Tolerance')" } },
// Дискретный ген морфы: вариант 0 доминирует, 1 (рецессивный) виден только в гомозиготе.
{ "defName": "GeneMorph", "kind": "Discrete", "label": "gene.morph",
"variants": 2, "variantWeights": [0.82, 0.18], "mutationChance": 0.05, "tags": ["morphology"],
"effects": { "variant": "value" } }
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"type": "Gene",
// Гены среды: свет, температура, почва. Покрывают экологические измерения генома растения,
// которые читают системы роста/жизненного цикла.
"defs": [
{ "defName": "GeneOptimalLight", "parent": "BaseNumericGene", "label": "gene.optimalLight",
"default": 0.6, "min": 0.0, "max": 1.0, "tags": ["environment", "light"],
"effects": { "optimalLight": "value" } },
{ "defName": "GeneLightTolerance", "parent": "BaseNumericGene", "label": "gene.lightTolerance",
"default": 0.5, "min": 0.05, "max": 1.0, "tags": ["environment", "light"],
"effects": { "lightTolerance": "value" } },
{ "defName": "GeneOptimalTemperature", "parent": "BaseNumericGene", "label": "gene.optimalTemperature",
"default": 14, "min": -20, "max": 45, "tags": ["environment", "temperature"],
"effects": { "optimalTemperature": "value" } },
{ "defName": "GeneTemperatureTolerance", "parent": "BaseNumericGene", "label": "gene.temperatureTolerance",
"default": 16, "min": 2, "max": 40, "tags": ["environment", "temperature"],
"effects": { "temperatureTolerance": "value" } },
// Жёсткие края диапазона роста (модель RimWorld): на сколько °C ниже/выше плато оптимума рост
// спадает до нуля. Ниже (optimal-tolerance-coldHardiness) или выше (optimal+tolerance+heatHardiness)
// растение дормантно и копит температурный стресс. Асимметрично: вид может терпеть жару лучше холода.
{ "defName": "GeneColdHardiness", "parent": "BaseNumericGene", "label": "gene.coldHardiness",
"default": 8, "min": 0, "max": 40, "tags": ["environment", "temperature"],
"effects": { "coldHardiness": "value" } },
{ "defName": "GeneHeatHardiness", "parent": "BaseNumericGene", "label": "gene.heatHardiness",
"default": 10, "min": 0, "max": 40, "tags": ["environment", "temperature"],
"effects": { "heatHardiness": "value" } },
{ "defName": "GeneOptimalFertility", "parent": "BaseNumericGene", "label": "gene.optimalFertility",
"default": 1.4, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
"effects": { "optimalFertility": "value" } },
{ "defName": "GeneFertilityTolerance", "parent": "BaseNumericGene", "label": "gene.fertilityTolerance",
"default": 0.9, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
"effects": { "fertilityTolerance": "value" } }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"type": "Gene",
// Гены роста и размножения: бодрость, продолжительность жизни, расселение, репродукция.
"defs": [
{ "defName": "GeneVigor", "parent": "BaseNumericGene", "label": "gene.vigor",
"default": 1.0, "min": 0.1, "max": 3.0, "tags": ["growth"],
"effects": { "vigor": "value" } },
{ "defName": "GeneLifespan", "parent": "BaseNumericGene", "label": "gene.lifespan",
"default": 160, "min": 5, "max": 500, "tags": ["lifecycle"],
"effects": { "lifespan": "value" } },
{ "defName": "GeneDispersalRange", "parent": "BaseNumericGene", "label": "gene.dispersalRange",
"default": 2, "min": 1, "max": 8, "tags": ["lifecycle", "reproduction"],
"effects": { "dispersalRange": "value" } },
{ "defName": "GeneReproduceInterval", "parent": "BaseNumericGene", "label": "gene.reproduceInterval",
"default": 14, "min": 1, "max": 60, "tags": ["lifecycle", "reproduction"],
"effects": { "reproduceInterval": "value" } },
{ "defName": "GeneSelfPollination", "parent": "BaseNumericGene", "label": "gene.selfPollination",
"default": 0.2, "min": 0.0, "max": 1.0, "tags": ["reproduction"],
"effects": { "selfPollination": "value" } },
{ "defName": "GeneMutationRate", "parent": "BaseNumericGene", "label": "gene.mutationRate",
"default": 0.05, "min": 0.0, "max": 1.0, "tags": ["reproduction"],
"effects": { "mutationRate": "value" } }
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"type": "Gene",
// Общие (абстрактные) гены: база наследования для конкретных генов. Каждый ген задаёт, как
// генерируются/мутируют аллели и как ген вкладывается в признаки (effects: имя признака →
// формула; value — выраженное значение гена). Конкретные гены лежат рядом по подтемам.
"defs": [
{ "defName": "BaseNumericGene", "abstract": true, "kind": "Numeric",
"spread": 0.08, "mutationChance": 0.05, "mutationMagnitude": 0.12 }
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"type": "Hediff",
// Хедифы — состояния организма (фаза A4: лёгкий каркас). Гон (Rut) навешивается сезонно на взрослых
// и поднимает половое влечение. Раны/болезни/возрастные эффекты со стадиями придут в A5/A6.
"defs": [
{ "defName": "Rut", "label": "hediff.rut" },
// Болезнь (фаза A6): прогрессирует, параллельно растёт иммунитет (гонка). Обычно иммунитет
// успевает победить (~3 дня), но больной зверь медленнее и слабее; слабые/невезучие гибнут.
{
"defName": "Fever", "label": "hediff.fever",
"initialSeverity": 0.05, "severityPerDay": 0.25, "immunityPerDay": 0.32,
"lethalSeverity": 1.0, "pain": 0.3, "ambientPerDay": 0.03,
"capMods": { "Moving": 0.6, "Consciousness": 0.85, "Digestion": 0.8 }
},
// Рана/кровотечение (предатор-кластер): первый травматический урон. Не прогрессирует сама
// (severityPerDay 0), но immunityPerDay её «сворачивает» (рана заживает ~за 2 дня). Пока активна —
// теряется кровь (bloodLossPerDay × тяжесть); кровь на нуле = смерть. Боль и капмоды от тяжёлых ран.
// Каждый укус усиливает тяжесть и сбрасывает заживление (см. HealthState.Intensify).
{
"defName": "Bleeding", "label": "hediff.bleeding",
"initialSeverity": 0.3, "immunityPerDay": 0.5, "bloodLossPerDay": 0.8,
"pain": 0.25, "capMods": { "Moving": 0.92, "Consciousness": 0.95 }
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"type": "Need",
// Нужды как данные (фаза A2, рефактор расширяемости): добавить нужду виду = добавить запись здесь.
// kind=deplete убывает и утоляется действием; kind=drive растёт под хедифом (гон) и сбрасывается им.
// action — id поведения-исполнителя (реестр в коде). minBrain — гейт интеллектом (активен с A7):
// нужда, чей minBrain выше эффективного интеллекта особи (brainSize × Consciousness), в выбор ИИ не
// входит. Пороги — тиры мозга: 0.0 голод-рефлекс, 0.25 жажда (низший позвоночный), 0.40 сон/секс
// (зверь). У оленя мозг ~0.45: здоров — весь набор; повреждение мозга/болезнь роняют сознание →
// эффективный интеллект падает → первыми отключаются сон/спаривание (обратная связь A7).
"defs": [
{ "defName": "Hunger", "label": "need.hunger", "kind": "deplete", "action": "Eat",
"decayPerDay": 0.55, "feedPerDay": 4, "lethal": true, "minBrain": 0.0 },
{ "defName": "Thirst", "label": "need.thirst", "kind": "deplete", "action": "Drink",
"decayPerDay": 0.9, "feedPerDay": 8, "lethal": true, "minBrain": 0.25 },
{ "defName": "Rest", "label": "need.rest", "kind": "deplete", "action": "Sleep",
"decayPerDay": 0.7, "feedPerDay": 3, "lethal": false, "minBrain": 0.40 },
{ "defName": "Mating", "label": "need.mating", "kind": "drive", "action": "Mate",
"decayPerDay": 1.0, "risePerDay": 1.2, "risesUnder": "Rut", "minBrain": 0.40 }
]
}
@@ -1,14 +1,7 @@
{ {
"type": "Pawn", "type": "Pawn",
// Животные.
"defs": [ "defs": [
{ "defName": "BaseBeing", "abstract": true, "kind": "being", "sizeCells": 1.1 },
{ "defName": "BeingMale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Male_south" },
{ "defName": "BeingFemale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Female_south" },
{ "defName": "BeingThin", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Thin_south" },
{ "defName": "BeingFat", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Fat_south" },
{ "defName": "BeingHulk", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Hulk_south" },
{ "defName": "BaseAnimal", "abstract": true, "kind": "animal" },
{ "defName": "Bear", "parent": "BaseAnimal", "label": "pawn.bear", "texture": "things/pawn/animal/bear/Bear_east", "sizeCells": 1.7 }, { "defName": "Bear", "parent": "BaseAnimal", "label": "pawn.bear", "texture": "things/pawn/animal/bear/Bear_east", "sizeCells": 1.7 },
{ "defName": "DeerDoe", "parent": "BaseAnimal", "label": "pawn.deer", "texture": "things/pawn/animal/deer/DeerFemale_east", "sizeCells": 1.4 }, { "defName": "DeerDoe", "parent": "BaseAnimal", "label": "pawn.deer", "texture": "things/pawn/animal/deer/DeerFemale_east", "sizeCells": 1.4 },
{ "defName": "DeerBuck", "parent": "BaseAnimal", "label": "pawn.deer", "texture": "things/pawn/animal/deer/DeerMale_east", "sizeCells": 1.6 }, { "defName": "DeerBuck", "parent": "BaseAnimal", "label": "pawn.deer", "texture": "things/pawn/animal/deer/DeerMale_east", "sizeCells": 1.6 },
+11
View File
@@ -0,0 +1,11 @@
{
"type": "Pawn",
// Жители: варианты тел.
"defs": [
{ "defName": "BeingMale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Male_south" },
{ "defName": "BeingFemale", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Female_south" },
{ "defName": "BeingThin", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Thin_south" },
{ "defName": "BeingFat", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Fat_south" },
{ "defName": "BeingHulk", "parent": "BaseBeing", "label": "pawn.being", "texture": "things/pawn/humanlike/bodies/FurCovered_Hulk_south" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"type": "Pawn",
// Общие (абстрактные) существа: база для жителей и животных.
"defs": [
{ "defName": "BaseBeing", "abstract": true, "kind": "being", "sizeCells": 1.1 },
{ "defName": "BaseAnimal", "abstract": true, "kind": "animal" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"type": "Plant",
// Ягодный почвопокров луга (еда).
"defs": [
{ "defName": "Strawberry", "parent": "BaseBerryBush", "label": "plant.strawberry", "sizeCells": 1.0, "texture": "things/plant/strawberryplant/StrawberryPlant" },
{ "defName": "Raspberry", "parent": "BaseBerryBush", "label": "plant.raspberry", "texture": "things/plant/raspberryplant/raspberrybusha" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"type": "Plant",
// Кусты: ягодный подлесок. Патч даёт им древесину при сборе (веточки) — см. Patches/Patches.json.
"defs": [
{ "defName": "BushA", "parent": "BaseBush", "texture": "things/plant/bush/BushA" },
{ "defName": "BushB", "parent": "BaseBush", "texture": "things/plant/bush/BushB" },
{ "defName": "BushC", "parent": "BaseBush", "texture": "things/plant/bush/BushC" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"type": "Plant",
// Пустыня: кактусы и агава на песке. Большинство без плодов; сагуаро/агава плодоносят.
"defs": [
{ "defName": "SaguaroCactusA", "parent": "BaseCactus", "label": "plant.saguaro", "sizeCells": 2.2, "trunkRadiusCells": 0.18,
"fruitProduct": "ProductBerry", "texture": "things/plant/saguarocactus/SaguaroCactusA" },
{ "defName": "SaguaroCactusB", "parent": "BaseCactus", "label": "plant.saguaro", "sizeCells": 2.2, "trunkRadiusCells": 0.18,
"fruitProduct": "ProductBerry", "texture": "things/plant/saguarocactus/SaguaroCactusB" },
{ "defName": "PebbleCactusA", "parent": "BaseCactus", "sizeCells": 1.0, "texture": "things/plant/pebblecactus/PebbleCactusA" },
{ "defName": "PebbleCactusB", "parent": "BaseCactus", "sizeCells": 1.0, "texture": "things/plant/pebblecactus/PebbleCactusB" },
{ "defName": "PincushionCactusA", "parent": "BaseCactus", "sizeCells": 0.9, "texture": "things/plant/pincushioncactus/PincushionCactusA" },
{ "defName": "AgaveA", "parent": "BaseCactus", "label": "plant.agave", "sizeCells": 1.3, "fruitProduct": "ProductBerry", "texture": "things/plant/agave/AgaveA" },
{ "defName": "AgaveB", "parent": "BaseCactus", "label": "plant.agave", "sizeCells": 1.3, "fruitProduct": "ProductBerry", "texture": "things/plant/agave/AgaveB" }
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"type": "Plant",
// Луг: декоративные цветы с быстрым расселением.
"defs": [
{ "defName": "DandelionA", "parent": "BaseFlower", "label": "plant.dandelion", "texture": "things/plant/dandelion/Dandelion" },
{ "defName": "DandelionB", "parent": "BaseFlower", "label": "plant.dandelion", "texture": "things/plant/dandelion/DandelionB" },
{ "defName": "DandelionC", "parent": "BaseFlower", "label": "plant.dandelion", "texture": "things/plant/dandelion/DandelionC" },
{ "defName": "DaylilyA", "parent": "BaseFlower", "label": "plant.daylily", "texture": "things/plant/daylily/DaylilyA" },
{ "defName": "DaylilyB", "parent": "BaseFlower", "label": "plant.daylily", "texture": "things/plant/daylily/DaylilyB" },
{ "defName": "RoseA", "parent": "BaseFlower", "label": "plant.rose", "sizeCells": 1.1, "texture": "things/plant/rose/RoseA" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"type": "Plant",
// Трава: быстрый почвопокров, основной скаттер лугов и леса.
"defs": [
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.5,
"harvestProduct": "ProductGrass",
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 19, "temperatureTolerance": 8,
"coldHardiness": 9, "heatHardiness": 6,
"optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
"harvestAmount": 2, "leafHue": 0.36 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 2, "label": "plant.stage.sprout" },
{ "sizeCells": 1.5, "label": "plant.stage.mature" }
] }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"type": "Plant",
// Грибы: подлесок леса, растут в тени крон.
"defs": [
{ "defName": "GlowstoolA", "parent": "BaseMushroom", "sizeCells": 0.9, "texture": "things/plant/glowstool/GlowstoolA" },
{ "defName": "GlowstoolB", "parent": "BaseMushroom", "sizeCells": 0.9, "texture": "things/plant/glowstool/GlowstoolB" },
{ "defName": "TimbershroomA", "parent": "BaseMushroom", "sizeCells": 1.1, "texture": "things/plant/timbershroom/TimbershroomA" },
{ "defName": "TimbershroomB", "parent": "BaseMushroom", "sizeCells": 1.1, "texture": "things/plant/timbershroom/TimbershroomB" },
{ "defName": "NutrifungusA", "parent": "BaseMushroom", "sizeCells": 0.9, "texture": "things/plant/nutrifungus/NutrifungusA" }
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"type": "Plant",
// Деревья: умеренные (BaseTree), тёплые (BaseWarmTree) и холодные (BaseColdTree) ниши.
"defs": [
{ "defName": "TreeOakA", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakA" },
{ "defName": "TreeOakB", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakB" },
{ "defName": "TreeBirchA", "parent": "BaseTree", "label": "plant.birch", "texture": "things/plant/treebirch/TreeBirchA" },
{ "defName": "TreeGrayPineA", "parent": "BaseTree", "label": "plant.pine", "texture": "things/plant/treegraypine/TreeGrayPineA" },
{ "defName": "TreeMapleA", "parent": "BaseTree", "label": "plant.maple", "texture": "things/plant/treemaple/TreeMapleA" },
{ "defName": "TreeMapleB", "parent": "BaseTree", "label": "plant.maple", "texture": "things/plant/treemaple/TreeMapleB" },
{ "defName": "TreePoplarA", "parent": "BaseTree", "label": "plant.poplar", "texture": "things/plant/treepoplar/TreePoplarA" },
{ "defName": "TreeTeakA", "parent": "BaseWarmTree", "label": "plant.teak", "texture": "things/plant/treeteak/TreeTeakA" },
{ "defName": "TreeTeakB", "parent": "BaseWarmTree", "label": "plant.teak", "texture": "things/plant/treeteak/TreeTeakB" },
{ "defName": "TreeCypressA", "parent": "BaseColdTree", "label": "plant.cypress", "texture": "things/plant/treecypress/TreeCypressA" },
{ "defName": "TreeCypressB", "parent": "BaseColdTree", "label": "plant.cypress", "texture": "things/plant/treecypress/TreeCypressB" }
]
}
+116
View File
@@ -0,0 +1,116 @@
{
"type": "Plant",
// Общие (абстрактные) растения: базовые геномы по типам ниш. Конкретные виды наследуют их
// через "parent" и лежат рядом в файлах по семействам (Trees, Bushes, Cacti, …).
"defs": [
{ "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 10,
"coldHardiness": 14, "heatHardiness": 12,
"optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0,
"lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
"fruitYield": 5, "fruitSeason": 2, "harvestAmount": 10, "leafHue": 0.30 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
// Деревья тёплой и холодной температурных ниш (showcase температурных генов).
{ "defName": "BaseWarmTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.65, "lightTolerance": 0.5, "optimalTemperature": 23, "temperatureTolerance": 9,
"coldHardiness": 8, "heatHardiness": 16,
"optimalFertility": 1.5, "fertilityTolerance": 0.9, "vigor": 1.1,
"lifespan": 150, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
"fruitYield": 5, "fruitSeason": 2, "harvestAmount": 10, "leafHue": 0.22 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
{ "defName": "BaseColdTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.26,
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.55, "lightTolerance": 0.5, "optimalTemperature": 8, "temperatureTolerance": 12,
"coldHardiness": 18, "heatHardiness": 8,
"optimalFertility": 1.2, "fertilityTolerance": 1.0, "vigor": 0.9,
"lifespan": 220, "dispersalRange": 2, "reproduceInterval": 16, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.12, "spread": 0.08,
"fruitYield": 4, "fruitSeason": 2, "harvestAmount": 12, "leafHue": 0.45 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
{ "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4,
"fruitProduct": "ProductBerry",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 9,
"coldHardiness": 10, "heatHardiness": 8,
"optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0,
"lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5,
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
"fruitYield": 3, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.32 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 3, "label": "plant.stage.sprout" },
{ "sizeCells": 1.4, "label": "plant.stage.mature" }
] },
// Кактусы: жаролюбивы, засухоустойчивы (низкий оптимум почвы), морозо-нестойки → гибнут в морозы.
{ "defName": "BaseCactus", "abstract": true, "label": "plant.cactus", "sizeCells": 1.2, "trunkRadiusCells": 0,
"harvestProduct": "ProductFiber",
"genome": { "optimalLight": 0.95, "lightTolerance": 0.35, "optimalTemperature": 26, "temperatureTolerance": 8,
"coldHardiness": 20, "heatHardiness": 18,
"optimalFertility": 0.4, "fertilityTolerance": 0.5, "vigor": 0.5,
"lifespan": 300, "dispersalRange": 2, "reproduceInterval": 25, "selfPollination": 0.6,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
"fruitYield": 3, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.5 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 5, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] },
// Грибы: подлесок леса. Низкий оптимум света (0.15) → растут в тени крон, где другим темно
// (showcase световой пригодности). Богатая почва, короткая жизнь, быстрое спороношение.
{ "defName": "BaseMushroom", "abstract": true, "label": "plant.mushroom", "sizeCells": 0.9, "trunkRadiusCells": 0,
"harvestProduct": "ProductMushroom",
"genome": { "optimalLight": 0.15, "lightTolerance": 0.22, "optimalTemperature": 14, "temperatureTolerance": 11,
"coldHardiness": 12, "heatHardiness": 8,
"optimalFertility": 1.6, "fertilityTolerance": 1.1, "vigor": 1.2,
"lifespan": 25, "dispersalRange": 3, "reproduceInterval": 4, "selfPollination": 0.9,
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
"fruitYield": 0, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.5 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.4, "growDays": 2, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] },
// Луг: цветы (декоративные, быстрое расселение) и ягодный почвопокров (еда).
{ "defName": "BaseFlower", "abstract": true, "label": "plant.flower", "sizeCells": 1.0, "trunkRadiusCells": 0,
"genome": { "optimalLight": 0.9, "lightTolerance": 0.35, "optimalTemperature": 20, "temperatureTolerance": 9,
"coldHardiness": 8, "heatHardiness": 8,
"optimalFertility": 1.0, "fertilityTolerance": 1.1, "vigor": 1.4,
"lifespan": 18, "dispersalRange": 4, "reproduceInterval": 3, "selfPollination": 0.85,
"mutationRate": 0.08, "variantChance": 0.3, "spread": 0.1,
"fruitYield": 0, "fruitSeason": 0, "harvestAmount": 0, "leafHue": 0.2 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.4, "growDays": 2, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] },
{ "defName": "BaseBerryBush", "abstract": true, "label": "plant.berrybush", "sizeCells": 1.2, "trunkRadiusCells": 0,
"fruitProduct": "ProductBerry",
"genome": { "optimalLight": 0.8, "lightTolerance": 0.4, "optimalTemperature": 18, "temperatureTolerance": 9,
"coldHardiness": 10, "heatHardiness": 7,
"optimalFertility": 1.2, "fertilityTolerance": 1.0, "vigor": 1.1,
"lifespan": 40, "dispersalRange": 2, "reproduceInterval": 6, "selfPollination": 0.6,
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
"fruitYield": 4, "fruitSeason": 2, "harvestAmount": 0, "leafHue": 0.33 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 3, "label": "plant.stage.sprout" },
{ "label": "plant.stage.mature" }
] }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"type": "Product",
// Продукты сбора/плодоношения растений. На них ссылаются PlantDef.harvestProduct / fruitProduct;
// количество задаётся генами (harvestAmount / fruitYield).
"defs": [
{ "defName": "ProductWood", "label": "product.wood", "kind": "material" },
{ "defName": "ProductGrass", "label": "product.grass", "kind": "material" },
{ "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" },
// Животные продукты (фаза A5): добываются из трупа (разделка/гниение). Инвентарь/добыча — позже.
{ "defName": "ProductMeat", "label": "product.meat", "kind": "food" },
{ "defName": "ProductBone", "label": "product.bone", "kind": "material" },
{ "defName": "ProductLeather", "label": "product.leather", "kind": "material" }
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"type": "Terrain",
"defs": [
{ "defName": "DeepWater", "label": "terrain.deepwater", "maxHeight": 0.30, "color": [23, 63, 95] },
{ "defName": "Water", "label": "terrain.water", "maxHeight": 0.42, "color": [32, 99, 155] },
{ "defName": "Sand", "label": "terrain.sand", "maxHeight": 0.46, "color": [237, 201, 120],
"isLand": true, "surface": "terrain/surfaces/sand", "fertility": 0.4,
"scatter": [
{ "chance": 0.05, "options": ["SaguaroCactusA", "SaguaroCactusB"] },
{ "chance": 0.10, "options": ["PebbleCactusA", "PebbleCactusB", "PincushionCactusA"] },
{ "chance": 0.04, "options": ["AgaveA", "AgaveB"] }
] },
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.72, "color": [90, 160, 70],
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
"scatter": [
{ "chance": 1.0, "options": ["GrassA"] },
{ "chance": 0.5, "options": ["GrassA"] },
{ "chance": 0.22, "options": ["BushA", "BushB", "BushC"] },
{ "chance": 0.20, "options": ["Strawberry", "Raspberry"] },
{ "chance": 0.14, "options": ["DandelionA", "DandelionB", "DandelionC", "DaylilyA", "DaylilyB", "RoseA"] }
] },
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
"scatter": [
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA",
"TreeMapleA", "TreeMapleB", "TreePoplarA", "TreeCypressA", "TreeCypressB", "TreeTeakA", "TreeTeakB"] },
{ "chance": 0.6, "options": ["GrassA"] },
{ "chance": 0.16, "options": ["BushA", "BushB", "BushC"] },
{ "chance": 0.14, "options": ["Strawberry", "Raspberry"] },
{ "chance": 0.16, "options": ["GlowstoolA", "GlowstoolB", "TimbershroomA", "TimbershroomB", "NutrifungusA"] }
] },
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"type": "Thought",
// Мысли настроения (фаза A8): событие вешает на зверя временный сдвиг настроения, затухающий за
// durationDays. Триггер события — код (реестр AnimalThoughts), сила/длительность/метка — данные.
// Настроение есть только у тира 4+ (мозг ≥ 0.40); непрерывный фон (голод/жажда/усталость/боль)
// считается отдельно, мысли — поверх него. Негативные событийные мысли (потеря детёныша) придут с
// родительской опекой (горизонт) — пока горе идёт через непрерывный фон.
"defs": [
{ "defName": "GaveBirth", "label": "thought.gaveBirth", "moodOffset": 0.25, "durationDays": 3 },
{ "defName": "Mated", "label": "thought.mated", "moodOffset": 0.15, "durationDays": 1.5 },
{ "defName": "QuenchedThirst", "label": "thought.quenchedThirst", "moodOffset": 0.1, "durationDays": 0.5 }
]
}
@@ -1,8 +1,8 @@
{ {
"type": "WorldPreset", "type": "WorldPreset",
"defs": [ "defs": [
{ "defName": "Small", "label": "preset.small", "order": 0, "width": 80, "height": 50, "population": 40 }, { "defName": "Small", "label": "preset.small", "order": 0, "width": 128, "height": 80, "population": 60 },
{ "defName": "Medium", "label": "preset.medium", "order": 1, "width": 120, "height": 70, "population": 80 }, { "defName": "Medium", "label": "preset.medium", "order": 1, "width": 200, "height": 120, "population": 110 },
{ "defName": "Large", "label": "preset.large", "order": 2, "width": 180, "height": 110, "population": 140 } { "defName": "Large", "label": "preset.large", "order": 2, "width": 300, "height": 180, "population": 180 }
] ]
} }
-73
View File
@@ -1,73 +0,0 @@
{
"type": "Gene",
// Организм-агностичные гены: каждый ген задаёт, как генерируются/мутируют аллели и как ген
// вкладывается в признаки (effects: имя признака → формула; value — выраженное значение гена).
// Этот набор покрывает измерения генома растения (его используют системы роста/жизненного цикла).
"defs": [
{ "defName": "BaseNumericGene", "abstract": true, "kind": "Numeric",
"spread": 0.08, "mutationChance": 0.05, "mutationMagnitude": 0.12 },
{ "defName": "GeneOptimalLight", "parent": "BaseNumericGene", "label": "gene.optimalLight",
"default": 0.6, "min": 0.0, "max": 1.0, "tags": ["environment", "light"],
"effects": { "optimalLight": "value" } },
{ "defName": "GeneLightTolerance", "parent": "BaseNumericGene", "label": "gene.lightTolerance",
"default": 0.5, "min": 0.05, "max": 1.0, "tags": ["environment", "light"],
"effects": { "lightTolerance": "value" } },
{ "defName": "GeneOptimalTemperature", "parent": "BaseNumericGene", "label": "gene.optimalTemperature",
"default": 14, "min": -20, "max": 45, "tags": ["environment", "temperature"],
"effects": { "optimalTemperature": "value" } },
{ "defName": "GeneTemperatureTolerance", "parent": "BaseNumericGene", "label": "gene.temperatureTolerance",
"default": 16, "min": 2, "max": 40, "tags": ["environment", "temperature"],
"effects": { "temperatureTolerance": "value" } },
{ "defName": "GeneOptimalFertility", "parent": "BaseNumericGene", "label": "gene.optimalFertility",
"default": 1.4, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
"effects": { "optimalFertility": "value" } },
{ "defName": "GeneFertilityTolerance", "parent": "BaseNumericGene", "label": "gene.fertilityTolerance",
"default": 0.9, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
"effects": { "fertilityTolerance": "value" } },
{ "defName": "GeneVigor", "parent": "BaseNumericGene", "label": "gene.vigor",
"default": 1.0, "min": 0.1, "max": 3.0, "tags": ["growth"],
"effects": { "vigor": "value" } },
{ "defName": "GeneLifespan", "parent": "BaseNumericGene", "label": "gene.lifespan",
"default": 160, "min": 5, "max": 500, "tags": ["lifecycle"],
"effects": { "lifespan": "value" } },
{ "defName": "GeneDispersalRange", "parent": "BaseNumericGene", "label": "gene.dispersalRange",
"default": 2, "min": 1, "max": 8, "tags": ["lifecycle", "reproduction"],
"effects": { "dispersalRange": "value" } },
{ "defName": "GeneReproduceInterval", "parent": "BaseNumericGene", "label": "gene.reproduceInterval",
"default": 14, "min": 1, "max": 60, "tags": ["lifecycle", "reproduction"],
"effects": { "reproduceInterval": "value" } },
{ "defName": "GeneSelfPollination", "parent": "BaseNumericGene", "label": "gene.selfPollination",
"default": 0.2, "min": 0.0, "max": 1.0, "tags": ["reproduction"],
"effects": { "selfPollination": "value" } },
{ "defName": "GeneMutationRate", "parent": "BaseNumericGene", "label": "gene.mutationRate",
"default": 0.05, "min": 0.0, "max": 1.0, "tags": ["reproduction"],
"effects": { "mutationRate": "value" } },
// --- Контент (фаза G4): плодоношение, добыча, цвет ---
{ "defName": "GeneFruitYield", "parent": "BaseNumericGene", "label": "gene.fruitYield",
"default": 0, "min": 0, "max": 12, "tags": ["fruiting"],
"effects": { "fruitYield": "value" } },
{ "defName": "GeneFruitSeason", "parent": "BaseNumericGene", "label": "gene.fruitSeason",
"default": 1, "min": 0, "max": 3, "spread": 0, "mutationChance": 0, "tags": ["fruiting"],
"effects": { "fruitSeason": "value" } },
{ "defName": "GeneHarvestAmount", "parent": "BaseNumericGene", "label": "gene.harvestAmount",
"default": 1, "min": 0, "max": 50, "tags": ["harvest"],
"effects": { "harvestAmount": "value" } },
{ "defName": "GeneLeafHue", "parent": "BaseNumericGene", "label": "gene.leafHue",
"default": 0.33, "min": 0, "max": 1, "spread": 0.04, "tags": ["morphology", "color"],
"effects": { "leafHue": "value" } },
// Производный ген: признак собирается группировкой по регэкспу — сумма всех генов-толерантностей
// (демонстрация gsom-функций фазы G5). Собственное значение гена не используется.
{ "defName": "GeneHardiness", "parent": "BaseNumericGene", "label": "gene.hardiness",
"default": 0, "min": 0, "max": 1, "spread": 0, "mutationChance": 0, "tags": ["derived"],
"effects": { "hardiness": "gsum('Gene.*Tolerance')" } },
// Дискретный ген морфы: вариант 0 доминирует, 1 (рецессивный) виден только в гомозиготе.
{ "defName": "GeneMorph", "kind": "Discrete", "label": "gene.morph",
"variants": 2, "variantWeights": [0.82, 0.18], "mutationChance": 0.05, "tags": ["morphology"],
"effects": { "variant": "value" } }
]
}
-48
View File
@@ -1,48 +0,0 @@
{
"type": "Plant",
"defs": [
{ "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 16,
"optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0,
"lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
"fruitYield": 5, "fruitSeason": 2, "harvestAmount": 10, "leafHue": 0.30 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 4, "label": "plant.stage.seedling" },
{ "sizeCells": 1.2, "growDays": 9, "label": "plant.stage.sapling" },
{ "sizeCells": 2.0, "label": "plant.stage.mature" }
] },
{ "defName": "TreeOakA", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakA" },
{ "defName": "TreeOakB", "parent": "BaseTree", "label": "plant.oak", "texture": "things/plant/treeoak/TreeOakB" },
{ "defName": "TreeBirchA", "parent": "BaseTree", "label": "plant.birch", "texture": "things/plant/treebirch/TreeBirchA" },
{ "defName": "TreeGrayPineA", "parent": "BaseTree", "label": "plant.pine", "texture": "things/plant/treegraypine/TreeGrayPineA" },
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2,
"harvestProduct": "ProductGrass",
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 20, "temperatureTolerance": 12,
"optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
"harvestAmount": 2, "leafHue": 0.36 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.5, "growDays": 2, "label": "plant.stage.sprout" },
{ "sizeCells": 1.2, "label": "plant.stage.mature" }
] },
{ "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4,
"fruitProduct": "ProductBerry",
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 13,
"optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0,
"lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5,
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
"fruitYield": 3, "fruitSeason": 1, "harvestAmount": 3, "leafHue": 0.32 },
"stages": [
{ "texture": "things/plant/seed_default", "sizeCells": 0.6, "growDays": 3, "label": "plant.stage.sprout" },
{ "sizeCells": 1.4, "label": "plant.stage.mature" }
] },
{ "defName": "BushA", "parent": "BaseBush", "texture": "things/plant/bush/BushA" },
{ "defName": "BushB", "parent": "BaseBush", "texture": "things/plant/bush/BushB" },
{ "defName": "BushC", "parent": "BaseBush", "texture": "things/plant/bush/BushC" }
]
}
-11
View File
@@ -1,11 +0,0 @@
{
"type": "Product",
// Продукты сбора/плодоношения растений. На них ссылаются PlantDef.harvestProduct / fruitProduct;
// количество задаётся генами (harvestAmount / fruitYield).
"defs": [
{ "defName": "ProductWood", "label": "product.wood", "kind": "material" },
{ "defName": "ProductGrass", "label": "product.grass", "kind": "material" },
{ "defName": "ProductBerry", "label": "product.berry", "kind": "food" },
{ "defName": "ProductAcorn", "label": "product.acorn", "kind": "food" }
]
}
-22
View File
@@ -1,22 +0,0 @@
{
"type": "Terrain",
"defs": [
{ "defName": "DeepWater", "label": "terrain.deepwater", "maxHeight": 0.30, "color": [23, 63, 95] },
{ "defName": "Water", "label": "terrain.water", "maxHeight": 0.42, "color": [32, 99, 155] },
{ "defName": "Sand", "label": "terrain.sand", "maxHeight": 0.48, "color": [237, 201, 120],
"isLand": true, "surface": "terrain/surfaces/sand", "fertility": 0.4 },
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.68, "color": [90, 160, 70],
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
"scatter": [
{ "chance": 0.35, "options": ["GrassA"] },
{ "chance": 0.07, "options": ["BushA", "BushB", "BushC"] }
] },
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
"scatter": [
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA"] }
] },
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
]
}
+82 -2
View File
@@ -6,7 +6,65 @@
"season.summer": "summer", "season.summer": "summer",
"season.autumn": "autumn", "season.autumn": "autumn",
"season.winter": "winter", "season.winter": "winter",
"hud.controls": "WASD — camera, wheel — zoom, ` — console, F1 — inspector", "hud.controls": "WASD — camera, wheel — zoom, LMB — select, ` — console, F1 — inspector",
"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",
"inspect.age": "Age: {0:0.0} d · lives to {1:0}",
"inspect.state": "State: {0}",
"inspect.state.growing": "growing",
"inspect.state.mature": "mature",
"inspect.state.dormantcold": "dormant — too cold",
"inspect.state.dormanthot": "dormant — too hot",
"inspect.stress": "Stress: {0:0.0} d to death",
"inspect.temp": "Temperature: grows {0:0}…{1:0}°C, optimal {2:0}…{3:0}°C",
"inspect.genes": "— Genes —",
"inspect.gene.vigor": "Vigor {0:0.00} · lifespan {1:0} d",
"inspect.gene.env": "Opt. light {0:0.00} · opt. soil {1:0.00}",
"inspect.gene.repro": "Dispersal {0:0.0} · interval {1:0.0} d",
"inspect.gene.repro2": "Self-pollin. {0:0.00} · mutation {1:0.00}",
"inspect.gene.hardy": "Cold/heat {0:0}/{1:0} · leaf hue {2:0.00}",
"inspect.gene.variant": "Morph: recessive variant",
"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",
"hediff.bleeding": "bleeding",
"inspect.hint": "LMB — select · RMB/Esc — clear",
"hud.paused": "PAUSED", "hud.paused": "PAUSED",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation", "menu.subtitle": "a god-game: minimal graphics, deep simulation",
@@ -35,6 +93,7 @@
"settings.vsync": "Vertical sync", "settings.vsync": "Vertical sync",
"settings.resolution": "Resolution", "settings.resolution": "Resolution",
"settings.volume": "Volume", "settings.volume": "Volume",
"settings.uiscale": "UI scale",
"settings.apply": "Apply", "settings.apply": "Apply",
"settings.back": "Back", "settings.back": "Back",
"pause.title": "Paused", "pause.title": "Paused",
@@ -63,14 +122,31 @@
"plant.pine": "pine", "plant.pine": "pine",
"plant.grass": "grass tuft", "plant.grass": "grass tuft",
"plant.bush": "bush", "plant.bush": "bush",
"plant.maple": "maple",
"plant.poplar": "poplar",
"plant.teak": "teak",
"plant.cypress": "cypress",
"plant.cactus": "cactus",
"plant.saguaro": "saguaro",
"plant.agave": "agave",
"plant.mushroom": "mushroom",
"plant.flower": "flower",
"plant.dandelion": "dandelion",
"plant.daylily": "daylily",
"plant.rose": "rose",
"plant.berrybush": "berry bush",
"plant.strawberry": "strawberry",
"plant.raspberry": "raspberry",
"plant.stage.seedling": "seedling", "plant.stage.seedling": "seedling",
"plant.stage.sprout": "sprout", "plant.stage.sprout": "sprout",
"plant.stage.sapling": "sapling", "plant.stage.sapling": "sapling",
"plant.stage.mature": "mature", "plant.stage.mature": "mature",
"product.wood": "wood", "product.wood": "wood",
"product.grass": "grass", "product.grass": "grass",
"product.fiber": "fiber",
"product.berry": "berries", "product.berry": "berries",
"product.acorn": "acorn", "product.acorn": "acorn",
"product.mushroom": "mushrooms",
"pawn.being": "being", "pawn.being": "being",
"pawn.bear": "bear", "pawn.bear": "bear",
"pawn.deer": "deer", "pawn.deer": "deer",
@@ -80,9 +156,13 @@
"pawn.wolf": "wolf", "pawn.wolf": "wolf",
"pawn.muffalo": "muffalo", "pawn.muffalo": "muffalo",
"pawn.squirrel": "squirrel", "pawn.squirrel": "squirrel",
"thought.gaveBirth": "gave birth",
"thought.mated": "mated",
"thought.quenchedThirst": "drank its fill",
"net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu", "net.hud": "Multiplayer: {0} | beings: {1} | Esc — back to menu",
"net.connecting": "connecting…", "net.connecting": "connecting…",
"net.connected": "connected", "net.connected": "connected",
"net.failed": "connection failed", "net.failed": "connection failed",
"net.lost": "connection lost" "net.lost": "connection lost",
"net.reconnecting": "reconnecting…"
} }
+82 -2
View File
@@ -6,7 +6,65 @@
"season.summer": "лето", "season.summer": "лето",
"season.autumn": "осень", "season.autumn": "осень",
"season.winter": "зима", "season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор", "hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор",
"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": "Полностью выросло",
"inspect.age": "Возраст: {0:0.0} дн · живёт до {1:0}",
"inspect.state": "Состояние: {0}",
"inspect.state.growing": "растёт",
"inspect.state.mature": "созрело",
"inspect.state.dormantcold": "покой — слишком холодно",
"inspect.state.dormanthot": "покой — слишком жарко",
"inspect.stress": "Стресс: {0:0.0} дн до гибели",
"inspect.temp": "Температура: рост {0:0}…{1:0}°C, оптимум {2:0}…{3:0}°C",
"inspect.genes": "— Гены —",
"inspect.gene.vigor": "Бодрость {0:0.00} · жизнь {1:0} дн",
"inspect.gene.env": "Опт. свет {0:0.00} · опт. почва {1:0.00}",
"inspect.gene.repro": "Расселение {0:0.0} кл · период {1:0.0} дн",
"inspect.gene.repro2": "Самоопыление {0:0.00} · мутации {1:0.00}",
"inspect.gene.hardy": "Морозо/жаро {0:0}/{1:0} · оттенок {2:0.00}",
"inspect.gene.variant": "Морфа: рецессивный вариант",
"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": "лихорадка",
"hediff.bleeding": "кровотечение",
"inspect.hint": "ЛКМ — выбрать · ПКМ/Esc — снять",
"hud.paused": "ПАУЗА", "hud.paused": "ПАУЗА",
"menu.title": "LittleSim", "menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции", "menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
@@ -35,6 +93,7 @@
"settings.vsync": "Вертикальная синхронизация", "settings.vsync": "Вертикальная синхронизация",
"settings.resolution": "Разрешение", "settings.resolution": "Разрешение",
"settings.volume": "Громкость", "settings.volume": "Громкость",
"settings.uiscale": "Масштаб UI",
"settings.apply": "Применить", "settings.apply": "Применить",
"settings.back": "Назад", "settings.back": "Назад",
"pause.title": "Пауза", "pause.title": "Пауза",
@@ -63,14 +122,31 @@
"plant.pine": "сосна", "plant.pine": "сосна",
"plant.grass": "пучок травы", "plant.grass": "пучок травы",
"plant.bush": "куст", "plant.bush": "куст",
"plant.maple": "клён",
"plant.poplar": "тополь",
"plant.teak": "тик",
"plant.cypress": "кипарис",
"plant.cactus": "кактус",
"plant.saguaro": "сагуаро",
"plant.agave": "агава",
"plant.mushroom": "гриб",
"plant.flower": "цветок",
"plant.dandelion": "одуванчик",
"plant.daylily": "лилейник",
"plant.rose": "роза",
"plant.berrybush": "ягодный куст",
"plant.strawberry": "земляника",
"plant.raspberry": "малина",
"plant.stage.seedling": "росток", "plant.stage.seedling": "росток",
"plant.stage.sprout": "всходы", "plant.stage.sprout": "всходы",
"plant.stage.sapling": "саженец", "plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое", "plant.stage.mature": "взрослое",
"product.wood": "древесина", "product.wood": "древесина",
"product.grass": "трава", "product.grass": "трава",
"product.fiber": "волокно",
"product.berry": "ягоды", "product.berry": "ягоды",
"product.acorn": "жёлудь", "product.acorn": "жёлудь",
"product.mushroom": "грибы",
"pawn.being": "житель", "pawn.being": "житель",
"pawn.bear": "медведь", "pawn.bear": "медведь",
"pawn.deer": "олень", "pawn.deer": "олень",
@@ -80,9 +156,13 @@
"pawn.wolf": "волк", "pawn.wolf": "волк",
"pawn.muffalo": "муффало", "pawn.muffalo": "муффало",
"pawn.squirrel": "белка", "pawn.squirrel": "белка",
"thought.gaveBirth": "родила потомство",
"thought.mated": "спарилась",
"thought.quenchedThirst": "напилась вволю",
"net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню", "net.hud": "Мультиплеер: {0} | жителей: {1} | Esc — в меню",
"net.connecting": "подключение…", "net.connecting": "подключение…",
"net.connected": "подключено", "net.connected": "подключено",
"net.failed": "не удалось подключиться", "net.failed": "не удалось подключиться",
"net.lost": "соединение потеряно" "net.lost": "соединение потеряно",
"net.reconnecting": "переподключение…"
} }
+20
View File
@@ -60,6 +60,26 @@ Mods/<Имя>/
вид — `being`/`animal`). CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип вид — `being`/`animal`). CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип
дефа = новый класс + `RegisterType` в `GameContent.Load`. дефа = новый класс + `RegisterType` в `GameContent.Load`.
`Defs/` сканируется рекурсивно (`Defs/**/*.json`), и имя файла ни на что не влияет —
важны только `type` и `defName`. Поэтому контент Core разложен по подпапкам для удобства
поиска: одна папка на тип дефа, внутри — общие (абстрактные) дефы в `_Bases.json` и
конкретные, разбитые по семействам:
```
Defs/
Genes/ _Bases.json, Environment.json, Growth.json, Content.json
Plants/ _Bases.json, Trees.json, Bushes.json, Grass.json, Cacti.json,
Mushrooms.json, Flowers.json, Berries.json
Pawns/ _Bases.json, Beings.json, Animals.json
Terrain/ Terrain.json
Products/ Products.json
Patches/ Patches.json
WorldPresets/ WorldPresets.json
```
`parent` разрешается после слияния всех файлов, так что ссылка на родителя из другого
файла/папки (`TreeOakA``BaseTree`) работает независимо от расположения.
## Локализация ## Локализация
`Languages/<код>/*.json` — плоские словари «ключ → строка»: `Languages/<код>/*.json` — плоские словари «ключ → строка»:
-36
View File
@@ -1,36 +0,0 @@
# Roadmap LittleSim
Этап закрыт, когда механика работает, наблюдаема (HUD/консоль/визуал)
и управляется командой консоли.
## Этап 0 — Каркас ✅
- [x] Репозиторий с движком-сабмодулем, общий solution
- [x] Генерация рельефа по сиду (вода/песок/трава/лес/горы)
- [x] Жители-заглушки (блуждание), камера бога, HUD, консоль (`regen`)
## Этап 1 — Жители как агенты
- [~] Потребности: голод, усталость (тикают со временем) — усталость/энергия готова (`PawnNeeds`), голод впереди
- [~] Utility-выбор действия: искать еду / отдыхать / бродить — каркас на движке (`MrGameEng.AI`), выбор «отдыхать/бродить» работает; «искать еду» впереди
- [ ] Еда на траве/в лесу: растёт, собирается, истощается
- [ ] Смерть от голода; визуальное состояние жителя (цвет/прозрачность)
- [ ] Консоль: `spawn <n>`, `feed`, `starve` для тестов
## Этап 2 — Общество
- [ ] Дома и поселения (житель несёт еду домой, спит дома)
- [ ] Размножение при сытости; рост населения
- [ ] Простые профессии: собиратель, строитель
## Этап 3 — Бог
- [ ] Вера как ресурс (генерируется жителями)
- [ ] Первые силы: дождь (ускоряет рост еды), молния (поджигает лес)
- [ ] Терраформинг клеток
## Этап 4 — Экология и время
- [ ] День/ночь, сезоны
- [ ] Восстановление/деградация ресурсов, пожары
- [ ] Миграции при нехватке ресурсов
## Бэклог
- Сохранение/загрузка мира (сериализация Friflo)
- Фиксированный тик симуляции, независимый от рендера
- Исторические графики (население, еда) в дев-оверлее
+572
View File
@@ -0,0 +1,572 @@
# Животные (зоология)
> Статус: **A1–A8 реализованы целиком + сериализация животных** — базовый план зоологии и его сквозное
> требование сейв/лоада закрыты; дальше экологический горизонт (хищники, опека, эпидемии, миграция) и
> бэклог реализма. Это живой план — стиль и правила как в [концепте](concept.md) и [модах](mods.md).
>
> **Сериализация животных (готово): сейв/лоад популяции и трупов.** `WorldSave` получил `Animals`
> (`AnimalSave`: геном→признаки+пол, поколение, стадия/возраст, нужды+истощение, здоровье — кровь/HP
> частей/хедифы тяжесть+иммунитет, настроение+мысли, беременность+геном отца) и `Corpses` (`CorpseSave`:
> вид/позиция/таймер-стадия разложения/мясо). `SaveAnimals` снимает популяцию (Health/Mood/Pregnant — через
> entity, т.к. запрос ≤5 типов); `RestoreAnimals`/`RestoreAnimalState` собирают особь фабрикой из генома и
> накладывают динамическое состояние (ref-правки до структурного `AddComponent(Pregnant)`; труп
> пере-тинтится через `Stage=-1`). `Corpse` получил поле `Species` (для спрайта/разделки); `CreateCorpse`
> теперь принимает индекс вида. На загрузке стадо НЕ пересыпается, если в сейве есть животные; старый/пустой
> сейв — регенерируется из сида (не крашит). Эволюция/болезни/беременности переживают сейв/лоад. Сборка +
> `--check-content` чистые.
>
> **A8 часть 2 (готово): инспектор животных.** `InspectPanel` теперь осматривает и животных (не только
> растения): вкладки **Обзор** (стадия/возраст/пол/поколение/беременность), **Нужды** (значения нужд %),
> **Здоровье** (кровь/боль, способности Consciousness/Moving/Sight, активные хедифы с тяжестью),
> **Настроение** (значение % + список мыслей с их сдвигом). Набор вкладок зависит от типа выбранного
> (растение vs животное). `SelectionSystem` теперь пикает и животных (запрос расширен с `PlantGrowth` на
> `AnimalOrganism`). Локализация `inspect.*`/`need.*`/`hediff.*`/`cap.*`/`animalstage.*` (ru/en). Сборка +
> `--check-content` чистые.
>
> **A8 часть 1 (готово): настроение — ядро.** Тип дефа `ThoughtDef` + `thoughts.json` (мысли: роды/
> спаривание/утоление жажды — сила и длительность данными); компонент `Mood` (значение 0..1, активность по
> тиру мозга, список затухающих мыслей) на всех животных; `AnimalMoodSystem` (тир 4+: настроение из
> непрерывного фона — средняя удовлетворённость убывающих нужд минус боль из здоровья — плюс сумма мыслей;
> мысли угасают). Реестр `ThoughtSet` + id-константы `AnimalThoughts`; событийные триггеры: роды
> (`AnimalPregnancySystem`), спаривание и утоление сильной жажды (`AnimalActionSystem`). **Эффект на
> поведение**: низкое настроение (стресс/истощение) подавляет спаривание в `AnimalDecisionSystem`. Команда
> `mood [species]`. Сборка + `--check-content` чистые (11 типов дефов). Осталось A8.2: вкладки инспектора
> (needs/health/mood) — сейчас животные в инспекторе не осматриваются (он только растительный).
>
> **A7 (готово): мозг → интеллект → гейт нужд/действий.** Эффективный интеллект = `brainSize` (ген) ×
> `Consciousness` (capacity из здоровья) — `AnimalFactory.Intelligence`. `AnimalContext` гейтит выбор ИИ:
> нужда с `minBrain` выше интеллекта особи в выбор не входит (`AnimalDecisionSystem` читает `Health`
> поэлементно). Пороги в `needs.json` — тиры мозга (голод 0.0, жажда 0.25, сон/секс 0.40; у оленя мозг
> ~0.45 → здоров весь набор). **Обратная связь от повреждения мозга**: болезнь/боль/кровопотеря роняют
> сознание → эффективный интеллект падает → первыми отключаются сон/спаривание (лихорадящий олень
> сводится к еде/питью). Команда `intel <species> [consciousness]`. Сборка + `--check-content` чистые.
>
> **A5 часть 1 (готово): смерть → труп → разложение.** `Corpse`-компонент + `CorpseSystem`
> (Fresh→Rotting→Skeleton→исчезает, скорость от температуры, тинт по стадии); смерть теперь оставляет
> труп (`AnimalFactory.CreateCorpse`, мясо ∝ размер тела) вместо деспавна; `AnimalDef.CorpseTexture` +
> `corpseTexture` оленя (`Dessicated_*`, скелет — бледный тинт, своего спрайта пока нет). Продукты
> `ProductMeat/Bone/Leather` в `products.json` (разделка/падальщики — позже). Сборка/линт чистые.
> **A5 часть 2 (готово): части тела, кровь, capacities.** `BodyDef`/`BodyPartDef` + `bodies.json`
> (`Quadruped` — данные: иерархия органов, вклады в способности); `AnimalDef.Body`; `HealthState`
> расширен `Parts`/`BloodLevel`/`Pain`/`Capacities`; `CapacityCalc` (обобщённый расчёт по зависимостям
> кровь→сердце/лёгкие→сознание→движение/зрение, модовые способности — сырыми); фабрика строит части
> (HP × размер тела) и считает способности. Источников урона ещё нет → всё на полном HP, способности =
> 1 (каркас под болезни A6/хищников). Команда `body <species>`. Сборка/линт чистые (10 типов дефов).
> Осталось в A5: смерть через здоровье (vital-часть/кровь) + истощение как hediff — когда появятся раны.
>
> **A6 (готово): болезни и иммунитет.** `HediffDef` расширён (severity/immunity/lethal/pain/capMods/
> ambient); хедифы теперь инстансы (`Hediff` с тяжестью/иммунитетом). `AnimalHealthSystem` (медленный
> тик ~час): фоновое заражение, гонка тяжесть↔иммунитет (выздоровление или смерть), пересчёт способностей
> с учётом боли и capMods; смерть по здоровью (летальная болезнь/кровь/потеря сознания) → труп. **Первый
> реальный урон** — `Moving` подключён к скорости (больные звери медленнее). Болезнь `Fever` в
> `hediffs.json`; команда `infect [hediff]`. Сборка/линт чистые.
>
> **A4 (готово):** нужда `Mating`; лёгкий hediff-каркас (`HediffDef`, `hediffs.json` с `Rut`,
> компонент `Health`/`HealthState`); гены `GeneBreedingSeason`/`GeneGestationDays`/`GeneLitterSize`;
> `AnimalRutSystem` (сезонный гон-хедиф поднимает влечение — вне сезона зачатия нет); действие
> `Mate` (поиск партнёра, сближение); `Pregnant` + `AnimalPregnancySystem` (вынашивание → роды
> через `Genome.Breed`, наследование пола без YY, поколение+1, потолок численности). Сборка
> чистая, `--check-content` (33 гена).
>
> **Рефактор расширяемости (готово):** устранены хардкод-блокеры — данными стали: **геном вида**
> (`AnimalDef.Genome` = словарь `geneId→base`, шаблон строится generically); **нужды**
> (`NeedDef`/`needs.json` + `NeedSet` + динамический `AnimalNeeds.Values` + реестр поведений по id);
> **диета** (`AnimalDef.Diet`); **стадии роста** (`AnimalStageDef`/`AnimalDef.Stages` — возраст входа,
> масштаб, текстуры, флаг «взрослая»; пусто → встроенный дефолт); **спавн** (`AnimalDef.SpawnPer1000Cells`
> — все виды рассыпаются по суше из данных, не только олень). Добавить ген/нужду/рацион/стадию/спавн
> виду = правка JSON без кода. Остаются как известные: реестр действий-кода (новое *поведение* = код,
> сознательная граница), часть глобальных баланс-констант (скорость/выпас/потолок).
>
> **A3 (готово):** компонент `AnimalGrowth` (стадия + возраст); ген пола `GeneSex` (XX/XY,
> генерация основателя без YY через `AnimalSet.GenerateGenome`) и ген `GeneMaturityAge`;
> стадии Baby→Juvenile→Adult→Senior с порогами из генов; `AnimalGrowthSystem` (смена
> спрайта детёныш/самец/самка и размера по стадии) + `AnimalMortalitySystem` (смерть от
> старости и от истощения → деспавн, замыкает контур ёмкости среды). Спавн стада —
> разновозрастный; команда `animal` показывает пол/созревание. Сборка чистая, `--check-content`
> (30 генов).
>
> **A1 (готово):** тип дефа `Animal` (`AnimalDef : PawnDef`) + `animals.json` (олень);
> 8 животных генов в `genes.json`; `AnimalPhenotype`/`AnimalOrganism`; `AnimalSet`
> (шаблон генома вида) + `AnimalFactory`; детерминированный спавн стада оленей по суше в
> `WorldScene` с рендером и тинтом меха по гену; консольная команда `animal <species>`.
>
> **A2 (готово):** нужды `AnimalNeeds` (голод/жажда/отдых) с падением по гену метаболизма;
> два слоя ИИ — `AnimalDecisionSystem` (движковый `UtilityAi`, выбор + поиск ближайшей
> еды/воды) и `AnimalActionSystem` (движение к цели + утоление: выедание растений, питьё у
> кромки воды, сон); `AnimalAppearanceSystem` (яркость по острой нужде); кромка воды из
> террейна; команда `popstats [species]` (средние признаков + размах поколений). Контур
> ёмкости среды частичный (трава выедается, но смерть от голода — в A3). Сборка чистая;
> визуальная проверка (олени ищут корм/воду, пасутся) — вручную: «Новый мир».
## Видение
Животные — следующая большая система после генов. Главный принцип: животное живёт по
**тем же законам, что и растение** — гены → признаки (фенотип) → поведение систем, —
но разворачивает их в полноценную зоологию: двуполое размножение с вынашиванием,
здоровье по модели RimWorld (части тела, органы, кровь, болезни), мозг и интеллект,
нужды и настроение. Цель прежняя: **эмерджентность** (динамика популяций, отбор,
пищевые цепи складываются из правил, а не скриптуются) и **наблюдаемость** (всё видно
в мире, HUD, инспекторе, консоли).
Первое показательное животное — **олень** (богатый набор текстур: ♂/♀/детёныш/труп).
## Опора на существующий фундамент
Ничего из перечисленного не строим заново — переиспользуем:
- организм-агностичный `GeneDef`, управляемый `Genome` (диплоидные аллели),
`Genome.Breed(мать, отец, mutationChance)`, `Phenotype.Compute`, `GenomeTemplate`
(шаблон вида, ср. `PlantSet.BuildTemplate`);
- формульный движок и regex-группы (`gsum('Gene.*')`) для производных признаков;
- движковый `UtilityAi<TContext>`, `ResponseCurve`, `Suitability.Trapezoid`;
- день/ночь, сезоны, климат, террейн `water`, `ProductDef`;
- паттерн компонентов растений: `…Organism { Genome; Phenotype Traits }`,
`…Growth { species, stage, ageDays }`, `…Needs`, `…Brain`, `AppearanceSystem` (sim→`Sprite`).
- Friflo допускает managed-ссылку в `IComponent` (доказано `Genome`/`Tilemap`) —
поэтому `Health`/`Pregnant` несут managed-объекты.
## Принцип моддинга: всё расширяется данными
Главное требование — **система должна целиком модифицироваться через моды без правки
кода**. Для этого:
1. **Никаких C#-`enum` для видового контента.** Виды, части тела, органы, способности
(capacities), болезни, нужды, мысли — это **дефы со строковыми id**, интернируемые
в int при загрузке (ровно как уже сделано с `geneId`). Enum нельзя расширить модом —
строковый id можно.
2. **Формулы вместо констант** везде, где можно (как `effects` у генов): модер меняет
баланс формулой в дефе, а не пересборкой.
3. **Реестр поведенческих действий** (`actionId → исполнитель-код`). Деф нужды/ИИ
ссылается на `actionId`. Граница честная и в духе проекта (движок без сэмпла,
фичи демонстрируются игрой): **комбинировать готовые кирпичи — мод; принципиально
новое поведение — код + деф.**
4. **Patch-система (фаза G5)** уже позволяет модеру менять чужие дефы по regex
(например, «всем хищникам +агрессия»).
5. Любой деф переопределяется одноимённым из позднего мода; локализация — в `Languages`.
Итог: модер добавляет новый вид зверя, орган, болезнь, нужду или мысль одними JSON-файлами.
## Геном животного
Животные гены живут в `genes.json` рядом с растительными (один организм-агностичный
тип `Gene`). Предлагаемый набор (числовые, если не сказано иное):
| Группа | Гены |
|---|---|
| Тело/метаболизм | `GeneMaxBodySize`, `GeneMetabolism`, `GeneMoveSpeed`, `GeneBloodVolume`, `GeneVision`, `GeneInsulation` (мех/терморегуляция) |
| Жизненный цикл | `GeneMaturityAge`, `GeneLifespan` (есть), `GeneGestationDays`, `GeneLitterSize` |
| Органы | `GeneHeartEfficiency`, `GeneLungCapacity`, `GeneImmunityGain`, `GeneHealRate`, `GenePainTolerance` |
| Мозг | `GeneBrainSize` → признак `brainSize` (см. «Мозг и интеллект») |
| Поведение | `GeneAggression`, `GeneSociability`, `GeneActivityPattern` (дневной/ночной/сумеречный) |
| Среда | переиспользуем `GeneOptimalTemperature/Tolerance/Cold/HeatHardiness` — звери тоже мёрзнут/перегреваются через ту же трапецию |
| Морфология | `GeneFurColor` (тинт спрайта, как `leafHue`), `GeneSex` (дискретный, см. ниже) |
**Пол выводится из диплоидности бесплатно.** Локус с аллелями {X=0, Y=1}: самка = XX,
самец = XY. При `Genome.Breed` мать может отдать только X (у неё нет Y), отец — X/Y
поровну → потомок XX/XY ровно 50/50, **без спец-кода в скрещивании**. У гена пола
`mutationChance: 0` (как у `GeneFruitSeason`), чтобы X не мутировал в Y.
> Тонкость генерации (не скрещивания): обычный `Generate` дискретного гена выбрал бы
> два аллеля независимо и мог бы выдать невозможный **YY**. Поэтому пол особи-основателя
> назначается явно — XX или XY (по 50/50) — а дальнейшее наследование корректно само.
Признаки собираются в `AnimalPhenotype.FromTraits(traits)` — зеркало `PlantPhenotype`.
Организм: `AnimalOrganism { Genome; AnimalPhenotype Traits; int Generation }` (поколение =
max(род.) + 1, у основателей 0 — для `popstats` и видимого дрейфа).
## Жизненный цикл и стадии роста
Зеркало `PlantGrowth`: `AnimalGrowth { species, stage, ageDays }`. Стадии задаются
данными в дефе вида: `Embryo` (в утробе) → `Baby``Juvenile``Adult``Senior`,
пороги из генов (`MaturityAge`, `Lifespan`). `AnimalGrowthSystem` двигает стадию,
масштабирует `sizeCells`, меняет спрайт/набор текстур (Baby ↔ ♀/♂). Стадия `Senior`
повышает шанс возрастных hediff и смерти — стык со здоровьем.
> **Путь смерти по фазам.** Чтобы на A4 (размножение) популяция не взрывалась без
> убыли, простая **смерть по старости и от голода/жажды (деспавн)** появляется уже в A3.
> На A5 она «апгрейдится»: смерть идёт через здоровье (возрастные hediff, истощение, отказ
> органов) и оставляет **труп** вместо деспавна.
## Нужды и ИИ
Обобщаем `PawnNeeds` (одна энергия) → `AnimalNeeds`: голод, жажда, сон, секс (0..1).
- скорости падения из `GeneMetabolism` × размер (масштаб по Клайберу: ∝ size^0.75);
- **голод** — ест растения/продукты. **Поедание реально воздействует на сущность
растения** (важно для контура ёмкости среды): выедает накопленные плоды
(`Fruiting.RipeFruit`) и/или убавляет биомассу/стадию растения; перевыпас может убить
растение. **Диета** (`AnimalDef.diet`) — список съедобного: теги растений/`ProductDef`
(для будущих хищников — живая добыча/мясо/трупы), плюс способ (выедание плодов vs выпас);
- **жажда** — пьёт на клетках террейна `water`;
- **сон** — спит в неактивную фазу суток (`GeneActivityPattern`): дневные ночью и т.д.;
- **секс** — удовлетворяется размножением.
ИИ разбит на **два слоя — выбор и исполнение**:
1. **Выбор**`AnimalDecisionSystem` на движковом `UtilityAi`: из активных нужд выбирает
действие `FindFood / FindWater / Sleep / SeekMate / Wander(default)` (готовые
`ResponseCurve`) и пишет его + цель в `AnimalBrain { Action; Target; DecideIn }`.
Действия и нужды описаны дефами `NeedDef` (decay-формула, `minBrain`).
2. **Исполнение** — отдельный слой систем (реестр `actionId → исполнитель`, см. «Принцип
моддинга») доводит выбранное действие до результата по схеме **найти цель → дойти →
выполнить → удовлетворить нужду**:
- *Eat*: ближайшее съедобное (плод/биомасса) → подойти → съесть (убавить растение) → ↑ сытость;
- *Drink*: ближайшая клетка `water` → подойти → попить → ↓ жажда;
- *Sleep*: остановиться → спать → ↓ усталость;
- *Mate*: подходящий партнёр → сблизиться → акт → `Pregnant` + сброс `Mating`;
- *Wander*: движение по умолчанию.
Без этого слоя нужда только падает, но не исполняется.
**Передвижение и поиск цели.** Движение к цели — рулевое смещение к точке (реюз
`Transform2D` + `WanderSystem`/`SeparationSystem` для обхода препятствий: стволы деревьев,
другие звери), без полноценного A* на старте. Вода непроходима для сухопутных → `Drink`
целит клетку суши у кромки воды. «Ближайшее съедобное/вода/партнёр» — **пространственный
запрос по сетке/тайлмапу** с радиусом из `Vision`/`Sight` (не O(n²)); ничьи в «ближайшем»
разрешаются детерминированно (по id сущности).
> Порядок фаз: формальный **гейтинг нужд интеллектом — фаза A7**. В A2 у оленя просто
> полный набор нужд зверя (тир 4); поле `minBrain` уже пишем в дефы, но активируется оно
> на A7 (см. «Мозг и интеллект»).
## Размножение (млекопитающие)
Главное отличие от растений — двуполость и отложенное во времени вынашивание:
1. **Влечение и гон (через хедиф).** Нужда `Mating` есть **круглый год** на низком фоне.
В брачный сезон вида на взрослых вешается **хедиф `Rut` (гон)**, который поднимает
влечение (множитель на рост `Mating` / снижение порога действия). **Только под `Rut`
влечение переходит порог `SeekMate` → вне сезона зачатия фактически нет**, хотя нужда
формально существует. Хедиф снимается с концом сезона и **виден в инспекторе** (как
любой hediff). Это унифицирует сезонность с hediff-механизмом, а не городит спец-флаг.
2. **Спаривание → беременность.** Два взрослых разного пола сближаются (действие `Mate`) →
акт → у самки появляется `Pregnant : IComponent { Genome FatherGenome; float
ConceivedDay; float GestationDays; int LitterSize }` — managed-ссылка на геном отца
(оплодотворение = «слепок» отца). Беременность повышает метаболизм (голод) и снижает
`Moving`. (Можно реализовать и как hediff, но держим компонентом ради managed-ссылки
на геном отца.)
3. **Роды.** По достижении срока спавним `LitterSize` детёнышей, каждому
`Genome.Breed(мать, отец, mutationRate)`, стадия `Baby`, рядом с матерью. Лактация/
опека — опционально после.
Стратегии r/K выводятся из генов: мелкие (заяц) — большой помёт, короткая жизнь;
крупные (олень/медведь) — мало потомства, долгая жизнь.
**Решённые параметры (A4):**
- **Гон = хедиф `Rut`** (а не «нужда растёт в сезон»): навешивается в брачный сезон
`GeneBreedingSeason` (как `fruitSeason`, 0..3) — у **оленя осень** (→ роды весной к
обилию корма); у r-стратегов (заяц) — круглогодично/частый гон. Под `Rut` влечение
переходит порог действия; вне `Rut` зачатия нет.
- **Hediff-каркас рождается здесь (A4).** Гон — первый хедиф, поэтому в A4 появляется
лёгкий каркас: контейнер `Health`/`HealthState` со списком хедифов, `HediffDef` со
стадиями и модификаторами, навешивание/снятие по триггеру. A5/A6 расширяют его
частями тела, кровью, болезнями.
- **Триггер акта** — взрослые, разный пол, у обоих `Mating > 0.6`, рядом (1–2 клетки),
самка не беременна, оба не спят/не убегают.
- **Послеродовый кулдаун** — самка не зачинает снова до родов + восстановления;
самец восстанавливается быстро → **полигиния** (один самец кроет нескольких самок).
- **Выбор партнёра (половой отбор)** — НЕ в A4: на старте берётся ближайший подходящий;
предпочтение по размеру/здоровью/настроению добавим позже отдельной итерацией.
## Здоровье — полная модель RimWorld
### Данные (дефы)
- **`BodyDef`** — дерево частей. У части: `coverage` (доля тела → вес попадания),
`parent`, `maxHp` (масштаб от размера тела), флаги (`vital`, `solid`, `skinCovered`),
**вклады в capacities** `{ breathing: 0.5, consciousness: 1.0, moving: 0.25, … }`.
Пример (олень): `body → torso → (heart, lungs×2, liver, stomach, kidneys×2, spine),
head → (brain, eyes×2, ears×2, jaw), legs×4 → hooves`.
- **`HediffDef`** — *всё* есть hediff (рана, потеря части, болезнь, имплант, можно и
беременность): `stages` с порогами по severity; стадия задаёт `capMods`
(offset/factor/setMax по capacity), `painOffset/painFactor`, `bleedRate`, для болезней —
параметры иммунитета.
- **`CapacityDef`** — общий каталог способностей строковыми id: `Consciousness, Moving,
Manipulation, Sight, Hearing, Breathing, BloodPumping, BloodFiltration, Digestion,
Metabolism` (расширяемо модом). Это **каталог**, а не обязательный набор: вид имеет
лишь те способности, в которые вкладываются части его `BodyDef` (у копытного оленя нет
`Manipulation` — некому вкладываться).
### Компонент
`Health : IComponent { HealthState State }` — managed-класс (как `Genome`):
`List<PartInstance>` (деф + текущий hp), `List<Hediff>` (severity, привязка к части,
иммунитет), кэш `Capacities`, `Pain`, `BloodLevel`, `Dead`.
> Контейнер растёт по фазам: **рождается в A4** с одним лишь `List<Hediff>` (под гон-хедиф
> `Rut`); части тела, кровь, capacities и `Dead` добавляются в A5, болезни/иммунитет — A6.
### Расчёт capacities (обобщённая дата-модель)
Способности зависят друг от друга, считаем в порядке зависимостей:
1. `BloodLevel` (0..1) ← запас минус кровопотеря;
2. `BloodPumping` ← сердце × BloodLevel; `Breathing` ← лёгкие × BloodLevel;
`BloodFiltration` ← печень/почки;
3. `Consciousness` ← мозг × BloodPumping × Breathing × (1 болевой штраф);
4. `Moving` ← ноги/позвоночник × Consciousness × BloodPumping; `Sight` ← глаза ×
Consciousness; `Digestion` и пр. — домножаются на вышестоящие.
Формула одна и общая: `capacity = Σ(вклады работающих частей × partHp/maxHp) ×
вышестоящие capacity`. **Без хардкода на каждую способность** — новую способность
вводит деф. Каждая capacity — множитель на действия: `Moving`→скорость, `Sight`→радиус
поиска, `Digestion`→скорость насыщения, `Consciousness`→интеллект и сам факт сознания.
### Кровь — как hediff (реалистичная модель RW)
Кровопотеря = hediff `BloodLoss` (severity 0..1). Кровоточащие раны поднимают её через
`bleedRate`; без кровотечения медленно рассасывается; severity = 1 → смерть; влияет на
`BloodPumping`/`Consciousness` через `capMods`. `GeneBloodVolume` масштабирует скорость
роста severity (больше крови → медленнее теряет).
### Болезни и иммунитет (гонка)
severity болезни растёт за день; параллельно растёт иммунитет (0..1) со скоростью из
стата (зависит от `BloodFiltration`, отдыха, сытости, `GeneImmunityGain`). Иммунитет→1
раньше = выздоровел; severity→летальный порог раньше = смерть. Голодный/раненый зверь
борется с болезнью медленнее.
### Смерть
Триггеры: разрушена `vital`-часть (мозг/сердце), `Consciousness ≤ порога`,
`BloodLevel = 0`, летальная severity болезни, старость (возрастные hediff: катаракта,
больная спина, деменция, закупорка артерий + шанс смерти от старости). На смерти:
стоп-ИИ, переход в труп.
### Тик
Медленный «health tick» раз в игровой час + событийный пересчёт при уроне.
Детерминизм: привязка к игровому времени, не к FPS.
### Источники урона в v1
**Почти никаких.** Строим весь каркас (части, кровь, capacities, hediff-framework, условия
смерти), но не вводим *травмирующих* источников. На старте (A1–A2) смерть только **от
старости**.
Порядок источников урона по фазам: **истощение/обезвоживание** — уже в **A3** (простое
правило «нужда=0 дольше N дней → смерть», замыкает контур ёмкости среды; в A5
переоформляется в hediff истощения) → **температура** (гипотермия/тепловой удар, hediff на
той же трапеции, что у растений) → **болезни** (A6) → **хищничество** (горизонт).
## Труп и разложение
Труп — отдельная сущность с `Corpse { RotDays; Stage }`. Конвейер (скорость зависит от
температуры/сезона — реюз климата):
```
Fresh ──→ Rotting ──→ Skeleton ──→ (исчезает)
│ │ │
│ разделка │ разделка └─ остаётся ProductBone
└──────────┴─→ ProductMeat (кол-во ∝ GeneBodySize) + опц. ProductLeather
```
- свежий/гниющий можно **разделать** → мясо (объём от размера тела) + шкура;
спрайт `Dessicated_*` (для оленя уже есть) на стадии Rotting;
- **скелет** — финальная стадия разложения (как просили, скелет — часть процесса):
мясо уже не получить, остаются кости (`ProductBone`);
- после скелета труп **исчезает в ничто**. На будущее — возврат плодородия в почву
(ниточка к растениям, опционально).
- мясо/кости/падаль → кормовая база будущих хищников и падальщиков.
Новые продукты: `ProductMeat`, `ProductBone`, `ProductLeather`.
**Спрайт скелета — нужен настоящий (решено).** В проекте костяных/скелетных текстур
нет (проверено) → это **ассет-задача**: создать/найти спрайт скелета (желательно
универсальный + по-видовой при наличии), путь вида `things/pawn/animal/deer/Skeleton_*`.
До появления ассета стадию Skeleton можно временно рисовать обработкой `Dessicated_*`,
но финальная версия требует своего спрайта.
## Мозг и интеллект
`GeneBrainSize` → признак `brainSize`. **Эффективный интеллект** = `brainSize ×
Consciousness` — т.е. повреждение/болезнь мозга, боль, кровопотеря динамически снижают
интеллект (сильная связка со здоровьем).
Интеллект **гейтит набор нужд и действий**. У `NeedDef`/действия поле `minBrain`:
активный набор особи = те, у кого `minBrain ≤ brainSize`. **Без enum** — шкала
непрерывная, тиры ниже лишь ярлыки для авторов контента (числа порогов — контент,
правятся модом). `brainSize` нормирован 0..1.
| Тир | `minBrain` | Кто | Что открывается |
|---|---|---|---|
| 1 — рефлекс | 0.00 | губки, простейшие | пассивный голод, реактивность |
| 2 — примитив | ~0.10 | насекомые, черви | голод; реактивное поведение |
| 3 — низший позвоночный | ~0.25 | рыбы, рептилии, амфибии | +жажда; простое избегание |
| 4 — зверь | ~0.45 | млекопитающие, птицы (**олень**, заяц, волк) | +сон, секс, Flee, SeekMate; **настроение**; факультативно стадность/память |
| 5 — умный (на будущее) | ~0.75 | приматы, врановые, люди | +социальные нужды, игра, любопытство, память о ресурсах, планирование, обучение, сложное настроение/отношения |
Олень = **тир 4**. Настроение, сон и брачное поведение включаются с тира 4.
## Настроение
`Mood` (0..1) собирается **из двух источников** (решено):
1. **Непрерывные состояния → в настроение напрямую** по кривой `ResponseCurve`:
уровни голода, жажды, усталости, боли (из здоровья), температуры вне комфорта.
Это не дискретные мысли, а текущий вклад нужд/состояния.
2. **События → `ThoughtDef { moodOffset, durationDays, trigger }`** (модель thoughts
RimWorld, данными): «дала потомство», «спарилась», «напилась после долгой жажды»,
«потеряла детёныша» — временный сдвиг настроения с затуханием.
Настроение есть только с тира 4 (зверь) — гейт интеллектом; у примитивных его нет.
Влияет на поведение (низкое → паника/бегство, не спаривается) и видно в инспекторе.
## Ориентировочные числа для оленя (старт)
Шкала времени мира: **год = 60 игровых дней**, сезон = 15 дней (для справки растения
живут ~160 дней). Числа — стартовая точка, балансируются при реализации; это значения
по умолчанию генома вида (`GenomeTemplate`), вокруг которых идёт разброс и мутации.
| Параметр | Ген/поле | Значение | Заметка |
|---|---|---|---|
| Размер тела | `GeneMaxBodySize` | ♀ 1.4 / ♂ 1.6 sizeCells | как в текущих `DeerDoe`/`DeerBuck` |
| Продолжительность жизни | `GeneLifespan` | ~360 дн (~6 лет) | дольше растений, видно несколько поколений |
| Взросление (→Adult) | `GeneMaturityAge` | ~90 дн (~1.5 года) | Baby 020, Juvenile 2090, Adult 90~290, Senior 290+ |
| Срок беременности | `GeneGestationDays` | ~30 дн (2 сезона) | гон осенью → роды весной |
| Помёт | `GeneLitterSize` | 1 (изредка 2) | K-стратег |
| Сезон гона | `GeneBreedingSeason` | осень | у зайца — круглый год |
| Размер мозга | `GeneBrainSize` | ~0.45 (тир 4) | открывает сон/секс/настроение |
| Объём крови | `GeneBloodVolume` | ~1.0 (×размер) | масштабирует скорость кровопотери |
| Скорость | `GeneMoveSpeed` | ~1.1–1.3 | олень быстрый |
| Голод | `NeedDef` decay | полный голод за ~1.5 дн | по Клайберу ∝ size^0.75 |
| Жажда | `NeedDef` decay | полная жажда за ~0.75 дн | быстрее голода |
| Сон | `NeedDef` decay | суточный цикл | спит в неактивную фазу |
## Идеи для реализма (бэклог)
Опционально, по мере фаз — каждая ложится на дата-модель:
- **Гон/брачный сезон** — хедиф `Rut`, навешиваемый в сезон, поднимает влечение (реализовано в A4).
- **Терморегуляция мехом** — `GeneInsulation` сдвигает температурную трапецию;
новорождённые мёрзнут сильнее.
- **Метаболизм по Клайберу** — расход ∝ size^0.75 (крупные едят больше абсолютно,
меньше на кг).
- **Циркадные ритмы** — дневные/ночные/сумеречные виды (`GeneActivityPattern`).
- **Стадность** — `GeneSociability`: стадные держатся группой (реюз `SeparationSystem`
+ когезия) → эмерджентные стада.
- **Сезонный окрас** — арктические лиса/заяц/волк белеют зимой (текстуры `*_arctic`
есть) — сезонный свитч спрайта/тинта.
- **Половой диморфизм** — рога/размер у самцов (ген, проявляется при XY).
- **r/K-стратегии** — выводятся из `litterSize/gestation/lifespan`.
- **Инбридинг-депрессия** (продвинутое) — родственное скрещивание снижает fitness
через накопление рецессивных аллелей.
- **Память о ресурсах/территория** (для умных) — водопои, кормовые места.
- **Возврат нутриентов** — полное разложение трупа повышает плодородие клетки.
## Новые типы дефов (для модеров)
| Тип | Назначение |
|---|---|
| `AnimalDef : PawnDef` (подтип) | вид: геном-шаблон (ссылки на гены), `BodyDef`, диета, текстуры по стадиям/полу, размеры. Наследник `PawnDef` — существующие 9 животных-`PawnDef` остаются валидными; поведение на компонентах, не на типе дефа |
| `BodyDef` | дерево частей тела с coverage/parent/maxHp/флагами/вкладами в capacities |
| `HediffDef` | раны/болезни/состояния: стадии, capMods, боль, кровотечение, иммунитет |
| `CapacityDef` | именованная способность (строковый id) |
| `NeedDef` | нужда: формула расхода, `minBrain`, привязка к действию |
| `ThoughtDef` | мысль настроения: offset, длительность, условие-триггер |
| `ProductDef` (есть) | + `ProductMeat`, `ProductBone`, `ProductLeather` |
CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип = класс + `RegisterType` в
`GameContent.Load` (см. [mods.md](mods.md)).
## Отбор, экология и наблюдаемость
Смысл генетики — чтобы за поколениями популяция **реально адаптировалась к климату
сида** (дрейф признаков под отбором). Чтобы это было видно (столп «наблюдаемость»):
- **Команда `popstats <species>`** — среднее и разброс ключевых признаков популяции +
счётчик поколений. Без неё отбор «есть, но невидим». Появляется, как только в мире
есть популяция (≈A1–A2), расширяется по мере добавления признаков.
- **Контур ёмкости среды (Лотка–Вольтерра через корм).** Даже без хищников травоядные
могут **выесть растения → голод → крах популяции → восстановление флоры → новый рост**.
Это бесплатная эмерджентность уже на A2, **если поедание реально уменьшает растения**.
Явно проверяем этот контур на A2 (поедание убавляет плод/растение, голод растёт при
нехватке корма). **Важно:** полный «крах» популяции требует **смерти от голода** — она
появляется в **A3** (простое правило). На A2 контур наблюдается частично (звери голодают
и тормозят, но не вымирают), полностью замыкается с A3.
## Сквозные требования (каждая фаза)
- **Детерминизм.** Любая случайность (спавн, выбор партнёра, мутации, выбор части под
урон, шанс инфекции) — из seeded `Random`, выведенного из сида мира (+ id сущности),
никогда `Random.Shared`. Один сид = воспроизводимый мир. Ничьи в пространственных
запросах («ближайший корм/вода/партнёр») разрешаются по id сущности, не по порядку обхода.
- **Сериализация — чек-лист по каждой фазе.** Каждая фаза сразу делает сейв/лоад своих
данных: A1 геном (уже умеет, geneId→Allele) → A3 стадия/возраст/пол → A4 беременность
(срок + **геном отца**) + список хедифов (гон `Rut`) → A5 здоровье (части тела, кровь,
capacities) + труп (стадия/таймер разложения) → A8 настроение/мысли. Пустой/старый
сейв — регенерируется, не крашит.
- **Sim/presentation split.** Системы симуляции пишут только компоненты; спрайт/тинт
(пол, стадия, мех, гниль) ставит `AppearanceSystem`. Никаких draw/UI из симуляции.
## План реализации (фазы A1–A8)
По аналогии с фазами генов G1–G5. Каждая фаза — отдельный коммит, дев-команда для
проверки, проверка через `--check-content` (где можно), и пункты сквозных требований выше.
**Вкладки инспектора добавляются по мере появления данных:** needs@A2, health@A5, mood@A8.
1. **A1 — фундамент.** `AnimalDef` + животные гены + `AnimalPhenotype`/`AnimalOrganism`
+ per-species `GenomeTemplate` + спавн оленя в `WorldScene` + рендер + команда
`animal <species>`.
2. **A2 — нужды + ИИ (выбор + исполнение).** `AnimalNeeds` + `AnimalDecisionSystem`
(выбор) + **слой исполнения** (Eat/Drink/Sleep: найти→дойти→выполнить→удовлетворить) +
еда-растения / вода-террейн / сон по фазе суток + `AppearanceSystem`. **Проверить
контур ёмкости среды** (поедание убавляет растения → колебания). Команда `popstats`.
3. **A3 — жизненный цикл.** Стадии Baby→Adult→Senior, ген пола (XX/XY, генерация без
YY), спрайт по стадии/полу, **простая смерть по старости и от голода/жажды (деспавн)**
— последняя замыкает контур ёмкости среды.
4. **A4 — размножение.** Нужда `Mating` + **лёгкий hediff-каркас** + гон-хедиф `Rut`
(сезон) + исполнение `Mate` → `Pregnant` → роды через `Genome.Breed`.
5. **A5 — здоровье I.** `BodyDef`, части тела, кровь (hediff), capacities, **апгрейд
смерти**: через здоровье + **труп** с разложением до скелета (вместо деспавна A3).
6. **A6 — здоровье II.** Болезни/`HediffDef` с иммунитетом, боль.
7. **A7 — мозг/интеллект.** Ген→интеллект→гейт нужд/действий + обратная связь от
повреждения мозга.
8. **A8 — настроение.** `Mood` + `ThoughtDef` + вкладки инспектора (needs/health/mood —
`InspectPanel` уже умеет вкладки overview/genes/products).
## После A8 — экологический горизонт
То, ради чего вся глубина: превращает «зоопарк» в живую экосистему. Каждая фича опирается
на уже построенные системы.
> **ГОТОВО — хищный кластер (часть 1): диета-гены + охота + урон + бегство.** Диета теперь
> ДАННЫЕ-через-ГЕНЫ: `GeneHerbivory`/`GeneCarnivory`/`GeneOmnivory` → признаки
> `herbivory/carnivory/omnivory`; рацион выводит `AnimalFactory.Diet` (ест растения, если
> max(herb,omni) ≥ 0.5; ест мясо, если max(carn,omni) ≥ 0.5 — всеядность включает оба; откат
> на `AnimalDef.Diet` у видов без генов диеты). Новые виды: **Wolf** (хищник, охотится) и
> **WildBoar** (всеядный); у оленя добавлен `GeneHerbivory`. Действие `Hunt`
> (`AnimalDecisionSystem` ищет ближайший труп-падаль ИЛИ живую добычу не крупнее себя;
> `AnimalActionSystem.Hunt` ведёт к цели и при контакте кусает или ест труп). **Первый
> травматический урон**: `HealthState.ApplyInjury` (урон части тела по весу попадания + острая
> кровопотеря + хедиф `Bleeding`); `Bleeding` (`bloodLossPerDay`) точит кровь в
> `AnimalHealthSystem` (+восстановление крови в покое), кровь на нуле → смерть → труп → падаль.
> Боевые параметры — данные на `AnimalDef` (`attackDamage/attackBleed/attackBloodLoss/
> attackRangeCells`). **Бегство**: `AnimalActions.Flee` — жертва, заметившая хищника не мельче
> себя в радиусе восприятия, убегает (приоритет над нуждами); сам хищник бежит лишь от заметно
> более крупного мясоеда. Общий путь смерти — `AnimalFactory.Die`. Консоль: `diet [species]`;
> `--check-content` печатает роль каждого вида. **Не покрыто (горизонт):** падальщики как
> отдельная роль, удар по настроению от страха, снижение страха стадом, спрайт скелета.
- **Хищный кластер.** Хищничество (волк→олень): диета = живая добыча; действие `Hunt`
(выследить → преследовать → атака → **урон через здоровье A5** → добить → съесть труп).
Это **первый «активный» (травмирующий) источник урона** для системы здоровья и
полноценная Лотка–Вольтерра с хищником. Рядом: **падальщики** (едят трупы Fresh/Rotting)
и **угроза/бегство** (хищник в радиусе `Sight` → страх → действие `Flee`, удар по
настроению; стадо снижает страх).
- **Родительская опека / импринтинг.** Детёныш ходит за матерью, голод гасится близостью
к лактирующей матери до ювенильной стадии; мать ведёт/защищает. Реализм + детёныши не
мрут от голода в одиночку.
- **Эпидемии.** Болезни (A6) распространяются по контакту/плотности → плотностно-зависимый
регулятор популяции (вспышки-крахи). Исход решает иммунитет (`GeneImmunityGain`).
- **Миграция.** При локальном истощении корма / некомфортном климате — уход к лучшим
клеткам (концепт: «голод+неурожай→миграция»). Эмерджентно из нужд + пространственного
поиска с большим радиусом.
Сюда же тяготеют пункты бэклога: половой отбор (выбор партнёра), сезонный окрас,
инбридинг-депрессия, возврат нутриентов в почву.
## Открытые вопросы
- Конкретный список `ThoughtDef`-событий для оленя (набор и величины offset/длительности)
— детализируется при реализации A8.
- Ассет: создать/найти спрайт скелета (`things/pawn/animal/deer/Skeleton_*`) к фазе A5.
-84
View File
@@ -1,84 +0,0 @@
# Растения как экосистема
Дизайн системы растений: рост зависит от среды, среда описана факторами, реакция растения на
факторы задаётся **генами**, а размножение передаёт гены потомкам с мутацией — появляется
естественный отбор. Документ — единый ориентир для фаз A–D; детали каждой фазы уточняются её
отдельным планом.
## Модель роста
Скорость роста растения в данный момент:
```
скорость = база × пригодность(свет) × пригодность(температура) × пригодность(плодородность)
```
Каждая «пригодность» — число в [0..1] из кривой отклика (движковый `ResponseCurve`, модуль
`MrGameEng.AI`) с **оптимумом** и **шириной толерантности**, которые берутся из генов растения.
Растение вне своих оптимумов растёт медленно или стоит; в своих — быстро. Так все факторы среды
сводятся к одному механизму, а различие видов/особей — целиком в генах.
Фактор «возраст/стадия» остаётся как сейчас (`PlantGrowthStage` в дефах): пригодности умножают
прирост `AgeDays`, стадии переключают спрайт/размер по накопленному возрасту.
## Факторы среды
- **Свет** — амбиент день/ночь (`MrGameEng.Lighting.DayNight`), сэмплируемый в точке
(`SampleAt(world)`); глобальный в фазе A, локальный (с тенями) в фазе D.
- **Температура** — непрерывная, из климата (`MrGameEng.Core.Climate`): сезонный синус по дню года
+ суточные колебания (холоднее ночью).
- **Плодородность** — пока константа типа террейна (`TerrainDef.Fertility`); позже возможна
по-клеточная почва с истощением/восстановлением.
## Гены (диплоидный геном, гибридная экспрессия)
Каждое растение несёт **геном**: по паре аллелей на ген. Экспрессия гибридная:
- **Числовые гены** (оптимум света, толерантность к свету, оптимум температуры, толерантность,
потребность в плодородности, макс. размер, базовая скорость, продолжительность жизни, дальность
расселения, плодовитость, темп мутаций, способность к самоопылению) — фенотип = **смешение**
(среднее двух аллелей). Реализм отбора: промежуточные значения, плавный дрейф.
- **Дискретные гены** (например, цвет/морфа, наличие признака) — **доминант/рецессив**: рецессивный
фенотип виден только в гомозиготе (aa).
Наследование (мейоз): каждый родитель отдаёт по одному случайному аллелю на ген; потомок получает
по одному от каждого. Затем — мутация (с вероятностью из гена темпа мутаций сдвигаем числовой
аллель/переключаем дискретный). Всё детерминировано сидом мира.
Стартовая популяция: геномы генерируются из сида (вид задаёт базовый геном и разброс аллелей в
дефе; особи отклоняются).
## Размножение (полуреалистичное)
- **Перекрёстное опыление (приоритет):** зрелое растение ищет зрелого соседа того же вида в радиусе
(ген дальности; запрос через spatial-hash движка). Есть партнёр → семя с геномом от двух
родителей (Мендель + мутация).
- **Самоопыление (запас):** нет партнёра и ген «способность к самоопылению» позволяет → семя из
одного родителя (само-скрещивание аллелей + мутация).
- **Расселение:** семя падает в клетку в радиусе расселения; прорастает, если клетка подходит
(суша, не занято сверх лимита, пригодность не нулевая).
- **Лимит плотности:** не больше N растений на клетку/площадь — иначе взрыв численности.
- **Старение/смерть:** возраст превышает ген жизни → смерть; или длительный стресс (пригодность
около нуля) → гибель. Смерть освобождает место — баланс с размножением и основа отбора.
## Производительность и сейв
- Представление — **по сущности на растение** (видно в рендере и ECS-инспекторе, индивидуальные
гены). Защита от взрыва: лимит плотности, смерть, разумные радиусы, проверки размножения по
таймеру/сезону (не каждый кадр на каждое растение), spatial-hash для соседей.
- **Персист:** геномы, позиции, возраст и стадия растений сохраняются в `WorldSave` (мир больше не
воспроизводится только из сида — эволюция переживает загрузку).
- **Детерминизм:** мутации/расселение/опыление — на сидированном `Random`, как в остальной симуляции.
## Фазы реализации
- **A — климат и день/ночь (движок).** `Climate` (температура/сезон поверх `Calendar`),
`DayNight` (амбиент, `Renderer2D.AmbientLight` на World-слоях, сэмплируемый `SampleAt`),
затемнение мира ночью, HUD: сезон/температура/время. Фундамент для влияния среды на рост.
- **B — гены и рост по пригодностям (игра).** Компонент `PlantGenome`, расчёт пригодностей
(свет из `DayNight.SampleAt`, температура из `Climate`, плодородность из террейна), замена
плоского `GrowthRate`. Геном виден/правится в инспекторе.
- **C — размножение и смерть (игра).** Перекрёстное/самоопыление, расселение, лимит плотности,
старение/стресс-смерть, персист популяции в сейв.
- **D — полноценное 2D-освещение (движок).** Точечные источники + тени (окклюзия от препятствий);
`DayNight.SampleAt` начинает возвращать локальную освещённость, и затенённые растения растут хуже.
+1 -1
Submodule engine updated: d044cafad9...96e19c7c61
+3 -1
View File
@@ -9,7 +9,9 @@ $Project = Join-Path $Root "src\LittleSim\LittleSim.csproj"
Push-Location $Root Push-Location $Root
try { try {
dotnet build LittleSim.sln -c $Configuration # Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с
# тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты).
dotnet build $Project -c $Configuration
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
+3 -1
View File
@@ -18,5 +18,7 @@ done
cd "$ROOT" cd "$ROOT"
dotnet build LittleSim.sln -c "$CONFIGURATION" # Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с
# тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты).
dotnet build src/LittleSim/LittleSim.csproj -c "$CONFIGURATION"
dotnet run --project src/LittleSim/LittleSim.csproj -c "$CONFIGURATION" --no-build "$@" dotnet run --project src/LittleSim/LittleSim.csproj -c "$CONFIGURATION" --no-build "$@"
+14 -5
View File
@@ -16,24 +16,33 @@ public class LittleSimWebGame : Game
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary> /// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
public EngineContext Context { get; } = new EngineContext(); public EngineContext Context { get; } = new EngineContext();
private readonly Uri _server;
private GraphicsDeviceManager _graphics; private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch = null!; private SpriteBatch _spriteBatch = null!;
private Texture2D _pixel = null!; private Texture2D _pixel = null!;
private WorldViewScene _scene = null!;
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary> /// <summary>Игра-наблюдатель: мира не создаёт, ждёт <see cref="Connect"/> из UI.</summary>
public LittleSimWebGame(Uri server) public LittleSimWebGame()
{ {
_server = server;
_graphics = new GraphicsDeviceManager(this); _graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content"; Content.RootDirectory = "Content";
} }
/// <summary>Состояние соединения со сценой-наблюдателем — читает оверлей подключения.</summary>
public ConnectionStatus Status => _scene?.Status ?? ConnectionStatus.Idle;
/// <summary>Число реплицированных жителей — для статуса в оверлее.</summary>
public int EntityCount => _scene?.EntityCount ?? 0;
/// <summary>Подключиться к серверу <paramref name="server"/> (вызывается из Blazor-оверлея).</summary>
public void Connect(Uri server) => _scene?.Connect(server);
/// <inheritdoc /> /// <inheritdoc />
protected override void Initialize() protected override void Initialize()
{ {
base.Initialize(); base.Initialize();
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel)); _scene = new WorldViewScene(() => _spriteBatch, () => _pixel);
Context.Scenes.Switch(_scene);
} }
/// <inheritdoc /> /// <inheritdoc />
+10 -1
View File
@@ -1,4 +1,4 @@
@page "/" @page "/"
@page "/index.html" @page "/index.html"
@inject IJSRuntime JsRuntime @inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas @using nkast.Wasm.Canvas
@@ -18,3 +18,12 @@
"> ">
<canvas id="theCanvas" style="touch-action:none;"></canvas> <canvas id="theCanvas" style="touch-action:none;"></canvas>
</div> </div>
<div class="connect-bar">
<span class="title">LittleSim</span>
<input class="server" placeholder="ws://host:9050"
@bind="_serverAddress" @bind:event="oninput"
@onkeydown="OnAddressKey" />
<button class="connect" @onclick="Connect">Подключиться</button>
<span class="status @StatusClass">@StatusText</span>
</div>
+80 -8
View File
@@ -1,7 +1,7 @@
using System; using System;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using Microsoft.Xna.Framework;
namespace LittleSim.Web.Pages namespace LittleSim.Web.Pages
{ {
@@ -10,7 +10,12 @@ namespace LittleSim.Web.Pages
[Inject] [Inject]
private NavigationManager Navigation { get; set; } = null!; private NavigationManager Navigation { get; set; } = null!;
private Game? _game; private LittleSimWebGame? _game;
private string _serverAddress = "";
private Uri? _pendingServer; // запрошен Connect до создания игры — подключим в первом тике
private ConnectionStatus _lastStatus = ConnectionStatus.Idle;
private int _lastCount = -1;
private int _frame;
protected override void OnAfterRender(bool firstRender) protected override void OnAfterRender(bool firstRender)
{ {
@@ -18,25 +23,92 @@ namespace LittleSim.Web.Pages
if (firstRender) if (firstRender)
{ {
_serverAddress = DefaultServerAddress();
StateHasChanged();
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this)); JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
} }
} }
// Гонится из requestAnimationFrame (index.html). Игру создаём лениво в первом тике —
// мира она не создаёт, лишь ждёт адрес сервера из формы.
[JSInvokable] [JSInvokable]
public void TickDotNet() public void TickDotNet()
{ {
if (_game == null) if (_game == null)
{ {
_game = new LittleSimWebGame(ResolveServerUri()); _game = new LittleSimWebGame();
_game.Run(); _game.Run();
} }
_game.Tick(); _game.Tick();
if (_pendingServer is not null)
{
_game.Connect(_pendingServer);
_pendingServer = null;
}
// Перерисовываем оверлей только при смене статуса (или изредка — чтобы освежить счётчик),
// а не каждый кадр rAF.
var status = _game.Status;
var count = _game.EntityCount;
if (
status != _lastStatus
|| (status == ConnectionStatus.Connected && count != _lastCount && _frame % 15 == 0)
)
{
_lastStatus = status;
_lastCount = count;
InvokeAsync(StateHasChanged);
}
_frame++;
} }
// Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию — private void OnAddressKey(KeyboardEventArgs e)
// хост самой страницы на порту LittleSim.Server. {
private Uri ResolveServerUri() if (e.Key == "Enter")
{
Connect();
}
}
private void Connect()
{
if (!Uri.TryCreate(_serverAddress?.Trim(), UriKind.Absolute, out var uri))
{
return; // адрес не похож на ws://… — игнорируем
}
if (_game is not null)
{
_game.Connect(uri);
}
else
{
_pendingServer = uri;
}
}
private string StatusText =>
_lastStatus switch
{
ConnectionStatus.Connecting => "подключение…",
ConnectionStatus.Connected => $"подключено · {_lastCount} жит.",
ConnectionStatus.Reconnecting => "переподключение…",
_ => "не подключено",
};
private string StatusClass =>
_lastStatus switch
{
ConnectionStatus.Connected => "ok",
ConnectionStatus.Connecting or ConnectionStatus.Reconnecting => "warn",
_ => "idle",
};
// Адрес по умолчанию: ?server=ws://host:port из URL страницы, иначе хост страницы на порту сервера.
private string DefaultServerAddress()
{ {
var page = new Uri(Navigation.Uri); var page = new Uri(Navigation.Uri);
var query = page.Query.TrimStart('?'); var query = page.Query.TrimStart('?');
@@ -45,7 +117,7 @@ namespace LittleSim.Web.Pages
var separator = pair.IndexOf('='); var separator = pair.IndexOf('=');
if (separator > 0 && pair[..separator] == "server") if (separator > 0 && pair[..separator] == "server")
{ {
return new Uri(Uri.UnescapeDataString(pair[(separator + 1)..])); return Uri.UnescapeDataString(pair[(separator + 1)..]);
} }
} }
@@ -54,7 +126,7 @@ namespace LittleSim.Web.Pages
Scheme = "ws", Scheme = "ws",
Host = page.Host, Host = page.Host,
Port = WebNetSchema.DefaultPort, Port = WebNetSchema.DefaultPort,
}.Uri; }.Uri.ToString();
} }
} }
} }
+72
View File
@@ -0,0 +1,72 @@
.connect-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
box-sizing: border-box;
background: rgba(12, 16, 24, 0.82);
border-bottom: 1px solid rgba(108, 198, 255, 0.25);
color: #d8e0ea;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
z-index: 10;
}
.connect-bar .title {
font-weight: 600;
color: #6cc6ff;
letter-spacing: 0.02em;
}
.connect-bar .server {
flex: 0 1 320px;
min-width: 0;
padding: 5px 9px;
background: #0c1018;
border: 1px solid #2a3850;
border-radius: 4px;
color: #e6edf6;
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 13px;
}
.connect-bar .server:focus {
outline: none;
border-color: #6cc6ff;
}
.connect-bar .connect {
padding: 5px 14px;
background: #1c2c44;
border: 1px solid #3a567f;
border-radius: 4px;
color: #d8e0ea;
font-size: 13px;
cursor: pointer;
}
.connect-bar .connect:hover {
background: #25395a;
border-color: #6cc6ff;
}
.connect-bar .status {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.connect-bar .status.ok {
color: #7fdca0;
}
.connect-bar .status.warn {
color: #f0c060;
}
.connect-bar .status.idle {
color: #8a96a6;
}
+93 -17
View File
@@ -9,31 +9,74 @@ using MrGameEng.Net;
namespace LittleSim.Web; namespace LittleSim.Web;
/// <summary>Состояние соединения веб-клиента — его читает оверлей подключения (Blazor).</summary>
public enum ConnectionStatus
{
/// <summary>Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться».</summary>
Idle,
/// <summary>Идёт первая попытка подключения.</summary>
Connecting,
/// <summary>Соединение установлено, снапшоты приходят.</summary>
Connected,
/// <summary>Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф).</summary>
Reconnecting,
}
/// <summary> /// <summary>
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу /// Браузерный клиент мира LittleSim: по команде <see cref="Connect"/> подключается к
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует /// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают /// EntityStore и рисует жителей через KNI SpriteBatch (WebGL). Мира не создаёт — только
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты /// наблюдает: симуляция целиком на сервере, сюда приезжают лишь компоненты схемы (позиция +
/// кадра, усталость затемняет квадратик жителя. /// потребности). Позиции сглаживаются до частоты кадра, усталость затемняет квадратик. При
/// обрыве переподключается сам с экспоненциальным бэкоффом, очищая устаревшие сущности.
/// </summary> /// </summary>
public sealed class WorldViewScene : Scene public sealed class WorldViewScene : Scene
{ {
private readonly Uri _server; // Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
private const float MaxBackoffSeconds = 8f;
private readonly Func<SpriteBatch> _spriteBatch; private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel; private readonly Func<Texture2D> _pixel;
private ReplicationClient _replication = null!; private ReplicationClient _replication = null!;
private Task<WebSocketClient>? _connecting; private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection; private WebSocketClient? _connection;
private Uri? _server; // целевой сервер; null — пользователь ещё не подключался
private double _nextAttemptAt;
private int _attempt;
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary> /// <summary>Текущее состояние соединения — для оверлея подключения.</summary>
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel) public ConnectionStatus Status { get; private set; } = ConnectionStatus.Idle;
/// <summary>Сколько реплицированных жителей сейчас в мире клиента.</summary>
public int EntityCount => _replication?.EntityCount ?? 0;
/// <summary>Сцена-наблюдатель; сервер задаётся позже через <see cref="Connect"/>.</summary>
public WorldViewScene(Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
{ {
_server = server;
_spriteBatch = spriteBatch; _spriteBatch = spriteBatch;
_pixel = pixel; _pixel = pixel;
} }
/// <summary>
/// Подключиться к <paramref name="server"/> (ws:// или wss://). Сбрасывает прежнее
/// соединение и счётчик попыток — подключение начнётся в ближайшем тике. Дальше клиент сам
/// переподключается к этому адресу при обрывах.
/// </summary>
public void Connect(Uri server)
{
_connection?.Close();
_connection = null;
_replication?.Clear();
_server = server;
_attempt = 0;
_nextAttemptAt = 0f;
Status = ConnectionStatus.Connecting;
}
/// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary> /// <summary>Сглаживание сетевой позиции: цель из снапшота, визуал лерпится покадрово.</summary>
private struct NetLerp : IComponent private struct NetLerp : IComponent
{ {
@@ -51,35 +94,68 @@ public sealed class WorldViewScene : Scene
UpdateSystems.Add(new NetSmoothingSystem()); UpdateSystems.Add(new NetSmoothingSystem());
DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel)); DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel));
Log.Info($"LittleSim.Web: connecting to {_server}…"); Log.Info("LittleSim.Web ready — awaiting connect");
_connecting = WebSocketClient.ConnectAsync(_server);
} }
protected override void OnUnload() => _connection?.Close(); protected override void OnUnload() => _connection?.Close();
private void Pump() private void Pump()
{ {
if (_server is null)
{
return; // адрес ещё не задан — ждём команды Connect из оверлея
}
var now = Context.Clock.UnscaledTotalTime;
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
if (_connecting is { IsCompleted: true } finished) if (_connecting is { IsCompleted: true } finished)
{ {
_connecting = null; _connecting = null;
if (finished.IsFaulted) if (finished.IsCompletedSuccessfully)
{
_connection = finished.Result;
_replication.Clear(); // сбрасываем устаревшие сущности перед свежим полным снапшотом
_attempt = 0;
Status = ConnectionStatus.Connected;
Log.Info($"Connected to {_server}");
}
else
{ {
Log.Error( Log.Error(
$"Connect to {_server} failed: " $"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message + finished.Exception?.GetBaseException().Message
); );
ScheduleReconnect(now);
} }
else }
{
_connection = finished.Result; // Обрыв установленного соединения: чистим и уходим в переподключение.
Log.Info($"Connected to {_server}"); if (_connection is { IsOpen: false })
} {
_connection = null;
_replication.Clear();
ScheduleReconnect(now);
} }
if (_connection is not null) if (_connection is not null)
{ {
_replication.Pump(_connection); _replication.Pump(_connection);
} }
else if (_connecting is null && now >= _nextAttemptAt)
{
Status = _attempt == 0 ? ConnectionStatus.Connecting : ConnectionStatus.Reconnecting;
_connecting = WebSocketClient.ConnectAsync(_server);
}
}
// Экспоненциальный бэкофф: 1, 2, 4, 8, 8, … секунд между попытками.
private void ScheduleReconnect(double now)
{
var delay = MathF.Min(MaxBackoffSeconds, 1f * (1 << Math.Min(_attempt, 3)));
_attempt++;
_nextAttemptAt = now + delay;
Status = ConnectionStatus.Reconnecting;
} }
/// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary> /// <summary>Вызывает делегат каждый тик — мелкая логика сцены без отдельного класса.</summary>
+3
View File
@@ -28,6 +28,9 @@ public sealed class GameSettings
/// <summary>Общая громкость 0..1.</summary> /// <summary>Общая громкость 0..1.</summary>
public float Volume { get; set; } = 1f; public float Volume { get; set; } = 1f;
/// <summary>Масштаб интерфейса (1 — обычный); применяется к Myra-десктопу всех сцен.</summary>
public float UiScale { get; set; } = 1f;
} }
/// <summary>Чтение/запись <see cref="GameSettings"/> и применение их к движку.</summary> /// <summary>Чтение/запись <see cref="GameSettings"/> и применение их к движку.</summary>
+117
View File
@@ -62,6 +62,117 @@ public sealed class PawnSave
public float ChangeIn { get; set; } public float ChangeIn { get; set; }
} }
/// <summary>Сериализуемый хедиф особи (фаза A6): деф (по имени), тяжесть и набранный иммунитет.</summary>
public sealed class HediffSave
{
/// <summary>Имя дефа хедифа (гон/болезнь).</summary>
public string DefName { get; set; } = "";
/// <summary>Текущая тяжесть.</summary>
public float Severity { get; set; }
/// <summary>Набранный иммунитет [0,1].</summary>
public float Immunity { get; set; }
}
/// <summary>Сериализуемая событийная мысль (фаза A8): деф (по имени) и остаток времени до угасания.</summary>
public sealed class ThoughtSave
{
/// <summary>Имя дефа мысли.</summary>
public string DefName { get; set; } = "";
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays { get; set; }
}
/// <summary>
/// Сериализуемое состояние одного животного (фазы A1–A8): вид, позиция, геном (источник признаков и
/// пола), поколение, стадия/возраст, нужды, здоровье (кровь, HP частей, хедифы), настроение и
/// беременность. Спрайт пересобирается фабрикой из дефа. Чтобы популяция (эволюция/состояние)
/// переживала сейв/лоад, а не пересыпалась заново из сида.
/// </summary>
public sealed class AnimalSave
{
/// <summary>Имя дефа вида — по нему берётся индекс/стадии/текстуры/тело.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Номер поколения (для popstats/дрейфа).</summary>
public int Generation { get; set; }
/// <summary>Текущая стадия роста.</summary>
public int Stage { get; set; }
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays { get; set; }
/// <summary>Геном особи: аллели по id генов (переменного состава; несёт и пол через GeneSex).</summary>
public Dictionary<string, Allele> Genome { get; set; } = new();
/// <summary>Значения нужд по индексам NeedSet.</summary>
public float[] Needs { get; set; } = [];
/// <summary>Накоплено дней истощения.</summary>
public float Starve { get; set; }
/// <summary>Уровень крови [0,1].</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>HP частей тела по порядку анатомии вида (пусто — у вида нет тела).</summary>
public float[] PartHp { get; set; } = [];
/// <summary>Активные хедифы (гон/болезни).</summary>
public List<HediffSave> Hediffs { get; set; } = [];
/// <summary>Текущее настроение [0,1].</summary>
public float Mood { get; set; }
/// <summary>Активно ли настроение (тир 4+).</summary>
public bool MoodActive { get; set; }
/// <summary>Активные событийные мысли.</summary>
public List<ThoughtSave> Thoughts { get; set; } = [];
/// <summary>Беременна ли самка.</summary>
public bool Pregnant { get; set; }
/// <summary>Геном отца (если беременна) — «слепок» на момент зачатия.</summary>
public Dictionary<string, Allele> FatherGenome { get; set; } = new();
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays { get; set; }
/// <summary>Размер помёта.</summary>
public int Litter { get; set; }
}
/// <summary>Сериализуемый труп (фаза A5): вид (для спрайта), позиция, таймер/стадия разложения, мясо.</summary>
public sealed class CorpseSave
{
/// <summary>Имя дефа вида — по нему берётся спрайт трупа.</summary>
public string Species { get; set; } = "";
/// <summary>Позиция X в мировых координатах.</summary>
public float X { get; set; }
/// <summary>Позиция Y в мировых координатах.</summary>
public float Y { get; set; }
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays { get; set; }
/// <summary>Размер тела (для масштаба спрайта).</summary>
public float BodySize { get; set; }
/// <summary>Доступное мясо.</summary>
public float Meat { get; set; }
}
/// <summary> /// <summary>
/// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание), /// Полное состояние мира для сохранения/загрузки: конфиг мира (имя/размер/сид/сглаживание),
/// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он /// прошедшее время симуляции и снимок всех жителей. Рельеф не сохраняется — он
@@ -99,6 +210,12 @@ public sealed class WorldSave
/// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary> /// <summary>Снимок всех растений (геномы, позиции, возраст, стадия).</summary>
public List<PlantSave> Plants { get; set; } = []; public List<PlantSave> Plants { get; set; } = [];
/// <summary>Снимок всех животных (геном, стадия/возраст, нужды, здоровье, настроение, беременность).</summary>
public List<AnimalSave> Animals { get; set; } = [];
/// <summary>Снимок трупов (вид, позиция, таймер/стадия разложения, мясо).</summary>
public List<CorpseSave> Corpses { get; set; } = [];
/// <summary>Конфиг мира для пересоздания сцены.</summary> /// <summary>Конфиг мира для пересоздания сцены.</summary>
public WorldConfig ToConfig() => public WorldConfig ToConfig() =>
new() new()
+138
View File
@@ -0,0 +1,138 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Content;
/// <summary>
/// Готовая (resolved) таблица животных для сцены: по каждому <see cref="AnimalDef"/> — загруженный
/// регион атласа и набор генов вида (<see cref="GenomeTemplate"/>) для генерации особей. Строится
/// один раз при загрузке сцены (нужны атлас и устройство), как <see cref="PlantSet"/>. Системы и
/// фабрика индексируются в неё без словарей. Фаза A1 — один спрайт на вид; стадии/пол придут позже.
/// </summary>
public sealed class AnimalSet
{
/// <summary>Вид животного: его деф, спрайты по полу/стадии и набор генов для генерации особи.</summary>
public sealed class Species
{
/// <summary>Исходный деф животного.</summary>
public required AnimalDef Def { get; init; }
/// <summary>Спрайт взрослой самки (он же общий/fallback).</summary>
public required Texture2DRegion Female { get; init; }
/// <summary>Спрайт взрослого самца (fallback — самка).</summary>
public required Texture2DRegion Male { get; init; }
/// <summary>Спрайт детёныша/подростка (fallback — самка).</summary>
public required Texture2DRegion Baby { get; init; }
/// <summary>Спрайт трупа (fallback — самка).</summary>
public required Texture2DRegion Corpse { get; init; }
/// <summary>Стадии роста (из дефа или встроенный дефолт), по возрастанию возраста входа.</summary>
public required AnimalStageDef[] Stages { get; init; }
/// <summary>Анатомия вида (части тела) или null, если вид без тела.</summary>
public required BodyDef? Body { get; init; }
/// <summary>Набор генов вида: базовые значения для генерации особи.</summary>
public required GenomeTemplate Template { get; init; }
}
// Встроенный набор стадий по умолчанию (если вид не задал свой): как было до выноса в данные.
private static readonly AnimalStageDef[] DefaultStages =
[
new() { Name = "Baby", EnterAt = 0f, RelativeTo = "maturity", Scale = 0.45f, Texture = "baby" },
new() { Name = "Juvenile", EnterAt = 0.22f, RelativeTo = "maturity", Scale = 0.7f, Texture = "baby" },
new() { Name = "Adult", EnterAt = 1f, RelativeTo = "maturity", Scale = 1f, Texture = "adult", Adult = true },
new() { Name = "Senior", EnterAt = 0.8f, RelativeTo = "lifespan", Scale = 0.92f, Texture = "adult", Adult = true },
];
private readonly Species[] _species;
private readonly Dictionary<AnimalDef, int> _index = new();
/// <summary>Общий реестр генов (по id) для скрещивания и вычисления признаков животных.</summary>
public IReadOnlyDictionary<string, GeneDef> GeneRegistry { get; }
/// <summary>Строит таблицу из всех (не-abstract) <see cref="AnimalDef"/> мода.</summary>
public AnimalSet(GameContent content, ModAtlases atlases, GraphicsDevice device)
{
var genes = content
.Defs.All<GeneDef>()
.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
GeneRegistry = genes;
var defs = content.Defs.All<AnimalDef>().ToArray();
_species = new Species[defs.Length];
for (var i = 0; i < defs.Length; i++)
{
var def = defs[i];
var female = atlases.GetRegion(device, def.Texture);
_species[i] = new Species
{
Def = def,
Female = female,
Male = string.IsNullOrEmpty(def.MaleTexture)
? female
: atlases.GetRegion(device, def.MaleTexture),
Baby = string.IsNullOrEmpty(def.BabyTexture)
? female
: atlases.GetRegion(device, def.BabyTexture),
Corpse = string.IsNullOrEmpty(def.CorpseTexture)
? female
: atlases.GetRegion(device, def.CorpseTexture),
Stages = def.Stages.Length > 0 ? def.Stages : DefaultStages,
Body = content.Defs.TryGet<BodyDef>(def.Body, out var body) ? body : null,
Template = BuildTemplate(def.Genome, genes),
};
_index[def] = i;
}
}
/// <summary>Число видов в таблице.</summary>
public int Count => _species.Length;
/// <summary>Вид по индексу.</summary>
public Species this[int index] => _species[index];
/// <summary>Индекс вида по дефу.</summary>
public int IndexOf(AnimalDef def) => _index[def];
/// <summary>
/// Генерирует геном новой особи-основателя вида: аллели из шаблона + явно назначенный пол
/// (XX самка / XY самец, 50/50) — чтобы не возник невозможный YY (см. Defs/Genes/Animal.json GeneSex).
/// Скрещивание (A4) наследует пол само (мать XX отдаёт только X).
/// </summary>
public Genome GenerateGenome(int species, Random random)
{
var genome = _species[species].Template.Generate(random);
genome["GeneSex"] = random.NextSingle() < 0.5f ? new Allele(0f, 0f) : new Allele(0f, 1f);
return genome;
}
// Строит шаблон генома вида из ДАННЫХ (geneId→base из Defs/Animals/) generically: разброс берётся из
// самого гена. Состав открыт — модер добавляет ген строкой JSON, без правки кода. Дискретные гены
// (база/разброс не нужны — варианты из GeneDef) тоже поддержаны.
private static GenomeTemplate BuildTemplate(
IReadOnlyDictionary<string, float> genome,
IReadOnlyDictionary<string, GeneDef> genes
)
{
var entries = new List<GenomeTemplate.Entry>(genome.Count);
foreach (var (geneId, baseValue) in genome)
{
if (!genes.TryGetValue(geneId, out var gene))
{
throw new KeyNotFoundException(
$"Animal genome references unknown gene '{geneId}' — check Defs/Genes/."
);
}
var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread;
entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread));
}
return new GenomeTemplate(entries);
}
}
+5
View File
@@ -63,6 +63,11 @@ public sealed class GameContent
defs.RegisterType<ProductDef>("Product"); defs.RegisterType<ProductDef>("Product");
defs.RegisterType<PlantDef>("Plant"); defs.RegisterType<PlantDef>("Plant");
defs.RegisterType<PawnDef>("Pawn"); defs.RegisterType<PawnDef>("Pawn");
defs.RegisterType<AnimalDef>("Animal");
defs.RegisterType<HediffDef>("Hediff");
defs.RegisterType<NeedDef>("Need");
defs.RegisterType<ThoughtDef>("Thought");
defs.RegisterType<BodyDef>("Body");
defs.RegisterType<WorldPresetDef>("WorldPreset"); defs.RegisterType<WorldPresetDef>("WorldPreset");
// Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа. // Валидация имён при загрузке (фаза G5): гены и продукты обязаны иметь префикс типа.
defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'"); defs.RegisterValidator("Gene", "defName", "^Gene", "gene defName must start with 'Gene'");
+223 -7
View File
@@ -14,7 +14,7 @@ public sealed class ScatterEntry
public List<string> Options { get; init; } = []; public List<string> Options { get; init; } = [];
} }
/// <summary>Тип клетки рельефа (Defs/terrain.json): порог высоты, цвет, поверхность, скаттер растений.</summary> /// <summary>Тип клетки рельефа (Defs/Terrain/): порог высоты, цвет, поверхность, скаттер растений.</summary>
public sealed class TerrainDef : Def public sealed class TerrainDef : Def
{ {
/// <summary>Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты).</summary> /// <summary>Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты).</summary>
@@ -67,7 +67,7 @@ public sealed class PlantGrowthStage
} }
/// <summary> /// <summary>
/// Продукт (Defs/products.json): что растение даёт при сборе (древесина, трава) или плодоношении /// Продукт (Defs/Products/): что растение даёт при сборе (древесина, трава) или плодоношении
/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество /// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество
/// определяют гены. <see cref="Def.Label"/> — ключ локализации (product.*). /// определяют гены. <see cref="Def.Label"/> — ключ локализации (product.*).
/// </summary> /// </summary>
@@ -93,9 +93,15 @@ public sealed class GenomeDef
/// <summary>Оптимальная температура (°C) и её толерантность.</summary> /// <summary>Оптимальная температура (°C) и её толерантность.</summary>
public float OptimalTemperature { get; init; } = 18f; public float OptimalTemperature { get; init; } = 18f;
/// <summary>Ширина толерантности по температуре (°C).</summary> /// <summary>Ширина толерантности по температуре (°C) — половина плато полного роста вокруг оптимума.</summary>
public float TemperatureTolerance { get; init; } = 12f; public float TemperatureTolerance { get; init; } = 12f;
/// <summary>Морозостойкость (°C): на сколько ниже плато рост спадает до нуля (жёсткая нижняя граница).</summary>
public float ColdHardiness { get; init; } = 8f;
/// <summary>Жаростойкость (°C): на сколько выше плато рост спадает до нуля (жёсткая верхняя граница).</summary>
public float HeatHardiness { get; init; } = 10f;
/// <summary>Оптимальная плодородность почвы и её толерантность.</summary> /// <summary>Оптимальная плодородность почвы и её толерантность.</summary>
public float OptimalFertility { get; init; } = 1f; public float OptimalFertility { get; init; } = 1f;
@@ -139,7 +145,7 @@ public sealed class GenomeDef
public float LeafHue { get; init; } = 0.33f; public float LeafHue { get; init; } = 0.33f;
} }
/// <summary>Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary> /// <summary>Растение (Defs/Plants/): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary>
public sealed class PlantDef : Def public sealed class PlantDef : Def
{ {
/// <summary>Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры.</summary> /// <summary>Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры.</summary>
@@ -165,7 +171,7 @@ public sealed class PlantDef : Def
} }
/// <summary> /// <summary>
/// Пресет размера мира (Defs/worldpresets.json): габариты сетки и население. Меню «Новый /// Пресет размера мира (Defs/WorldPresets/): габариты сетки и население. Меню «Новый
/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не /// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не
/// хардкодом. <see cref="Def.Label"/> — ключ локализации (preset.*). /// хардкодом. <see cref="Def.Label"/> — ключ локализации (preset.*).
/// </summary> /// </summary>
@@ -184,8 +190,8 @@ public sealed class WorldPresetDef : Def
public int Order { get; init; } public int Order { get; init; }
} }
/// <summary>Существо (Defs/pawns.json): текстура, размер и вид (житель/животное).</summary> /// <summary>Существо (Defs/Pawns/): текстура, размер и вид (житель/животное). Не sealed — <see cref="AnimalDef"/> наследует.</summary>
public sealed class PawnDef : Def public class PawnDef : Def
{ {
/// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary> /// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary>
public const string KindBeing = "being"; public const string KindBeing = "being";
@@ -202,3 +208,213 @@ public sealed class PawnDef : Def
/// <summary>Вид: <see cref="KindBeing"/> или <see cref="KindAnimal"/>.</summary> /// <summary>Вид: <see cref="KindBeing"/> или <see cref="KindAnimal"/>.</summary>
public string Kind { get; init; } = ""; public string Kind { get; init; } = "";
} }
/// <summary>
/// Стадия роста животного как ДАННЫЕ (вложенный объект <see cref="AnimalDef.Stages"/>): возраст входа =
/// <see cref="EnterAt"/> × якорь (<see cref="RelativeTo"/> — созревание или продолжительность жизни),
/// масштаб тела и набор текстур ("baby"/"adult"). Стадии перечисляются по возрастанию возраста входа.
/// Модер задаёт любой набор стадий (личинка/куколка/имаго и т.п.) без правки кода.
/// </summary>
public sealed class AnimalStageDef
{
/// <summary>Имя стадии (для тултипов/отладки).</summary>
public string Name { get; init; } = "";
/// <summary>Множитель к якорю: возраст входа = EnterAt × (созревание | продолжительность жизни).</summary>
public float EnterAt { get; init; }
/// <summary>Якорь возраста входа: "maturity" (созревание) или "lifespan" (продолжительность жизни).</summary>
public string RelativeTo { get; init; } = "maturity";
/// <summary>Масштаб размера тела на этой стадии (× ген размера).</summary>
public float Scale { get; init; } = 1f;
/// <summary>Набор текстур: "baby" (детёныш) или "adult" (по полу — самец/самка).</summary>
public string Texture { get; init; } = "adult";
/// <summary>Взрослая стадия — способна размножаться.</summary>
public bool Adult { get; init; }
}
/// <summary>
/// Вид-животное (Defs/Animals/): подтип <see cref="PawnDef"/> (текстура/размер/вид) плюс геном
/// вида. Поведение, здоровье и стадии живут на компонентах сущности (фазы A2+), а не на типе дефа —
/// существующие 9 животных-<see cref="PawnDef"/> остаются валидными.
/// </summary>
public sealed class AnimalDef : PawnDef
{
/// <summary>Текстура взрослого самца (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/> (самка/общая).</summary>
public string MaleTexture { get; init; } = "";
/// <summary>Текстура детёныша/подростка (фаза A3); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string BabyTexture { get; init; } = "";
/// <summary>Текстура трупа (фаза A5); пусто — берётся <see cref="PawnDef.Texture"/>.</summary>
public string CorpseTexture { get; init; } = "";
/// <summary>Имя <see cref="BodyDef"/> — анатомия вида (части тела/органы); пусто — без частей тела.</summary>
public string Body { get; init; } = "";
/// <summary>
/// Геном вида как ДАННЫЕ: ключ — id гена (<see cref="MrGameEng.Genetics.GeneDef"/>), значение —
/// центр аллелей особи. Состав открыт — модер добавляет/убирает ген одной строкой JSON, без правки
/// кода; разброс берётся из самого гена (<c>GeneDef.Spread</c>). Дискретные гены (напр. пол) сюда
/// не пишутся — назначаются генерацией.
/// </summary>
public Dictionary<string, float> Genome { get; init; } = new();
/// <summary>
/// Диета как ДАННЫЕ: что вид ест ("plant" — травоядное; "meat" — хищник/падальщик; оба — всеядное).
/// Поиск корма гейтится этим списком — модер задаёт рацион без правки кода.
/// </summary>
public string[] Diet { get; init; } = [];
/// <summary>
/// Стадии роста (данные); пусто — встроенный набор Baby→Juvenile→Adult→Senior по умолчанию.
/// </summary>
public AnimalStageDef[] Stages { get; init; } = [];
/// <summary>Плотность стартового спавна: голов на 1000 клеток суши (0 — вид не спавнится сам).</summary>
public float SpawnPer1000Cells { get; init; }
/// <summary>Базовая скорость передвижения (мировых единиц/сек при гене moveSpeed = 1).</summary>
public float BaseSpeed { get; init; } = 28f;
/// <summary>Базовый радиус восприятия в клетках (× ген vision) — поиск корма/воды/партнёра.</summary>
public float VisionCells { get; init; } = 14f;
/// <summary>Сила выедания: на сколько игровых дней роста убавляется растение за день кормёжки.</summary>
public float ForageBiteDays { get; init; } = 40f;
/// <summary>Дальность атаки в клетках: на этом расстоянии хищник кусает добычу (контакт).</summary>
public float AttackRangeCells { get; init; } = 1.2f;
/// <summary>Урон части тела добычи за игровой день укусов (0 — вид не атакует, не хищник по бою).</summary>
public float AttackDamage { get; init; }
/// <summary>Прирост тяжести раны-кровотечения у добычи за игровой день укусов.</summary>
public float AttackBleed { get; init; }
/// <summary>Острая кровопотеря добычи за игровой день укусов (доля объёма крови) — основной путь к смерти.</summary>
public float AttackBloodLoss { get; init; }
}
/// <summary>
/// Нужда (Defs/Needs/) как ДАННЫЕ: имя, тип (deplete — убывает, низкое = срочно; drive — растёт под
/// условием, высокое = срочно), действие-исполнитель (по id, см. реестр поведений), темпы и порог
/// интеллекта. Модер добавляет нужду виду/всем одной записью; если она ссылается на существующее
/// действие — без правки кода. Хранилище нужд особи — динамическое (см. <c>NeedSet</c>/<c>AnimalNeeds</c>).
/// </summary>
public sealed class NeedDef : Def
{
/// <summary>Тип: "deplete" (убывает, утоляется действием) или "drive" (растёт под условием).</summary>
public string Kind { get; init; } = "deplete";
/// <summary>Id действия-исполнителя, удовлетворяющего нужду (Eat/Drink/Sleep/Mate/…).</summary>
public string Action { get; init; } = "";
/// <summary>Темп убывания за игровой день (drive: темп спада, когда условие отсутствует).</summary>
public float DecayPerDay { get; init; } = 0.5f;
/// <summary>drive: темп роста за день, пока активно условие <see cref="RisesUnder"/>.</summary>
public float RisePerDay { get; init; } = 1f;
/// <summary>deplete: насколько за день восполняет нужду исполнение действия.</summary>
public float FeedPerDay { get; init; } = 4f;
/// <summary>drive: id хедифа, под которым нужда растёт (напр. "Rut"); null — растёт всегда.</summary>
public string? RisesUnder { get; init; }
/// <summary>deplete: на нуле копит счётчик голодной смерти (A3).</summary>
public bool Lethal { get; init; }
/// <summary>Порог размера мозга, с которого нужда активна (гейт интеллектом — фаза A7).</summary>
public float MinBrain { get; init; }
}
/// <summary>
/// Мысль настроения (Defs/Thoughts/) как ДАННЫЕ (фаза A8, модель thoughts RimWorld): событие
/// (роды/спаривание/утоление жажды/…) вешает на особь временный сдвиг настроения, затухающий за
/// <see cref="DurationDays"/>. Триггеры событий — код (реестр <c>AnimalThoughts</c>), а величины/метка —
/// данные: модер правит силу и длительность мысли одной записью JSON, без пересборки.
/// </summary>
public sealed class ThoughtDef : Def
{
/// <summary>Сдвиг настроения [-1,1] (положительный — радость, отрицательный — горе).</summary>
public float MoodOffset { get; init; }
/// <summary>Сколько игровых дней мысль живёт, прежде чем угаснуть.</summary>
public float DurationDays { get; init; } = 1f;
}
/// <summary>
/// Часть тела/орган (вложенный объект <see cref="BodyDef.Parts"/>) как ДАННЫЕ: иерархия (родитель),
/// вес попадания, максимум HP (масштабируется размером тела), флаг жизненной важности и вклады в
/// способности (capacity id → доля). Модер задаёт любую анатомию (8 ног, два сердца) без правки кода.
/// </summary>
public sealed class BodyPartDef
{
/// <summary>Имя части (уникально в теле); на него ссылается <see cref="Parent"/> детей.</summary>
public string Name { get; init; } = "";
/// <summary>Имя родительской части ("" — корневая, напр. торс).</summary>
public string Parent { get; init; } = "";
/// <summary>Вес попадания (доля тела) — для будущего выбора задетой части.</summary>
public float Coverage { get; init; } = 0.1f;
/// <summary>Базовый максимум HP (× размер тела при создании особи).</summary>
public float MaxHp { get; init; } = 10f;
/// <summary>Жизненно важная: уничтожение → смерть.</summary>
public bool Vital { get; init; }
/// <summary>Вклады в способности: capacity id (см. <c>AnimalCapacities</c>) → доля (сумма по телу ≈ 1).</summary>
public Dictionary<string, float> Capacities { get; init; } = new();
}
/// <summary>
/// Анатомия вида (Defs/Bodies/): дерево частей тела/органов. Используется системой здоровья (фаза
/// A5): из неё строятся инстансы частей особи, по их HP/крови считаются способности (capacities).
/// </summary>
public sealed class BodyDef : Def
{
/// <summary>Части тела/органы вида.</summary>
public BodyPartDef[] Parts { get; init; } = [];
}
/// <summary>
/// Состояние/хедиф организма (Defs/Hediffs/): гон, болезни, позже раны/возрастные эффекты. Фаза A6:
/// болезнь прогрессирует (<see cref="SeverityPerDay"/>), параллельно растёт иммунитет
/// (<see cref="ImmunityPerDay"/>) — гонка: иммунитет→1 раньше = выздоровление, severity→
/// <see cref="LethalSeverity"/> раньше = смерть. Снижает способности (<see cref="CapMods"/>) и даёт боль.
/// </summary>
public sealed class HediffDef : Def
{
/// <summary>Начальная тяжесть при навешивании (гон — 1; болезнь — малая, напр. 0.05).</summary>
public float InitialSeverity { get; init; } = 1f;
/// <summary>Прирост тяжести за игровой день (0 — статичный хедиф вроде гона).</summary>
public float SeverityPerDay { get; init; }
/// <summary>Прирост иммунитета за день (0 — иммунитета нет, не выздоравливает сам).</summary>
public float ImmunityPerDay { get; init; }
/// <summary>Тяжесть, при которой наступает смерть (0 — не летально).</summary>
public float LethalSeverity { get; init; }
/// <summary>Боль при тяжести 1 (масштабируется тяжестью) — вход в сознание/настроение.</summary>
public float Pain { get; init; }
/// <summary>Шанс подхватить фоном за день (0 — не фоновая болезнь).</summary>
public float AmbientPerDay { get; init; }
/// <summary>
/// Кровопотеря за игровой день при тяжести 1 (масштабируется тяжестью): доля объёма крови, которую
/// рана теряет в сутки. 0 — хедиф не вызывает кровотечения. Кровь на нуле → смерть (см. система здоровья).
/// </summary>
public float BloodLossPerDay { get; init; }
/// <summary>Модификаторы способностей: capacity id → множитель при тяжести 1 (лерп 1→factor по severity).</summary>
public Dictionary<string, float> CapMods { get; init; } = new();
}
+54
View File
@@ -0,0 +1,54 @@
namespace LittleSim.Content;
/// <summary>
/// Готовый реестр нужд из <see cref="NeedDef"/>: присваивает каждой нужде индекс (по нему системы
/// читают значения в <c>AnimalNeeds.Values</c> без словарей), знает тип (deplete/drive), начальное
/// значение и какое действие какую нужду утоляет. Делает набор нужд расширяемым данными: добавил
/// <see cref="NeedDef"/> — особи получают новую нужду, без правки кода (если действие уже есть).
/// </summary>
public sealed class NeedSet
{
private readonly NeedDef[] _defs;
private readonly Dictionary<string, int> _byAction = new(StringComparer.Ordinal);
/// <summary>Строит реестр из всех <see cref="NeedDef"/> мода (порядок = индексы нужд).</summary>
public NeedSet(GameContent content)
{
_defs = content.Defs.All<NeedDef>().ToArray();
for (var i = 0; i < _defs.Length; i++)
{
if (!string.IsNullOrEmpty(_defs[i].Action))
{
_byAction[_defs[i].Action] = i;
}
}
}
/// <summary>Число нужд.</summary>
public int Count => _defs.Length;
/// <summary>Деф нужды по индексу.</summary>
public NeedDef this[int index] => _defs[index];
/// <summary>Нужда-влечение (растёт под условием), а не убывающая.</summary>
public bool IsDrive(int index) => string.Equals(_defs[index].Kind, "drive", StringComparison.Ordinal);
/// <summary>Убывающая нужда (голод/жажда/сон) — низкое значение срочно.</summary>
public bool IsDeplete(int index) => !IsDrive(index);
/// <summary>Индекс нужды, которую утоляет действие, или -1.</summary>
public int NeedForAction(string actionId) =>
_byAction.TryGetValue(actionId, out var index) ? index : -1;
/// <summary>Новый массив значений нужд особи: deplete стартуют полными (1), drive — пустыми (0).</summary>
public float[] NewValues()
{
var values = new float[_defs.Length];
for (var i = 0; i < values.Length; i++)
{
values[i] = IsDrive(i) ? 0f : 1f;
}
return values;
}
}
+4 -2
View File
@@ -81,7 +81,7 @@ public sealed class PlantSet
/// <summary>Индекс вида по дефу.</summary> /// <summary>Индекс вида по дефу.</summary>
public int IndexOf(PlantDef def) => _index[def]; public int IndexOf(PlantDef def) => _index[def];
// Переводит базовый геном вида (числа из plants.json) в набор генов: каждое число — центр // Переводит базовый геном вида (числа из Defs/Plants/) в набор генов: каждое число — центр
// аллелей соответствующего общего GeneDef; морфа — дискретный ген с долей рецессива из вида. // аллелей соответствующего общего GeneDef; морфа — дискретный ген с долей рецессива из вида.
private static GenomeTemplate BuildTemplate( private static GenomeTemplate BuildTemplate(
GenomeDef g, GenomeDef g,
@@ -92,7 +92,7 @@ public sealed class PlantSet
genes.TryGetValue(id, out var def) genes.TryGetValue(id, out var def)
? def ? def
: throw new KeyNotFoundException( : throw new KeyNotFoundException(
$"Gene def '{id}' not found — is Mods/Core/Defs/genes.json loaded?" $"Gene def '{id}' not found — is Mods/Core/Defs/Genes/ loaded?"
); );
GenomeTemplate.Entry Numeric(string id, float baseValue) => GenomeTemplate.Entry Numeric(string id, float baseValue) =>
@@ -103,6 +103,8 @@ public sealed class PlantSet
Numeric("GeneLightTolerance", g.LightTolerance), Numeric("GeneLightTolerance", g.LightTolerance),
Numeric("GeneOptimalTemperature", g.OptimalTemperature), Numeric("GeneOptimalTemperature", g.OptimalTemperature),
Numeric("GeneTemperatureTolerance", g.TemperatureTolerance), Numeric("GeneTemperatureTolerance", g.TemperatureTolerance),
Numeric("GeneColdHardiness", g.ColdHardiness),
Numeric("GeneHeatHardiness", g.HeatHardiness),
Numeric("GeneOptimalFertility", g.OptimalFertility), Numeric("GeneOptimalFertility", g.OptimalFertility),
Numeric("GeneFertilityTolerance", g.FertilityTolerance), Numeric("GeneFertilityTolerance", g.FertilityTolerance),
Numeric("GeneVigor", g.Vigor), Numeric("GeneVigor", g.Vigor),
+22
View File
@@ -0,0 +1,22 @@
namespace LittleSim.Content;
/// <summary>
/// Реестр мыслей настроения (<see cref="ThoughtDef"/>) с доступом по id (фаза A8): системы-источники
/// событий (роды/спаривание/жажда) берут нужную мысль по строковому id (см. <c>AnimalThoughts</c>) и
/// вешают её на особь. Метка/сила/длительность мысли — данные; модер правит их в thoughts.json. Зеркало
/// <see cref="NeedSet"/> по духу. Удалил модер мысль из дефов — <see cref="Get"/> вернёт null (no-op).
/// </summary>
public sealed class ThoughtSet
{
private readonly Dictionary<string, ThoughtDef> _byId;
/// <summary>Строит реестр из всех <see cref="ThoughtDef"/> мода.</summary>
public ThoughtSet(GameContent content) =>
_byId = content.Defs.All<ThoughtDef>().ToDictionary(t => t.DefName, StringComparer.Ordinal);
/// <summary>Число мыслей.</summary>
public int Count => _byId.Count;
/// <summary>Деф мысли по id или null, если такой нет (мод убрал).</summary>
public ThoughtDef? Get(string id) => _byId.GetValueOrDefault(id);
}
+31
View File
@@ -1,6 +1,7 @@
using LittleSim.Content; using LittleSim.Content;
using LittleSim.Net; using LittleSim.Net;
using LittleSim.Scenes; using LittleSim.Scenes;
using LittleSim.Sim;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Genetics; using MrGameEng.Genetics;
using MrGameEng.Host; using MrGameEng.Host;
@@ -20,6 +21,36 @@ if (args.Contains("--check-content"))
+ $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits " + $"{content.Defs.NamesOf("Plant").Count} plants, {traits.Count} traits "
+ $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})" + $"(hardiness={traits.GetValueOrDefault("hardiness"):0.##})"
); );
// Диета животных из генов (предатор-кластер): для каждого вида строим центральный геном из его
// баз, считаем признаки формулами и выводим рацион (травоядное/хищник/всеядное) — проверка без
// GPU, что хищники заданы и роль выводится из генов herbivory/carnivory/omnivory.
foreach (var def in content.Defs.All<AnimalDef>())
{
var entries = new List<GenomeTemplate.Entry>();
foreach (var (geneId, baseValue) in def.Genome)
{
if (registry.TryGetValue(geneId, out var gene))
{
var spread = gene.Kind == GeneKind.Discrete ? 0f : gene.Spread;
entries.Add(new GenomeTemplate.Entry(gene, baseValue, spread));
}
}
var genome = new GenomeTemplate(entries).Generate(new Random(7));
var t = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, registry));
var (eatsPlants, eatsMeat) = AnimalFactory.Diet(t, def);
var role =
eatsMeat ? (eatsPlants ? "omnivore" : "carnivore")
: eatsPlants ? "herbivore"
: "none";
var hunt = eatsMeat && def.AttackDamage > 0f ? $", attack {def.AttackDamage:0}/d" : "";
Console.WriteLine(
$" {def.DefName}: {role} (herb {t.Herbivory:0.##}/carn {t.Carnivory:0.##}/omni {t.Omnivory:0.##}),"
+ $" spawn {def.SpawnPer1000Cells:0.##}/1000{hunt}"
);
}
return; return;
} }
catch (Exception error) catch (Exception error)
+1 -1
View File
@@ -46,7 +46,7 @@ public sealed class BootScene : Scene
host.Graphics.ApplyChanges(); host.Graphics.ApplyChanges();
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume; Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label _label = new Label
{ {
TextColor = Ui.Accent, TextColor = Ui.Accent,
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class CreditsScene : Scene
{ {
var lang = Context.Services.Get<GameContent>().Languages; var lang = Context.Services.Get<GameContent>().Languages;
var input = this.UseInput(); var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12); var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("credits.title"))); column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
+5 -1
View File
@@ -13,7 +13,10 @@ public static class GameLayers
/// <summary>Y-sort слой существ и объектов мира: кто ниже на экране — тот ближе.</summary> /// <summary>Y-sort слой существ и объектов мира: кто ниже на экране — тот ближе.</summary>
public static LayerId Beings { get; private set; } public static LayerId Beings { get; private set; }
/// <summary>Регистрирует слои при первом вызове.</summary> /// <summary>Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).</summary>
public static LayerId Selection { get; private set; }
/// <summary>Регистрирует слои при первом вызове (порядок = порядок отрисовки).</summary>
public static void EnsureRegistered(Renderer2D renderer) public static void EnsureRegistered(Renderer2D renderer)
{ {
if (_registered) if (_registered)
@@ -22,6 +25,7 @@ public static class GameLayers
} }
Beings = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort); Beings = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort);
Selection = renderer.Layers.Register("Selection", LayerSpace.World, LayerSortMode.Depth);
_registered = true; _registered = true;
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class LoadGameScene : Scene
{ {
_content = Context.Services.Get<GameContent>(); _content = Context.Services.Get<GameContent>();
var input = this.UseInput(); var input = this.UseInput();
_desktop = this.UseUI(); _desktop = this.UseScaledUI();
Rebuild(); Rebuild();
UpdateSystems.Add( UpdateSystems.Add(
+1 -1
View File
@@ -17,7 +17,7 @@ public sealed class MainMenuScene : Scene
{ {
var content = Context.Services.Get<GameContent>(); var content = Context.Services.Get<GameContent>();
var lang = content.Languages; var lang = content.Languages;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12); var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("menu.title"))); column.Widgets.Add(Ui.Title(lang.Get("menu.title")));
+42 -14
View File
@@ -30,6 +30,9 @@ public sealed class MultiplayerScene : Scene
{ {
private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f); private static readonly RectF Bounds = new(0f, 0f, 1280f, 720f);
// Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
private const float MaxBackoffSeconds = 8f;
private readonly Uri _server; private readonly Uri _server;
private Task<WebSocketClient>? _connecting; private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection; private WebSocketClient? _connection;
@@ -38,6 +41,8 @@ public sealed class MultiplayerScene : Scene
private GameContent _content = null!; private GameContent _content = null!;
private Label _hud = null!; private Label _hud = null!;
private string _statusKey = "net.connecting"; private string _statusKey = "net.connecting";
private double _nextAttemptAt; // время (unscaled) следующей попытки подключения
private int _attempt; // 0 — первая попытка; растёт при обрывах, задаёт бэкофф
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary> /// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
public MultiplayerScene(Uri server) => _server = server; public MultiplayerScene(Uri server) => _server = server;
@@ -64,7 +69,7 @@ public sealed class MultiplayerScene : Scene
entity.AddComponent(new NetLerp()); entity.AddComponent(new NetLerp());
}; };
var desktop = this.UseUI(); var desktop = this.UseScaledUI();
_hud = new Label { Left = 10, Top = 8 }; _hud = new Label { Left = 10, Top = 8 };
desktop.Root = Ui.Screen(_hud); desktop.Root = Ui.Screen(_hud);
@@ -86,39 +91,53 @@ public sealed class MultiplayerScene : Scene
} }
); );
_connecting = WebSocketClient.ConnectAsync(_server); // Первую попытку запускает сам Pump (единый путь с переподключением): _nextAttemptAt = 0.
} }
protected override void OnUnload() => _connection?.Close(); protected override void OnUnload() => _connection?.Close();
private void Pump() private void Pump()
{ {
var now = Context.Clock.UnscaledTotalTime;
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
if (_connecting is { IsCompleted: true } finished) if (_connecting is { IsCompleted: true } finished)
{ {
_connecting = null; _connecting = null;
if (finished.IsFaulted) if (finished.IsCompletedSuccessfully)
{
_connection = finished.Result;
_replication.Clear(); // сбрасываем устаревшие сущности перед свежим полным снапшотом
_attempt = 0;
_statusKey = "net.connected";
Log.Info($"Connected to {_server}");
}
else
{ {
_statusKey = "net.failed";
Log.Error( Log.Error(
$"Connect to {_server} failed: " $"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message + finished.Exception?.GetBaseException().Message
); );
ScheduleReconnect(now);
} }
else }
{
_connection = finished.Result; // Обрыв уже установленного соединения: чистим и уходим в переподключение.
_statusKey = "net.connected"; if (_connection is { IsOpen: false })
Log.Info($"Connected to {_server}"); {
} _connection = null;
_replication.Clear();
ScheduleReconnect(now);
} }
if (_connection is not null) if (_connection is not null)
{ {
_replication.Pump(_connection); _replication.Pump(_connection);
if (!_connection.IsOpen) }
{ else if (_connecting is null && now >= _nextAttemptAt)
_statusKey = "net.lost"; {
} _statusKey = _attempt == 0 ? "net.connecting" : "net.reconnecting";
_connecting = WebSocketClient.ConnectAsync(_server);
} }
_hud.Text = _content.Languages.Format( _hud.Text = _content.Languages.Format(
@@ -133,4 +152,13 @@ public sealed class MultiplayerScene : Scene
Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f)); Context.Scenes.Switch(new MainMenuScene(), Transitions.Fade(0.4f));
} }
} }
// Экспоненциальный бэкофф: 1, 2, 4, 8, 8, … секунд между попытками.
private void ScheduleReconnect(double now)
{
var delay = MathF.Min(MaxBackoffSeconds, 1f * (1 << Math.Min(_attempt, 3)));
_attempt++;
_nextAttemptAt = now + delay;
_statusKey = "net.reconnecting";
}
} }
+1 -1
View File
@@ -33,7 +33,7 @@ public sealed class NewWorldScene : Scene
var content = Context.Services.Get<GameContent>(); var content = Context.Services.Get<GameContent>();
var lang = content.Languages; var lang = content.Languages;
var input = this.UseInput(); var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList(); var presets = content.Defs.All<WorldPresetDef>().OrderBy(p => p.Order).ToList();
_preset = presets[0]; _preset = presets[0];
+4
View File
@@ -24,6 +24,7 @@ internal sealed class PauseMenu
private readonly GameSpeed _speed; private readonly GameSpeed _speed;
private readonly GameHost _host; private readonly GameHost _host;
private readonly AudioManager _audio; private readonly AudioManager _audio;
private readonly Desktop _desktop;
private readonly Func<string> _onSave; private readonly Func<string> _onSave;
private readonly Action _onMainMenu; private readonly Action _onMainMenu;
private readonly Action _onQuit; private readonly Action _onQuit;
@@ -36,6 +37,7 @@ internal sealed class PauseMenu
EngineContext context, EngineContext context,
GameContent content, GameContent content,
GameSpeed speed, GameSpeed speed,
Desktop desktop,
Func<string> onSave, Func<string> onSave,
Action onMainMenu, Action onMainMenu,
Action onQuit Action onQuit
@@ -44,6 +46,7 @@ internal sealed class PauseMenu
_context = context; _context = context;
_content = content; _content = content;
_speed = speed; _speed = speed;
_desktop = desktop;
_host = (GameHost)context.Services.Get<Game>(); _host = (GameHost)context.Services.Get<Game>();
_audio = context.Services.Get<AudioManager>(); _audio = context.Services.Get<AudioManager>();
_onSave = onSave; _onSave = onSave;
@@ -144,6 +147,7 @@ internal sealed class PauseMenu
{ {
GameSettingsStore.Save(_settings); GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio); GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(_context, _desktop, _settings.UiScale); // масштаб UI — сразу
ShowSettings(); // язык мог смениться ShowSettings(); // язык мог смениться
}, },
onBack: () => onBack: () =>
+2 -1
View File
@@ -32,7 +32,7 @@ public sealed class SettingsScene : Scene
_host = (GameHost)Context.Services.Get<Game>(); _host = (GameHost)Context.Services.Get<Game>();
var input = this.UseInput(); var input = this.UseInput();
_desktop = this.UseUI(); _desktop = this.UseScaledUI();
Rebuild(); Rebuild();
UpdateSystems.Add( UpdateSystems.Add(
@@ -53,6 +53,7 @@ public sealed class SettingsScene : Scene
{ {
GameSettingsStore.Save(_settings); GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio); GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(Context, _desktop, _settings.UiScale); // масштаб UI — сразу
Rebuild(); // язык мог смениться — перестраиваем подписи Rebuild(); // язык мог смениться — перестраиваем подписи
} }
+832 -6
View File
@@ -45,15 +45,28 @@ public sealed class WorldScene : Scene
/// <summary>Максимум растений на клетку — потолок плотности для размножения.</summary> /// <summary>Максимум растений на клетку — потолок плотности для размножения.</summary>
public const int DensityCap = 4; public const int DensityCap = 4;
/// <summary>
/// День года, с которого начинается мир — тёплая весна (~16 °C), а не холодный рубеж года.
/// Сдвигает сезонную фазу климата, давая длинный тёплый сезон роста до первой зимы.
/// </summary>
public const int StartDayOfYear = 10;
/// <summary>Час суток на старте мира — утро (07:00), а не полночь.</summary>
public const int StartHour = 7;
private readonly WorldConfig _config; private readonly WorldConfig _config;
private readonly WorldSave? _save; private readonly WorldSave? _save;
private readonly RectF _bounds; private readonly RectF _bounds;
private GameSpeed _speed = null!; private GameSpeed _speed = null!;
private PauseMenu _pause = null!; private PauseMenu _pause = null!;
private Selection _selection = null!;
private InspectPanel _inspect = null!;
private readonly List<Action> _speedRefreshers = []; private readonly List<Action> _speedRefreshers = [];
private PlantSet _plants = null!; private PlantSet _plants = null!;
private AnimalSet _animals = null!;
private NeedSet _needs = null!;
private float[] _cellFertility = []; private float[] _cellFertility = [];
private bool[] _cellLand = []; private bool[] _cellLand = [];
private bool[] _cellOccluderBase = []; // горы (статично из террейна) private bool[] _cellOccluderBase = []; // горы (статично из террейна)
@@ -87,12 +100,19 @@ public sealed class WorldScene : Scene
GameLayers.EnsureRegistered(renderer); GameLayers.EnsureRegistered(renderer);
this.UseTilemaps(); this.UseTilemaps();
var calendar = Context.UseCalendar(SecondsPerDay); var calendar = Context.UseCalendar(SecondsPerDay, StartHour / 24.0);
var climate = Context.UseClimate(ClimateSettings.Default); var climate = Context.UseClimate(
ClimateSettings.Default with
{
StartDayOfYear = StartDayOfYear,
}
);
var dayNight = new DayNight(calendar, DayNightSettings.Default); var dayNight = new DayNight(calendar, DayNightSettings.Default);
// Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва. // Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
_plants = new PlantSet(content, atlases, device); _plants = new PlantSet(content, atlases, device);
_animals = new AnimalSet(content, atlases, device);
_needs = new NeedSet(content);
var loadingPlants = _save?.Plants is { Count: > 0 }; var loadingPlants = _save?.Plants is { Count: > 0 };
BuildTerrain( BuildTerrain(
content, content,
@@ -106,6 +126,17 @@ public sealed class WorldScene : Scene
RestorePlants(content); RestorePlants(content);
} }
// Животные: из сейва (сохранённая популяция — эволюция/состояние не теряются) либо
// детерминированный спавн из сида (новый мир или старый сейв без животных).
if (_save?.Animals is { Count: > 0 })
{
RestoreAnimals(content);
}
else
{
SpawnAnimals();
}
// Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира, // Освещение: лайтмап (день/ночь × окклюзия от гор/крон + точечные) множится поверх мира,
// а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже). // а Lighting.SampleAt даёт локальный свет системе роста (подлесок под кронами растёт хуже).
var lighting = this.UseLighting( var lighting = this.UseLighting(
@@ -121,18 +152,35 @@ public sealed class WorldScene : Scene
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds)); var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели. // HUD, полоса скорости и меню-пауза в одной корневой панели.
var desktop = this.UseUI(); var desktop = this.UseScaledUI();
var hudLabel = new Label { Left = 10, Top = 8 }; var hudLabel = new Label { Left = 10, Top = 8 };
var speedBar = BuildSpeedBar(content); var speedBar = BuildSpeedBar(content);
_pause = new PauseMenu( _pause = new PauseMenu(
Context, Context,
content, content,
_speed, _speed,
desktop,
onSave: SaveWorld, onSave: SaveWorld,
onMainMenu: () => Switch(new MainMenuScene()), onMainMenu: () => Switch(new MainMenuScene()),
onQuit: () => Context.Services.Get<Game>().Exit() onQuit: () => Context.Services.Get<Game>().Exit()
); );
desktop.Root = Ui.Screen(hudLabel, speedBar, _pause.Root); _selection = new Selection();
var brackets = new SelectionBrackets(
Store,
atlases.GetRegion(device, "ui/overlays/selectionbracket"),
GameLayers.Selection
);
_inspect = new InspectPanel(
Store,
_plants,
_animals,
_needs,
content,
climate,
_selection,
brackets
);
desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root);
this.UseInspector(renderer); this.UseInspector(renderer);
var console = this.UseDevConsole(); var console = this.UseDevConsole();
@@ -168,6 +216,7 @@ public sealed class WorldScene : Scene
Store, Store,
_plants, _plants,
calendar, calendar,
climate,
Context.Clock, Context.Clock,
_config.Width, _config.Width,
_config.Height, _config.Height,
@@ -178,9 +227,86 @@ public sealed class WorldScene : Scene
_config.Seed _config.Seed
) )
); );
// Животные (фаза A2): нужды падают, ИИ выбирает действие, исполнение ведёт к цели и утоляет
// нужду (еда выедает растения — контур ёмкости среды). Внешний вид тускнеет с острой нуждой.
var shore = ComputeShore();
var thoughts = new ThoughtSet(content);
UpdateSystems.Add(new AnimalNeedsSystem(Context.Clock, SecondsPerDay, _needs));
UpdateSystems.Add(
new AnimalRutSystem(_animals, climate, content.Defs.Get<HediffDef>("Rut"))
);
UpdateSystems.Add(
new AnimalHealthSystem(
Store,
_animals,
CellSize,
Context.Clock,
SecondsPerDay,
content.Defs.All<HediffDef>().Where(h => h.AmbientPerDay > 0f).ToArray(),
_config.Seed + 0x3EAD
)
);
// Настроение (A8) до выбора решения: стресс (низкое настроение) подавляет спаривание.
UpdateSystems.Add(new AnimalMoodSystem(Context.Clock, SecondsPerDay, _needs));
UpdateSystems.Add(
new AnimalDecisionSystem(
Store,
Context.Clock,
_animals,
_needs,
CellSize,
shore,
_config.Seed + 0x5EED
)
);
var bleeding = content.Defs.TryGet<HediffDef>("Bleeding", out var bl) ? bl : null;
UpdateSystems.Add(
new AnimalActionSystem(
Store,
_plants,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
CellSize,
_bounds,
bleeding,
_config.Seed + 0x1B17
)
);
UpdateSystems.Add(new AnimalAppearanceSystem(_needs));
UpdateSystems.Add(new AnimalGrowthSystem(Context.Clock, SecondsPerDay, _animals, CellSize));
UpdateSystems.Add(new AnimalMortalitySystem(Store, _animals, CellSize));
UpdateSystems.Add(new CorpseSystem(Store, Context.Clock, SecondsPerDay, climate));
UpdateSystems.Add(
new AnimalPregnancySystem(
Store,
_animals,
_needs,
thoughts,
Context.Clock,
SecondsPerDay,
CellSize,
maxAnimals: 500,
_config.Seed + 0x4BED
)
);
UpdateSystems.Add( UpdateSystems.Add(
new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen) new GodCameraSystem(camera, input, renderer, console, () => _pause.IsOpen)
); );
UpdateSystems.Add(
new SelectionSystem(
Store,
input,
renderer,
_selection,
() => _pause.IsOpen,
() => desktop.IsMouseOverGUI
)
);
UpdateSystems.Add(new CallbackSystem(() => _inspect.Refresh()));
UpdateSystems.Add( UpdateSystems.Add(
new HudSystem( new HudSystem(
Context, Context,
@@ -309,7 +435,15 @@ public sealed class WorldScene : Scene
{ {
if (input.IsKeyPressed(Keys.Escape)) if (input.IsKeyPressed(Keys.Escape))
{ {
_pause.Toggle(); // Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу.
if (_selection.HasSelection)
{
_selection.Clear();
}
else
{
_pause.Toggle();
}
} }
if (_pause.IsOpen) if (_pause.IsOpen)
@@ -378,11 +512,108 @@ public sealed class WorldScene : Scene
} }
); );
SaveAnimals(save);
new SaveStore().Write(save); new SaveStore().Write(save);
Log.Info($"World '{_config.Name}' saved ({save.Plants.Count} plants)"); Log.Info(
$"World '{_config.Name}' saved ({save.Plants.Count} plants, "
+ $"{save.Animals.Count} animals, {save.Corpses.Count} corpses)"
);
return _config.Name; return _config.Name;
} }
// Снимок популяции животных и трупов: всё нужное, чтобы эволюция/состояние пережили сейв/лоад
// (а не пересыпались из сида). Health/Mood/Pregnant берём через entity — запрос ограничен 5 типами.
private void SaveAnimals(WorldSave save)
{
Store
.Query<AnimalOrganism, AnimalGrowth, AnimalNeeds, Transform2D>()
.ForEachEntity(
(
ref AnimalOrganism org,
ref AnimalGrowth grow,
ref AnimalNeeds needs,
ref Transform2D transform,
Entity entity
) =>
{
var record = new AnimalSave
{
Species = _animals[org.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
Generation = org.Generation,
Stage = grow.Stage,
AgeDays = grow.AgeDays,
Genome = org.Genome.ToDictionary(),
Needs = needs.Values,
Starve = needs.Starve,
};
if (entity.GetComponent<Health>().State is { } health)
{
record.BloodLevel = health.BloodLevel;
record.PartHp = health.Parts.Select(p => p.Hp).ToArray();
foreach (var hediff in health.Hediffs)
{
record.Hediffs.Add(
new HediffSave
{
DefName = hediff.Def.DefName,
Severity = hediff.Severity,
Immunity = hediff.Immunity,
}
);
}
}
var mood = entity.GetComponent<Mood>();
record.Mood = mood.Value;
record.MoodActive = mood.Active;
if (mood.Thoughts is not null)
{
foreach (var thought in mood.Thoughts)
{
record.Thoughts.Add(
new ThoughtSave
{
DefName = thought.Def.DefName,
RemainingDays = thought.RemainingDays,
}
);
}
}
if (entity.TryGetComponent<Pregnant>(out var preg))
{
record.Pregnant = true;
record.FatherGenome = preg.FatherGenome.ToDictionary();
record.DueInDays = preg.DueInDays;
record.Litter = preg.Litter;
}
save.Animals.Add(record);
}
);
Store
.Query<Corpse, Transform2D>()
.ForEachEntity(
(ref Corpse corpse, ref Transform2D transform, Entity _) =>
save.Corpses.Add(
new CorpseSave
{
Species = _animals[corpse.Species].Def.DefName,
X = transform.Position.X,
Y = transform.Position.Y,
RotDays = corpse.RotDays,
BodySize = corpse.Meat / AnimalFactory.MeatPerBodySize,
Meat = corpse.Meat,
}
)
);
}
private void RestorePlants(GameContent content) private void RestorePlants(GameContent content)
{ {
var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома var fallback = new Random(_config.Seed); // для старых/битых сейвов без генома
@@ -410,6 +641,218 @@ public sealed class WorldScene : Scene
} }
} }
// Восстанавливает популяцию животных и трупы из сейва: фабрика собирает особь из генома (геном
// несёт и пол), затем динамическое состояние (нужды/стадия/здоровье/настроение/беременность)
// накладывается поверх. Старый/битый сейв без генома — особь регенерируется из шаблона вида.
private void RestoreAnimals(GameContent content)
{
var fallback = new Random(_config.Seed);
foreach (var saved in _save!.Animals)
{
if (!content.Defs.TryGet<AnimalDef>(saved.Species, out var def))
{
continue; // вид пропал (мод убрали) — пропускаем особь
}
var index = _animals.IndexOf(def);
var genome = saved.Genome is { Count: > 0 }
? new Genome(saved.Genome)
: _animals.GenerateGenome(index, fallback);
var entity = AnimalFactory.Create(
Store,
_animals,
_needs,
index,
new Vector2(saved.X, saved.Y),
saved.AgeDays,
genome,
saved.Generation,
CellSize
);
RestoreAnimalState(entity, saved, content);
}
foreach (var saved in _save.Corpses)
{
if (!content.Defs.TryGet<AnimalDef>(saved.Species, out var def))
{
continue;
}
var corpse = AnimalFactory.CreateCorpse(
Store,
_animals,
_animals.IndexOf(def),
new Vector2(saved.X, saved.Y),
saved.BodySize,
CellSize
);
ref var data = ref corpse.GetComponent<Corpse>();
data.RotDays = saved.RotDays;
data.Stage = -1; // форсируем пере-тинт стадии в CorpseSystem на следующем тике
data.Meat = saved.Meat;
}
Log.Info(
$"Restored {_save.Animals.Count} animals, {_save.Corpses.Count} corpses from save"
);
}
// Накладывает на свежесозданную особь сохранённое динамическое состояние. Ref-компоненты правим до
// AddComponent(Pregnant) (структурное изменение инвалидирует ссылки); Health — managed, безопасен.
private void RestoreAnimalState(Entity entity, AnimalSave saved, GameContent content)
{
ref var needs = ref entity.GetComponent<AnimalNeeds>();
if (saved.Needs is { Length: > 0 } && saved.Needs.Length == _needs.Count)
{
needs.Values = saved.Needs; // состав совпал — иначе остаются свежие из фабрики
}
needs.Starve = saved.Starve;
entity.GetComponent<AnimalGrowth>().Stage = saved.Stage;
ref var mood = ref entity.GetComponent<Mood>();
mood.Value = saved.Mood;
mood.Active = saved.MoodActive;
mood.Thoughts.Clear();
foreach (var thought in saved.Thoughts)
{
if (content.Defs.TryGet<ThoughtDef>(thought.DefName, out var tdef))
{
mood.Thoughts.Add(
new ThoughtInstance { Def = tdef, RemainingDays = thought.RemainingDays }
);
}
}
if (entity.GetComponent<Health>().State is { } health)
{
health.BloodLevel = saved.BloodLevel;
for (var i = 0; i < health.Parts.Length && i < saved.PartHp.Length; i++)
{
health.Parts[i].Hp = saved.PartHp[i];
}
foreach (var hediff in saved.Hediffs)
{
if (content.Defs.TryGet<HediffDef>(hediff.DefName, out var hdef))
{
health.AddHediff(hdef, hediff.Severity, hediff.Immunity);
}
}
health.RecomputeCapacities();
}
if (saved.Pregnant && saved.FatherGenome is { Count: > 0 })
{
entity.AddComponent(
new Pregnant
{
FatherGenome = new Genome(saved.FatherGenome),
DueInDays = saved.DueInDays,
Litter = saved.Litter,
}
);
}
}
// Детерминированный начальный спавн животных по данным: каждый вид с SpawnPer1000Cells > 0
// рассыпается по клеткам суши с плотностью из дефа; геном и возраст особи — из шаблона вида.
private void SpawnAnimals()
{
var landCells = 0;
for (var i = 0; i < _cellLand.Length; i++)
{
if (_cellLand[i])
{
landCells++;
}
}
if (_animals.Count == 0 || landCells == 0)
{
return;
}
var random = new Random(_config.Seed + 0x5EED);
var spawned = 0;
for (var s = 0; s < _animals.Count; s++)
{
var def = _animals[s].Def;
if (def.SpawnPer1000Cells <= 0f)
{
continue; // вид не спавнится сам (напр. появляется только в сейве/событием)
}
var count = Math.Max(1, (int)MathF.Round(landCells / 1000f * def.SpawnPer1000Cells));
var maxAge =
(def.Genome.TryGetValue("GeneLifespan", out var lifespan) ? lifespan : 160f)
* 0.85f;
var placed = 0;
var attempts = 0;
while (placed < count && attempts < count * 50)
{
attempts++;
var cell = random.Next(_cellLand.Length);
if (!_cellLand[cell])
{
continue;
}
var cx = cell % _config.Width;
var cy = cell / _config.Width;
var px = (cx + 0.2f + random.NextSingle() * 0.6f) * CellSize;
var py = (cy + 0.2f + random.NextSingle() * 0.6f) * CellSize;
AnimalFactory.Create(
Store,
_animals,
_needs,
s,
new Vector2(px, py),
ageDays: random.NextSingle() * maxAge,
_animals.GenerateGenome(s, random),
generation: 0,
CellSize
);
placed++;
}
spawned += placed;
}
Log.Info($"Spawned {spawned} animals ({_animals.Count} species)");
}
// Кромка воды: центры клеток суши, граничащих с водой — куда звери ходят пить (вода непроходима).
private Vector2[] ComputeShore()
{
var shore = new List<Vector2>();
for (var y = 0; y < _config.Height; y++)
{
for (var x = 0; x < _config.Width; x++)
{
var cell = y * _config.Width + x;
if (!_cellLand[cell])
{
continue;
}
var nearWater =
(x > 0 && !_cellLand[cell - 1])
|| (x < _config.Width - 1 && !_cellLand[cell + 1])
|| (y > 0 && !_cellLand[cell - _config.Width])
|| (y < _config.Height - 1 && !_cellLand[cell + _config.Width]);
if (nearWater)
{
shore.Add(new Vector2((x + 0.5f) * CellSize, (y + 0.5f) * CellSize));
}
}
}
return shore.ToArray();
}
// Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями. // Текущая сетка окклюдеров для лайтмапа: статичные горы + клетки со зрелыми деревьями.
private bool[] BuildOccluders() private bool[] BuildOccluders()
{ {
@@ -508,6 +951,41 @@ public sealed class WorldScene : Scene
"plant <species> [seed] — sample a species genome and show its gene-driven traits/products", "plant <species> [seed] — sample a species genome and show its gene-driven traits/products",
(c, args) => RunPlantDemo(c, content, args) (c, args) => RunPlantDemo(c, content, args)
); );
console.Register(
"animal",
"animal <species> [seed] — sample an animal genome and show its gene-driven traits",
(c, args) => RunAnimalDemo(c, content, args)
);
console.Register(
"diet",
"diet [species] — gene-derived diet (herbivory/carnivory/omnivory), role and hunting stats",
(c, args) => RunDiet(c, content, args)
);
console.Register(
"popstats",
"popstats [species] — live population trait means and generation span (selection drift)",
(c, args) => RunPopStats(c, args)
);
console.Register(
"body",
"body <species> — dump a species body parts and a healthy individual's capacities",
(c, args) => RunBodyDemo(c, content, args)
);
console.Register(
"infect",
"infect [hediff] — give every animal a disease (default Fever) to test the health system",
(c, args) => RunInfect(c, content, args)
);
console.Register(
"intel",
"intel <species> [consciousness] — effective intelligence (brain x consciousness) and which needs it gates",
(c, args) => RunIntel(c, content, args)
);
console.Register(
"mood",
"mood [species] — live population mood (avg/min/max) and content/stressed counts",
(c, args) => RunMood(c, args)
);
console.Register( console.Register(
"menu", "menu",
"menu — return to the main menu", "menu — return to the main menu",
@@ -587,6 +1065,11 @@ public sealed class WorldScene : Scene
$" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, " $" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, "
+ $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}" + $"optimalLight {traits.OptimalLight:0.##}, leafHue {traits.LeafHue:0.##}"
); );
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
console.WriteLine(
$" temp: grows {tMin:0.#}..{tMax:0.#}°C, optimal {tLow:0.#}..{tHigh:0.#}°C "
+ $"(cold {traits.ColdHardiness:0.#}, heat {traits.HeatHardiness:0.#})"
);
if (def.HarvestProduct is { } harvest) if (def.HarvestProduct is { } harvest)
{ {
@@ -610,6 +1093,349 @@ public sealed class WorldScene : Scene
} }
} }
// Демонстрация животного (фаза A1): по виду берём его набор генов, генерируем особь и печатаем
// признаки, вычисленные формулами генов (тело/мозг/метаболизм/чувства/мех).
private void RunAnimalDemo(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine("usage: animal <species> [seed] e.g. animal Deer");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
var index = _animals.IndexOf(def);
var seed = args.Length > 1 ? int.Parse(args[1]) : Random.Shared.Next();
var genome = _animals.GenerateGenome(index, new Random(seed));
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry));
var sex = AnimalFactory.IsMaleGenome(genome) ? "male" : "female";
console.WriteLine($"{def.DefName} ({sex}, seed {seed}):");
console.WriteLine(
$" body {traits.BodySize:0.##} cells, maturity {traits.MaturityAge:0} d, "
+ $"lifespan {traits.Lifespan:0} d, brain {traits.BrainSize:0.##}, metabolism {traits.Metabolism:0.##}"
);
console.WriteLine(
$" move {traits.MoveSpeed:0.##}, vision {traits.Vision:0.##}, blood {traits.BloodVolume:0.##}, "
+ $"insulation {traits.Insulation:0.##}, furHue {traits.FurHue:0.##}"
);
}
// Диета из генов (предатор-кластер): по каждому виду печатает herbivory/carnivory/omnivory,
// выведенный рацион (ест растения/мясо), роль и боевые параметры — проверка, что хищники заданы.
private void RunDiet(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
for (var s = 0; s < _animals.Count; s++)
{
var sp = _animals[s];
if (
args.Length > 0
&& !string.Equals(sp.Def.DefName, args[0], StringComparison.OrdinalIgnoreCase)
)
{
continue;
}
var genome = _animals.GenerateGenome(s, new Random(0x01E7 + s));
var traits = AnimalPhenotype.FromTraits(
Phenotype.Compute(genome, _animals.GeneRegistry)
);
var (eatsPlants, eatsMeat) = AnimalFactory.Diet(traits, sp.Def);
var role =
eatsMeat ? (eatsPlants ? "omnivore" : "carnivore")
: eatsPlants ? "herbivore"
: "none";
console.WriteLine(
$"{sp.Def.DefName}: {role} — herb {traits.Herbivory:0.##} / carn {traits.Carnivory:0.##} / "
+ $"omni {traits.Omnivory:0.##} -> plants={eatsPlants} meat={eatsMeat}, spawn {sp.Def.SpawnPer1000Cells:0.##}/1000"
);
if (eatsMeat && sp.Def.AttackDamage > 0f)
{
console.WriteLine(
$" hunt: dmg {sp.Def.AttackDamage:0}/d, bleed {sp.Def.AttackBleed:0.#}/d, "
+ $"bloodloss {sp.Def.AttackBloodLoss:0.#}/d, range {sp.Def.AttackRangeCells:0.#} cells"
);
}
}
if (args.Length == 0)
{
console.WriteLine($"species: {string.Join(", ", species)}");
}
}
// Наблюдаемость отбора (фаза A2): средние ключевых признаков живой популяции и размах поколений —
// видно дрейф генов под отбором. Без аргумента — все виды; с аргументом — один вид.
private void RunPopStats(DevConsole console, string[] args)
{
if (_animals.Count == 0)
{
console.WriteLine("no animal species");
return;
}
var count = new int[_animals.Count];
var body = new double[_animals.Count];
var brain = new double[_animals.Count];
var life = new double[_animals.Count];
var move = new double[_animals.Count];
var maxGen = new int[_animals.Count];
Store
.Query<AnimalOrganism>()
.ForEachEntity(
(ref AnimalOrganism o, Entity _) =>
{
var s = o.Species;
count[s]++;
body[s] += o.Traits.BodySize;
brain[s] += o.Traits.BrainSize;
life[s] += o.Traits.Lifespan;
move[s] += o.Traits.MoveSpeed;
if (o.Generation > maxGen[s])
{
maxGen[s] = o.Generation;
}
}
);
var filter = args.Length > 0 ? args[0] : null;
var any = false;
for (var s = 0; s < _animals.Count; s++)
{
var name = _animals[s].Def.DefName;
if (
count[s] == 0
|| (
filter is not null
&& !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase)
)
)
{
continue;
}
any = true;
var c = count[s];
console.WriteLine(
$"{name}: n={c}, gen 0..{maxGen[s]}, body {body[s] / c:0.##}, "
+ $"brain {brain[s] / c:0.##}, lifespan {life[s] / c:0} d, move {move[s] / c:0.##}"
);
}
if (!any)
{
console.WriteLine(filter is null ? "no animals alive" : $"no '{filter}' alive");
}
}
// Наблюдаемость здоровья (фаза A5): дерево частей тела вида и способности здоровой особи.
private void RunBodyDemo(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine("usage: body <species> e.g. body Deer");
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
if (string.IsNullOrEmpty(def.Body) || !content.Defs.TryGet<BodyDef>(def.Body, out var body))
{
console.WriteLine($"{def.DefName}: no body");
return;
}
console.WriteLine($"{def.DefName} body '{body.DefName}': {body.Parts.Length} parts");
foreach (var part in body.Parts)
{
var caps =
part.Capacities.Count > 0
? " ["
+ string.Join(
", ",
part.Capacities.Select(kv => $"{kv.Key} {kv.Value:0.##}")
)
+ "]"
: "";
console.WriteLine(
$" {part.Name}{(part.Vital ? " *vital" : "")} hp{part.MaxHp:0}{caps}"
);
}
var state = HealthState.Create(body, 1f);
console.WriteLine(
"capacities (healthy): "
+ string.Join(
", ",
state.Capacities.OrderBy(k => k.Key).Select(kv => $"{kv.Key} {kv.Value:0.##}")
)
);
}
// Тест системы здоровья (фаза A6): навешивает болезнь всем живым животным.
private void RunInfect(DevConsole console, GameContent content, string[] args)
{
var name = args.Length > 0 ? args[0] : "Fever";
if (!content.Defs.TryGet<HediffDef>(name, out var def))
{
console.WriteLine($"no hediff '{name}'");
return;
}
var count = 0;
Store
.Query<Health>()
.ForEachEntity(
(ref Health h, Entity _) =>
{
h.State?.Add(def);
count++;
}
);
console.WriteLine($"infected {count} animals with {def.DefName}");
}
// Наблюдаемость интеллекта (фаза A7): мозг особи (ген) × сознание = эффективный интеллект, который
// гейтит набор нужд/действий. Показывает активные/отключённые нужды при заданном уровне сознания
// (по умолчанию 1 — здоров; меньше — имитация повреждения мозга/болезни/боли).
private void RunIntel(DevConsole console, GameContent content, string[] args)
{
var species = content.Defs.NamesOf("Animal");
if (args.Length == 0)
{
console.WriteLine(
"usage: intel <species> [consciousness 0..1] [seed] e.g. intel Deer 0.6"
);
console.WriteLine($"species: {string.Join(", ", species)}");
return;
}
if (!content.Defs.TryGet<AnimalDef>(args[0], out var def))
{
console.WriteLine($"no animal '{args[0]}'; species: {string.Join(", ", species)}");
return;
}
var consciousness =
args.Length > 1
? Math.Clamp(
float.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture),
0f,
1f
)
: 1f;
var seed = args.Length > 2 ? int.Parse(args[2]) : Random.Shared.Next();
var index = _animals.IndexOf(def);
var genome = _animals.GenerateGenome(index, new Random(seed));
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, _animals.GeneRegistry));
var intelligence = traits.BrainSize * consciousness;
console.WriteLine(
$"{def.DefName} (seed {seed}): brain {traits.BrainSize:0.##} x consciousness {consciousness:0.##} "
+ $"= intelligence {intelligence:0.###}"
);
var active = new List<string>();
var gated = new List<string>();
for (var k = 0; k < _needs.Count; k++)
{
var need = _needs[k];
(need.MinBrain > intelligence ? gated : active).Add(
$"{need.DefName}({need.MinBrain:0.##})"
);
}
console.WriteLine($" active: {(active.Count > 0 ? string.Join(", ", active) : "")}");
console.WriteLine($" gated: {(gated.Count > 0 ? string.Join(", ", gated) : "")}");
}
// Наблюдаемость настроения (фаза A8): среднее/мин/макс настроения живой популяции по виду и счётчики
// довольных (>0.7) / угнетённых (<0.35). Считает только особей с активным настроением (тир 4+).
private void RunMood(DevConsole console, string[] args)
{
if (_animals.Count == 0)
{
console.WriteLine("no animal species");
return;
}
var count = new int[_animals.Count];
var sum = new double[_animals.Count];
var min = new float[_animals.Count];
var max = new float[_animals.Count];
var content_ = new int[_animals.Count];
var stressed = new int[_animals.Count];
Array.Fill(min, 1f);
Store
.Query<AnimalOrganism, Mood>()
.ForEachEntity(
(ref AnimalOrganism o, ref Mood m, Entity _) =>
{
if (!m.Active)
{
return; // тир ниже зверя — настроения нет
}
var s = o.Species;
count[s]++;
sum[s] += m.Value;
min[s] = MathF.Min(min[s], m.Value);
max[s] = MathF.Max(max[s], m.Value);
if (m.Value > 0.7f)
{
content_[s]++;
}
else if (m.Value < 0.35f)
{
stressed[s]++;
}
}
);
var filter = args.Length > 0 ? args[0] : null;
var any = false;
for (var s = 0; s < _animals.Count; s++)
{
var name = _animals[s].Def.DefName;
if (
count[s] == 0
|| (
filter is not null
&& !string.Equals(name, filter, StringComparison.OrdinalIgnoreCase)
)
)
{
continue;
}
any = true;
console.WriteLine(
$"{name}: n={count[s]}, mood {sum[s] / count[s]:0.##} (min {min[s]:0.##}, max {max[s]:0.##}), "
+ $"content {content_[s]}, stressed {stressed[s]}"
);
}
if (!any)
{
console.WriteLine(filter is null ? "no animals with mood" : $"no '{filter}' with mood");
}
}
private static string ProductLabel(GameContent content, string productDefName) => private static string ProductLabel(GameContent content, string productDefName) =>
content.Defs.TryGet<ProductDef>(productDefName, out var product) content.Defs.TryGet<ProductDef>(productDefName, out var product)
? content.Languages.Get(product.Label) ? content.Languages.Get(product.Label)
+235
View File
@@ -0,0 +1,235 @@
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Scenes;
using Microsoft.Xna.Framework;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Единая точка создания сущности-животного: спрайт вида, признаки из генома формулами генов, тинт по
/// оттенку меха и компонент-организм. Переиспользуется начальным спавном, размножением и загрузкой —
/// чтобы все животные собирались одинаково. Зеркало <see cref="PlantFactory"/>.
/// </summary>
public static class AnimalFactory
{
// Якоря оттенка меха: тёплый рыжевато-песочный → холодный серо-голубой (по гену furColor).
private static readonly Color FurWarm = new(255, 226, 188);
private static readonly Color FurCool = new(206, 222, 235);
/// <summary>Создаёт животное вида <paramref name="species"/> заданного возраста, генома и поколения.</summary>
public static Entity Create(
EntityStore store,
AnimalSet animals,
NeedSet needs,
int species,
Vector2 position,
float ageDays,
Genome genome,
int generation,
int cellSize
)
{
var sp = animals[species];
var traits = AnimalPhenotype.FromTraits(Phenotype.Compute(genome, animals.GeneRegistry));
var isMale = IsMaleGenome(genome);
var stage = StageAt(ageDays, traits.MaturityAge, traits.Lifespan, sp.Stages);
var region = RegionFor(sp, stage, isMale);
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = FurTint(traits);
return store.CreateEntity(
new Transform2D(
position,
scale: new Vector2(Scale(sp, traits, stage, region, cellSize))
),
sprite,
new AnimalOrganism
{
Genome = genome,
Traits = traits,
Species = species,
IsMale = isMale,
Generation = generation,
},
new AnimalGrowth { Stage = stage, AgeDays = ageDays },
// Нужды — динамический массив по NeedSet (deplete полны, drive пусты); «мозг» решает сразу.
new AnimalNeeds { Values = needs.NewValues() },
new AnimalBrain
{
Action = AnimalActions.Wander,
TargetPlant = -1,
TargetMate = -1,
},
new Health { State = HealthState.Create(sp.Body, traits.BodySize) },
// Настроение (фаза A8): нейтральное на старте, активность по тиру мозга считает MoodSystem.
new Mood
{
Value = 0.5f,
Active = false,
Thoughts = [],
}
);
}
/// <summary>Сколько мяса даёт труп на единицу размера тела (для будущей разделки/падальщиков).</summary>
public const float MeatPerBodySize = 25f;
// Тинт свежего трупа (стадии гниения/скелета задаёт CorpseSystem).
private static readonly Color CorpseFreshTint = new(176, 148, 128);
/// <summary>Создаёт сущность-труп после смерти животного: спрайт-падаль, запас мяса (∝ размер тела).</summary>
public static Entity CreateCorpse(
EntityStore store,
AnimalSet animals,
int species,
Vector2 position,
float bodySize,
int cellSize
)
{
var sp = animals[species];
var region = sp.Corpse;
var sprite = new Sprite(region, GameLayers.Beings);
sprite.CenterOrigin();
sprite.Color = CorpseFreshTint;
var sizeCells = bodySize > 0f ? bodySize : sp.Def.SizeCells;
return store.CreateEntity(
new Transform2D(position, scale: new Vector2(cellSize * sizeCells / region.Width)),
sprite,
new Corpse
{
Species = species,
RotDays = 0f,
Stage = 0,
Meat = bodySize * MeatPerBodySize,
}
);
}
/// <summary>Порог выраженности признака диеты, с которого вид реально ест данный корм.</summary>
public const float DietThreshold = 0.5f;
/// <summary>
/// Рацион особи из ГЕНОВ: ест растения, если max(herbivory, omnivory) ≥ порога; ест мясо
/// (охота/падаль), если max(carnivory, omnivory) ≥ порога — всеядность (omnivory) включает оба
/// источника, специалисты задаются высоким herbivory ЛИБО carnivory. Если у вида нет генов диеты
/// (все три ≈ 0) — откат на список <see cref="AnimalDef.Diet"/> (легаси/модерский fallback).
/// </summary>
public static (bool EatsPlants, bool EatsMeat) Diet(in AnimalPhenotype traits, AnimalDef def)
{
if (traits.Herbivory <= 0f && traits.Carnivory <= 0f && traits.Omnivory <= 0f)
{
return (DietContains(def, "plant"), DietContains(def, "meat"));
}
var eatsPlants = MathF.Max(traits.Herbivory, traits.Omnivory) >= DietThreshold;
var eatsMeat = MathF.Max(traits.Carnivory, traits.Omnivory) >= DietThreshold;
return (eatsPlants, eatsMeat);
}
private static bool DietContains(AnimalDef def, string food)
{
foreach (var f in def.Diet)
{
if (string.Equals(f, food, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>Гибель особи: оставляет труп (мясо ∝ размер тела) и удаляет сущность. Общий путь смерти.</summary>
public static void Die(EntityStore store, AnimalSet animals, Entity entity, int cellSize)
{
if (entity.IsNull || !entity.HasComponent<AnimalOrganism>())
{
return;
}
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var position = entity.GetComponent<Transform2D>().Position;
CreateCorpse(store, animals, org.Species, position, org.Traits.BodySize, cellSize);
entity.DeleteEntity();
}
/// <summary>
/// Эффективный интеллект особи (фаза A7): размер мозга (ген) × сознание (capacity из здоровья).
/// Повреждение мозга/болезнь/боль/кровопотеря снижают Consciousness → динамически роняют интеллект,
/// что гейтит набор доступных нужд/действий (см. <see cref="AnimalContext"/>). Вид без частей тела
/// (сознание не вычисляется) считается полностью сознательным — чтобы не отключить ему всё.
/// </summary>
public static float Intelligence(in AnimalPhenotype traits, HealthState? health)
{
var consciousness = health is { Parts.Length: > 0 }
? health.Capacity(AnimalCapacities.Consciousness)
: 1f;
return traits.BrainSize * consciousness;
}
/// <summary>Пол из локуса GeneSex: наличие аллеля Y (значение ≈1) → самец; иначе самка (XX).</summary>
public static bool IsMaleGenome(Genome genome) =>
genome.Has("GeneSex") && (genome["GeneSex"].A > 0.5f || genome["GeneSex"].B > 0.5f);
/// <summary>Индекс стадии по возрасту: последняя стадия, чей возраст входа (EnterAt × якорь) ≤ возраст.</summary>
public static int StageAt(
float ageDays,
float maturity,
float lifespan,
AnimalStageDef[] stages
)
{
var stage = 0;
for (var i = 1; i < stages.Length; i++)
{
var anchor = string.Equals(stages[i].RelativeTo, "lifespan", StringComparison.Ordinal)
? lifespan
: maturity;
if (ageDays >= stages[i].EnterAt * anchor)
{
stage = i;
}
}
return stage;
}
/// <summary>Спрайт стадии: набор "baby" — текстура детёныша; иначе по полу (самец/самка).</summary>
public static Texture2DRegion RegionFor(AnimalSet.Species sp, int stage, bool isMale) =>
string.Equals(sp.Stages[stage].Texture, "baby", StringComparison.Ordinal) ? sp.Baby
: isMale ? sp.Male
: sp.Female;
/// <summary>Взрослая ли стадия (способна размножаться) — флаг из данных стадии.</summary>
public static bool IsAdult(AnimalSet.Species sp, int stage) => sp.Stages[stage].Adult;
/// <summary>Масштаб Transform2D для спрайта стадии (размер тела × множитель стадии / ширина региона).</summary>
public static float Scale(
AnimalSet.Species sp,
in AnimalPhenotype traits,
int stage,
Texture2DRegion region,
int cellSize
)
{
var sizeCells =
(traits.BodySize > 0f ? traits.BodySize : sp.Def.SizeCells) * sp.Stages[stage].Scale;
return cellSize * sizeCells / region.Width;
}
/// <summary>
/// Цвет животного из генов: лёгкий тёплый/холодный сдвиг меха (ген furHue) поверх текстуры —
/// субтильный, чтобы не перекрашивать уже цветной спрайт зверя (как leafHue у растений).
/// Публичен, чтобы <see cref="AnimalAppearanceSystem"/> домножал его на яркость по нуждам.
/// </summary>
public static Color FurTint(in AnimalPhenotype traits)
{
var fur = Color.Lerp(FurWarm, FurCool, Math.Clamp(traits.FurHue, 0f, 1f));
return Color.Lerp(Color.White, fur, 0.5f);
}
}
+512
View File
@@ -0,0 +1,512 @@
using System.Collections.Generic;
using Friflo.Engine.ECS;
using LittleSim.Content;
using MrGameEng.Genetics;
namespace LittleSim.Sim;
/// <summary>
/// Признаки (фенотип) животного, вычисленные из генома формулами генов (см. <c>Defs/Genes/</c>):
/// готовые значения, которые читают системы, не касаясь генов напрямую. Зеркало
/// <see cref="PlantPhenotype"/>. Имена полей соответствуют именам признаков в эффектах генов.
/// </summary>
public struct AnimalPhenotype
{
/// <summary>Размер тела в клетках (масштаб спрайта; позже — HP/расход энергии).</summary>
public float BodySize;
/// <summary>Скорость обмена веществ — множитель расхода нужд.</summary>
public float Metabolism;
/// <summary>Скорость передвижения (множитель).</summary>
public float MoveSpeed;
/// <summary>Объём крови (множитель) — масштаб скорости кровопотери.</summary>
public float BloodVolume;
/// <summary>Дальность зрения (множитель радиуса поиска).</summary>
public float Vision;
/// <summary>Размер мозга (0..1) — основа интеллекта.</summary>
public float BrainSize;
/// <summary>Теплоизоляция мехом (0..1).</summary>
public float Insulation;
/// <summary>Оттенок меха (0..1) — сдвиг тинта спрайта.</summary>
public float FurHue;
/// <summary>Возраст созревания (игровых дней) — порог стадии Adult.</summary>
public float MaturityAge;
/// <summary>Травоядность [0..1] — способность/тяга питаться растениями.</summary>
public float Herbivory;
/// <summary>Хищничество [0..1] — тяга охотиться и питаться мясом (живая добыча/падаль).</summary>
public float Carnivory;
/// <summary>Всеядность [0..1] — генералист: включает оба источника пищи (растения и мясо).</summary>
public float Omnivory;
/// <summary>Продолжительность жизни (игровых дней).</summary>
public float Lifespan;
/// <summary>Сезон гона (0 весна … 3 зима).</summary>
public int BreedingSeason;
/// <summary>Срок вынашивания (игровых дней).</summary>
public float GestationDays;
/// <summary>Размер помёта.</summary>
public float LitterSize;
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static AnimalPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
float T(string name) => traits.GetValueOrDefault(name);
return new AnimalPhenotype
{
BodySize = T("bodySize"),
Metabolism = T("metabolism"),
MoveSpeed = T("moveSpeed"),
BloodVolume = T("bloodVolume"),
Vision = T("vision"),
BrainSize = T("brainSize"),
Insulation = T("insulation"),
FurHue = T("furHue"),
MaturityAge = T("maturityAge"),
Herbivory = T("herbivory"),
Carnivory = T("carnivory"),
Omnivory = T("omnivory"),
Lifespan = T("lifespan"),
BreedingSeason = (int)MathF.Round(Math.Clamp(T("breedingSeason"), 0f, 3f)),
GestationDays = T("gestationDays"),
LitterSize = T("litterSize"),
};
}
}
/// <summary>
/// Компонент-организм животного: управляемый <see cref="Genome"/> (для размножения, сейва и
/// пересчёта), выраженный фенотип <see cref="Traits"/>, индекс вида в <see cref="Content.AnimalSet"/>
/// и номер поколения (для <c>popstats</c> и видимого дрейфа). Геном — ссылочный объект, поэтому
/// компонент несёт ссылку, а не копию (как у растений). Зеркало <see cref="PlantOrganism"/>.
/// </summary>
public struct AnimalOrganism : IComponent
{
/// <summary>Геном особи (аллели по генам); источник истины для признаков и размножения.</summary>
public Genome Genome;
/// <summary>Выраженные признаки — то, что читают системы.</summary>
public AnimalPhenotype Traits;
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/>.</summary>
public int Species;
/// <summary>Пол: самец (XY) или самка (XX) — выведен из локуса GeneSex при создании.</summary>
public bool IsMale;
/// <summary>Номер поколения: max(родителей) + 1; у основателей 0.</summary>
public int Generation;
}
/// <summary>
/// Состояние роста животного (фаза A3): текущая стадия и накопленный возраст в игровых днях. Плоские
/// данные — переход между стадиями (Baby→Juvenile→Adult→Senior), смену спрайта и размера выполняет
/// <see cref="AnimalGrowthSystem"/>, читая пороги из генов (возраст созревания, продолжительность жизни).
/// Зеркало <see cref="PlantGrowth"/>.
/// </summary>
public struct AnimalGrowth : IComponent
{
/// <summary>Текущая стадия: 0 Baby, 1 Juvenile, 2 Adult, 3 Senior.</summary>
public int Stage;
/// <summary>Накопленный возраст в игровых днях.</summary>
public float AgeDays;
}
/// <summary>Инстанс части тела особи: ссылка на деф части и текущий/максимальный HP (масштаб по размеру).</summary>
public struct PartInstance
{
/// <summary>Деф части тела (анатомия вида).</summary>
public Content.BodyPartDef Def;
/// <summary>Текущий HP.</summary>
public float Hp;
/// <summary>Максимальный HP (база × размер тела).</summary>
public float MaxHp;
/// <summary>Доля сохранности [0,1].</summary>
public readonly float Fraction => MaxHp > 0f ? Math.Clamp(Hp / MaxHp, 0f, 1f) : 0f;
}
/// <summary>Инстанс хедифа на особи: деф + текущая тяжесть и набранный иммунитет (гонка, фаза A6).</summary>
public struct Hediff
{
/// <summary>Деф хедифа (гон/болезнь/…).</summary>
public HediffDef Def;
/// <summary>Текущая тяжесть.</summary>
public float Severity;
/// <summary>Набранный иммунитет [0,1]; при 1 — выздоровление.</summary>
public float Immunity;
}
/// <summary>
/// Состояние здоровья особи (фазы A4–A6): активные хедифы (инстансы) + части тела, кровь и вычисленные
/// способности (capacities). Managed-объект, как <see cref="Genome"/> (Friflo допускает ссылку в
/// компоненте). Болезни (A6) — первый источник урона: снижают способности и могут убить. Растёт по
/// фазам — контейнер один.
/// </summary>
public sealed class HealthState
{
private readonly List<Hediff> _hediffs = [];
/// <summary>Активные хедифы (гон, болезни).</summary>
public IReadOnlyList<Hediff> Hediffs => _hediffs;
/// <summary>Части тела особи (из анатомии вида); пусто — у вида нет тела.</summary>
public PartInstance[] Parts { get; private set; } = [];
/// <summary>Уровень крови [0,1]; ниже порога — потеря сознания и смерть (когда появятся раны).</summary>
public float BloodLevel { get; set; } = 1f;
/// <summary>Боль [0,1] — снижает сознание (когда появятся раны).</summary>
public float Pain { get; set; }
/// <summary>Погибла ли особь по здоровью (vital-часть уничтожена / кровь на нуле).</summary>
public bool Dead { get; set; }
/// <summary>Вычисленные способности (capacity id → [0,1]); у здоровой особи все = 1.</summary>
public Dictionary<string, float> Capacities { get; private set; } = new();
/// <summary>Способность по id (1, если не вычислена — напр. у вида без тела), для множителей действий.</summary>
public float Capacity(string id) => Capacities.TryGetValue(id, out var v) ? v : 1f;
/// <summary>
/// Пересчитывает боль (из хедифов) и способности: базовые из частей/крови/боли, затем модификаторы
/// способностей от хедифов (лерп 1→factor по тяжести). Вызывать после изменения частей/крови/хедифов.
/// </summary>
public void RecomputeCapacities()
{
var pain = 0f;
foreach (var h in _hediffs)
{
pain += h.Def.Pain * h.Severity;
}
Pain = Math.Clamp(pain, 0f, 1f);
var caps = CapacityCalc.Compute(Parts, BloodLevel, Pain);
foreach (var h in _hediffs)
{
if (h.Def.CapMods.Count == 0)
{
continue;
}
var t = Math.Clamp(h.Severity, 0f, 1f);
foreach (var (capacity, factor) in h.Def.CapMods)
{
caps[capacity] = caps.GetValueOrDefault(capacity, 1f) * (1f + (factor - 1f) * t);
}
}
Capacities = caps;
}
/// <summary>
/// Прогрессирует болезни (тяжесть и иммунитет растут со временем); снимает выздоровевшие
/// (иммунитет ≥ 1); возвращает true, если какая-то болезнь достигла летальной тяжести.
/// </summary>
public bool AdvanceHediffs(float days)
{
var lethal = false;
for (var i = _hediffs.Count - 1; i >= 0; i--)
{
var h = _hediffs[i];
if (h.Def.SeverityPerDay <= 0f && h.Def.ImmunityPerDay <= 0f)
{
continue; // статичный хедиф (гон) не прогрессирует
}
h.Severity += h.Def.SeverityPerDay * days;
h.Immunity += h.Def.ImmunityPerDay * days;
if (h.Immunity >= 1f)
{
_hediffs.RemoveAt(i); // иммунитет победил — выздоровление
continue;
}
if (h.Def.LethalSeverity > 0f && h.Severity >= h.Def.LethalSeverity)
{
lethal = true;
}
_hediffs[i] = h;
}
return lethal;
}
/// <summary>Строит здоровье особи: части из анатомии вида (HP × размер тела) + начальные способности.</summary>
public static HealthState Create(Content.BodyDef? body, float bodySize)
{
var state = new HealthState();
if (body is { Parts.Length: > 0 })
{
var scale = MathF.Max(0.2f, bodySize);
state.Parts = new PartInstance[body.Parts.Length];
for (var i = 0; i < body.Parts.Length; i++)
{
var hp = body.Parts[i].MaxHp * scale;
state.Parts[i] = new PartInstance
{
Def = body.Parts[i],
Hp = hp,
MaxHp = hp,
};
}
}
state.RecomputeCapacities();
return state;
}
/// <summary>Несёт ли особь хедиф с данным именем дефа.</summary>
public bool Has(string defName)
{
foreach (var h in _hediffs)
{
if (h.Def.DefName == defName)
{
return true;
}
}
return false;
}
/// <summary>Навешивает хедиф (если ещё не навешан) с начальной тяжестью из дефа.</summary>
public void Add(HediffDef def)
{
if (!Has(def.DefName))
{
_hediffs.Add(new Hediff { Def = def, Severity = def.InitialSeverity });
}
}
/// <summary>Восстанавливает хедиф с заданной тяжестью и иммунитетом (для загрузки сейва).</summary>
public void AddHediff(HediffDef def, float severity, float immunity)
{
if (!Has(def.DefName))
{
_hediffs.Add(
new Hediff
{
Def = def,
Severity = severity,
Immunity = immunity,
}
);
}
}
/// <summary>Снимает хедиф по имени дефа; возвращает, был ли он.</summary>
public bool Remove(string defName)
{
for (var i = 0; i < _hediffs.Count; i++)
{
if (_hediffs[i].Def.DefName == defName)
{
_hediffs.RemoveAt(i);
return true;
}
}
return false;
}
/// <summary>
/// Травма (укус хищника): повреждает часть тела (выбор по весу попадания <c>coverage</c>), вызывает
/// мгновенную кровопотерю и навешивает/усиливает кровотечение, затем пересчитывает способности.
/// Первый травматический источник урона — оживляет каркас здоровья A5.
/// </summary>
public void ApplyInjury(
float partDamage,
float bloodLoss,
HediffDef? bleeding,
float bleedSeverity,
Random rng
)
{
if (Parts.Length > 0 && partDamage > 0f)
{
var idx = PickPart(rng);
var part = Parts[idx];
part.Hp = MathF.Max(0f, part.Hp - partDamage);
Parts[idx] = part;
}
BloodLevel = Math.Clamp(BloodLevel - bloodLoss, 0f, 1f);
if (bleeding is not null && bleedSeverity > 0f)
{
Intensify(bleeding, bleedSeverity);
}
RecomputeCapacities();
}
// Выбор задетой части по весу попадания (coverage); детерминируется переданным rng.
private int PickPart(Random rng)
{
var total = 0f;
foreach (var p in Parts)
{
total += p.Def?.Coverage ?? 0f;
}
if (total <= 0f)
{
return rng.Next(Parts.Length);
}
var roll = (float)rng.NextDouble() * total;
for (var i = 0; i < Parts.Length; i++)
{
if (Parts[i].Def is null)
{
continue;
}
roll -= Parts[i].Def.Coverage;
if (roll <= 0f)
{
return i;
}
}
return Parts.Length - 1;
}
/// <summary>
/// Навешивает хедиф или усиливает существующий: тяжесть растёт (до 1), а заживление (иммунитет)
/// сбрасывается — свежая рана начинает затягиваться заново. Для повторных укусов/ранений.
/// </summary>
public void Intensify(HediffDef def, float addSeverity)
{
for (var i = 0; i < _hediffs.Count; i++)
{
if (_hediffs[i].Def.DefName == def.DefName)
{
var h = _hediffs[i];
h.Severity = Math.Clamp(h.Severity + addSeverity, 0f, 1f);
h.Immunity = 0f; // свежая рана — заживление с нуля
_hediffs[i] = h;
return;
}
}
_hediffs.Add(new Hediff { Def = def, Severity = Math.Clamp(addSeverity, 0f, 1f) });
}
}
/// <summary>Здоровье особи: managed-состояние со списком хедифов (см. <see cref="HealthState"/>).</summary>
public struct Health : IComponent
{
/// <summary>Состояние здоровья (хедифы; позже части тела/кровь).</summary>
public HealthState State;
}
/// <summary>
/// Труп животного (фаза A5): отдельная сущность после смерти. Разлагается во времени по стадиям
/// (Fresh→Rotting→Skeleton→исчезает), скорость зависит от температуры. Несёт запас мяса (∝ размер тела)
/// для будущей разделки/падальщиков. Управляется <see cref="Sim.CorpseSystem"/>.
/// </summary>
public struct Corpse : IComponent
{
/// <summary>Индекс вида в <see cref="Content.AnimalSet"/> (для спрайта, разделки, сейва).</summary>
public int Species;
/// <summary>Накоплено игровых дней разложения.</summary>
public float RotDays;
/// <summary>Стадия: 0 свежий, 1 гниющий, 2 скелет.</summary>
public int Stage;
/// <summary>Доступное мясо (∝ размер тела) — для разделки/падальщиков (фаза-горизонт).</summary>
public float Meat;
}
/// <summary>Инстанс мысли настроения на особи (фаза A8): деф события + остаток времени до угасания.</summary>
public struct ThoughtInstance
{
/// <summary>Деф мысли (сдвиг настроения и длительность).</summary>
public Content.ThoughtDef Def;
/// <summary>Сколько игровых дней мысль ещё действует.</summary>
public float RemainingDays;
}
/// <summary>
/// Настроение особи (фаза A8): значение [0,1] и активные событийные мысли. Считается
/// <see cref="Sim.AnimalMoodSystem"/> из непрерывного фона (нужды/боль) и суммы мыслей. Есть только у
/// тира 4+ (<see cref="Active"/> по размеру мозга) — у примитивных настроения нет. Низкое настроение
/// влияет на поведение (стресс не даёт спариваться) и видно в инспекторе. Список мыслей — managed-ссылка
/// (как <c>AnimalNeeds.Values</c>): события добавляют мысли через <see cref="Add"/>.
/// </summary>
public struct Mood : IComponent
{
/// <summary>Текущее настроение [0,1]; у неактивного (тир &lt; 4) держится нейтральным.</summary>
public float Value;
/// <summary>Есть ли у особи настроение (гейт тиром мозга) — иначе фон/мысли не считаются.</summary>
public bool Active;
/// <summary>Активные событийные мысли (затухают со временем).</summary>
public List<ThoughtInstance> Thoughts;
/// <summary>
/// Вешает событийную мысль (или обновляет её длительность, если уже висит — без накопления
/// дубликатов). Вызывается из систем-источников событий (роды, спаривание, утоление жажды).
/// </summary>
public readonly void Add(Content.ThoughtDef? def)
{
if (Thoughts is null || def is null)
{
return;
}
for (var i = 0; i < Thoughts.Count; i++)
{
if (Thoughts[i].Def == def)
{
var refreshed = Thoughts[i];
refreshed.RemainingDays = def.DurationDays;
Thoughts[i] = refreshed;
return;
}
}
Thoughts.Add(new ThoughtInstance { Def = def, RemainingDays = def.DurationDays });
}
}
/// <summary>
/// Беременность самки (фаза A4): managed-ссылка на геном отца («слепок» на момент зачатия), остаток
/// срока вынашивания и размер помёта. По истечении срока <see cref="Sim.AnimalPregnancySystem"/>
/// рожает детёнышей через <see cref="Genome.Breed"/> и снимает компонент.
/// </summary>
public struct Pregnant : IComponent
{
/// <summary>Геном отца на момент зачатия.</summary>
public Genome FatherGenome;
/// <summary>Остаток срока вынашивания (игровых дней).</summary>
public float DueInDays;
/// <summary>Сколько детёнышей родится.</summary>
public int Litter;
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
using System.Collections.Generic;
namespace LittleSim.Sim;
/// <summary>Идентификаторы способностей (capacities) организма — на них ссылаются вклады частей тела.</summary>
public static class AnimalCapacities
{
public const string Consciousness = "Consciousness";
public const string Moving = "Moving";
public const string Breathing = "Breathing";
public const string BloodPumping = "BloodPumping";
public const string Sight = "Sight";
public const string Digestion = "Digestion";
}
/// <summary>
/// Обобщённый расчёт способностей (capacities) из частей тела и крови (фаза A5). Сырой вклад способности
/// = Σ(доля части × доля её HP). Затем известные способности домножаются по зависимостям (кровь →
/// сердце/лёгкие → сознание → движение/зрение), как в модели RimWorld; неизвестные (модовые) проходят
/// сырыми. У здоровой особи (полное HP, кровь 1, боль 0) все способности = 1.
/// </summary>
public static class CapacityCalc
{
/// <summary>Считает карту способностей из инстансов частей, уровня крови (0..1) и боли (0..1).</summary>
public static Dictionary<string, float> Compute(PartInstance[] parts, float blood, float pain)
{
var raw = new Dictionary<string, float>(System.StringComparer.Ordinal);
foreach (var part in parts)
{
if (part.Def is null)
{
continue;
}
var fraction = part.Fraction;
foreach (var (capacity, contribution) in part.Def.Capacities)
{
raw[capacity] = raw.GetValueOrDefault(capacity) + contribution * fraction;
}
}
float Raw(string id) => raw.GetValueOrDefault(id);
static float Clamp01(float v) => v < 0f ? 0f : v > 1f ? 1f : v;
var bloodLevel = Clamp01(blood);
var bloodPumping = Raw(AnimalCapacities.BloodPumping) * bloodLevel;
var breathing = Raw(AnimalCapacities.Breathing) * bloodLevel;
var consciousness =
Raw(AnimalCapacities.Consciousness)
* Clamp01(bloodPumping)
* Clamp01(breathing)
* (1f - Clamp01(pain));
var result = new Dictionary<string, float>(System.StringComparer.Ordinal)
{
[AnimalCapacities.BloodPumping] = bloodPumping,
[AnimalCapacities.Breathing] = breathing,
[AnimalCapacities.Consciousness] = consciousness,
[AnimalCapacities.Moving] = Raw(AnimalCapacities.Moving) * consciousness * Clamp01(bloodPumping),
[AnimalCapacities.Sight] = Raw(AnimalCapacities.Sight) * consciousness,
[AnimalCapacities.Digestion] = Raw(AnimalCapacities.Digestion) * Clamp01(bloodPumping),
};
// Модовые способности, не охваченные зависимостями, — сырыми.
foreach (var (capacity, value) in raw)
{
result.TryAdd(capacity, value);
}
return result;
}
}
+2 -1
View File
@@ -56,7 +56,8 @@ public static class PlantFactory
CellFertility = cellFertility, CellFertility = cellFertility,
}, },
new PlantOrganism { Genome = genome, Traits = traits }, new PlantOrganism { Genome = genome, Traits = traits },
new Fruiting() new Fruiting(),
new TemperatureStress()
); );
} }
+3 -5
View File
@@ -53,14 +53,12 @@ public sealed class PlantGrowthSystem(
ref var traits = ref orgs[i].Traits; ref var traits = ref orgs[i].Traits;
var light = lighting.SampleAt(t[i].Position); var light = lighting.SampleAt(t[i].Position);
// Температура — трапеция RimWorld: плато полного роста, жёсткие края (дормантность вне).
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
var rate = var rate =
traits.Vigor traits.Vigor
* Suitability.Gaussian(light, traits.OptimalLight, traits.LightTolerance) * Suitability.Gaussian(light, traits.OptimalLight, traits.LightTolerance)
* Suitability.Gaussian( * Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax)
temperature,
traits.OptimalTemperature,
traits.TemperatureTolerance
)
* Suitability.Gaussian( * Suitability.Gaussian(
grow.CellFertility, grow.CellFertility,
traits.OptimalFertility, traits.OptimalFertility,
+50 -13
View File
@@ -2,6 +2,7 @@ using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems; using Friflo.Engine.ECS.Systems;
using LittleSim.Content; using LittleSim.Content;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Genetics; using MrGameEng.Genetics;
using MrGameEng.Graphics; using MrGameEng.Graphics;
@@ -9,21 +10,30 @@ using MrGameEng.Graphics;
namespace LittleSim.Sim; namespace LittleSim.Sim;
/// <summary> /// <summary>
/// Жизненный цикл растений: размножение (наследование по Менделю) и смерть от старости. Запускается /// Жизненный цикл растений: размножение (наследование по Менделю), смерть от старости и от
/// не каждый кадр, а раз в <see cref="IntervalDays"/> игрового дня. За тик: строит по-клеточную сетку /// температурного стресса (вымерзание/перегрев). Запускается не каждый кадр, а раз в
/// (счётчик плотности + «представитель» вида/генома), собирает умерших по возрасту, затем зрелые с /// <see cref="IntervalDays"/> игрового дня. За тик: строит по-клеточную сетку (счётчик плотности +
/// вероятностью <c>elapsed/ReproduceInterval</c> сеют потомка — приоритет перекрёстного опыления /// «представитель» вида/генома), копит/рассасывает температурный стресс по текущей температуре,
/// (зрелый сосед того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в /// собирает умерших (старость или превышенный стресс), затем зрелые с вероятностью
/// случайную клетку радиуса, если это суша и не превышен лимит плотности. Структурные изменения /// <c>elapsed/ReproduceInterval</c> сеют потомка — приоритет перекрёстного опыления (зрелый сосед
/// (создание/удаление сущностей) применяются после проходов по запросу — это безопасно. /// того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в случайную клетку
/// радиуса, если это суша и не превышен лимит плотности. Структурные изменения (создание/удаление
/// сущностей) применяются после проходов по запросу — это безопасно.
/// </summary> /// </summary>
public sealed class PlantLifecycleSystem : BaseSystem public sealed class PlantLifecycleSystem : BaseSystem
{ {
private const float IntervalDays = 0.25f; private const float IntervalDays = 0.25f;
/// <summary>Сколько игровых дней дормантности вне диапазона роста убивают растение.</summary>
private const float LethalStressDays = 6f;
/// <summary>Во сколько раз стресс рассасывается быстрее в пригодной температуре, чем копится вне.</summary>
private const float StressRecovery = 1.5f;
private readonly EntityStore _store; private readonly EntityStore _store;
private readonly PlantSet _plants; private readonly PlantSet _plants;
private readonly Calendar _calendar; private readonly Calendar _calendar;
private readonly Climate _climate;
private readonly GameClock _clock; private readonly GameClock _clock;
private readonly int _width; private readonly int _width;
private readonly int _height; private readonly int _height;
@@ -32,7 +42,12 @@ public sealed class PlantLifecycleSystem : BaseSystem
private readonly bool[] _cellLand; private readonly bool[] _cellLand;
private readonly int _densityCap; private readonly int _densityCap;
private readonly Random _rng; private readonly Random _rng;
private readonly ArchetypeQuery<PlantGrowth, PlantOrganism, Transform2D> _query; private readonly ArchetypeQuery<
PlantGrowth,
PlantOrganism,
Transform2D,
TemperatureStress
> _query;
private readonly int[] _count; private readonly int[] _count;
private readonly CellRep[] _rep; private readonly CellRep[] _rep;
@@ -45,6 +60,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
EntityStore store, EntityStore store,
PlantSet plants, PlantSet plants,
Calendar calendar, Calendar calendar,
Climate climate,
GameClock clock, GameClock clock,
int width, int width,
int height, int height,
@@ -58,6 +74,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
_store = store; _store = store;
_plants = plants; _plants = plants;
_calendar = calendar; _calendar = calendar;
_climate = climate;
_clock = clock; _clock = clock;
_width = width; _width = width;
_height = height; _height = height;
@@ -66,7 +83,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
_cellLand = cellLand; _cellLand = cellLand;
_densityCap = densityCap; _densityCap = densityCap;
_rng = new Random(seed); _rng = new Random(seed);
_query = store.Query<PlantGrowth, PlantOrganism, Transform2D>(); _query = store.Query<PlantGrowth, PlantOrganism, Transform2D, TemperatureStress>();
_count = new int[width * height]; _count = new int[width * height];
_rep = new CellRep[width * height]; _rep = new CellRep[width * height];
} }
@@ -82,13 +99,13 @@ public sealed class PlantLifecycleSystem : BaseSystem
var elapsed = _accumulator; var elapsed = _accumulator;
_accumulator = 0f; _accumulator = 0f;
BuildGridAndCollect(); BuildGridAndCollect(elapsed);
Reproduce(elapsed); Reproduce(elapsed);
Apply(); Apply();
} }
// Проход 1: сетка плотности + представители, сбор умерших и зрелых-кандидатов. // Проход 1: сетка плотности + представители, температурный стресс, сбор умерших и зрелых-кандидатов.
private void BuildGridAndCollect() private void BuildGridAndCollect(float elapsed)
{ {
Array.Clear(_count); Array.Clear(_count);
for (var c = 0; c < _rep.Length; c++) for (var c = 0; c < _rep.Length; c++)
@@ -100,11 +117,14 @@ public sealed class PlantLifecycleSystem : BaseSystem
_candidates.Clear(); _candidates.Clear();
_births.Clear(); _births.Clear();
foreach (var (growths, organisms, transforms, entities) in _query.Chunks) var temperature = _climate.Temperature; // глобальная — едина для всех растений в этот тик
foreach (var (growths, organisms, transforms, stresses, entities) in _query.Chunks)
{ {
var g = growths.Span; var g = growths.Span;
var orgs = organisms.Span; var orgs = organisms.Span;
var t = transforms.Span; var t = transforms.Span;
var st = stresses.Span;
for (var i = 0; i < g.Length; i++) for (var i = 0; i < g.Length; i++)
{ {
ref var grow = ref g[i]; ref var grow = ref g[i];
@@ -115,6 +135,23 @@ public sealed class PlantLifecycleSystem : BaseSystem
continue; continue;
} }
// Температурный стресс: вне диапазона роста (трапеция = 0) копится, внутри — рассасывается.
var (tMin, tLow, tHigh, tMax) = org.Traits.TemperatureBand();
ref var stress = ref st[i].Days;
if (Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax) <= 0f)
{
stress += elapsed;
if (stress >= LethalStressDays)
{
_deaths.Add(entities.EntityAt(i)); // вымерзло/перегрелось
continue;
}
}
else
{
stress = MathF.Max(0f, stress - elapsed * StressRecovery);
}
var pos = t[i].Position; var pos = t[i].Position;
var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1); var cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 1);
var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1); var cy = Math.Clamp((int)(pos.Y / _cellSize), 0, _height - 1);
+29 -1
View File
@@ -5,7 +5,7 @@ using MrGameEng.Genetics;
namespace LittleSim.Sim; namespace LittleSim.Sim;
/// <summary> /// <summary>
/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>genes.json</c>): /// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>Defs/Genes/</c>):
/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую. /// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую.
/// Имена полей соответствуют именам признаков в эффектах генов. /// Имена полей соответствуют именам признаков в эффектах генов.
/// </summary> /// </summary>
@@ -15,6 +15,8 @@ public struct PlantPhenotype
public float LightTolerance; public float LightTolerance;
public float OptimalTemperature; public float OptimalTemperature;
public float TemperatureTolerance; public float TemperatureTolerance;
public float ColdHardiness;
public float HeatHardiness;
public float OptimalFertility; public float OptimalFertility;
public float FertilityTolerance; public float FertilityTolerance;
public float Vigor; public float Vigor;
@@ -33,6 +35,18 @@ public struct PlantPhenotype
/// <summary>Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта.</summary> /// <summary>Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта.</summary>
public bool IsVariant; public bool IsVariant;
/// <summary>
/// Четыре точки температурной трапеции роста (модель RimWorld): жёсткий минимум, нижний край плато,
/// верхний край плато, жёсткий максимум. Плато — оптимум ± толерантность; жёсткие края отстоят от
/// плато на морозо-/жаростойкость. Кормит <see cref="MrGameEng.AI.Suitability.Trapezoid"/>.
/// </summary>
public readonly (float Min, float OptimalLow, float OptimalHigh, float Max) TemperatureBand()
{
var low = OptimalTemperature - TemperatureTolerance;
var high = OptimalTemperature + TemperatureTolerance;
return (low - ColdHardiness, low, high, high + HeatHardiness);
}
/// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary> /// <summary>Собирает фенотип из карты признаков, посчитанной <see cref="Phenotype.Compute"/>.</summary>
public static PlantPhenotype FromTraits(IReadOnlyDictionary<string, float> traits) public static PlantPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{ {
@@ -43,6 +57,8 @@ public struct PlantPhenotype
LightTolerance = T("lightTolerance"), LightTolerance = T("lightTolerance"),
OptimalTemperature = T("optimalTemperature"), OptimalTemperature = T("optimalTemperature"),
TemperatureTolerance = T("temperatureTolerance"), TemperatureTolerance = T("temperatureTolerance"),
ColdHardiness = T("coldHardiness"),
HeatHardiness = T("heatHardiness"),
OptimalFertility = T("optimalFertility"), OptimalFertility = T("optimalFertility"),
FertilityTolerance = T("fertilityTolerance"), FertilityTolerance = T("fertilityTolerance"),
Vigor = T("vigor"), Vigor = T("vigor"),
@@ -85,3 +101,15 @@ public struct Fruiting : IComponent
/// <summary>Накоплено зрелых плодов (0..FruitYield).</summary> /// <summary>Накоплено зрелых плодов (0..FruitYield).</summary>
public float RipeFruit; public float RipeFruit;
} }
/// <summary>
/// Накопленный температурный стресс растения в игровых днях. Пока температура вне диапазона роста
/// (трапеция <see cref="PlantPhenotype.TemperatureBand"/> даёт ноль — слишком холодно или жарко),
/// стресс копится; внутри диапазона — рассасывается. Превышение порога убивает растение
/// (вымерзание/перегрев). Управляется <c>PlantLifecycleSystem</c>.
/// </summary>
public struct TemperatureStress : IComponent
{
/// <summary>Накоплено дней дормантности вне диапазона роста; при превышении лимита — гибель.</summary>
public float Days;
}
+136
View File
@@ -0,0 +1,136 @@
using System;
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
using MrGameEng.Input;
namespace LittleSim.Sim;
/// <summary>
/// Текущее выделение игрока (как в RimWorld): id выбранной сущности или -1. Общий источник истины
/// для системы пикинга (<see cref="SelectionSystem"/>) и панели осмотра (<c>InspectPanel</c>):
/// первая пишет, вторая читает и рисует рамку/инфо. Хранит id, а не <see cref="Entity"/>, чтобы
/// безопасно переживать удаление сущности (растение умерло) — валидность проверяется через
/// <c>EntityStore.TryGetEntityById</c>.
/// </summary>
public sealed class Selection
{
/// <summary>Runtime-id выбранной сущности, или -1, если ничего не выбрано.</summary>
public int EntityId { get; private set; } = -1;
/// <summary>Есть ли активное выделение.</summary>
public bool HasSelection => EntityId >= 0;
/// <summary>Выбрать сущность по id.</summary>
public void Select(int entityId) => EntityId = entityId;
/// <summary>Снять выделение.</summary>
public void Clear() => EntityId = -1;
}
/// <summary>
/// Пикинг мышью: ЛКМ выбирает верхнюю сущность под курсором (ближайшую по ограничивающей окружности
/// спрайта), клик по пустому месте или ПКМ снимает выделение. Молчит, пока ввод захвачен оверлеем
/// (консоль/инспектор) или активен <paramref name="blocked"/> (меню-пауза), и игнорирует клики над
/// UI-панелями (<paramref name="overUi"/>). Выбор работает и на паузе — мир можно осматривать стоя.
/// </summary>
public sealed class SelectionSystem(
EntityStore store,
InputManager input,
Renderer2D renderer,
Selection selection,
Func<bool> blocked,
Func<bool> overUi
) : BaseSystem
{
protected override void OnUpdateGroup()
{
if (blocked() || overUi())
{
return; // меню-пауза/консоль или клик пришёлся на виджет UI — это не пикинг мира
}
if (input.IsMousePressed(MouseButton.Right))
{
selection.Clear();
return;
}
if (!input.IsMousePressed(MouseButton.Left))
{
return;
}
var mouse = input.MousePosition;
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
if (TryPick(world, out var id))
{
selection.Select(id);
}
else
{
selection.Clear(); // клик по пустой земле снимает выделение
}
}
// Ближайший осматриваемый объект (растение или животное), чья ограничивающая окружность спрайта
// накрывает точку мира. Запросы ограничены PlantGrowth/AnimalOrganism — так из пикинга выпадают
// спрайты рамки выделения, трупы и прочий не-контент. Из двух кандидатов берётся более близкий.
private bool TryPick(Vector2 world, out int id)
{
id = -1;
var best = float.MaxValue;
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, PlantGrowth>()
.Chunks
)
{
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, AnimalOrganism>()
.Chunks
)
{
Scan(world, transforms.Span, sprites.Span, entities, ref id, ref best);
}
return id >= 0;
}
// Обновляет лучшего кандидата (id/дистанцию) по чанку спрайтов: точка должна попасть в окружность.
private static void Scan(
Vector2 world,
Span<Transform2D> transforms,
Span<Sprite> sprites,
ChunkEntities entities,
ref int id,
ref float best
)
{
for (var i = 0; i < transforms.Length; i++)
{
if (sprites[i].Region is not { } region)
{
continue;
}
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transforms[i],
region,
sprites[i].Origin
);
var distance = Vector2.DistanceSquared(center, world);
if (distance <= radius * radius && distance < best)
{
best = distance;
id = entities.EntityAt(i).Id;
}
}
}
}
+75
View File
@@ -0,0 +1,75 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Рамка выделения в стиле RimWorld: четыре угловые скобки вокруг выбранного объекта. Реализована
/// как четыре спрайт-сущности на слое-оверлее (<see cref="Scenes.GameLayers.Selection"/>) — их рисует
/// штатный спрайт-рендер, а одна и та же текстура уголка (<c>ui/overlays/selectionbracket</c>)
/// зеркалится во все четыре угла флагами <see cref="SpriteFlip"/> (батчер при флипе меняет только UV,
/// квадрат остаётся на месте). Скобки не имеют <c>PlantGrowth</c>, поэтому не попадают под пикинг и
/// прочие запросы растений. <see cref="Show"/> двигает их к объекту, <see cref="Hide"/> прячет.
/// </summary>
public sealed class SelectionBrackets
{
// По уголку на каждый угол: исходный арт лежит в левом-нижнем, флип разносит его по остальным.
private static readonly SpriteFlip[] Corners =
[
SpriteFlip.None,
SpriteFlip.X,
SpriteFlip.Y,
SpriteFlip.X | SpriteFlip.Y,
];
private static readonly Color Tint = new(150, 210, 255);
private readonly Entity[] _brackets = new Entity[4];
private readonly Texture2DRegion _region;
/// <summary>Создаёт четыре скрытые скобки на слое <paramref name="layer"/> из текстуры <paramref name="region"/>.</summary>
public SelectionBrackets(EntityStore store, Texture2DRegion region, LayerId layer)
{
_region = region;
var origin = new Vector2(region.Width / 2f, region.Height / 2f);
for (var i = 0; i < _brackets.Length; i++)
{
_brackets[i] = store.CreateEntity(
Transform2D.At(Vector2.Zero),
new Sprite
{
Region = null, // скрыта, пока ничего не выбрано
Color = Tint,
Origin = origin, // центр текстуры → квад центрируется на позиции
Layer = layer,
Flip = Corners[i],
}
);
}
}
/// <summary>Ставит рамку вокруг точки <paramref name="center"/> с полуразмером <paramref name="radius"/>.</summary>
public void Show(Vector2 center, float radius)
{
// Квад чуть больше объекта — скобки садятся снаружи (как в RimWorld); арт-уголок ~⅓ ширины.
var scale = new Vector2(radius * 2.2f / _region.Width);
for (var i = 0; i < _brackets.Length; i++)
{
ref var transform = ref _brackets[i].GetComponent<Transform2D>();
ref var sprite = ref _brackets[i].GetComponent<Sprite>();
transform.Position = center;
transform.Scale = scale;
sprite.Region = _region;
}
}
/// <summary>Прячет рамку (сбрасывает регион — рендер пропускает спрайты без текстуры).</summary>
public void Hide()
{
for (var i = 0; i < _brackets.Length; i++)
{
_brackets[i].GetComponent<Sprite>().Region = null;
}
}
}
+5 -1
View File
@@ -155,11 +155,15 @@ public sealed class GodCameraSystem(
if (newZoom != oldZoom) if (newZoom != oldZoom)
{ {
// Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же. // Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же.
// Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция
// ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position —
// иначе у краёв карты/при широком обзоре зум «уезжает» от курсора.
var anchor = renderer.ScreenToWorld( var anchor = renderer.ScreenToWorld(
new Vector2(input.MousePosition.X, input.MousePosition.Y) new Vector2(input.MousePosition.X, input.MousePosition.Y)
); );
var effective = renderer.Camera.WorldCenter;
camera.Zoom = newZoom; camera.Zoom = newZoom;
camera.Position = anchor - (anchor - camera.Position) * (oldZoom / newZoom); camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom);
} }
} }
} }
+526
View File
@@ -0,0 +1,526 @@
using System;
using System.Collections.Generic;
using System.Text;
using Friflo.Engine.ECS;
using LittleSim.Content;
using LittleSim.Sim;
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Mods;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Панель осмотра выбранной сущности (инфо-карточка в стиле RimWorld): угловая рамка вокруг объекта
/// (<see cref="SelectionBrackets"/>) и боковая панель в левом нижнем углу с вкладками. Набор вкладок
/// зависит от типа: растение — «Обзор/Гены/Продукты», животное — «Обзор/Нужды/Здоровье/Настроение».
/// Источник выделения — <see cref="Selection"/>; панель только читает компоненты и обновляется каждый
/// кадр (<see cref="Refresh"/>), оставаясь живой даже на паузе. Все строки идут через
/// <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
/// </summary>
internal sealed class InspectPanel
{
private enum Tab
{
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;
private readonly SelectionBrackets _brackets;
private readonly VerticalStackPanel _panel;
private readonly Label _title;
private readonly Label _body;
private readonly List<(Tab Tab, TextButton Button)> _tabs = [];
private Tab _tab = Tab.Overview;
public InspectPanel(
EntityStore store,
PlantSet plants,
AnimalSet animals,
NeedSet needs,
GameContent content,
Climate climate,
Selection selection,
SelectionBrackets brackets
)
{
_store = store;
_plants = plants;
_animals = animals;
_needs = needs;
_content = content;
_climate = climate;
_selection = selection;
_brackets = brackets;
_title = new Label { TextColor = Ui.Accent, Wrap = true };
_body = new Label { TextColor = new Color(210, 216, 226), Wrap = true };
var tabBar = new HorizontalStackPanel { Spacing = 4 };
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
{
Spacing = 6,
Padding = new Thickness(14),
Width = 380,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(12, 0, 0, 14),
Background = new SolidBrush(new Color(10, 13, 18, 240)),
Border = new SolidBrush(new Color(96, 134, 168)),
BorderThickness = new Thickness(2),
Visible = false,
};
_panel.Widgets.Add(_title);
_panel.Widgets.Add(tabBar);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(_body);
_panel.Widgets.Add(
new Label
{
Text = _content.Languages.Get("inspect.hint"),
TextColor = Ui.Muted,
Wrap = true,
}
);
}
/// <summary>Боковая инфо-панель — добавляется в корень экрана.</summary>
public VerticalStackPanel Panel => _panel;
/// <summary>
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
/// рамку к объекту и пересобирает текст активной вкладки. Осматриваются растения и животные.
/// </summary>
public void Refresh()
{
if (
!_selection.HasSelection
|| !_store.TryGetEntityById(_selection.EntityId, out var entity)
|| entity.IsNull
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
)
{
ClearSelection();
return;
}
var isPlant = entity.HasComponent<PlantGrowth>() && entity.HasComponent<PlantOrganism>();
var isAnimal = entity.HasComponent<AnimalOrganism>();
if (!isPlant && !isAnimal)
{
ClearSelection(); // не контент (труп/прочее) — пока не осматривается
return;
}
var transform = entity.GetComponent<Transform2D>();
var sprite = entity.GetComponent<Sprite>();
if (sprite.Region is { } region)
{
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transform,
region,
sprite.Origin
);
_brackets.Show(center, radius);
}
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) };
button.Click += (_, _) => _tab = tab;
bar.Widgets.Add(button);
_tabs.Add((tab, button));
}
// Показывает только вкладки текущего типа, подсвечивает активную; если активная не из набора —
// откатывает на первую (Обзор) при смене типа выбранного объекта.
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<PlantGrowth>();
ref readonly var org = ref entity.GetComponent<PlantOrganism>();
var species = _plants[grow.Species];
var def = species.Def;
var traits = org.Traits;
_title.Text = languages.Get(def.Label) + (traits.IsVariant ? " *" : "");
ShowTabs(PlantTabs);
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildOverview(text, entity, grow, traits, species);
break;
case Tab.Genes:
BuildGenes(text, traits);
break;
case Tab.Products:
BuildProducts(text, entity, def, traits);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void UpdateAnimal(Entity entity)
{
var languages = _content.Languages;
ref readonly var org = ref entity.GetComponent<AnimalOrganism>();
var sp = _animals[org.Species];
_title.Text = languages.Get(sp.Def.Label);
ShowTabs(AnimalTabs);
var text = new StringBuilder();
switch (_tab)
{
case Tab.Overview:
BuildAnimalOverview(text, entity, org, sp);
break;
case Tab.Needs:
BuildAnimalNeeds(text, entity);
break;
case Tab.Health:
BuildAnimalHealth(text, entity);
break;
case Tab.Mood:
BuildAnimalMood(text, entity);
break;
}
_body.Text = text.ToString().TrimEnd();
}
private void BuildOverview(
StringBuilder text,
Entity entity,
in PlantGrowth grow,
in PlantPhenotype traits,
PlantSet.Species species
)
{
var languages = _content.Languages;
var lastStage = species.Stages.Length - 1;
var mature = grow.Stage >= lastStage;
text.AppendLine(
languages.Format(
"inspect.stage",
StageLabel(species.Def, grow.Stage),
grow.Stage + 1,
species.Stages.Length
)
);
if (mature)
{
text.AppendLine(languages.Get("inspect.mature"));
}
else
{
var from = species.Stages[grow.Stage].EnterDay;
var to = species.Stages[grow.Stage + 1].EnterDay;
var pct = to > from ? (grow.AgeDays - from) / (to - from) * 100f : 0f;
text.AppendLine(languages.Format("inspect.growing", Math.Clamp(pct, 0f, 100f)));
}
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, traits.Lifespan));
// Состояние по температуре (рост/покой) и накопленный стресс.
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
var temperature = _climate.Temperature;
var suit = Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax);
var state =
suit <= 0f
? languages.Get(
temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot"
)
: languages.Get(mature ? "inspect.state.mature" : "inspect.state.growing");
text.AppendLine(languages.Format("inspect.state", state));
if (entity.TryGetComponent<TemperatureStress>(out var stress) && stress.Days > 0.05f)
{
text.AppendLine(languages.Format("inspect.stress", stress.Days));
}
text.AppendLine(languages.Format("inspect.temp", tMin, tMax, tLow, tHigh));
}
private void BuildGenes(StringBuilder text, in PlantPhenotype traits)
{
var languages = _content.Languages;
text.AppendLine(languages.Format("inspect.gene.vigor", traits.Vigor, traits.Lifespan));
text.AppendLine(
languages.Format("inspect.gene.env", traits.OptimalLight, traits.OptimalFertility)
);
text.AppendLine(
languages.Format("inspect.gene.repro", traits.DispersalRange, traits.ReproduceInterval)
);
text.AppendLine(
languages.Format("inspect.gene.repro2", traits.SelfPollination, traits.MutationRate)
);
text.AppendLine(
languages.Format(
"inspect.gene.hardy",
traits.ColdHardiness,
traits.HeatHardiness,
traits.LeafHue
)
);
if (traits.IsVariant)
{
text.AppendLine(languages.Get("inspect.gene.variant"));
}
}
private void BuildProducts(
StringBuilder text,
Entity entity,
PlantDef def,
in PlantPhenotype traits
)
{
var languages = _content.Languages;
if (def.HarvestProduct is { } harvest)
{
text.AppendLine(
languages.Format("inspect.harvest", ProductLabel(harvest), traits.HarvestAmount)
);
}
if (def.FruitProduct is { } fruit && traits.FruitYield >= 1f)
{
var ripe = entity.TryGetComponent<Fruiting>(out var fruiting) ? fruiting.RipeFruit : 0f;
var season = languages.Get(
"season." + ((Season)traits.FruitSeason).ToString().ToLowerInvariant()
);
text.AppendLine(
languages.Format(
"inspect.fruit",
ProductLabel(fruit),
traits.FruitYield,
season,
ripe
)
);
}
else
{
text.AppendLine(languages.Get("inspect.barren"));
}
}
// — Животное: Обзор — стадия, возраст, пол/поколение, беременность.
private void BuildAnimalOverview(
StringBuilder text,
Entity entity,
in AnimalOrganism org,
AnimalSet.Species sp
)
{
var languages = _content.Languages;
var grow = entity.GetComponent<AnimalGrowth>();
var stageName = languages.Get(
"animalstage." + sp.Stages[grow.Stage].Name.ToLowerInvariant()
);
text.AppendLine(
languages.Format("inspect.stage", stageName, grow.Stage + 1, sp.Stages.Length)
);
text.AppendLine(languages.Format("inspect.age", grow.AgeDays, org.Traits.Lifespan));
var sex = languages.Get(org.IsMale ? "inspect.sex.male" : "inspect.sex.female");
text.AppendLine(languages.Format("inspect.animal.sex", sex, org.Generation));
if (entity.TryGetComponent<Pregnant>(out var preg))
{
text.AppendLine(languages.Format("inspect.animal.pregnant", preg.DueInDays));
}
}
// — Животное: Нужды — значение каждой нужды (голод/жажда/отдых/влечение) в процентах.
private void BuildAnimalNeeds(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var values = entity.GetComponent<AnimalNeeds>().Values;
if (values is null)
{
return;
}
for (var k = 0; k < _needs.Count && k < values.Length; k++)
{
text.AppendLine(
languages.Format(
"inspect.needline",
languages.Get(_needs[k].Label),
values[k] * 100f
)
);
}
}
// — Животное: Здоровье — кровь/боль, ключевые способности, активные хедифы (раны/болезни/гон).
private void BuildAnimalHealth(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var state = entity.GetComponent<Health>().State;
if (state is null)
{
text.AppendLine(languages.Get("inspect.health.none"));
return;
}
text.AppendLine(
languages.Format("inspect.health.blood", state.BloodLevel * 100f, state.Pain * 100f)
);
if (state.Parts.Length > 0)
{
text.AppendLine(languages.Get("inspect.health.caps"));
AppendCapacity(text, state, AnimalCapacities.Consciousness, "cap.consciousness");
AppendCapacity(text, state, AnimalCapacities.Moving, "cap.moving");
AppendCapacity(text, state, AnimalCapacities.Sight, "cap.sight");
}
if (state.Hediffs.Count > 0)
{
text.AppendLine(languages.Get("inspect.health.hediffs"));
foreach (var hediff in state.Hediffs)
{
text.AppendLine(
languages.Format(
"inspect.health.hediffline",
languages.Get(hediff.Def.Label),
hediff.Severity * 100f
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.health.none"));
}
}
private void AppendCapacity(
StringBuilder text,
HealthState state,
string capId,
string labelKey
)
{
var languages = _content.Languages;
text.AppendLine(
languages.Format(
"inspect.health.cap",
languages.Get(labelKey),
state.Capacity(capId) * 100f
)
);
}
// — Животное: Настроение — значение и активные событийные мысли с их сдвигом (тир 4+).
private void BuildAnimalMood(StringBuilder text, Entity entity)
{
var languages = _content.Languages;
var mood = entity.GetComponent<Mood>();
if (!mood.Active)
{
text.AppendLine(languages.Get("inspect.mood.none"));
return;
}
text.AppendLine(languages.Format("inspect.mood.value", mood.Value * 100f));
if (mood.Thoughts is { Count: > 0 })
{
text.AppendLine(languages.Get("inspect.mood.thoughts"));
foreach (var thought in mood.Thoughts)
{
var pct = thought.Def.MoodOffset * 100f;
var offset = (pct >= 0f ? "+" : "") + pct.ToString("0") + "%";
text.AppendLine(
languages.Format(
"inspect.mood.thoughtline",
languages.Get(thought.Def.Label),
offset
)
);
}
}
else
{
text.AppendLine(languages.Get("inspect.mood.calm"));
}
}
private string StageLabel(PlantDef def, int stage) =>
stage < def.Stages.Count && def.Stages[stage].Label is { } key
? _content.Languages.Get(key)
: "—";
private string ProductLabel(string productDefName) =>
_content.Defs.TryGet<ProductDef>(productDefName, out var product)
? _content.Languages.Get(product.Label)
: productDefName;
}
+13
View File
@@ -82,6 +82,19 @@ internal static class SettingsPanel
) )
); );
// Масштаб интерфейса — сегменты пресетов.
var scales = new List<float> { 0.75f, 1f, 1.25f, 1.5f };
column.Widgets.Add(
Segment(
lang.Get("settings.uiscale"),
scales,
s => $"{(int)MathF.Round(s * 100)}%",
() => settings.UiScale,
s => settings.UiScale = s,
refreshers
)
);
// Громкость — кнопки −/+ с подписью процента. // Громкость — кнопки −/+ с подписью процента.
column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers)); column.Widgets.Add(Volume(lang.Get("settings.volume"), settings, refreshers));
+47
View File
@@ -0,0 +1,47 @@
using LittleSim.App;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.UI;
/// <summary>
/// Масштаб интерфейса для Myra-десктопа. Scale увеличивает всю отрисовку, а <c>BoundsFetcher</c>
/// отдаёт логический размер «экран ÷ масштаб», поэтому раскладка перетекает (а не обрезается):
/// при масштабе 1.25 виджеты крупнее, но углы/центры остаются прижаты к краям экрана. Ввод Myra
/// трансформируется тем же преобразованием, так что попадания/<c>IsMouseOverGUI</c> остаются верными.
/// </summary>
internal static class UiScaling
{
/// <summary>Применяет масштаб <paramref name="scale"/> (клампится в разумные пределы) к десктопу.</summary>
public static void Apply(EngineContext context, Desktop desktop, float scale)
{
scale = System.Math.Clamp(scale, 0.75f, 2f);
desktop.TransformOrigin = Vector2.Zero; // масштабируем от левого-верхнего угла — края на месте
desktop.Scale = new Vector2(scale);
desktop.BoundsFetcher = () =>
{
var viewport = context.GetGraphicsDevice().Viewport;
return new Rectangle(
0,
0,
(int)MathF.Round(viewport.Width / scale),
(int)MathF.Round(viewport.Height / scale)
);
};
}
}
/// <summary>Расширение сцены: создаёт Myra-десктоп и сразу применяет масштаб UI из настроек.</summary>
internal static class ScaledUiExtensions
{
/// <summary>Как <c>UseUI</c>, но десктоп уже масштабирован по <see cref="GameSettings.UiScale"/>.</summary>
public static Desktop UseScaledUI(this Scene scene)
{
var desktop = scene.UseUI();
UiScaling.Apply(scene.Context, desktop, GameSettingsStore.Load().UiScale);
return desktop;
}
}