Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60ae072e9 | ||
|
|
f46ae15dc6 | ||
|
|
6b29a40b7f | ||
|
|
6f38d949ab | ||
|
|
cc1841c2e2 | ||
|
|
4fda693994 | ||
|
|
d712afa518 |
@@ -19,6 +19,15 @@
|
|||||||
{ "defName": "GeneTemperatureTolerance", "parent": "BaseNumericGene", "label": "gene.temperatureTolerance",
|
{ "defName": "GeneTemperatureTolerance", "parent": "BaseNumericGene", "label": "gene.temperatureTolerance",
|
||||||
"default": 16, "min": 2, "max": 40, "tags": ["environment", "temperature"],
|
"default": 16, "min": 2, "max": 40, "tags": ["environment", "temperature"],
|
||||||
"effects": { "temperatureTolerance": "value" } },
|
"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",
|
{ "defName": "GeneOptimalFertility", "parent": "BaseNumericGene", "label": "gene.optimalFertility",
|
||||||
"default": 1.4, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
|
"default": 1.4, "min": 0.1, "max": 3.0, "tags": ["environment", "soil"],
|
||||||
"effects": { "optimalFertility": "value" } },
|
"effects": { "optimalFertility": "value" } },
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"defs": [
|
"defs": [
|
||||||
{ "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
|
{ "defName": "BaseTree", "abstract": true, "sizeCells": 2.0, "trunkRadiusCells": 0.28,
|
||||||
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
|
"harvestProduct": "ProductWood", "fruitProduct": "ProductAcorn",
|
||||||
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 16,
|
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 14, "temperatureTolerance": 10,
|
||||||
|
"coldHardiness": 14, "heatHardiness": 12,
|
||||||
"optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0,
|
"optimalFertility": 1.4, "fertilityTolerance": 0.9, "vigor": 1.0,
|
||||||
"lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
|
"lifespan": 160, "dispersalRange": 2, "reproduceInterval": 14, "selfPollination": 0.2,
|
||||||
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
|
"mutationRate": 0.05, "variantChance": 0.15, "spread": 0.08,
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
|
|
||||||
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2,
|
{ "defName": "GrassA", "label": "plant.grass", "texture": "things/plant/grass/grassa", "sizeCells": 1.2,
|
||||||
"harvestProduct": "ProductGrass",
|
"harvestProduct": "ProductGrass",
|
||||||
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 20, "temperatureTolerance": 12,
|
"genome": { "optimalLight": 0.85, "lightTolerance": 0.35, "optimalTemperature": 19, "temperatureTolerance": 8,
|
||||||
|
"coldHardiness": 9, "heatHardiness": 6,
|
||||||
"optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
|
"optimalFertility": 1.0, "fertilityTolerance": 1.0, "vigor": 1.3,
|
||||||
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
|
"lifespan": 22, "dispersalRange": 3, "reproduceInterval": 3, "selfPollination": 0.8,
|
||||||
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
|
"mutationRate": 0.06, "variantChance": 0.2, "spread": 0.1,
|
||||||
@@ -32,7 +34,8 @@
|
|||||||
|
|
||||||
{ "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4,
|
{ "defName": "BaseBush", "abstract": true, "label": "plant.bush", "sizeCells": 1.4,
|
||||||
"fruitProduct": "ProductBerry",
|
"fruitProduct": "ProductBerry",
|
||||||
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 13,
|
"genome": { "optimalLight": 0.6, "lightTolerance": 0.5, "optimalTemperature": 17, "temperatureTolerance": 9,
|
||||||
|
"coldHardiness": 10, "heatHardiness": 8,
|
||||||
"optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0,
|
"optimalFertility": 1.1, "fertilityTolerance": 1.0, "vigor": 1.0,
|
||||||
"lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5,
|
"lifespan": 60, "dispersalRange": 2, "reproduceInterval": 7, "selfPollination": 0.5,
|
||||||
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
|
"mutationRate": 0.05, "variantChance": 0.18, "spread": 0.1,
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.68, "color": [90, 160, 70],
|
{ "defName": "Grass", "label": "terrain.grass", "maxHeight": 0.68, "color": [90, 160, 70],
|
||||||
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
|
"isLand": true, "surface": "terrain/surfaces/mossy", "fertility": 1.0,
|
||||||
"scatter": [
|
"scatter": [
|
||||||
{ "chance": 0.35, "options": ["GrassA"] },
|
{ "chance": 0.6, "options": ["GrassA"] },
|
||||||
{ "chance": 0.07, "options": ["BushA", "BushB", "BushC"] }
|
{ "chance": 0.07, "options": ["BushA", "BushB", "BushC"] }
|
||||||
] },
|
] },
|
||||||
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
|
{ "defName": "Forest", "label": "terrain.forest", "maxHeight": 0.85, "color": [44, 110, 50],
|
||||||
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
|
"isLand": true, "surface": "terrain/surfaces/soil", "fertility": 1.6,
|
||||||
"scatter": [
|
"scatter": [
|
||||||
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA"] }
|
{ "chance": 0.35, "options": ["TreeOakA", "TreeOakB", "TreeBirchA", "TreeGrayPineA"] },
|
||||||
|
{ "chance": 0.3, "options": ["GrassA"] }
|
||||||
] },
|
] },
|
||||||
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
|
{ "defName": "Mountain", "label": "terrain.mountain", "maxHeight": 1.01, "color": [136, 132, 128],
|
||||||
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
|
"surface": "terrain/surfaces/roughhewnrock", "fertility": 0.2, "blocksLight": true }
|
||||||
|
|||||||
@@ -6,7 +6,29 @@
|
|||||||
"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.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.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",
|
||||||
@@ -84,5 +106,6 @@
|
|||||||
"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…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,29 @@
|
|||||||
"season.summer": "лето",
|
"season.summer": "лето",
|
||||||
"season.autumn": "осень",
|
"season.autumn": "осень",
|
||||||
"season.winter": "зима",
|
"season.winter": "зима",
|
||||||
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
|
"hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор",
|
||||||
|
"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.hint": "ЛКМ — выбрать · ПКМ/Esc — снять",
|
||||||
"hud.paused": "ПАУЗА",
|
"hud.paused": "ПАУЗА",
|
||||||
"menu.title": "LittleSim",
|
"menu.title": "LittleSim",
|
||||||
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
|
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
|
||||||
@@ -84,5 +106,6 @@
|
|||||||
"net.connecting": "подключение…",
|
"net.connecting": "подключение…",
|
||||||
"net.connected": "подключено",
|
"net.connected": "подключено",
|
||||||
"net.failed": "не удалось подключиться",
|
"net.failed": "не удалось подключиться",
|
||||||
"net.lost": "соединение потеряно"
|
"net.lost": "соединение потеряно",
|
||||||
|
"net.reconnecting": "переподключение…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
- Фиксированный тик симуляции, независимый от рендера
|
|
||||||
- Исторические графики (население, еда) в дев-оверлее
|
|
||||||
@@ -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...08381703f7
+3
-1
@@ -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
@@ -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 "$@"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Адрес сервера: ?server=ws://host:port в URL страницы; по умолчанию —
|
// Перерисовываем оверлей только при смене статуса (или изредка — чтобы освежить счётчик),
|
||||||
// хост самой страницы на порту LittleSim.Server.
|
// а не каждый кадр rAF.
|
||||||
private Uri ResolveServerUri()
|
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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAddressKey(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
}
|
||||||
|
|
||||||
|
// Обрыв установленного соединения: чистим и уходим в переподключение.
|
||||||
|
if (_connection is { IsOpen: false })
|
||||||
{
|
{
|
||||||
_connection = finished.Result;
|
_connection = null;
|
||||||
Log.Info($"Connected to {_server}");
|
_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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
}
|
||||||
|
|
||||||
|
// Обрыв уже установленного соединения: чистим и уходим в переподключение.
|
||||||
|
if (_connection is { IsOpen: false })
|
||||||
{
|
{
|
||||||
_connection = finished.Result;
|
_connection = null;
|
||||||
_statusKey = "net.connected";
|
_replication.Clear();
|
||||||
Log.Info($"Connected to {_server}");
|
ScheduleReconnect(now);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_connection is not null)
|
if (_connection is not null)
|
||||||
{
|
{
|
||||||
_replication.Pump(_connection);
|
_replication.Pump(_connection);
|
||||||
if (!_connection.IsOpen)
|
|
||||||
{
|
|
||||||
_statusKey = "net.lost";
|
|
||||||
}
|
}
|
||||||
|
else if (_connecting is null && now >= _nextAttemptAt)
|
||||||
|
{
|
||||||
|
_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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ public sealed class WorldScene : Scene
|
|||||||
|
|
||||||
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!;
|
||||||
@@ -132,7 +134,15 @@ public sealed class WorldScene : Scene
|
|||||||
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();
|
||||||
|
_inspect = new InspectPanel(Store, _plants, content, climate, _selection);
|
||||||
|
desktop.Root = Ui.Screen(
|
||||||
|
hudLabel,
|
||||||
|
_inspect.Highlight,
|
||||||
|
_inspect.Panel,
|
||||||
|
speedBar,
|
||||||
|
_pause.Root
|
||||||
|
);
|
||||||
|
|
||||||
this.UseInspector(renderer);
|
this.UseInspector(renderer);
|
||||||
var console = this.UseDevConsole();
|
var console = this.UseDevConsole();
|
||||||
@@ -168,6 +178,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,
|
||||||
@@ -181,6 +192,17 @@ public sealed class WorldScene : Scene
|
|||||||
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,
|
||||||
|
p => _inspect.IsOverPanel(p) || speedBar.Bounds.Contains(p)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
UpdateSystems.Add(new CallbackSystem(() => _inspect.Refresh(renderer)));
|
||||||
UpdateSystems.Add(
|
UpdateSystems.Add(
|
||||||
new HudSystem(
|
new HudSystem(
|
||||||
Context,
|
Context,
|
||||||
@@ -308,9 +330,17 @@ public sealed class WorldScene : Scene
|
|||||||
private void Hotkeys(InputManager input)
|
private void Hotkeys(InputManager input)
|
||||||
{
|
{
|
||||||
if (input.IsKeyPressed(Keys.Escape))
|
if (input.IsKeyPressed(Keys.Escape))
|
||||||
|
{
|
||||||
|
// Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу.
|
||||||
|
if (_selection.HasSelection)
|
||||||
|
{
|
||||||
|
_selection.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
_pause.Toggle();
|
_pause.Toggle();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_pause.IsOpen)
|
if (_pause.IsOpen)
|
||||||
{
|
{
|
||||||
@@ -587,6 +617,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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<Point, bool> overUi
|
||||||
|
) : BaseSystem
|
||||||
|
{
|
||||||
|
protected override void OnUpdateGroup()
|
||||||
|
{
|
||||||
|
if (blocked())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.IsMousePressed(MouseButton.Right))
|
||||||
|
{
|
||||||
|
selection.Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.IsMousePressed(MouseButton.Left))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mouse = input.MousePosition;
|
||||||
|
if (overUi(mouse))
|
||||||
|
{
|
||||||
|
return; // клик пришёлся на панель — это не пикинг мира
|
||||||
|
}
|
||||||
|
|
||||||
|
var world = renderer.ScreenToWorld(new Vector2(mouse.X, mouse.Y));
|
||||||
|
if (TryPick(world, out var id))
|
||||||
|
{
|
||||||
|
selection.Select(id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
selection.Clear(); // клик по пустой земле снимает выделение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ближайшая сущность, чья ограничивающая окружность спрайта накрывает точку мира.
|
||||||
|
private bool TryPick(Vector2 world, out int id)
|
||||||
|
{
|
||||||
|
id = -1;
|
||||||
|
var bestDistance = float.MaxValue;
|
||||||
|
foreach (var (transforms, sprites, entities) in store.Query<Transform2D, Sprite>().Chunks)
|
||||||
|
{
|
||||||
|
var t = transforms.Span;
|
||||||
|
var s = sprites.Span;
|
||||||
|
for (var i = 0; i < t.Length; i++)
|
||||||
|
{
|
||||||
|
if (s[i].Region is not { } region)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (center, radius) = CullingMath.SpriteBoundingCircle(
|
||||||
|
in t[i],
|
||||||
|
region,
|
||||||
|
s[i].Origin
|
||||||
|
);
|
||||||
|
var distance = Vector2.DistanceSquared(center, world);
|
||||||
|
if (distance <= radius * radius && distance < bestDistance)
|
||||||
|
{
|
||||||
|
bestDistance = distance;
|
||||||
|
id = entities.EntityAt(i).Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return id >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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="Selection"/>;
|
||||||
|
/// панель только читает компоненты и обновляется каждый кадр (<see cref="Refresh"/>), оставаясь
|
||||||
|
/// живой даже на паузе. Строки идут через <see cref="LanguageManager"/> (ключи <c>inspect.*</c>).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class InspectPanel
|
||||||
|
{
|
||||||
|
private readonly EntityStore _store;
|
||||||
|
private readonly PlantSet _plants;
|
||||||
|
private readonly GameContent _content;
|
||||||
|
private readonly Climate _climate;
|
||||||
|
private readonly Selection _selection;
|
||||||
|
|
||||||
|
private readonly Panel _highlight;
|
||||||
|
private readonly VerticalStackPanel _panel;
|
||||||
|
private readonly Label _title;
|
||||||
|
private readonly Label _body;
|
||||||
|
|
||||||
|
public InspectPanel(
|
||||||
|
EntityStore store,
|
||||||
|
PlantSet plants,
|
||||||
|
GameContent content,
|
||||||
|
Climate climate,
|
||||||
|
Selection selection
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_plants = plants;
|
||||||
|
_content = content;
|
||||||
|
_climate = climate;
|
||||||
|
_selection = selection;
|
||||||
|
|
||||||
|
_title = new Label { TextColor = Ui.Accent, Wrap = true };
|
||||||
|
_body = new Label { TextColor = new Color(210, 216, 226), Wrap = true };
|
||||||
|
|
||||||
|
_panel = new VerticalStackPanel
|
||||||
|
{
|
||||||
|
Spacing = 6,
|
||||||
|
Padding = new Thickness(10),
|
||||||
|
Width = 290,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(0, 10, 10, 0),
|
||||||
|
Background = new SolidBrush(new Color(8, 10, 14, 235)),
|
||||||
|
Visible = false,
|
||||||
|
};
|
||||||
|
_panel.Widgets.Add(_title);
|
||||||
|
_panel.Widgets.Add(new HorizontalSeparator());
|
||||||
|
_panel.Widgets.Add(_body);
|
||||||
|
|
||||||
|
_highlight = new Panel
|
||||||
|
{
|
||||||
|
Border = new SolidBrush(Ui.Accent),
|
||||||
|
BorderThickness = new Thickness(2),
|
||||||
|
Visible = false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Рамка-подсветка в мире — добавляется в корень экрана позади панели.</summary>
|
||||||
|
public Panel Highlight => _highlight;
|
||||||
|
|
||||||
|
/// <summary>Боковая инфо-панель — добавляется в корень экрана.</summary>
|
||||||
|
public VerticalStackPanel Panel => _panel;
|
||||||
|
|
||||||
|
/// <summary>Над панелью ли точка экрана (тогда клик не считается пикингом мира).</summary>
|
||||||
|
public bool IsOverPanel(Point screen) => _panel.Visible && _panel.Bounds.Contains(screen);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подтягивает выделение каждый кадр: проверяет, жива ли сущность (иначе снимает выбор), двигает
|
||||||
|
/// рамку по экрану и пересобирает текст. Не-растения панель не показывает (пока осматриваем только их).
|
||||||
|
/// </summary>
|
||||||
|
public void Refresh(Renderer2D renderer)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!_selection.HasSelection
|
||||||
|
|| !_store.TryGetEntityById(_selection.EntityId, out var entity)
|
||||||
|
|| entity.IsNull
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (_selection.HasSelection)
|
||||||
|
{
|
||||||
|
_selection.Clear(); // сущность исчезла (растение погибло)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!entity.HasComponent<PlantGrowth>()
|
||||||
|
|| !entity.HasComponent<PlantOrganism>()
|
||||||
|
|| !entity.HasComponent<Transform2D>()
|
||||||
|
|| !entity.HasComponent<Sprite>()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateHighlight(renderer, entity);
|
||||||
|
UpdateText(entity);
|
||||||
|
_panel.Visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Hide()
|
||||||
|
{
|
||||||
|
_panel.Visible = false;
|
||||||
|
_highlight.Visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateHighlight(Renderer2D renderer, Entity entity)
|
||||||
|
{
|
||||||
|
var transform = entity.GetComponent<Transform2D>();
|
||||||
|
var sprite = entity.GetComponent<Sprite>();
|
||||||
|
if (sprite.Region is not { } region)
|
||||||
|
{
|
||||||
|
_highlight.Visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (center, radius) = CullingMath.SpriteBoundingCircle(
|
||||||
|
in transform,
|
||||||
|
region,
|
||||||
|
sprite.Origin
|
||||||
|
);
|
||||||
|
var screenCenter = renderer.WorldToScreen(center);
|
||||||
|
var edge = renderer.WorldToScreen(center + new Vector2(radius, 0f));
|
||||||
|
var screenRadius = MathF.Max(4f, MathF.Abs(edge.X - screenCenter.X));
|
||||||
|
_highlight.Visible = true;
|
||||||
|
_highlight.Left = (int)(screenCenter.X - screenRadius);
|
||||||
|
_highlight.Top = (int)(screenCenter.Y - screenRadius);
|
||||||
|
_highlight.Width = (int)(screenRadius * 2f);
|
||||||
|
_highlight.Height = (int)(screenRadius * 2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateText(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 ? " *" : "");
|
||||||
|
|
||||||
|
var lastStage = species.Stages.Length - 1;
|
||||||
|
var mature = grow.Stage >= lastStage;
|
||||||
|
var text = new StringBuilder();
|
||||||
|
|
||||||
|
// Стадия и процесс роста.
|
||||||
|
text.AppendLine(
|
||||||
|
languages.Format(
|
||||||
|
"inspect.stage",
|
||||||
|
StageLabel(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);
|
||||||
|
string state;
|
||||||
|
if (suit <= 0f)
|
||||||
|
{
|
||||||
|
state = languages.Get(
|
||||||
|
temperature <= tMin ? "inspect.state.dormantcold" : "inspect.state.dormanthot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state = 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));
|
||||||
|
|
||||||
|
// Гены / фенотип.
|
||||||
|
text.AppendLine();
|
||||||
|
text.AppendLine(languages.Get("inspect.genes"));
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Продукты: сбор и плоды.
|
||||||
|
text.AppendLine();
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
text.Append(languages.Get("inspect.hint"));
|
||||||
|
_body.Text = text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user