Compare commits

..
Author SHA1 Message Date
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 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
58 changed files with 1634 additions and 473 deletions
+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 }
]
}
@@ -1,14 +1,7 @@
{
"type": "Pawn",
// Животные.
"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": "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 },
+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" }
] }
]
}
@@ -5,7 +5,9 @@
"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": "ProductAcorn", "label": "product.acorn", "kind": "food" },
{ "defName": "ProductMushroom", "label": "product.mushroom", "kind": "food" }
]
}
+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 }
]
}
-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" }
]
}
-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 }
]
}
+46 -2
View File
@@ -6,7 +6,32 @@
"season.summer": "summer",
"season.autumn": "autumn",
"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.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",
"menu.title": "LittleSim",
"menu.subtitle": "a god-game: minimal graphics, deep simulation",
@@ -35,6 +60,7 @@
"settings.vsync": "Vertical sync",
"settings.resolution": "Resolution",
"settings.volume": "Volume",
"settings.uiscale": "UI scale",
"settings.apply": "Apply",
"settings.back": "Back",
"pause.title": "Paused",
@@ -63,14 +89,31 @@
"plant.pine": "pine",
"plant.grass": "grass tuft",
"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.sprout": "sprout",
"plant.stage.sapling": "sapling",
"plant.stage.mature": "mature",
"product.wood": "wood",
"product.grass": "grass",
"product.fiber": "fiber",
"product.berry": "berries",
"product.acorn": "acorn",
"product.mushroom": "mushrooms",
"pawn.being": "being",
"pawn.bear": "bear",
"pawn.deer": "deer",
@@ -84,5 +127,6 @@
"net.connecting": "connecting…",
"net.connected": "connected",
"net.failed": "connection failed",
"net.lost": "connection lost"
"net.lost": "connection lost",
"net.reconnecting": "reconnecting…"
}
+46 -2
View File
@@ -6,7 +6,32 @@
"season.summer": "лето",
"season.autumn": "осень",
"season.winter": "зима",
"hud.controls": "WASD — камера, колесо — зум, ` — консоль, F1 — инспектор",
"hud.controls": "WASD — камера, колесо — зум, ЛКМ — выбрать, ` — консоль, F1 — инспектор",
"inspect.tab.overview": "Обзор",
"inspect.tab.genes": "Гены",
"inspect.tab.products": "Продукты",
"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": "ПАУЗА",
"menu.title": "LittleSim",
"menu.subtitle": "бог-игра: минимум графики, максимум симуляции",
@@ -35,6 +60,7 @@
"settings.vsync": "Вертикальная синхронизация",
"settings.resolution": "Разрешение",
"settings.volume": "Громкость",
"settings.uiscale": "Масштаб UI",
"settings.apply": "Применить",
"settings.back": "Назад",
"pause.title": "Пауза",
@@ -63,14 +89,31 @@
"plant.pine": "сосна",
"plant.grass": "пучок травы",
"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.sprout": "всходы",
"plant.stage.sapling": "саженец",
"plant.stage.mature": "взрослое",
"product.wood": "древесина",
"product.grass": "трава",
"product.fiber": "волокно",
"product.berry": "ягоды",
"product.acorn": "жёлудь",
"product.mushroom": "грибы",
"pawn.being": "житель",
"pawn.bear": "медведь",
"pawn.deer": "олень",
@@ -84,5 +127,6 @@
"net.connecting": "подключение…",
"net.connected": "подключено",
"net.failed": "не удалось подключиться",
"net.lost": "соединение потеряно"
"net.lost": "соединение потеряно",
"net.reconnecting": "переподключение…"
}
+20
View File
@@ -60,6 +60,26 @@ Mods/<Имя>/
вид — `being`/`animal`). CLR-классы — `src/LittleSim/Content/GameDefs.cs`; новый тип
дефа = новый класс + `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` — плоские словари «ключ → строка»:
-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)
- Фиксированный тик симуляции, независимый от рендера
- Исторические графики (население, еда) в дев-оверлее
-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
try {
dotnet build LittleSim.sln -c $Configuration
# Только игровой проект (тянет за собой нужные движковые библиотеки) — не вся солюшн с
# тестами, сервером и WASM-клиентом (его emscripten-сборка занимает минуты).
dotnet build $Project -c $Configuration
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
+3 -1
View File
@@ -18,5 +18,7 @@ done
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 "$@"
+14 -5
View File
@@ -16,24 +16,33 @@ public class LittleSimWebGame : Game
/// <summary>Контекст ядра движка, общий со сценами и системами.</summary>
public EngineContext Context { get; } = new EngineContext();
private readonly Uri _server;
private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch = null!;
private Texture2D _pixel = null!;
private WorldViewScene _scene = null!;
/// <summary>Игра, подключающаяся к серверу <paramref name="server"/>.</summary>
public LittleSimWebGame(Uri server)
/// <summary>Игра-наблюдатель: мира не создаёт, ждёт <see cref="Connect"/> из UI.</summary>
public LittleSimWebGame()
{
_server = server;
_graphics = new GraphicsDeviceManager(this);
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 />
protected override void Initialize()
{
base.Initialize();
Context.Scenes.Switch(new WorldViewScene(_server, () => _spriteBatch, () => _pixel));
_scene = new WorldViewScene(() => _spriteBatch, () => _pixel);
Context.Scenes.Switch(_scene);
}
/// <inheritdoc />
+29 -20
View File
@@ -1,20 +1,29 @@
@page "/"
@page "/index.html"
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
margin:0%;
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width:100vw;
height:100vh;
">
<canvas id="theCanvas" style="touch-action:none;"></canvas>
</div>
@page "/"
@page "/index.html"
@inject IJSRuntime JsRuntime
@using nkast.Wasm.Canvas
<PageTitle>LittleSim</PageTitle>
<div id="canvasHolder" style="
background: #000;
margin:0%;
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width:100vw;
height:100vh;
">
<canvas id="theCanvas" style="touch-action:none;"></canvas>
</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 Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using Microsoft.Xna.Framework;
namespace LittleSim.Web.Pages
{
@@ -10,7 +10,12 @@ namespace LittleSim.Web.Pages
[Inject]
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)
{
@@ -18,25 +23,92 @@ namespace LittleSim.Web.Pages
if (firstRender)
{
_serverAddress = DefaultServerAddress();
StateHasChanged();
JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
}
}
// Гонится из requestAnimationFrame (index.html). Игру создаём лениво в первом тике —
// мира она не создаёт, лишь ждёт адрес сервера из формы.
[JSInvokable]
public void TickDotNet()
{
if (_game == null)
{
_game = new LittleSimWebGame(ResolveServerUri());
_game = new LittleSimWebGame();
_game.Run();
}
_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 страницы; по умолчанию —
// хост самой страницы на порту LittleSim.Server.
private Uri ResolveServerUri()
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 query = page.Query.TrimStart('?');
@@ -45,7 +117,7 @@ namespace LittleSim.Web.Pages
var separator = pair.IndexOf('=');
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",
Host = page.Host,
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;
/// <summary>Состояние соединения веб-клиента — его читает оверлей подключения (Blazor).</summary>
public enum ConnectionStatus
{
/// <summary>Сервер ещё не выбран — ждём, пока пользователь нажмёт «Подключиться».</summary>
Idle,
/// <summary>Идёт первая попытка подключения.</summary>
Connecting,
/// <summary>Соединение установлено, снапшоты приходят.</summary>
Connected,
/// <summary>Соединение оборвалось/не удалось — ждём следующей попытки (бэкофф).</summary>
Reconnecting,
}
/// <summary>
/// Браузерный клиент мира LittleSim: подключается к дедикейтед-серверу
/// (LittleSim.Server --listen), применяет дельта-снапшоты в свой EntityStore и рисует
/// жителей через KNI SpriteBatch (WebGL). Симуляция целиком на сервере — сюда приезжают
/// только компоненты схемы (позиция + потребности); позиции сглаживаются до частоты
/// кадра, усталость затемняет квадратик жителя.
/// Браузерный клиент мира LittleSim: по команде <see cref="Connect"/> подключается к
/// дедикейтед-серверу (LittleSim.Server --listen), применяет дельта-снапшоты в свой
/// EntityStore и рисует жителей через KNI SpriteBatch (WebGL). Мира не создаёт — только
/// наблюдает: симуляция целиком на сервере, сюда приезжают лишь компоненты схемы (позиция +
/// потребности). Позиции сглаживаются до частоты кадра, усталость затемняет квадратик. При
/// обрыве переподключается сам с экспоненциальным бэкоффом, очищая устаревшие сущности.
/// </summary>
public sealed class WorldViewScene : Scene
{
private readonly Uri _server;
// Бэкофф переподключения: задержка удваивается с каждой неудачей до потолка.
private const float MaxBackoffSeconds = 8f;
private readonly Func<SpriteBatch> _spriteBatch;
private readonly Func<Texture2D> _pixel;
private ReplicationClient _replication = null!;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
private Uri? _server; // целевой сервер; null — пользователь ещё не подключался
private double _nextAttemptAt;
private int _attempt;
/// <summary>Сцена, подключающаяся к <paramref name="server"/>.</summary>
public WorldViewScene(Uri server, Func<SpriteBatch> spriteBatch, Func<Texture2D> pixel)
/// <summary>Текущее состояние соединения — для оверлея подключения.</summary>
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;
_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>
private struct NetLerp : IComponent
{
@@ -51,35 +94,68 @@ public sealed class WorldViewScene : Scene
UpdateSystems.Add(new NetSmoothingSystem());
DrawSystems.Add(new PawnDrawSystem(_spriteBatch, _pixel));
Log.Info($"LittleSim.Web: connecting to {_server}…");
_connecting = WebSocketClient.ConnectAsync(_server);
Log.Info("LittleSim.Web ready — awaiting connect");
}
protected override void OnUnload() => _connection?.Close();
private void Pump()
{
if (_server is null)
{
return; // адрес ещё не задан — ждём команды Connect из оверлея
}
var now = Context.Clock.UnscaledTotalTime;
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
if (_connecting is { IsCompleted: true } finished)
{
_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(
$"Connect to {_server} failed: "
+ 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)
{
_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>
+3
View File
@@ -28,6 +28,9 @@ public sealed class GameSettings
/// <summary>Общая громкость 0..1.</summary>
public float Volume { get; set; } = 1f;
/// <summary>Масштаб интерфейса (1 — обычный); применяется к Myra-десктопу всех сцен.</summary>
public float UiScale { get; set; } = 1f;
}
/// <summary>Чтение/запись <see cref="GameSettings"/> и применение их к движку.</summary>
+12 -6
View File
@@ -14,7 +14,7 @@ public sealed class ScatterEntry
public List<string> Options { get; init; } = [];
}
/// <summary>Тип клетки рельефа (Defs/terrain.json): порог высоты, цвет, поверхность, скаттер растений.</summary>
/// <summary>Тип клетки рельефа (Defs/Terrain/): порог высоты, цвет, поверхность, скаттер растений.</summary>
public sealed class TerrainDef : Def
{
/// <summary>Верхняя граница высоты (клетка относится к первому дефу, чей порог выше её высоты).</summary>
@@ -67,7 +67,7 @@ public sealed class PlantGrowthStage
}
/// <summary>
/// Продукт (Defs/products.json): что растение даёт при сборе (древесина, трава) или плодоношении
/// Продукт (Defs/Products/): что растение даёт при сборе (древесина, трава) или плодоношении
/// (ягоды, жёлудь). Источник истины — деф; растения ссылаются на него по имени, а количество
/// определяют гены. <see cref="Def.Label"/> — ключ локализации (product.*).
/// </summary>
@@ -93,9 +93,15 @@ public sealed class GenomeDef
/// <summary>Оптимальная температура (°C) и её толерантность.</summary>
public float OptimalTemperature { get; init; } = 18f;
/// <summary>Ширина толерантности по температуре (°C).</summary>
/// <summary>Ширина толерантности по температуре (°C) — половина плато полного роста вокруг оптимума.</summary>
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>
public float OptimalFertility { get; init; } = 1f;
@@ -139,7 +145,7 @@ public sealed class GenomeDef
public float LeafHue { get; init; } = 0.33f;
}
/// <summary>Растение (Defs/plants.json): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary>
/// <summary>Растение (Defs/Plants/): текстура, размер, опциональный ствол-препятствие, стадии роста, геном.</summary>
public sealed class PlantDef : Def
{
/// <summary>Ключ текстуры (путь региона в атласах); база для стадий без своей текстуры.</summary>
@@ -165,7 +171,7 @@ public sealed class PlantDef : Def
}
/// <summary>
/// Пресет размера мира (Defs/worldpresets.json): габариты сетки и население. Меню «Новый
/// Пресет размера мира (Defs/WorldPresets/): габариты сетки и население. Меню «Новый
/// мир» строит список размеров из этих дефов — новая механика приходит данными мода, а не
/// хардкодом. <see cref="Def.Label"/> — ключ локализации (preset.*).
/// </summary>
@@ -184,7 +190,7 @@ public sealed class WorldPresetDef : Def
public int Order { get; init; }
}
/// <summary>Существо (Defs/pawns.json): текстура, размер и вид (житель/животное).</summary>
/// <summary>Существо (Defs/Pawns/): текстура, размер и вид (житель/животное).</summary>
public sealed class PawnDef : Def
{
/// <summary>Вид существа: жители ("being") живут в мировой сцене, животные ("animal") — в террейне.</summary>
+4 -2
View File
@@ -81,7 +81,7 @@ public sealed class PlantSet
/// <summary>Индекс вида по дефу.</summary>
public int IndexOf(PlantDef def) => _index[def];
// Переводит базовый геном вида (числа из plants.json) в набор генов: каждое число — центр
// Переводит базовый геном вида (числа из Defs/Plants/) в набор генов: каждое число — центр
// аллелей соответствующего общего GeneDef; морфа — дискретный ген с долей рецессива из вида.
private static GenomeTemplate BuildTemplate(
GenomeDef g,
@@ -92,7 +92,7 @@ public sealed class PlantSet
genes.TryGetValue(id, out var def)
? def
: 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) =>
@@ -103,6 +103,8 @@ public sealed class PlantSet
Numeric("GeneLightTolerance", g.LightTolerance),
Numeric("GeneOptimalTemperature", g.OptimalTemperature),
Numeric("GeneTemperatureTolerance", g.TemperatureTolerance),
Numeric("GeneColdHardiness", g.ColdHardiness),
Numeric("GeneHeatHardiness", g.HeatHardiness),
Numeric("GeneOptimalFertility", g.OptimalFertility),
Numeric("GeneFertilityTolerance", g.FertilityTolerance),
Numeric("GeneVigor", g.Vigor),
+91 -91
View File
@@ -1,91 +1,91 @@
using System;
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
/// </summary>
public sealed class BootScene : Scene
{
private readonly string[] _steps = { "Загрузка", "Loading" };
private readonly string? _connectTo;
private Task<GameContent>? _load;
private GameSettings _settings = new();
private Label _label = null!;
/// <summary>Обычный запуск — в главное меню.</summary>
public BootScene()
: this(null) { }
/// <summary>С <paramref name="connectTo"/> (ws://host:port) грузится сразу в сетевую сцену.</summary>
public BootScene(string? connectTo) => _connectTo = connectTo;
protected override void OnLoad()
{
Context.UseAudio();
Context.UseGameSpeed(1f, 3f, 6f);
_settings = GameSettingsStore.Load();
var host = (GameHost)Context.Services.Get<Game>();
host.Graphics.IsFullScreen = _settings.Fullscreen;
host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
host.Graphics.PreferredBackBufferWidth = _settings.Width;
host.Graphics.PreferredBackBufferHeight = _settings.Height;
host.Graphics.ApplyChanges();
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label
{
TextColor = Ui.Accent,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.Screen(_label);
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(() => GameContent.Load());
UpdateSystems.Add(new CallbackSystem(Tick));
}
private void Tick()
{
var word = _settings.Language == "en" ? _steps[1] : _steps[0];
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = word + dots;
if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
{
return;
}
if (_load.IsFaulted)
{
_label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
Log.Error($"Content load failed: {_label.Text}");
return;
}
var content = _load.Result;
_load = null;
Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language);
Scene next = _connectTo is null
? new MainMenuScene()
: new MultiplayerScene(new Uri(_connectTo));
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
}
}
using System;
using System.Threading.Tasks;
using LittleSim.App;
using LittleSim.Content;
using LittleSim.UI;
using Microsoft.Xna.Framework;
using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Host;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace LittleSim.Scenes;
/// <summary>
/// Загрузочный экран. Поднимает аудио и управление скоростью, грузит настройки и применяет
/// окно/громкость, затем грузит контент Core-мода в фоне (чистый CPU/диск — без GPU) с
/// анимированной надписью. По готовности регистрирует сервисы и уходит в главное меню.
/// </summary>
public sealed class BootScene : Scene
{
private readonly string[] _steps = { "Загрузка", "Loading" };
private readonly string? _connectTo;
private Task<GameContent>? _load;
private GameSettings _settings = new();
private Label _label = null!;
/// <summary>Обычный запуск — в главное меню.</summary>
public BootScene()
: this(null) { }
/// <summary>С <paramref name="connectTo"/> (ws://host:port) грузится сразу в сетевую сцену.</summary>
public BootScene(string? connectTo) => _connectTo = connectTo;
protected override void OnLoad()
{
Context.UseAudio();
Context.UseGameSpeed(1f, 3f, 6f);
_settings = GameSettingsStore.Load();
var host = (GameHost)Context.Services.Get<Game>();
host.Graphics.IsFullScreen = _settings.Fullscreen;
host.Graphics.SynchronizeWithVerticalRetrace = _settings.VSync;
host.Graphics.PreferredBackBufferWidth = _settings.Width;
host.Graphics.PreferredBackBufferHeight = _settings.Height;
host.Graphics.ApplyChanges();
Context.Services.Get<AudioManager>().MasterVolume = _settings.Volume;
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
_label = new Label
{
TextColor = Ui.Accent,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
desktop.Root = Ui.Screen(_label);
// Сборка атласов и дефов — без GPU, поэтому безопасно вне главного потока.
_load = Task.Run(() => GameContent.Load());
UpdateSystems.Add(new CallbackSystem(Tick));
}
private void Tick()
{
var word = _settings.Language == "en" ? _steps[1] : _steps[0];
var dots = new string('.', (int)(Context.Clock.UnscaledTotalTime * 2) % 4);
_label.Text = word + dots;
if (_load is null || !_load.IsCompleted || Context.Scenes.IsTransitioning)
{
return;
}
if (_load.IsFaulted)
{
_label.Text = _load.Exception?.GetBaseException().Message ?? "load failed";
Log.Error($"Content load failed: {_label.Text}");
return;
}
var content = _load.Result;
_load = null;
Context.Services.Add(content);
Context.Services.Add(new ModAtlases(content.AtlasCacheDirectory));
content.Languages.SetLanguage(_settings.Language);
Scene next = _connectTo is null
? new MainMenuScene()
: new MultiplayerScene(new Uri(_connectTo));
Context.Scenes.Switch(next, Transitions.Fade(0.6f));
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class CreditsScene : Scene
{
var lang = Context.Services.Get<GameContent>().Languages;
var input = this.UseInput();
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
column.Widgets.Add(Ui.Title(lang.Get("credits.title")));
+4
View File
@@ -13,6 +13,9 @@ public static class GameLayers
/// <summary>Y-sort слой существ и объектов мира: кто ниже на экране — тот ближе.</summary>
public static LayerId Beings { get; private set; }
/// <summary>Слой-оверлей поверх мира: рамка выделения (рисуется после существ, без Y-сорта).</summary>
public static LayerId Selection { get; private set; }
/// <summary>Регистрирует слои при первом вызове.</summary>
public static void EnsureRegistered(Renderer2D renderer)
{
@@ -22,6 +25,7 @@ public static class GameLayers
}
Beings = renderer.Layers.Register("Beings", LayerSpace.World, LayerSortMode.YSort);
Selection = renderer.Layers.Register("Selection", LayerSpace.World, LayerSortMode.Depth);
_registered = true;
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class LoadGameScene : Scene
{
_content = Context.Services.Get<GameContent>();
var input = this.UseInput();
_desktop = this.UseUI();
_desktop = this.UseScaledUI();
Rebuild();
UpdateSystems.Add(
+1 -1
View File
@@ -17,7 +17,7 @@ public sealed class MainMenuScene : Scene
{
var content = Context.Services.Get<GameContent>();
var lang = content.Languages;
var desktop = this.UseUI(); // ставит MyraEnvironment.Game до создания виджетов
var desktop = this.UseScaledUI(); // ставит MyraEnvironment.Game до создания виджетов
var column = Ui.Column(12);
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 const float MaxBackoffSeconds = 8f;
private readonly Uri _server;
private Task<WebSocketClient>? _connecting;
private WebSocketClient? _connection;
@@ -38,6 +41,8 @@ public sealed class MultiplayerScene : Scene
private GameContent _content = null!;
private Label _hud = null!;
private string _statusKey = "net.connecting";
private double _nextAttemptAt; // время (unscaled) следующей попытки подключения
private int _attempt; // 0 — первая попытка; растёт при обрывах, задаёт бэкофф
/// <summary>Сцена, подключающаяся к серверу <paramref name="server"/> (ws:// или wss://).</summary>
public MultiplayerScene(Uri server) => _server = server;
@@ -64,7 +69,7 @@ public sealed class MultiplayerScene : Scene
entity.AddComponent(new NetLerp());
};
var desktop = this.UseUI();
var desktop = this.UseScaledUI();
_hud = new Label { Left = 10, Top = 8 };
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();
private void Pump()
{
var now = Context.Clock.UnscaledTotalTime;
// Завершилась попытка подключения: успех — берём соединение; провал — планируем повтор.
if (_connecting is { IsCompleted: true } finished)
{
_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(
$"Connect to {_server} failed: "
+ finished.Exception?.GetBaseException().Message
);
ScheduleReconnect(now);
}
else
{
_connection = finished.Result;
_statusKey = "net.connected";
Log.Info($"Connected to {_server}");
}
}
// Обрыв уже установленного соединения: чистим и уходим в переподключение.
if (_connection is { IsOpen: false })
{
_connection = null;
_replication.Clear();
ScheduleReconnect(now);
}
if (_connection is not null)
{
_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(
@@ -133,4 +152,13 @@ public sealed class MultiplayerScene : Scene
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 lang = content.Languages;
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();
_preset = presets[0];
+4
View File
@@ -24,6 +24,7 @@ internal sealed class PauseMenu
private readonly GameSpeed _speed;
private readonly GameHost _host;
private readonly AudioManager _audio;
private readonly Desktop _desktop;
private readonly Func<string> _onSave;
private readonly Action _onMainMenu;
private readonly Action _onQuit;
@@ -36,6 +37,7 @@ internal sealed class PauseMenu
EngineContext context,
GameContent content,
GameSpeed speed,
Desktop desktop,
Func<string> onSave,
Action onMainMenu,
Action onQuit
@@ -44,6 +46,7 @@ internal sealed class PauseMenu
_context = context;
_content = content;
_speed = speed;
_desktop = desktop;
_host = (GameHost)context.Services.Get<Game>();
_audio = context.Services.Get<AudioManager>();
_onSave = onSave;
@@ -144,6 +147,7 @@ internal sealed class PauseMenu
{
GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(_context, _desktop, _settings.UiScale); // масштаб UI — сразу
ShowSettings(); // язык мог смениться
},
onBack: () =>
+2 -1
View File
@@ -32,7 +32,7 @@ public sealed class SettingsScene : Scene
_host = (GameHost)Context.Services.Get<Game>();
var input = this.UseInput();
_desktop = this.UseUI();
_desktop = this.UseScaledUI();
Rebuild();
UpdateSystems.Add(
@@ -53,6 +53,7 @@ public sealed class SettingsScene : Scene
{
GameSettingsStore.Save(_settings);
GameSettingsStore.Apply(_settings, _host.Graphics, _content.Languages, _audio);
UiScaling.Apply(Context, _desktop, _settings.UiScale); // масштаб UI — сразу
Rebuild(); // язык мог смениться — перестраиваем подписи
}
+54 -5
View File
@@ -45,12 +45,23 @@ public sealed class WorldScene : Scene
/// <summary>Максимум растений на клетку — потолок плотности для размножения.</summary>
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 WorldSave? _save;
private readonly RectF _bounds;
private GameSpeed _speed = null!;
private PauseMenu _pause = null!;
private Selection _selection = null!;
private InspectPanel _inspect = null!;
private readonly List<Action> _speedRefreshers = [];
private PlantSet _plants = null!;
@@ -87,8 +98,13 @@ public sealed class WorldScene : Scene
GameLayers.EnsureRegistered(renderer);
this.UseTilemaps();
var calendar = Context.UseCalendar(SecondsPerDay);
var climate = Context.UseClimate(ClimateSettings.Default);
var calendar = Context.UseCalendar(SecondsPerDay, StartHour / 24.0);
var climate = Context.UseClimate(
ClimateSettings.Default with
{
StartDayOfYear = StartDayOfYear,
}
);
var dayNight = new DayNight(calendar, DayNightSettings.Default);
// Рельеф детерминирован сидом. Растения: новый мир — скаттер из сида; загрузка — из сейва.
@@ -121,18 +137,26 @@ public sealed class WorldScene : Scene
var camera = Store.CreateEntity(new Camera(_bounds.Center, zoom: 1f, bounds: _bounds));
// HUD, полоса скорости и меню-пауза в одной корневой панели.
var desktop = this.UseUI();
var desktop = this.UseScaledUI();
var hudLabel = new Label { Left = 10, Top = 8 };
var speedBar = BuildSpeedBar(content);
_pause = new PauseMenu(
Context,
content,
_speed,
desktop,
onSave: SaveWorld,
onMainMenu: () => Switch(new MainMenuScene()),
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, content, climate, _selection, brackets);
desktop.Root = Ui.Screen(hudLabel, _inspect.Panel, speedBar, _pause.Root);
this.UseInspector(renderer);
var console = this.UseDevConsole();
@@ -168,6 +192,7 @@ public sealed class WorldScene : Scene
Store,
_plants,
calendar,
climate,
Context.Clock,
_config.Width,
_config.Height,
@@ -181,6 +206,17 @@ public sealed class WorldScene : Scene
UpdateSystems.Add(
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(
new HudSystem(
Context,
@@ -309,7 +345,15 @@ public sealed class WorldScene : Scene
{
if (input.IsKeyPressed(Keys.Escape))
{
_pause.Toggle();
// Esc сначала снимает выделение (если есть), и только потом открывает меню-паузу.
if (_selection.HasSelection)
{
_selection.Clear();
}
else
{
_pause.Toggle();
}
}
if (_pause.IsOpen)
@@ -587,6 +631,11 @@ public sealed class WorldScene : Scene
$" vigor {traits.Vigor:0.##}, lifespan {traits.Lifespan:0} d, "
+ $"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)
{
+2 -1
View File
@@ -56,7 +56,8 @@ public static class PlantFactory
CellFertility = cellFertility,
},
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;
var light = lighting.SampleAt(t[i].Position);
// Температура — трапеция RimWorld: плато полного роста, жёсткие края (дормантность вне).
var (tMin, tLow, tHigh, tMax) = traits.TemperatureBand();
var rate =
traits.Vigor
* Suitability.Gaussian(light, traits.OptimalLight, traits.LightTolerance)
* Suitability.Gaussian(
temperature,
traits.OptimalTemperature,
traits.TemperatureTolerance
)
* Suitability.Trapezoid(temperature, tMin, tLow, tHigh, tMax)
* Suitability.Gaussian(
grow.CellFertility,
traits.OptimalFertility,
+50 -13
View File
@@ -2,6 +2,7 @@ using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using LittleSim.Content;
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using MrGameEng.Core;
using MrGameEng.Genetics;
using MrGameEng.Graphics;
@@ -9,21 +10,30 @@ using MrGameEng.Graphics;
namespace LittleSim.Sim;
/// <summary>
/// Жизненный цикл растений: размножение (наследование по Менделю) и смерть от старости. Запускается
/// не каждый кадр, а раз в <see cref="IntervalDays"/> игрового дня. За тик: строит по-клеточную сетку
/// (счётчик плотности + «представитель» вида/генома), собирает умерших по возрасту, затем зрелые с
/// вероятностью <c>elapsed/ReproduceInterval</c> сеют потомка — приоритет перекрёстного опыления
/// (зрелый сосед того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в
/// случайную клетку радиуса, если это суша и не превышен лимит плотности. Структурные изменения
/// (создание/удаление сущностей) применяются после проходов по запросу — это безопасно.
/// Жизненный цикл растений: размножение (наследование по Менделю), смерть от старости и от
/// температурного стресса (вымерзание/перегрев). Запускается не каждый кадр, а раз в
/// <see cref="IntervalDays"/> игрового дня. За тик: строит по-клеточную сетку (счётчик плотности +
/// «представитель» вида/генома), копит/рассасывает температурный стресс по текущей температуре,
/// собирает умерших (старость или превышенный стресс), затем зрелые с вероятностью
/// <c>elapsed/ReproduceInterval</c> сеют потомка — приоритет перекрёстного опыления (зрелый сосед
/// того же вида в радиусе расселения), иначе самоопыление по гену. Семя падает в случайную клетку
/// радиуса, если это суша и не превышен лимит плотности. Структурные изменения (создание/удаление
/// сущностей) применяются после проходов по запросу — это безопасно.
/// </summary>
public sealed class PlantLifecycleSystem : BaseSystem
{
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 PlantSet _plants;
private readonly Calendar _calendar;
private readonly Climate _climate;
private readonly GameClock _clock;
private readonly int _width;
private readonly int _height;
@@ -32,7 +42,12 @@ public sealed class PlantLifecycleSystem : BaseSystem
private readonly bool[] _cellLand;
private readonly int _densityCap;
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 CellRep[] _rep;
@@ -45,6 +60,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
EntityStore store,
PlantSet plants,
Calendar calendar,
Climate climate,
GameClock clock,
int width,
int height,
@@ -58,6 +74,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
_store = store;
_plants = plants;
_calendar = calendar;
_climate = climate;
_clock = clock;
_width = width;
_height = height;
@@ -66,7 +83,7 @@ public sealed class PlantLifecycleSystem : BaseSystem
_cellLand = cellLand;
_densityCap = densityCap;
_rng = new Random(seed);
_query = store.Query<PlantGrowth, PlantOrganism, Transform2D>();
_query = store.Query<PlantGrowth, PlantOrganism, Transform2D, TemperatureStress>();
_count = new int[width * height];
_rep = new CellRep[width * height];
}
@@ -82,13 +99,13 @@ public sealed class PlantLifecycleSystem : BaseSystem
var elapsed = _accumulator;
_accumulator = 0f;
BuildGridAndCollect();
BuildGridAndCollect(elapsed);
Reproduce(elapsed);
Apply();
}
// Проход 1: сетка плотности + представители, сбор умерших и зрелых-кандидатов.
private void BuildGridAndCollect()
// Проход 1: сетка плотности + представители, температурный стресс, сбор умерших и зрелых-кандидатов.
private void BuildGridAndCollect(float elapsed)
{
Array.Clear(_count);
for (var c = 0; c < _rep.Length; c++)
@@ -100,11 +117,14 @@ public sealed class PlantLifecycleSystem : BaseSystem
_candidates.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 orgs = organisms.Span;
var t = transforms.Span;
var st = stresses.Span;
for (var i = 0; i < g.Length; i++)
{
ref var grow = ref g[i];
@@ -115,6 +135,23 @@ public sealed class PlantLifecycleSystem : BaseSystem
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 cx = Math.Clamp((int)(pos.X / _cellSize), 0, _width - 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;
/// <summary>
/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>genes.json</c>):
/// Растительные признаки (фенотип), вычисленные из генома формулами генов (см. <c>Defs/Genes/</c>):
/// готовые значения, которые читают системы роста и жизненного цикла, не касаясь генов напрямую.
/// Имена полей соответствуют именам признаков в эффектах генов.
/// </summary>
@@ -15,6 +15,8 @@ public struct PlantPhenotype
public float LightTolerance;
public float OptimalTemperature;
public float TemperatureTolerance;
public float ColdHardiness;
public float HeatHardiness;
public float OptimalFertility;
public float FertilityTolerance;
public float Vigor;
@@ -33,6 +35,18 @@ public struct PlantPhenotype
/// <summary>Морфа: рецессивный вариант (выраженное значение гена морфы ≈ 1) — другой тинт спрайта.</summary>
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>
public static PlantPhenotype FromTraits(IReadOnlyDictionary<string, float> traits)
{
@@ -43,6 +57,8 @@ public struct PlantPhenotype
LightTolerance = T("lightTolerance"),
OptimalTemperature = T("optimalTemperature"),
TemperatureTolerance = T("temperatureTolerance"),
ColdHardiness = T("coldHardiness"),
HeatHardiness = T("heatHardiness"),
OptimalFertility = T("optimalFertility"),
FertilityTolerance = T("fertilityTolerance"),
Vigor = T("vigor"),
@@ -85,3 +101,15 @@ public struct Fruiting : IComponent
/// <summary>Накоплено зрелых плодов (0..FruitYield).</summary>
public float RipeFruit;
}
/// <summary>
/// Накопленный температурный стресс растения в игровых днях. Пока температура вне диапазона роста
/// (трапеция <see cref="PlantPhenotype.TemperatureBand"/> даёт ноль — слишком холодно или жарко),
/// стресс копится; внутри диапазона — рассасывается. Превышение порога убивает растение
/// (вымерзание/перегрев). Управляется <c>PlantLifecycleSystem</c>.
/// </summary>
public struct TemperatureStress : IComponent
{
/// <summary>Накоплено дней дормантности вне диапазона роста; при превышении лимита — гибель.</summary>
public float Days;
}
+114
View File
@@ -0,0 +1,114 @@
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 — так из пикинга выпадают спрайты рамки выделения и прочий не-контент.
private bool TryPick(Vector2 world, out int id)
{
id = -1;
var bestDistance = float.MaxValue;
foreach (
var (transforms, sprites, _, entities) in store
.Query<Transform2D, Sprite, PlantGrowth>()
.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;
}
}
+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)
{
// Точка мира под курсором до зума; после — сдвигаем камеру, чтобы она осталась там же.
// Якорь и базовая позиция берутся из одного кадра камеры: WorldCenter — это позиция
// ПОСЛЕ клампа к границам (то, что реально нарисовано), а не сырой camera.Position —
// иначе у краёв карты/при широком обзоре зум «уезжает» от курсора.
var anchor = renderer.ScreenToWorld(
new Vector2(input.MousePosition.X, input.MousePosition.Y)
);
var effective = renderer.Camera.WorldCenter;
camera.Zoom = newZoom;
camera.Position = anchor - (anchor - camera.Position) * (oldZoom / newZoom);
camera.Position = anchor - (anchor - effective) * (oldZoom / newZoom);
}
}
}
+308
View File
@@ -0,0 +1,308 @@
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,
}
private readonly EntityStore _store;
private readonly PlantSet _plants;
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,
GameContent content,
Climate climate,
Selection selection,
SelectionBrackets brackets
)
{
_store = store;
_plants = plants;
_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");
_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<PlantGrowth>()
|| !entity.HasComponent<PlantOrganism>()
|| !entity.HasComponent<Transform2D>()
|| !entity.HasComponent<Sprite>()
)
{
if (_selection.HasSelection)
{
_selection.Clear(); // сущность исчезла (растение погибло)
}
_panel.Visible = false;
_brackets.Hide();
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);
}
UpdateText(entity);
_panel.Visible = true;
}
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 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 ? " *" : "");
foreach (var (tab, button) in _tabs)
{
button.TextColor = tab == _tab ? Ui.Accent : Ui.Muted;
}
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 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 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));
+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;
}
}