Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47f99bc29 | ||
|
|
9c9502414b | ||
|
|
b9bb7006be | ||
|
|
c60ae072e9 | ||
|
|
f46ae15dc6 | ||
|
|
6b29a40b7f | ||
|
|
6f38d949ab | ||
|
|
cc1841c2e2 | ||
|
|
4fda693994 | ||
|
|
d712afa518 |
@@ -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" } }
|
||||
]
|
||||
}
|
||||
@@ -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" } }
|
||||
]
|
||||
}
|
||||
@@ -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" } }
|
||||
]
|
||||
}
|
||||
@@ -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 },
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "Pawn",
|
||||
// Общие (абстрактные) существа: база для жителей и животных.
|
||||
"defs": [
|
||||
{ "defName": "BaseBeing", "abstract": true, "kind": "being", "sizeCells": 1.1 },
|
||||
{ "defName": "BaseAnimal", "abstract": true, "kind": "animal" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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" } }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
|
||||
@@ -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": "переподключение…"
|
||||
}
|
||||
|
||||
@@ -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` — плоские словари «ключ → строка»:
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Roadmap LittleSim
|
||||
|
||||
Этап закрыт, когда механика работает, наблюдаема (HUD/консоль/визуал)
|
||||
и управляется командой консоли.
|
||||
|
||||
## Этап 0 — Каркас ✅
|
||||
- [x] Репозиторий с движком-сабмодулем, общий solution
|
||||
- [x] Генерация рельефа по сиду (вода/песок/трава/лес/горы)
|
||||
- [x] Жители-заглушки (блуждание), камера бога, HUD, консоль (`regen`)
|
||||
|
||||
## Этап 1 — Жители как агенты
|
||||
- [~] Потребности: голод, усталость (тикают со временем) — усталость/энергия готова (`PawnNeeds`), голод впереди
|
||||
- [~] Utility-выбор действия: искать еду / отдыхать / бродить — каркас на движке (`MrGameEng.AI`), выбор «отдыхать/бродить» работает; «искать еду» впереди
|
||||
- [ ] Еда на траве/в лесу: растёт, собирается, истощается
|
||||
- [ ] Смерть от голода; визуальное состояние жителя (цвет/прозрачность)
|
||||
- [ ] Консоль: `spawn <n>`, `feed`, `starve` для тестов
|
||||
|
||||
## Этап 2 — Общество
|
||||
- [ ] Дома и поселения (житель несёт еду домой, спит дома)
|
||||
- [ ] Размножение при сытости; рост населения
|
||||
- [ ] Простые профессии: собиратель, строитель
|
||||
|
||||
## Этап 3 — Бог
|
||||
- [ ] Вера как ресурс (генерируется жителями)
|
||||
- [ ] Первые силы: дождь (ускоряет рост еды), молния (поджигает лес)
|
||||
- [ ] Терраформинг клеток
|
||||
|
||||
## Этап 4 — Экология и время
|
||||
- [ ] День/ночь, сезоны
|
||||
- [ ] Восстановление/деградация ресурсов, пожары
|
||||
- [ ] Миграции при нехватке ресурсов
|
||||
|
||||
## Бэклог
|
||||
- Сохранение/загрузка мира (сериализация Friflo)
|
||||
- Фиксированный тик симуляции, независимый от рендера
|
||||
- Исторические графики (население, еда) в дев-оверлее
|
||||
@@ -1,84 +0,0 @@
|
||||
# Растения как экосистема
|
||||
|
||||
Дизайн системы растений: рост зависит от среды, среда описана факторами, реакция растения на
|
||||
факторы задаётся **генами**, а размножение передаёт гены потомкам с мутацией — появляется
|
||||
естественный отбор. Документ — единый ориентир для фаз A–D; детали каждой фазы уточняются её
|
||||
отдельным планом.
|
||||
|
||||
## Модель роста
|
||||
|
||||
Скорость роста растения в данный момент:
|
||||
|
||||
```
|
||||
скорость = база × пригодность(свет) × пригодность(температура) × пригодность(плодородность)
|
||||
```
|
||||
|
||||
Каждая «пригодность» — число в [0..1] из кривой отклика (движковый `ResponseCurve`, модуль
|
||||
`MrGameEng.AI`) с **оптимумом** и **шириной толерантности**, которые берутся из генов растения.
|
||||
Растение вне своих оптимумов растёт медленно или стоит; в своих — быстро. Так все факторы среды
|
||||
сводятся к одному механизму, а различие видов/особей — целиком в генах.
|
||||
|
||||
Фактор «возраст/стадия» остаётся как сейчас (`PlantGrowthStage` в дефах): пригодности умножают
|
||||
прирост `AgeDays`, стадии переключают спрайт/размер по накопленному возрасту.
|
||||
|
||||
## Факторы среды
|
||||
|
||||
- **Свет** — амбиент день/ночь (`MrGameEng.Lighting.DayNight`), сэмплируемый в точке
|
||||
(`SampleAt(world)`); глобальный в фазе A, локальный (с тенями) в фазе D.
|
||||
- **Температура** — непрерывная, из климата (`MrGameEng.Core.Climate`): сезонный синус по дню года
|
||||
+ суточные колебания (холоднее ночью).
|
||||
- **Плодородность** — пока константа типа террейна (`TerrainDef.Fertility`); позже возможна
|
||||
по-клеточная почва с истощением/восстановлением.
|
||||
|
||||
## Гены (диплоидный геном, гибридная экспрессия)
|
||||
|
||||
Каждое растение несёт **геном**: по паре аллелей на ген. Экспрессия гибридная:
|
||||
|
||||
- **Числовые гены** (оптимум света, толерантность к свету, оптимум температуры, толерантность,
|
||||
потребность в плодородности, макс. размер, базовая скорость, продолжительность жизни, дальность
|
||||
расселения, плодовитость, темп мутаций, способность к самоопылению) — фенотип = **смешение**
|
||||
(среднее двух аллелей). Реализм отбора: промежуточные значения, плавный дрейф.
|
||||
- **Дискретные гены** (например, цвет/морфа, наличие признака) — **доминант/рецессив**: рецессивный
|
||||
фенотип виден только в гомозиготе (aa).
|
||||
|
||||
Наследование (мейоз): каждый родитель отдаёт по одному случайному аллелю на ген; потомок получает
|
||||
по одному от каждого. Затем — мутация (с вероятностью из гена темпа мутаций сдвигаем числовой
|
||||
аллель/переключаем дискретный). Всё детерминировано сидом мира.
|
||||
|
||||
Стартовая популяция: геномы генерируются из сида (вид задаёт базовый геном и разброс аллелей в
|
||||
дефе; особи отклоняются).
|
||||
|
||||
## Размножение (полуреалистичное)
|
||||
|
||||
- **Перекрёстное опыление (приоритет):** зрелое растение ищет зрелого соседа того же вида в радиусе
|
||||
(ген дальности; запрос через spatial-hash движка). Есть партнёр → семя с геномом от двух
|
||||
родителей (Мендель + мутация).
|
||||
- **Самоопыление (запас):** нет партнёра и ген «способность к самоопылению» позволяет → семя из
|
||||
одного родителя (само-скрещивание аллелей + мутация).
|
||||
- **Расселение:** семя падает в клетку в радиусе расселения; прорастает, если клетка подходит
|
||||
(суша, не занято сверх лимита, пригодность не нулевая).
|
||||
- **Лимит плотности:** не больше N растений на клетку/площадь — иначе взрыв численности.
|
||||
- **Старение/смерть:** возраст превышает ген жизни → смерть; или длительный стресс (пригодность
|
||||
около нуля) → гибель. Смерть освобождает место — баланс с размножением и основа отбора.
|
||||
|
||||
## Производительность и сейв
|
||||
|
||||
- Представление — **по сущности на растение** (видно в рендере и ECS-инспекторе, индивидуальные
|
||||
гены). Защита от взрыва: лимит плотности, смерть, разумные радиусы, проверки размножения по
|
||||
таймеру/сезону (не каждый кадр на каждое растение), spatial-hash для соседей.
|
||||
- **Персист:** геномы, позиции, возраст и стадия растений сохраняются в `WorldSave` (мир больше не
|
||||
воспроизводится только из сида — эволюция переживает загрузку).
|
||||
- **Детерминизм:** мутации/расселение/опыление — на сидированном `Random`, как в остальной симуляции.
|
||||
|
||||
## Фазы реализации
|
||||
|
||||
- **A — климат и день/ночь (движок).** `Climate` (температура/сезон поверх `Calendar`),
|
||||
`DayNight` (амбиент, `Renderer2D.AmbientLight` на World-слоях, сэмплируемый `SampleAt`),
|
||||
затемнение мира ночью, HUD: сезон/температура/время. Фундамент для влияния среды на рост.
|
||||
- **B — гены и рост по пригодностям (игра).** Компонент `PlantGenome`, расчёт пригодностей
|
||||
(свет из `DayNight.SampleAt`, температура из `Climate`, плодородность из террейна), замена
|
||||
плоского `GrowthRate`. Геном виден/правится в инспекторе.
|
||||
- **C — размножение и смерть (игра).** Перекрёстное/самоопыление, расселение, лимит плотности,
|
||||
старение/стресс-смерть, персист популяции в сейв.
|
||||
- **D — полноценное 2D-освещение (движок).** Точечные источники + тени (окклюзия от препятствий);
|
||||
`DayNight.SampleAt` начинает возвращать локальную освещённость, и затенённые растения растут хуже.
|
||||
+1
-1
Submodule engine updated: d044cafad9...96e19c7c61
+3
-1
@@ -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
@@ -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 "$@"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.connect-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(12, 16, 24, 0.82);
|
||||
border-bottom: 1px solid rgba(108, 198, 255, 0.25);
|
||||
color: #d8e0ea;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.connect-bar .title {
|
||||
font-weight: 600;
|
||||
color: #6cc6ff;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.connect-bar .server {
|
||||
flex: 0 1 320px;
|
||||
min-width: 0;
|
||||
padding: 5px 9px;
|
||||
background: #0c1018;
|
||||
border: 1px solid #2a3850;
|
||||
border-radius: 4px;
|
||||
color: #e6edf6;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.connect-bar .server:focus {
|
||||
outline: none;
|
||||
border-color: #6cc6ff;
|
||||
}
|
||||
|
||||
.connect-bar .connect {
|
||||
padding: 5px 14px;
|
||||
background: #1c2c44;
|
||||
border: 1px solid #3a567f;
|
||||
border-radius: 4px;
|
||||
color: #d8e0ea;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.connect-bar .connect:hover {
|
||||
background: #25395a;
|
||||
border-color: #6cc6ff;
|
||||
}
|
||||
|
||||
.connect-bar .status {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.connect-bar .status.ok {
|
||||
color: #7fdca0;
|
||||
}
|
||||
|
||||
.connect-bar .status.warn {
|
||||
color: #f0c060;
|
||||
}
|
||||
|
||||
.connect-bar .status.idle {
|
||||
color: #8a96a6;
|
||||
}
|
||||
@@ -9,31 +9,74 @@ using MrGameEng.Net;
|
||||
|
||||
namespace LittleSim.Web;
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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(); // язык мог смениться — перестраиваем подписи
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -56,7 +56,8 @@ public static class PlantFactory
|
||||
CellFertility = cellFertility,
|
||||
},
|
||||
new PlantOrganism { Genome = genome, Traits = traits },
|
||||
new Fruiting()
|
||||
new Fruiting(),
|
||||
new TemperatureStress()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,14 +53,12 @@ public sealed class PlantGrowthSystem(
|
||||
|
||||
ref var traits = ref orgs[i].Traits;
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user