Compare commits
19 Commits
83afb8606f
...
port/godot
| Author | SHA1 | Date | |
|---|---|---|---|
| a802fb318f | |||
| 289c918d6c | |||
| b1fc3f244b | |||
| 6f47700820 | |||
| 116193c1e3 | |||
| 8e2efdd878 | |||
| bf0041605f | |||
| 83c6343783 | |||
| 2db442be7e | |||
| f7cadaeb68 | |||
| 97b49d4145 | |||
| 39117a09ed | |||
| 0ab4715aee | |||
| 479899d3d1 | |||
| 66055f9549 | |||
| 067038de45 | |||
| e1fb988969 | |||
| 44b2ec111f | |||
| 29657f73f8 |
@@ -3,6 +3,7 @@
|
|||||||
"id": "canidae",
|
"id": "canidae",
|
||||||
"name": "Canidae",
|
"name": "Canidae",
|
||||||
"kind": "predator",
|
"kind": "predator",
|
||||||
|
"description": "\"We were the first to hunt together, the first to howl in harmony, and the first to sit across from prey and call it diplomacy.\"\n\nPack-hunters who became civilization-builders, defined by social cohesion, hierarchical instinct, and supernatural scent-reading. Disproportionately represented in military, law enforcement, and governance.",
|
||||||
"ability_mods": { "CON": 1, "WIS": 1 },
|
"ability_mods": { "CON": 1, "WIS": 1 },
|
||||||
"languages": ["common", "canid"],
|
"languages": ["common", "canid"],
|
||||||
"traits": [
|
"traits": [
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"id": "felidae",
|
"id": "felidae",
|
||||||
"name": "Felidae",
|
"name": "Felidae",
|
||||||
"kind": "predator",
|
"kind": "predator",
|
||||||
|
"description": "\"We do not need your pack. We do not need your herd. We need only what is ours, and we will take it when you look away.\"\n\nPrecision predators built for explosive power, preternatural reflexes, and absolute independence. They dominate fields requiring solo brilliance — surgery, assassination, fine art, engineering.",
|
||||||
"ability_mods": { "DEX": 1, "CHA": 1 },
|
"ability_mods": { "DEX": 1, "CHA": 1 },
|
||||||
"languages": ["common", "felid"],
|
"languages": ["common", "felid"],
|
||||||
"traits": [
|
"traits": [
|
||||||
@@ -36,22 +38,24 @@
|
|||||||
"id": "mustelidae",
|
"id": "mustelidae",
|
||||||
"name": "Mustelidae",
|
"name": "Mustelidae",
|
||||||
"kind": "predator",
|
"kind": "predator",
|
||||||
|
"description": "\"Small doesn't mean weak. It means you have to be smarter, meaner, and faster than everyone who thinks size is the only thing that matters.\"\n\nThe most physically diverse Clade — weasel-folk, ferret-folk, badger-folk, wolverine-folk, otter-folk — united by furnace metabolisms, ferocity wildly disproportionate to body size, and a reputation for being impossible to pin down.",
|
||||||
"ability_mods": { "DEX": 1, "INT": 1 },
|
"ability_mods": { "DEX": 1, "INT": 1 },
|
||||||
"languages": ["common", "mustelid"],
|
"languages": ["common", "mustelid"],
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "sinuous_frame", "name": "Sinuous Frame", "description": "Squeeze through openings sized for one size category smaller without penalty. Advantage on checks to escape grapples and restraints." },
|
{ "id": "metabolic_furnace", "name": "Metabolic Furnace", "description": "Advantage on CON saves against cold environments and cold-based damage. (You must consume twice the normal daily ration to fuel this — see Burn Rate.)" },
|
||||||
{ "id": "burning_metabolism", "name": "Burning Metabolism", "description": "Advantage on saves vs. cold and exhaustion. Requires double rations to function (see equipment costs)." },
|
{ "id": "sinuous_body", "name": "Sinuous Body", "description": "Move through spaces sized one category smaller than your actual size without squeezing penalties. Advantage on checks to escape grapples." },
|
||||||
{ "id": "ferocity", "name": "Ferocity", "description": "When reduced below half HP, deal +1 damage on melee attacks until end of next turn. Triggers once per long rest." }
|
{ "id": "ferocity", "name": "Ferocity", "description": "When reduced to half HP or below, gain +2 to melee attack rolls. Mustelidae don't retreat — they get angrier." }
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "high_metabolism", "name": "High Metabolism", "description": "Requires double rations daily. Without enough food, gain a level of exhaustion every 12 hours instead of 24." },
|
{ "id": "burn_rate", "name": "Burn Rate", "description": "You require double standard rations. Without them, gain one level of exhaustion per day of insufficient feeding. Starvation hits Mustelidae twice as fast as any other Clade." },
|
||||||
{ "id": "scent_marker", "name": "Scent Marker", "description": "Mustelid musk is unmistakable and difficult to mask. Disadvantage on Stealth checks against creatures with scent abilities unless you have a deep-cover scent-mask active." }
|
{ "id": "short_fuse", "name": "Short Fuse", "description": "Disadvantage on WIS saves against effects that provoke rage, aggression, or recklessness. The gap between 'annoyed' and 'fighting' is dangerously short." }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ursidae",
|
"id": "ursidae",
|
||||||
"name": "Ursidae",
|
"name": "Ursidae",
|
||||||
"kind": "predator",
|
"kind": "predator",
|
||||||
|
"description": "\"The bear doesn't chase. The bear waits. And eventually, everything comes to the bear.\"\n\nThe largest sentient Clade. Slow to anger, slow to move, slow to decide — and catastrophically powerful when any of those things finally tips. Frequently underestimated intellectually because size reads as simplicity; in truth, Ursidae civilizations produced some of the finest philosophy, architecture, and brewing in the world.",
|
||||||
"ability_mods": { "DEX": -1, "CON": 2 },
|
"ability_mods": { "DEX": -1, "CON": 2 },
|
||||||
"languages": ["common", "ursid"],
|
"languages": ["common", "ursid"],
|
||||||
"traits": [
|
"traits": [
|
||||||
@@ -68,6 +72,7 @@
|
|||||||
"id": "cervidae",
|
"id": "cervidae",
|
||||||
"name": "Cervidae",
|
"name": "Cervidae",
|
||||||
"kind": "prey",
|
"kind": "prey",
|
||||||
|
"description": "\"We were the first to know that every shadow could be death. That knowledge made us sharper than any claw.\"\n\nDeer-folk, elk-folk, moose-folk — defined by hypervigilance, explosive athleticism, and a cultural philosophy forged in millennia of being hunted. They have turned survival into a technology, a philosophy, and occasionally a weapon.",
|
||||||
"ability_mods": { "DEX": 1, "WIS": 1 },
|
"ability_mods": { "DEX": 1, "WIS": 1 },
|
||||||
"languages": ["common", "cervid"],
|
"languages": ["common", "cervid"],
|
||||||
"traits": [
|
"traits": [
|
||||||
@@ -84,6 +89,7 @@
|
|||||||
"id": "bovidae",
|
"id": "bovidae",
|
||||||
"name": "Bovidae",
|
"name": "Bovidae",
|
||||||
"kind": "prey",
|
"kind": "prey",
|
||||||
|
"description": "\"The wall holds because we hold it. When the wolves came, we didn't run. We stood, shoulder to shoulder, horns out, and we held.\"\n\nBull-folk, bison-folk, ram-folk, goat-folk — the heaviest prey Clade, defined by communal endurance, raw physical power, and a cultural identity built on the principle that strength means staying.",
|
||||||
"ability_mods": { "STR": 1, "CON": 1 },
|
"ability_mods": { "STR": 1, "CON": 1 },
|
||||||
"languages": ["common", "bovid"],
|
"languages": ["common", "bovid"],
|
||||||
"traits": [
|
"traits": [
|
||||||
@@ -100,6 +106,7 @@
|
|||||||
"id": "leporidae",
|
"id": "leporidae",
|
||||||
"name": "Leporidae",
|
"name": "Leporidae",
|
||||||
"kind": "prey",
|
"kind": "prey",
|
||||||
|
"description": "\"We don't fight. We outrun, outbreed, outthink, and outlast. You want to call that weakness? We'll be here when you're gone.\"\n\nThe smallest common prey Clade — rabbit-folk and hare-folk — surviving through explosive speed, prodigious reproduction, community networks, and a cultural genius for making themselves indispensable. They dominate medicine, communications, logistics, and information brokering.",
|
||||||
"ability_mods": { "STR": -1, "DEX": 2 },
|
"ability_mods": { "STR": -1, "DEX": 2 },
|
||||||
"languages": ["common", "leporid"],
|
"languages": ["common", "leporid"],
|
||||||
"traits": [
|
"traits": [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{
|
{
|
||||||
"id": "fangsworn",
|
"id": "fangsworn",
|
||||||
"name": "Fangsworn",
|
"name": "Fangsworn",
|
||||||
|
"description": "\"I swore my fangs to something bigger than myself. Whether that was smart is a separate question.\"\n\nThe professional warrior. Trained combatants — soldiers, mercenaries, duelists, bodyguards, career fighters who turned violence into discipline. Fangsworn don't rely on rage or instinct; they rely on repetition, conditioning, and the brutal arithmetic of who hits harder and more often. Every army needs a backbone, and it's always been teeth.",
|
||||||
"hit_die": 10,
|
"hit_die": 10,
|
||||||
"primary_ability": ["STR", "DEX"],
|
"primary_ability": ["STR", "DEX"],
|
||||||
"saves": ["STR", "CON"],
|
"saves": ["STR", "CON"],
|
||||||
@@ -9,7 +10,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "martial", "natural"],
|
"weapon_proficiencies": ["simple", "martial", "natural"],
|
||||||
"tool_proficiencies": [],
|
"tool_proficiencies": [],
|
||||||
"skills_choose": 2,
|
"skills_choose": 2,
|
||||||
"skill_options": ["athletics", "intimidation", "perception", "survival", "animal_handling"],
|
"skill_options": ["athletics", "animal_handling", "brawl", "force", "intimidation", "marksmanship", "pain_tolerance", "perception", "survival"],
|
||||||
"subclass_ids": ["pack_forged", "lone_fang"],
|
"subclass_ids": ["pack_forged", "lone_fang"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "rend_sword", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "rend_sword", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
{
|
{
|
||||||
"id": "bulwark",
|
"id": "bulwark",
|
||||||
"name": "Bulwark",
|
"name": "Bulwark",
|
||||||
|
"description": "\"Running is smart. Standing is stupid. I do it anyway, because someone behind me can't run.\"\n\nThe wall. The shield. The one who stays when everyone else runs. Bulwarks are defenders first — born from the herd-fighting traditions of prey Clades, where bovid wall-tactics, cervid herd-coordination, and leporid community-shielding fused into a single protective doctrine. They hold the line, they take the hit, and they make sure the people behind them get to live.",
|
||||||
"hit_die": 12,
|
"hit_die": 12,
|
||||||
"primary_ability": ["CON"],
|
"primary_ability": ["CON"],
|
||||||
"saves": ["CON", "CHA"],
|
"saves": ["CON", "CHA"],
|
||||||
@@ -66,7 +68,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "martial", "natural"],
|
"weapon_proficiencies": ["simple", "martial", "natural"],
|
||||||
"tool_proficiencies": [],
|
"tool_proficiencies": [],
|
||||||
"skills_choose": 2,
|
"skills_choose": 2,
|
||||||
"skill_options": ["athletics", "insight", "intimidation", "medicine", "perception"],
|
"skill_options": ["athletics", "endurance", "fortitude", "hardiness", "insight", "intimidation", "medicine", "pain_tolerance", "perception"],
|
||||||
"subclass_ids": ["herd_wall", "antler_guard"],
|
"subclass_ids": ["herd_wall", "antler_guard"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "hoof_club", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "hoof_club", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
@@ -118,6 +120,7 @@
|
|||||||
{
|
{
|
||||||
"id": "feral",
|
"id": "feral",
|
||||||
"name": "Feral",
|
"name": "Feral",
|
||||||
|
"description": "\"You think civilization fixed us? It just taught us to hold our breath.\"\n\nThe old brain. The one before words. Every sentient creature carries the Feral Age inside them — the pre-sapient animal, pure instinct. Most people keep that door locked. Ferals open it. The implications disturb everyone, especially prey-Clade Ferals whose ancestors survived by suppressing those exact urges; the rage of a hunted thing that has decided, today, to stop running is its own kind of terrible.",
|
||||||
"hit_die": 12,
|
"hit_die": 12,
|
||||||
"primary_ability": ["STR", "CON"],
|
"primary_ability": ["STR", "CON"],
|
||||||
"saves": ["STR", "CON"],
|
"saves": ["STR", "CON"],
|
||||||
@@ -125,7 +128,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "natural"],
|
"weapon_proficiencies": ["simple", "natural"],
|
||||||
"tool_proficiencies": [],
|
"tool_proficiencies": [],
|
||||||
"skills_choose": 2,
|
"skills_choose": 2,
|
||||||
"skill_options": ["athletics", "intimidation", "nature", "perception", "survival"],
|
"skill_options": ["athletics", "brawl", "endurance", "fortitude", "hardiness", "haulage", "intimidation", "nature", "perception", "survival"],
|
||||||
"subclass_ids": ["blood_memory", "stampede_heart"],
|
"subclass_ids": ["blood_memory", "stampede_heart"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "paw_axe", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "paw_axe", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
@@ -180,6 +183,7 @@
|
|||||||
{
|
{
|
||||||
"id": "shadow_pelt",
|
"id": "shadow_pelt",
|
||||||
"name": "Shadow-Pelt",
|
"name": "Shadow-Pelt",
|
||||||
|
"description": "\"Everyone watches the fangs. Nobody watches the shadow behind the fangs.\"\n\nThieves, assassins, spies, scouts. Shadow-Pelts specialize in precision over force, infiltration over confrontation. The calling is cross-Clade by design — felid stealth, mustelid sinuousness, the Leporid art of being small enough to overlook. Any species that can move quietly and think two steps ahead can take this path. Most cities pretend they don't exist. Most cities are wrong.",
|
||||||
"hit_die": 8,
|
"hit_die": 8,
|
||||||
"primary_ability": ["DEX"],
|
"primary_ability": ["DEX"],
|
||||||
"saves": ["DEX", "INT"],
|
"saves": ["DEX", "INT"],
|
||||||
@@ -187,7 +191,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "hand_crossbow", "short_sword", "rapier", "natural"],
|
"weapon_proficiencies": ["simple", "hand_crossbow", "short_sword", "rapier", "natural"],
|
||||||
"tool_proficiencies": ["thieves_tools"],
|
"tool_proficiencies": ["thieves_tools"],
|
||||||
"skills_choose": 4,
|
"skills_choose": 4,
|
||||||
"skill_options": ["acrobatics", "athletics", "deception", "insight", "intimidation", "investigation", "perception", "persuasion", "sleight_of_hand", "stealth"],
|
"skill_options": ["acrobatics", "athletics", "build_read", "deception", "driving", "insight", "intimidation", "investigation", "marksmanship", "perception", "persuasion", "sleight_of_hand", "stealth"],
|
||||||
"subclass_ids": ["noseblind", "ambush_artist"],
|
"subclass_ids": ["noseblind", "ambush_artist"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "thorn_blade", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "thorn_blade", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
@@ -243,6 +247,7 @@
|
|||||||
{
|
{
|
||||||
"id": "scent_broker",
|
"id": "scent_broker",
|
||||||
"name": "Scent-Broker",
|
"name": "Scent-Broker",
|
||||||
|
"description": "\"You think information is what you hear? What you read? Amateur. Information is what you smell when someone walks into the room. Fear. Lies. Lust. Disease. Lineage. Every creature broadcasts their secrets with every breath. I just learned to listen.\"\n\nA calling unique to Theriapolis. Scent-Brokers are intelligence operatives, diplomats, perfume-mages — readers of pheromones and emotional scent. They weaponize the gap between what people say and what their bodies tell on them. Half merchant, half spy, all nose.",
|
||||||
"hit_die": 8,
|
"hit_die": 8,
|
||||||
"primary_ability": ["WIS"],
|
"primary_ability": ["WIS"],
|
||||||
"saves": ["WIS", "CHA"],
|
"saves": ["WIS", "CHA"],
|
||||||
@@ -250,7 +255,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "natural"],
|
"weapon_proficiencies": ["simple", "natural"],
|
||||||
"tool_proficiencies": ["alchemists_supplies", "perfumers_kit"],
|
"tool_proficiencies": ["alchemists_supplies", "perfumers_kit"],
|
||||||
"skills_choose": 3,
|
"skills_choose": 3,
|
||||||
"skill_options": ["deception", "insight", "investigation", "medicine", "perception", "persuasion", "stealth"],
|
"skill_options": ["build_read", "deception", "insight", "investigation", "lung_craft", "medicine", "perception", "persuasion", "scent_speak", "stealth"],
|
||||||
"subclass_ids": ["perfumer", "tracker"],
|
"subclass_ids": ["perfumer", "tracker"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "fang_knife", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "fang_knife", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
@@ -302,6 +307,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "covenant_keeper",
|
"id": "covenant_keeper",
|
||||||
|
"description": "\"The Covenant says we don't eat the speaking ones. I'm here to make sure everyone remembers.\"\n\nThe oath made flesh. The law with teeth. Covenant-Keepers are part judge, part priest, part enforcer — their authority comes from the Covenant of Claws, the sacred-legal compact that defines sentience, prohibits cannibalism between Clades, and binds civilization together. Where the Covenant is honored, they are diplomats and arbiters. Where it is broken, they are the answer.",
|
||||||
"name": "Covenant-Keeper",
|
"name": "Covenant-Keeper",
|
||||||
"hit_die": 10,
|
"hit_die": 10,
|
||||||
"primary_ability": ["CHA"],
|
"primary_ability": ["CHA"],
|
||||||
@@ -310,7 +316,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "martial", "natural"],
|
"weapon_proficiencies": ["simple", "martial", "natural"],
|
||||||
"tool_proficiencies": [],
|
"tool_proficiencies": [],
|
||||||
"skills_choose": 2,
|
"skills_choose": 2,
|
||||||
"skill_options": ["athletics", "insight", "intimidation", "medicine", "persuasion", "religion"],
|
"skill_options": ["athletics", "brawl", "force", "insight", "intimidation", "lung_craft", "medicine", "persuasion", "religion", "scent_speak"],
|
||||||
"subclass_ids": ["the_warden", "the_bridge"],
|
"subclass_ids": ["the_warden", "the_bridge"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "rend_sword", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "rend_sword", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
@@ -362,6 +368,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "muzzle_speaker",
|
"id": "muzzle_speaker",
|
||||||
|
"description": "\"The first howl that meant something other than hunger — that was the beginning of everything. I'm what happens when you take that gift seriously.\"\n\nVoice is power, always has been. Muzzle-Speakers are bards evolved for a world where vocalization varies wildly by Clade and subsonic communication is ancestral. They use voice, cadence, rhythm, and inter-species emotive frequencies to inspire, manipulate, heal, and harm. Songs that calm a stampeding herd, war-howls that rally a fractured pack, lullabies sung in registers older than language.",
|
||||||
"name": "Muzzle-Speaker",
|
"name": "Muzzle-Speaker",
|
||||||
"hit_die": 8,
|
"hit_die": 8,
|
||||||
"primary_ability": ["CHA"],
|
"primary_ability": ["CHA"],
|
||||||
@@ -370,7 +377,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "natural"],
|
"weapon_proficiencies": ["simple", "natural"],
|
||||||
"tool_proficiencies": ["musical_instrument", "musical_instrument_2", "musical_instrument_3"],
|
"tool_proficiencies": ["musical_instrument", "musical_instrument_2", "musical_instrument_3"],
|
||||||
"skills_choose": 3,
|
"skills_choose": 3,
|
||||||
"skill_options": ["acrobatics", "animal_handling", "arcana", "athletics", "deception", "history", "insight", "intimidation", "investigation", "medicine", "nature", "perception", "performance", "persuasion", "religion", "sleight_of_hand", "stealth", "survival"],
|
"skill_options": ["acrobatics", "animal_handling", "arcana", "athletics", "brawl", "build_read", "deception", "driving", "endurance", "force", "fortitude", "hardiness", "haulage", "history", "insight", "intimidation", "investigation", "lung_craft", "marksmanship", "medicine", "nature", "pain_tolerance", "perception", "performance", "persuasion", "religion", "scent_speak", "sleight_of_hand", "stealth", "survival"],
|
||||||
"subclass_ids": ["warhorn", "whisperfur"],
|
"subclass_ids": ["warhorn", "whisperfur"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "fang_knife", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "fang_knife", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
@@ -424,6 +431,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "claw_wright",
|
"id": "claw_wright",
|
||||||
|
"description": "\"Your body doesn't fit the chair? I'll build you a better chair. Your paws can't hold the scalpel? I'll build you a better scalpel. The world wasn't designed for you? I'll redesign the world.\"\n\nThe paw that builds. Engineers, inventors, adaptive-technology specialists. In a world of body diversity — paws, hooves, talons, beaks, dewclaws, prehensile tails — generalist designers are invaluable. Half mechanical, half medical, all problem-solver. They work in armories, workshops, prosthetics labs, and the back rooms of underground hybrid clinics.",
|
||||||
"name": "Claw-Wright",
|
"name": "Claw-Wright",
|
||||||
"hit_die": 8,
|
"hit_die": 8,
|
||||||
"primary_ability": ["INT"],
|
"primary_ability": ["INT"],
|
||||||
@@ -432,7 +440,7 @@
|
|||||||
"weapon_proficiencies": ["simple", "natural", "firearms"],
|
"weapon_proficiencies": ["simple", "natural", "firearms"],
|
||||||
"tool_proficiencies": ["tinkers_tools", "artisans_tools", "artisans_tools_2"],
|
"tool_proficiencies": ["tinkers_tools", "artisans_tools", "artisans_tools_2"],
|
||||||
"skills_choose": 3,
|
"skills_choose": 3,
|
||||||
"skill_options": ["arcana", "investigation", "medicine", "nature", "perception", "sleight_of_hand"],
|
"skill_options": ["arcana", "build_read", "driving", "force", "haulage", "investigation", "medicine", "nature", "perception", "sleight_of_hand"],
|
||||||
"subclass_ids": ["combat_engineer", "body_wright"],
|
"subclass_ids": ["combat_engineer", "body_wright"],
|
||||||
"starting_kit": [
|
"starting_kit": [
|
||||||
{ "item_id": "hoof_club", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
{ "item_id": "hoof_club", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||||
|
|||||||
+120
-27
@@ -3,6 +3,7 @@
|
|||||||
"id": "wolf",
|
"id": "wolf",
|
||||||
"clade_id": "canidae",
|
"clade_id": "canidae",
|
||||||
"name": "Wolf-Folk",
|
"name": "Wolf-Folk",
|
||||||
|
"description": "\"The apex of the Canid line. Big bodies, bigger presence. Every room they enter gets smaller.\"\n\nBroad-shouldered, heavy-boned, thick through chest and thigh. Dense double-coat in timber grey to midnight black, tawny brown to arctic white; tall triangular ears; expressive tail whose carriage telegraphs status reflexively.",
|
||||||
"size": "medium_large",
|
"size": "medium_large",
|
||||||
"ability_mods": { "STR": 1 },
|
"ability_mods": { "STR": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"id": "fox",
|
"id": "fox",
|
||||||
"clade_id": "canidae",
|
"clade_id": "canidae",
|
||||||
"name": "Fox-Folk",
|
"name": "Fox-Folk",
|
||||||
|
"description": "\"Smaller, quicker, cleverer — and never, ever forgetting that the wolves looked down on them for it.\"\n\nLean, narrow-framed, built for speed and agility over power. Typically red-orange with cream underbelly and black stockings, but silver, cross, marble, and arctic white morphs are common. Signature bushy white-tipped tail.",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"ability_mods": { "DEX": 1 },
|
"ability_mods": { "DEX": 1 },
|
||||||
"base_speed_ft": 35,
|
"base_speed_ft": 35,
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"id": "coyote",
|
"id": "coyote",
|
||||||
"clade_id": "canidae",
|
"clade_id": "canidae",
|
||||||
"name": "Coyote-Folk",
|
"name": "Coyote-Folk",
|
||||||
|
"description": "\"Adaptable. Resourceful. Everywhere you don't want them and thriving anyway.\"\n\nWiry and rangy — built like they survive on scraps and spite. Grizzled tawny-grey, sometimes sandy brown or dusty red. Lean musculature, narrow muzzle, unsettlingly direct yellow-gold gaze.",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"ability_mods": { "CHA": 1 },
|
"ability_mods": { "CHA": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
@@ -54,23 +57,43 @@
|
|||||||
"id": "lion",
|
"id": "lion",
|
||||||
"clade_id": "felidae",
|
"clade_id": "felidae",
|
||||||
"name": "Lion-Folk",
|
"name": "Lion-Folk",
|
||||||
|
"description": "\"The only Felidae who learned to be social — and they never let anyone forget they're still cats.\"\n\nPowerfully built, broad through chest and shoulder. Short tawny-gold to warm brown fur. Males sport manes ranging tawny gold to deep brown-black, framing the face and shielding the throat; the gaze of something that has never been prey.",
|
||||||
"size": "medium_large",
|
"size": "medium_large",
|
||||||
"ability_mods": { "STR": 1 },
|
"ability_mods": { "STR": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "commanding_presence", "name": "Commanding Presence", "description": "Proficiency in Intimidation (expertise if already proficient). When intimidating, may roar — creatures within 15 ft. who hear it make a WIS save (DC = 8 + prof + CHA) or are frightened until end of next turn. Once per short rest." },
|
{ "id": "commanding_presence", "name": "Commanding Presence", "description": "Proficiency in Intimidation (expertise if already proficient). When intimidating, may roar — creatures within 15 ft. who hear it make a WIS save (DC = 8 + prof + CHA) or are frightened until end of next turn. Once per short rest." },
|
||||||
{ "id": "pride_fighter", "name": "Pride Fighter", "description": "Lion-folk can both grant and benefit from flanking. When you and an ally are adjacent to the same enemy, both gain +2 to attack rolls against that enemy." },
|
{ "id": "pride_fighter", "name": "Pride Fighter", "description": "Lion-folk can both grant and benefit from flanking. When you and an ally are adjacent to the same enemy, both gain +2 to attack rolls against that enemy." }
|
||||||
{ "id": "mane_guard", "name": "Mane Guard", "description": "+1 AC against attacks targeting the neck or throat." }
|
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "territorial_ego", "name": "Territorial Ego", "description": "Disadvantage on CHA (Persuasion) when negotiating shared resources, territory, or leadership positions." },
|
{ "id": "territorial_ego", "name": "Territorial Ego", "description": "Disadvantage on CHA (Persuasion) when negotiating shared resources, territory, or leadership positions." },
|
||||||
{ "id": "heat_lethargy", "name": "Heat Lethargy", "description": "In temperatures above 90°F, CON save (DC 10) every hour of strenuous activity or gain a level of exhaustion." }
|
{ "id": "heat_lethargy", "name": "Heat Lethargy", "description": "In temperatures above 90°F, CON save (DC 10) every hour of strenuous activity or gain a level of exhaustion." }
|
||||||
|
],
|
||||||
|
"variant_axis": "sex",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"id": "male",
|
||||||
|
"name": "Maned",
|
||||||
|
"traits": [
|
||||||
|
{ "id": "mane_guard", "name": "Mane Guard", "description": "+1 AC against attacks targeting the neck or throat. The mane is armor that grew there on its own." }
|
||||||
|
],
|
||||||
|
"detriments": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "female",
|
||||||
|
"name": "Maneless",
|
||||||
|
"traits": [
|
||||||
|
{ "id": "huntress_reflexes", "name": "Huntress Reflexes", "description": "Base speed +5 ft. Advantage on initiative rolls. Lionesses do the hunting in the pride for a reason." }
|
||||||
|
],
|
||||||
|
"detriments": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "leopard",
|
"id": "leopard",
|
||||||
"clade_id": "felidae",
|
"clade_id": "felidae",
|
||||||
"name": "Leopard-Folk",
|
"name": "Leopard-Folk",
|
||||||
|
"description": "\"You won't hear them. You won't see them. And then it's too late for either.\"\n\nLean, densely muscled, compact — every ounce of weight is functional. Golden-yellow to tawny with distinctive black rosettes (melanistic individuals all-black). Powerful shoulders for climbing and grappling.",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"ability_mods": { "DEX": 1 },
|
"ability_mods": { "DEX": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
@@ -88,6 +111,7 @@
|
|||||||
"id": "housecat",
|
"id": "housecat",
|
||||||
"clade_id": "felidae",
|
"clade_id": "felidae",
|
||||||
"name": "Housecat-Folk",
|
"name": "Housecat-Folk",
|
||||||
|
"description": "\"Small. Overlooked. Absolutely lethal in ways you'll never trace back to them.\"\n\nFine-boned and quick, built for spaces no one else fits into. Enormous coat variety — tabby, calico, solid, tuxedo, tortoiseshell, pointed. Extremely flexible spine; can fit through any opening their skull will pass.",
|
||||||
"size": "small",
|
"size": "small",
|
||||||
"ability_mods": { "INT": 1 },
|
"ability_mods": { "INT": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
@@ -105,93 +129,134 @@
|
|||||||
"id": "ferret",
|
"id": "ferret",
|
||||||
"clade_id": "mustelidae",
|
"clade_id": "mustelidae",
|
||||||
"name": "Ferret-Folk",
|
"name": "Ferret-Folk",
|
||||||
|
"description": "\"Charming, manic, and fundamentally incapable of leaving anything alone.\"\n\nLong-bodied and slinky, built like the skeleton is a suggestion. Sable, albino, champagne, silver, dark-eyed white; mask-face markings common. The whole body moves like a sine wave; sweet-musky scent fills a room.",
|
||||||
"size": "small",
|
"size": "small",
|
||||||
"ability_mods": { "CHA": 1 },
|
"ability_mods": { "CHA": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "weaver", "name": "Weaver", "description": "Move through any opening at least 6 inches wide. No penalty for combat in cramped spaces (tunnels, crawlspaces)." },
|
{ "id": "war_dance", "name": "War Dance", "description": "Bonus action: weave erratically. Until the start of your next turn, attack rolls against you have disadvantage if you moved at least 15 ft. this turn. Uses equal to proficiency bonus per long rest." },
|
||||||
{ "id": "social_charm", "name": "Social Charm", "description": "Advantage on Deception and Persuasion checks against creatures who underestimate you for your size." }
|
{ "id": "tunnel_runner", "name": "Tunnel Runner", "description": "Burrow speed of 15 ft. in loose soil or sand. Navigate tunnels and underground spaces without penalty. Advantage on checks to find or create underground passages." },
|
||||||
|
{ "id": "irrepressible", "name": "Irrepressible", "description": "Advantage on saves against the frightened condition. Ferret-folk have a pathological inability to take threats as seriously as they should." }
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "small_frame", "name": "Small Frame", "description": "Carrying capacity halved. Heavy weapons cannot be used effectively." }
|
{ "id": "attention_deficit", "name": "Attention Deficit", "description": "Disadvantage on checks requiring sustained concentration outside of combat (extended research, long stakeouts, detailed crafting over 4+ hours). Your focus is explosive, not sustained." },
|
||||||
|
{ "id": "musk_broadcast", "name": "Musk Broadcast", "description": "Under stress, fear, or arousal, your scent intensifies involuntarily. Stealth checks in these states are made with disadvantage, and creatures with scent abilities automatically know your emotional state." }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "badger",
|
"id": "badger",
|
||||||
"clade_id": "mustelidae",
|
"clade_id": "mustelidae",
|
||||||
"name": "Badger-Folk",
|
"name": "Badger-Folk",
|
||||||
|
"description": "\"Not big. Not fast. Just absolutely unwilling to stop.\"\n\nStocky, low center of gravity, dense muscle on a compact frame with disproportionately wide shoulders. Coarse bristly fur, classic black-and-white facial striping. Heavy digging claws. Built like a bunker with teeth.",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"ability_mods": { "CON": 1 },
|
"ability_mods": { "CON": 1 },
|
||||||
"base_speed_ft": 25,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "burrower", "name": "Burrower", "description": "Burrow speed of 10 ft. through loose soil, sand, or snow. Cannot burrow through stone or hardpacked earth." },
|
{ "id": "immovable", "name": "Immovable", "description": "Advantage on STR and CON saves against being knocked prone, pushed, or forcibly moved. Your low center of gravity and dense build make displacement extremely difficult." },
|
||||||
{ "id": "tenacious_grip", "name": "Tenacious Grip", "description": "Advantage on grapple attempts. Targets you grapple have disadvantage on checks to escape." }
|
{ "id": "digging_claws", "name": "Digging Claws", "description": "Burrow speed of 20 ft. Claw attacks deal 1d6 + STR slashing. Advantage on checks to break through barriers, dig through obstacles, or demolish structures." },
|
||||||
|
{ "id": "relentless_endurance", "name": "Relentless Endurance", "description": "When you take damage that would reduce you to 0 HP, reaction: CON save (DC = 10 + damage taken). On success, drop to 1 HP instead. Once per long rest." }
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "stocky_build", "name": "Stocky Build", "description": "Base speed 25 ft. Disadvantage on long-jump checks." }
|
{ "id": "tunnel_vision", "name": "Tunnel Vision", "description": "When engaged with a single target, disadvantage on Perception checks to notice other threats. Badger-folk lock on and everything else goes dark." },
|
||||||
|
{ "id": "antisocial_default", "name": "Antisocial Default", "description": "Disadvantage on CHA checks in social gatherings of 6+ people. Not shy — actively irritated by crowds." }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wolverine",
|
"id": "wolverine",
|
||||||
"clade_id": "mustelidae",
|
"clade_id": "mustelidae",
|
||||||
"name": "Wolverine-Folk",
|
"name": "Wolverine-Folk",
|
||||||
|
"description": "\"Nature's proof that fury is a viable survival strategy.\"\n\nDense, heavy-boned, thick through torso and limbs. Dark brown to black, often with a pale lateral stripe shoulder to hip. Frost-resistant ruff. The eyes of something that has calculated whether it can take you and decided yes.",
|
||||||
"size": "medium_large",
|
"size": "medium_large",
|
||||||
"ability_mods": { "STR": 1 },
|
"ability_mods": { "STR": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "savage_jaws", "name": "Savage Jaws", "description": "Unarmed bite deals 1d8 + STR piercing. On a critical hit, the wound bleeds: 1d4 damage at the start of the target's turn for 2 turns." },
|
{ "id": "wolverine_frenzy", "name": "Wolverine's Frenzy", "description": "Once per long rest, bonus action: enter a frenzy for 1 minute. While frenzied, gain resistance to all damage except psychic. (Distinct from the Feral class's Rage; renamed to avoid confusion. Doc: 'Feral Rage'.)" },
|
||||||
{ "id": "indomitable_ferocity", "name": "Indomitable Ferocity", "description": "When reduced to 0 HP, drop to 1 HP instead. Once per long rest." }
|
{ "id": "jaws_of_iron", "name": "Jaws of Iron", "description": "Bite attack deals 1d8 + STR piercing and can target objects. Chew through rope, leather, and soft wood as an action. Hard materials (metal, stone) take 1 minute per inch." },
|
||||||
|
{ "id": "arctic_survivor", "name": "Arctic Survivor", "description": "Immunity to the effects of extreme cold environments. Leave no tracks in snow. Advantage on Survival checks in tundra, mountain, and arctic terrain." }
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "feared_kin", "name": "Feared Kin", "description": "Disadvantage on CHA (Persuasion) checks with non-Mustelid creatures who recognize your species. Wolverine reputation precedes you." }
|
{ "id": "berserkers_toll", "name": "Berserker's Toll", "description": "After Wolverine's Frenzy ends, gain one level of exhaustion. The fire burns hot but the crash is real." },
|
||||||
|
{ "id": "feared_not_loved", "name": "Feared, Not Loved", "description": "Disadvantage on CHA (Persuasion) checks with creatures who know what you are. Wolverine-folk's reputation precedes them, and it is not a friendly reputation." }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "brown_bear",
|
"id": "brown_bear",
|
||||||
"clade_id": "ursidae",
|
"clade_id": "ursidae",
|
||||||
"name": "Brown Bear-Folk",
|
"name": "Brown Bear-Folk",
|
||||||
|
"description": "\"The standard by which all bears measure themselves, and everyone else measures trouble.\"\n\nEnormous shoulder hump of muscle, broad everywhere — the physically largest common species in the world. Dense, coarse, shaggy fur from honey-blonde to chocolate to grizzled silver-tip. Warm in calm; terrifying when not.",
|
||||||
"size": "large",
|
"size": "large",
|
||||||
"ability_mods": { "STR": 1 },
|
"ability_mods": { "STR": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "rending_claws", "name": "Rending Claws", "description": "Unarmed claw attacks deal 1d8 + STR slashing. Two-paw rend: if both claw attacks hit the same target in one Attack action, deal an extra 1d6 damage." },
|
{ "id": "devastating_swipe", "name": "Devastating Swipe", "description": "Unarmed claw attack deals 2d6 + STR slashing. On a hit, you may choose to forgo damage and instead shove the target up to 10 ft. in any direction." },
|
||||||
{ "id": "winter_hibernation", "name": "Winter Hibernation", "description": "Once per year, enter a deep restorative sleep for 1d4 weeks. On waking, fully heal and remove all levels of exhaustion." }
|
{ "id": "foragers_nose", "name": "Forager's Nose", "description": "Advantage on Survival checks to find food in any natural environment. Identify edible vs. toxic plants and fungi by scent." },
|
||||||
|
{ "id": "stubborn_vitality", "name": "Stubborn Vitality", "description": "Advantage on death saving throws." }
|
||||||
],
|
],
|
||||||
"detriments": []
|
"detriments": [
|
||||||
|
{ "id": "slow_burn", "name": "Slow Burn", "description": "Act last in the first round of any combat where you were not expecting a fight (initiative count 0, regardless of roll). Ursid threat-processing takes a moment to spool up." },
|
||||||
|
{ "id": "accidental_destruction","name": "Accidental Destruction","description": "When you fail a DEX check by 5 or more while interacting with objects or structures not built for your size, you break them. Tools, furniture, delicate mechanisms, occasionally other people's belongings." }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "polar_bear",
|
"id": "polar_bear",
|
||||||
"clade_id": "ursidae",
|
"clade_id": "ursidae",
|
||||||
"name": "Polar Bear-Folk",
|
"name": "Polar Bear-Folk",
|
||||||
|
"description": "\"Born in the white silence. Everything about them is patience and stored violence.\"\n\nSlightly leaner than brown bear-folk through the hip, broader through the chest, built for swimming and cold-weather endurance. White to cream-yellow guard hairs over a dense water-resistant undercoat; the skin beneath is black.",
|
||||||
"size": "large",
|
"size": "large",
|
||||||
"ability_mods": { "WIS": 1 },
|
"ability_mods": { "WIS": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "arctic_adaptation", "name": "Arctic Adaptation", "description": "Resistance to cold damage. Immunity to environmental cold effects. Swim speed equal to walking speed." },
|
{ "id": "arctic_apex", "name": "Arctic Apex", "description": "Immunity to cold damage and cold environments. Swim speed of 30 ft. Hold your breath for 15 minutes." },
|
||||||
{ "id": "white_pelt", "name": "White Pelt", "description": "Advantage on Stealth checks in snow, ice, or arctic terrain." }
|
{ "id": "patient_hunter", "name": "Patient Hunter", "description": "If you do not move during your turn, your next melee attack before the end of your next turn deals an additional 1d8 damage. Stillness precedes the strike." },
|
||||||
|
{ "id": "insulating_bulk", "name": "Insulating Bulk", "description": "Resistance to non-magical bludgeoning damage. Layers of fat and fur absorb impact." }
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "polar_appetite", "name": "Polar Appetite", "description": "Requires triple rations daily. Without them, gain a level of exhaustion every 8 hours." }
|
{ "id": "heat_vulnerable", "name": "Heat Vulnerable", "description": "In temperatures above 75°F / 24°C, CON save (DC 12) every hour or gain exhaustion. Above 90°F, the DC increases to 15. Polar bear-folk suffer in warm climates." },
|
||||||
|
{ "id": "resource_hungry", "name": "Resource Hungry", "description": "Requires triple standard rations (caloric needs are massive). Failure to meet this imposes exhaustion at an accelerated rate." }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "elk",
|
"id": "elk",
|
||||||
"clade_id": "cervidae",
|
"clade_id": "cervidae",
|
||||||
"name": "Elk-Folk",
|
"name": "Elk-Folk",
|
||||||
|
"description": "\"Herd-builders, wall-builders, civilization-builders. The hooves that stamped order into chaos.\"\n\nTall and long-legged, powerful through the haunches and chest. Tawny brown body, darker neck, pale rump patch. Males grow impressive branching antlers — broad, branching, shed and regrown annually — used in display, defense, and cultural adornment.",
|
||||||
"size": "medium_large",
|
"size": "medium_large",
|
||||||
"ability_mods": { "STR": 1 },
|
"ability_mods": { "STR": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 40,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "majestic_antlers", "name": "Majestic Antlers", "description": "Antler attack deals 1d8 + STR piercing. Charging attack: if you move at least 20 ft. straight before attacking, deal +1d6 damage and target makes a STR save (DC = 8 + prof + STR) or is knocked back 5 ft." }
|
{ "id": "herd_coordination", "name": "Herd Coordination", "description": "When you take the Help action to assist an ally's check or attack, the ally gains a +3 bonus instead of advantage. Cervidae herd instinct made tactical." },
|
||||||
|
{ "id": "endurance_runner", "name": "Endurance Runner", "description": "Base speed 40 ft. Advantage on CON saves against forced march, exhaustion from prolonged movement, and effects that would slow your pace on open ground." }
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "antler_drag", "name": "Antler Drag", "description": "During antler-shed season (1 month per year), antlers fall off — antler attack damage reduced by 1 die step until they regrow." }
|
{ "id": "herd_instinct", "name": "Herd Instinct", "description": "When an allied creature within 30 ft. takes the Dash action to flee combat, WIS save (DC 12) or use your reaction to Dash in the same direction. The herd moves together — even when only some of it should." }
|
||||||
|
],
|
||||||
|
"variant_axis": "sex",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"id": "male",
|
||||||
|
"name": "Bull",
|
||||||
|
"traits": [
|
||||||
|
{ "id": "antler_combat", "name": "Antler Combat", "description": "Antler attack deals 1d8 + STR piercing. On a hit, you may forgo damage to shove the target 5 ft. While the full rack is grown (outside antler-shed season), antler attacks have reach 10 ft." }
|
||||||
|
],
|
||||||
|
"detriments": [
|
||||||
|
{ "id": "antler_drag", "name": "Antler Drag", "description": "During antler-shed season (1 month per year), antlers fall off — antler attack damage reduced by 1 die step and the 10 ft. reach is lost until they regrow." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "female",
|
||||||
|
"name": "Cow",
|
||||||
|
"traits": [
|
||||||
|
{ "id": "kick", "name": "Kick", "description": "Hooved kick attack deals 1d8 + STR bludgeoning. On a critical hit, the target is knocked prone. The herd's other answer to threats." }
|
||||||
|
],
|
||||||
|
"detriments": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "deer",
|
"id": "deer",
|
||||||
"clade_id": "cervidae",
|
"clade_id": "cervidae",
|
||||||
"name": "Deer-Folk",
|
"name": "Deer-Folk",
|
||||||
|
"description": "\"Quiet, cautious, and always closer to gone than you realize.\"\n\nLean, graceful, built entirely for speed and evasion — not frail, just efficient. Reddish-brown in warm seasons, grey-brown in cold; white underbelly with a white tail-flag. Wide-set lateral eyes with peripheral vision approaching 310 degrees.",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"ability_mods": { "DEX": 1 },
|
"ability_mods": { "DEX": 1 },
|
||||||
"base_speed_ft": 35,
|
"base_speed_ft": 35,
|
||||||
@@ -208,19 +273,25 @@
|
|||||||
"id": "moose",
|
"id": "moose",
|
||||||
"clade_id": "cervidae",
|
"clade_id": "cervidae",
|
||||||
"name": "Moose-Folk",
|
"name": "Moose-Folk",
|
||||||
|
"description": "\"The exception to every rule about prey being fragile. Eight feet of 'try it.'\"\n\nMassive — thick-bodied, heavy-shouldered, long-legged — the largest Cervidae species, rivaling Ursidae in sheer mass. Dark brown to black, coarse and shaggy, with a loose dewlap at the throat. Males carry enormous palmate antlers that force doorway accommodations.",
|
||||||
"size": "large",
|
"size": "large",
|
||||||
"ability_mods": { "CON": 1 },
|
"ability_mods": { "CON": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "broad_antlers", "name": "Broad Antlers", "description": "Antler attack deals 1d10 + STR piercing. Charging attack: if you move at least 20 ft. straight before attacking, target makes a STR save (DC = 8 + prof + STR) or is knocked prone." },
|
{ "id": "dont_tread_on_me", "name": "Don't Tread on Me", "description": "Count as one size larger for grappling, shoving, and resisting forced movement. Creatures that hit you with a melee attack within 5 ft. provoke a free kick (1d8 + STR) as a reaction. Uses equal to proficiency bonus per long rest." },
|
||||||
{ "id": "swamp_strider", "name": "Swamp Strider", "description": "No movement penalty in marsh, mud, snow, or shallow water." }
|
{ "id": "palmate_antlers", "name": "Palmate Antlers", "description": "Antler attack deals 2d6 + STR bludgeoning. On a critical hit, the target is stunned until the end of their next turn. These antlers are not decorative." },
|
||||||
|
{ "id": "wetland_wader", "name": "Wetland Wader", "description": "No movement penalty in difficult terrain caused by water, mud, or swamp. Swim speed of 20 ft. Hold your breath for 5 minutes." }
|
||||||
],
|
],
|
||||||
"detriments": []
|
"detriments": [
|
||||||
|
{ "id": "ursid_scale_problems", "name": "Ursid-Scale Problems", "description": "As a Large creature, you share many of the infrastructure problems of Ursidae — standard furniture, doorways, and vehicles are too small. Disadvantage on DEX (Stealth) checks — you are not subtle." },
|
||||||
|
{ "id": "solitary_cervid", "name": "Solitary Cervid", "description": "Unlike most Cervidae, moose-folk are not herd-oriented. You do not benefit from Herd Coordination effects and have disadvantage on CHA checks in groups larger than 4. You don't do committees." }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "rabbit",
|
"id": "rabbit",
|
||||||
"clade_id": "leporidae",
|
"clade_id": "leporidae",
|
||||||
"name": "Rabbit-Folk",
|
"name": "Rabbit-Folk",
|
||||||
|
"description": "\"They built the warren-cities, the message networks, the emergency medical system. Underestimate them. They're counting on it.\"\n\nCompact and soft-featured, with deceptively powerful hindquarters. Enormous coat variety. Long ears (upright or lopping by lineage — culturally significant) and a constantly twitching nose.",
|
||||||
"size": "small",
|
"size": "small",
|
||||||
"ability_mods": { "WIS": 1 },
|
"ability_mods": { "WIS": 1 },
|
||||||
"base_speed_ft": 40,
|
"base_speed_ft": 40,
|
||||||
@@ -236,6 +307,7 @@
|
|||||||
"id": "hare",
|
"id": "hare",
|
||||||
"clade_id": "leporidae",
|
"clade_id": "leporidae",
|
||||||
"name": "Hare-Folk",
|
"name": "Hare-Folk",
|
||||||
|
"description": "\"Not rabbits. Bigger. Wilder. Born on the surface, not in a burrow. There's a difference, and they will tell you about it.\"\n\nLeaner and rangier than rabbit-folk — longer limbs, longer ears, longer stride. Tawny brown, grey, or seasonally white. Build is more runner than burrower; ears tipped black; gaze sharp and confrontational.",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"ability_mods": { "CON": 1 },
|
"ability_mods": { "CON": 1 },
|
||||||
"base_speed_ft": 45,
|
"base_speed_ft": 45,
|
||||||
@@ -253,6 +325,7 @@
|
|||||||
"id": "bull",
|
"id": "bull",
|
||||||
"clade_id": "bovidae",
|
"clade_id": "bovidae",
|
||||||
"name": "Bull-Folk",
|
"name": "Bull-Folk",
|
||||||
|
"description": "\"Big. Patient. And when patience runs out, apocalyptic.\"\n\nMassive, thick-necked, broad-shouldered, heavy through chest and belly — not fat, dense; everything about them says immovable. Heavy horns curving outward and up. Hooves instead of paws — hard, split, built for impact.",
|
||||||
"size": "large",
|
"size": "large",
|
||||||
"ability_mods": { "STR": 1 },
|
"ability_mods": { "STR": 1 },
|
||||||
"base_speed_ft": 25,
|
"base_speed_ft": 25,
|
||||||
@@ -267,16 +340,35 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ram",
|
"id": "sheep",
|
||||||
"clade_id": "bovidae",
|
"clade_id": "bovidae",
|
||||||
"name": "Ram-Folk",
|
"name": "Sheep-Folk",
|
||||||
|
"description": "\"We climb because the mountain asked us to. We grow wool because the wind asked us to. We come back, again and again, because that's what the herd is for.\"\n\nStocky and compact, lower center of gravity than bull-folk — powerful especially through the hindquarters. Heavy fleece, tightly curled and lanolin-rich, naturally weather-shedding. Spiral or sweeping horns, cloven hooves, horizontal-slit pupils. Sheep-folk built the high pastures and the wool trade that supports half the world's textile economy.",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"ability_mods": { "WIS": 1 },
|
"ability_mods": { "WIS": 1 },
|
||||||
"base_speed_ft": 30,
|
"base_speed_ft": 30,
|
||||||
"traits": [
|
"traits": [
|
||||||
{ "id": "mountain_born", "name": "Mountain Born", "description": "Climb speed equal to walking speed. Immune to altitude sickness. Advantage on DEX checks and saves on narrow, unstable, or steep surfaces." },
|
{ "id": "mountain_born", "name": "Mountain Born", "description": "Climb speed equal to walking speed. Immune to altitude sickness. Advantage on DEX checks and saves on narrow, unstable, or steep surfaces." },
|
||||||
{ "id": "headbutt", "name": "Headbutt", "description": "Horn attack deals 1d10 + STR when using Charge (20-ft. run-up). Target hit must make a CON save (DC = 8 + prof + STR) or be dazed (disadvantage on next attack roll)." },
|
{ "id": "headbutt", "name": "Headbutt", "description": "Horn attack deals 1d10 + STR when using Charge (20-ft. run-up). Target hit must make a CON save (DC = 8 + prof + STR) or be dazed (disadvantage on next attack roll)." },
|
||||||
{ "id": "wool_insulation", "name": "Wool Insulation", "description": "Resistance to cold damage. Advantage on saves against cold environments." }
|
{ "id": "wool_insulation", "name": "Wool Insulation", "description": "Resistance to cold damage. Advantage on saves against cold environments. The fleece does what the fleece is for." }
|
||||||
|
],
|
||||||
|
"detriments": [
|
||||||
|
{ "id": "horizontal_pupils", "name": "Horizontal Pupils", "description": "Disadvantage on Perception checks requiring depth perception at distances greater than 60 ft." },
|
||||||
|
{ "id": "herd_mentality", "name": "Herd Mentality", "description": "When 3+ visible allies are moving in a direction, WIS save (DC 10) or feel compelled to move with them." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "goat",
|
||||||
|
"clade_id": "bovidae",
|
||||||
|
"name": "Goat-Folk",
|
||||||
|
"description": "\"Yes, I can stand on that. Yes, I can eat that. Yes, I am going to.\"\n\nLeaner and more angular than sheep-folk, athletic through the shoulder, with the same low center of gravity. Coarse hair instead of wool. Curving horns swept back. Horizontal-slit pupils. Goat-folk thrive where nothing else can — desert mesas, cliff edges, salt marshes, the upper slopes where the air thins and the rocks don't hold.",
|
||||||
|
"size": "medium",
|
||||||
|
"ability_mods": { "WIS": 1 },
|
||||||
|
"base_speed_ft": 30,
|
||||||
|
"traits": [
|
||||||
|
{ "id": "mountain_born", "name": "Mountain Born", "description": "Climb speed equal to walking speed. Immune to altitude sickness. Advantage on DEX checks and saves on narrow, unstable, or steep surfaces." },
|
||||||
|
{ "id": "headbutt", "name": "Headbutt", "description": "Horn attack deals 1d10 + STR when using Charge (20-ft. run-up). Target hit must make a CON save (DC = 8 + prof + STR) or be dazed (disadvantage on next attack roll)." },
|
||||||
|
{ "id": "stubborn_metabolism", "name": "Stubborn Metabolism", "description": "Subsist on half normal rations. Advantage on CON saves against ingested poisons, spoiled food, and unusual environmental contaminants. Goat-line digestive systems are infamous for a reason." }
|
||||||
],
|
],
|
||||||
"detriments": [
|
"detriments": [
|
||||||
{ "id": "horizontal_pupils", "name": "Horizontal Pupils", "description": "Disadvantage on Perception checks requiring depth perception at distances greater than 60 ft." },
|
{ "id": "horizontal_pupils", "name": "Horizontal Pupils", "description": "Disadvantage on Perception checks requiring depth perception at distances greater than 60 ft." },
|
||||||
@@ -287,6 +379,7 @@
|
|||||||
"id": "bison",
|
"id": "bison",
|
||||||
"clade_id": "bovidae",
|
"clade_id": "bovidae",
|
||||||
"name": "Bison-Folk",
|
"name": "Bison-Folk",
|
||||||
|
"description": "\"The prairie made them. Nothing else could.\"\n\nMassive shoulder hump and enormous head, thick through chest and front; hindquarters comparatively lean. Dark brown to near-black, shaggy through head and shoulders. Short, curved, wickedly sharp horns. Everything about the front profile says battering ram.",
|
||||||
"size": "large",
|
"size": "large",
|
||||||
"ability_mods": { "CON": 1 },
|
"ability_mods": { "CON": 1 },
|
||||||
"base_speed_ft": 25,
|
"base_speed_ft": 25,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public sealed record CladeDef
|
|||||||
[JsonPropertyName("name")]
|
[JsonPropertyName("name")]
|
||||||
public string Name { get; init; } = "";
|
public string Name { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Codex-voice clade description: usually the doc's italicized
|
||||||
|
/// blockquote followed by a one-sentence summary. Surfaced on the
|
||||||
|
/// Step 0 card to establish the world's tone before mechanics.</summary>
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>STR/DEX/CON/INT/WIS/CHA → modifier (typically +1 each on two abilities).</summary>
|
/// <summary>STR/DEX/CON/INT/WIS/CHA → modifier (typically +1 each on two abilities).</summary>
|
||||||
[JsonPropertyName("ability_mods")]
|
[JsonPropertyName("ability_mods")]
|
||||||
public Dictionary<string, int> AbilityMods { get; init; } = new();
|
public Dictionary<string, int> AbilityMods { get; init; } = new();
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public sealed record ClassDef
|
|||||||
[JsonPropertyName("name")]
|
[JsonPropertyName("name")]
|
||||||
public string Name { get; init; } = "";
|
public string Name { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Codex-voice calling description: in-character quote followed
|
||||||
|
/// by a one-paragraph profile (mirrors CladeDef / SpeciesDef). Surfaced
|
||||||
|
/// on the Step III calling card.</summary>
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Hit die size: 6 / 8 / 10 / 12.</summary>
|
/// <summary>Hit die size: 6 / 8 / 10 / 12.</summary>
|
||||||
[JsonPropertyName("hit_die")]
|
[JsonPropertyName("hit_die")]
|
||||||
public int HitDie { get; init; } = 8;
|
public int HitDie { get; init; } = 8;
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public sealed record SpeciesDef
|
|||||||
[JsonPropertyName("name")]
|
[JsonPropertyName("name")]
|
||||||
public string Name { get; init; } = "";
|
public string Name { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Codex-voice species description: usually the doc's italicized
|
||||||
|
/// blockquote followed by a one-sentence physical/cultural summary.
|
||||||
|
/// Surfaced on the Step 1 card.</summary>
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
|
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
|
||||||
[JsonPropertyName("size")]
|
[JsonPropertyName("size")]
|
||||||
public string Size { get; init; } = "medium";
|
public string Size { get; init; } = "medium";
|
||||||
@@ -35,4 +41,43 @@ public sealed record SpeciesDef
|
|||||||
|
|
||||||
[JsonPropertyName("detriments")]
|
[JsonPropertyName("detriments")]
|
||||||
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
|
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "sex" (male/female) or "lineage" (e.g. sheep/goat for Ram-Folk).
|
||||||
|
/// Empty when the species has no variants. Determines how the variant
|
||||||
|
/// is resolved: sex-axis is auto-keyed off character Sex (purebred) or
|
||||||
|
/// parent role for hybrids (sire = male, dam = female); lineage-axis
|
||||||
|
/// requires an explicit per-species pick.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("variant_axis")]
|
||||||
|
public string VariantAxis { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variant entries layered onto the species' base traits/detriments.
|
||||||
|
/// The variant's id keys it: for sex-axis variants, "male" or "female";
|
||||||
|
/// for lineage variants, the lineage tag (e.g. "sheep", "goat").
|
||||||
|
/// Empty when the species has no variants.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("variants")]
|
||||||
|
public SpeciesVariantDef[] Variants { get; init; } = Array.Empty<SpeciesVariantDef>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One sex- or lineage-keyed variant of a species. Layered on top of the
|
||||||
|
/// base species' traits and detriments — the player ends up with both
|
||||||
|
/// sets, not one or the other.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SpeciesVariantDef
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("traits")]
|
||||||
|
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
|
||||||
|
|
||||||
|
[JsonPropertyName("detriments")]
|
||||||
|
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ public sealed class CharacterBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
|
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sire's chosen ability key ("STR", "DEX", "CON", "INT", "WIS", "CHA").
|
||||||
|
/// Hybrid PCs take ONE ability mod from each parent clade — this records
|
||||||
|
/// which one the player chose from sire's clade. Empty string means no
|
||||||
|
/// bonus from this side (graceful default for headless builds and old
|
||||||
|
/// content). The resulting mod value is whatever the sire clade grants
|
||||||
|
/// for that ability (e.g. picking CON from canidae yields +1, picking
|
||||||
|
/// CON from ursidae yields +2). Stacks additively with the dam pick if
|
||||||
|
/// both happen to land on the same ability.
|
||||||
|
/// </summary>
|
||||||
|
public string HybridSireChosenAbility { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Dam's chosen ability key — see <see cref="HybridSireChosenAbility"/>.</summary>
|
||||||
|
public string HybridDamChosenAbility { get; set; } = "";
|
||||||
|
|
||||||
// ── Builder fluent helpers ──────────────────────────────────────────
|
// ── Builder fluent helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
|
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
|
||||||
@@ -282,17 +297,16 @@ public sealed class CharacterBuilder
|
|||||||
var dominantSpecies = HybridDominantParent == ParentLineage.Sire
|
var dominantSpecies = HybridDominantParent == ParentLineage.Sire
|
||||||
? HybridSireSpecies! : HybridDamSpecies!;
|
? HybridSireSpecies! : HybridDamSpecies!;
|
||||||
|
|
||||||
// Blend ability mods: apply BOTH parent clades' mods, then BOTH
|
// Blend ability mods: take ONE chosen ability mod from each parent
|
||||||
// species mods. Same-key collisions accumulate (e.g. two clades
|
// clade — the picks are recorded on HybridSireChosenAbility /
|
||||||
// each granting +1 CON yield +2 CON). This is a small departure
|
// HybridDamChosenAbility (set by the wizard's StepClade lineage
|
||||||
// from clades.md's "take one from each" but matches the engine's
|
// bonus picker, or left empty in headless tests for no bonus).
|
||||||
// declarative-mod model and produces sensible totals; M4 ships it
|
// Picks stack additively if both parents land on the same ability.
|
||||||
// and the rule fine-tunes in playtesting.
|
// Species mods don't apply for hybrids per project decision; the
|
||||||
|
// 2-mod ceiling intentionally caps hybrid bonuses below purebred's.
|
||||||
var modded = BaseAbilities;
|
var modded = BaseAbilities;
|
||||||
modded = ApplyMods(modded, HybridSireClade!.AbilityMods);
|
modded = ApplyOneMod(modded, HybridSireChosenAbility, HybridSireClade!.AbilityMods);
|
||||||
modded = ApplyMods(modded, HybridDamClade!.AbilityMods);
|
modded = ApplyOneMod(modded, HybridDamChosenAbility, HybridDamClade!.AbilityMods);
|
||||||
modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods);
|
|
||||||
modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods);
|
|
||||||
|
|
||||||
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
|
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
|
||||||
{
|
{
|
||||||
@@ -367,6 +381,24 @@ public sealed class CharacterBuilder
|
|||||||
return a.Plus(dict);
|
return a.Plus(dict);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply a single mod sourced from <paramref name="modsSource"/> at key
|
||||||
|
/// <paramref name="chosenAbility"/>. No-op when the key is empty (player
|
||||||
|
/// hasn't chosen) or absent from the source (defensive — the wizard's
|
||||||
|
/// picker should only offer abilities present in the clade's mods, but
|
||||||
|
/// we don't want a typo'd save to crash character finalize).
|
||||||
|
/// </summary>
|
||||||
|
private static AbilityScores ApplyOneMod(
|
||||||
|
AbilityScores a, string chosenAbility, IReadOnlyDictionary<string, int> modsSource)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(chosenAbility)) return a;
|
||||||
|
if (!TryParseAbility(chosenAbility, out var id)) return a;
|
||||||
|
if (modsSource is null || !modsSource.TryGetValue(chosenAbility, out int value) || value == 0)
|
||||||
|
return a;
|
||||||
|
var dict = new Dictionary<AbilityId, int> { [id] = value };
|
||||||
|
return a.Plus(dict);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryParseAbility(string raw, out AbilityId id)
|
private static bool TryParseAbility(string raw, out AbilityId id)
|
||||||
{
|
{
|
||||||
switch (raw.ToUpperInvariant())
|
switch (raw.ToUpperInvariant())
|
||||||
@@ -401,6 +433,19 @@ public sealed class CharacterBuilder
|
|||||||
SkillId.SleightOfHand => "sleight_of_hand",
|
SkillId.SleightOfHand => "sleight_of_hand",
|
||||||
SkillId.Stealth => "stealth",
|
SkillId.Stealth => "stealth",
|
||||||
SkillId.Survival => "survival",
|
SkillId.Survival => "survival",
|
||||||
|
|
||||||
|
SkillId.Brawl => "brawl",
|
||||||
|
SkillId.BuildRead => "build_read",
|
||||||
|
SkillId.Driving => "driving",
|
||||||
|
SkillId.Endurance => "endurance",
|
||||||
|
SkillId.Force => "force",
|
||||||
|
SkillId.Fortitude => "fortitude",
|
||||||
|
SkillId.Hardiness => "hardiness",
|
||||||
|
SkillId.Haulage => "haulage",
|
||||||
|
SkillId.LungCraft => "lung_craft",
|
||||||
|
SkillId.Marksmanship => "marksmanship",
|
||||||
|
SkillId.PainTolerance => "pain_tolerance",
|
||||||
|
SkillId.ScentSpeak => "scent_speak",
|
||||||
_ => s.ToString().ToLowerInvariant(),
|
_ => s.ToString().ToLowerInvariant(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public enum SizeCategory : byte
|
|||||||
{
|
{
|
||||||
Tiny = 0, // reserved; no Phase 5 species uses this
|
Tiny = 0, // reserved; no Phase 5 species uses this
|
||||||
Small = 1, // rabbit-folk, housecat-folk, ferret-folk
|
Small = 1, // rabbit-folk, housecat-folk, ferret-folk
|
||||||
Medium = 2, // fox-folk, deer-folk, ram-folk, leopard-folk, badger-folk, hare-folk
|
Medium = 2, // fox-folk, deer-folk, sheep-folk, goat-folk, leopard-folk, badger-folk, hare-folk
|
||||||
MediumLarge = 3, // wolf-folk, elk-folk, lion-folk, wolverine-folk, coyote-folk
|
MediumLarge = 3, // wolf-folk, elk-folk, lion-folk, wolverine-folk, coyote-folk
|
||||||
Large = 4, // brown bear-folk, polar bear-folk, moose-folk, bull-folk, bison-folk
|
Large = 4, // brown bear-folk, polar bear-folk, moose-folk, bull-folk, bison-folk
|
||||||
Huge = 5, // reserved; no Phase 5 species uses this
|
Huge = 5, // reserved; no Phase 5 species uses this
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
namespace Theriapolis.Core.Rules.Stats;
|
namespace Theriapolis.Core.Rules.Stats;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Standard d20-adjacent skill list. Each skill is backed by a single
|
/// Skill list used by Theriapolis — extends the d20 baseline with 12
|
||||||
/// ability — see <see cref="SkillAbility"/>.
|
/// additional skills (M6.18) so each ability has 5 skills tied to it.
|
||||||
|
/// New skills appended at the end of the byte enum to preserve save-game
|
||||||
|
/// compatibility with characters created before M6.18. Each skill is
|
||||||
|
/// backed by a single ability — see <see cref="SkillIdExtensions.Ability"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum SkillId : byte
|
public enum SkillId : byte
|
||||||
{
|
{
|
||||||
@@ -24,6 +27,20 @@ public enum SkillId : byte
|
|||||||
SleightOfHand = 15,
|
SleightOfHand = 15,
|
||||||
Stealth = 16,
|
Stealth = 16,
|
||||||
Survival = 17,
|
Survival = 17,
|
||||||
|
|
||||||
|
// M6.18 — Theriapolis-flavored expansions, 5 per ability.
|
||||||
|
Brawl = 18, // STR
|
||||||
|
BuildRead = 19, // STR
|
||||||
|
Driving = 20, // DEX
|
||||||
|
Endurance = 21, // CON
|
||||||
|
Force = 22, // STR
|
||||||
|
Fortitude = 23, // CON
|
||||||
|
Hardiness = 24, // CON
|
||||||
|
Haulage = 25, // STR
|
||||||
|
LungCraft = 26, // CON
|
||||||
|
Marksmanship = 27, // DEX
|
||||||
|
PainTolerance = 28, // CON
|
||||||
|
ScentSpeak = 29, // CHA
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SkillIdExtensions
|
public static class SkillIdExtensions
|
||||||
@@ -48,6 +65,19 @@ public static class SkillIdExtensions
|
|||||||
SkillId.SleightOfHand => AbilityId.DEX,
|
SkillId.SleightOfHand => AbilityId.DEX,
|
||||||
SkillId.Stealth => AbilityId.DEX,
|
SkillId.Stealth => AbilityId.DEX,
|
||||||
SkillId.Survival => AbilityId.WIS,
|
SkillId.Survival => AbilityId.WIS,
|
||||||
|
|
||||||
|
SkillId.Brawl => AbilityId.STR,
|
||||||
|
SkillId.BuildRead => AbilityId.STR,
|
||||||
|
SkillId.Driving => AbilityId.DEX,
|
||||||
|
SkillId.Endurance => AbilityId.CON,
|
||||||
|
SkillId.Force => AbilityId.STR,
|
||||||
|
SkillId.Fortitude => AbilityId.CON,
|
||||||
|
SkillId.Hardiness => AbilityId.CON,
|
||||||
|
SkillId.Haulage => AbilityId.STR,
|
||||||
|
SkillId.LungCraft => AbilityId.CON,
|
||||||
|
SkillId.Marksmanship => AbilityId.DEX,
|
||||||
|
SkillId.PainTolerance => AbilityId.CON,
|
||||||
|
SkillId.ScentSpeak => AbilityId.CHA,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,6 +102,19 @@ public static class SkillIdExtensions
|
|||||||
"sleight_of_hand" => SkillId.SleightOfHand,
|
"sleight_of_hand" => SkillId.SleightOfHand,
|
||||||
"stealth" => SkillId.Stealth,
|
"stealth" => SkillId.Stealth,
|
||||||
"survival" => SkillId.Survival,
|
"survival" => SkillId.Survival,
|
||||||
|
|
||||||
|
"brawl" => SkillId.Brawl,
|
||||||
|
"build_read" => SkillId.BuildRead,
|
||||||
|
"driving" => SkillId.Driving,
|
||||||
|
"endurance" => SkillId.Endurance,
|
||||||
|
"force" => SkillId.Force,
|
||||||
|
"fortitude" => SkillId.Fortitude,
|
||||||
|
"hardiness" => SkillId.Hardiness,
|
||||||
|
"haulage" => SkillId.Haulage,
|
||||||
|
"lung_craft" => SkillId.LungCraft,
|
||||||
|
"marksmanship" => SkillId.Marksmanship,
|
||||||
|
"pain_tolerance" => SkillId.PainTolerance,
|
||||||
|
"scent_speak" => SkillId.ScentSpeak,
|
||||||
_ => throw new ArgumentException($"Unknown skill: '{raw}'"),
|
_ => throw new ArgumentException($"Unknown skill: '{raw}'"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
|
using Theriapolis.Core.Rules.Character;
|
||||||
|
using Theriapolis.Core.World.Generation;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Autoload singleton. Holds the cross-scene state that outlives any
|
||||||
|
/// single screen: the world seed and (post-worldgen) WorldGenContext,
|
||||||
|
/// the pending character from the M6 wizard hand-off, and the pending
|
||||||
|
/// save snapshot from the SaveLoadScreen load hand-off.
|
||||||
|
///
|
||||||
|
/// Per port-plan §M7 §4.3: TitleScreen + Wizard + SaveLoadScreen write
|
||||||
|
/// pending fields; WorldGenProgressScreen + PlayScreen consume them and
|
||||||
|
/// clear them.
|
||||||
|
///
|
||||||
|
/// Registered in <c>project.godot</c> under <c>[autoload]</c>; reachable
|
||||||
|
/// from any scene via <see cref="From"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class GameSession : Node
|
||||||
|
{
|
||||||
|
/// <summary>World seed for the next worldgen run. Set by TitleScreen
|
||||||
|
/// (new game) or by SaveLoadScreen (from the loaded header).</summary>
|
||||||
|
public ulong Seed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set by WorldGenProgressScreen on completion; consumed by
|
||||||
|
/// PlayScreen during <c>_Ready</c>.</summary>
|
||||||
|
public WorldGenContext? Ctx { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set by the Wizard hand-off (M6 → M7.1). PlayScreen
|
||||||
|
/// attaches this to the spawned player actor and clears the field.</summary>
|
||||||
|
public Character? PendingCharacter { get; set; }
|
||||||
|
public string PendingName { get; set; } = "Wanderer";
|
||||||
|
|
||||||
|
/// <summary>Set by SaveLoadScreen when the player picks a slot.
|
||||||
|
/// PlayScreen consumes via <c>ApplyRestoredBody</c> in <c>_Ready</c>.</summary>
|
||||||
|
public SaveBody? PendingRestore { get; set; }
|
||||||
|
public SaveHeader? PendingHeader { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Convenience accessor — any node can grab the session via
|
||||||
|
/// <c>GameSession.From(this)</c> without hard-coding the autoload path.</summary>
|
||||||
|
public static GameSession From(Node anyNode)
|
||||||
|
=> anyNode.GetNode<GameSession>("/root/GameSession");
|
||||||
|
|
||||||
|
/// <summary>Drop the per-run pending fields. Called on quit-to-title
|
||||||
|
/// so a fresh "New Character" run doesn't see stale handoff data.</summary>
|
||||||
|
public void ClearPending()
|
||||||
|
{
|
||||||
|
PendingCharacter = null;
|
||||||
|
PendingName = "Wanderer";
|
||||||
|
PendingRestore = null;
|
||||||
|
PendingHeader = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
using Theriapolis.Core.Entities;
|
||||||
|
using Theriapolis.Core.Time;
|
||||||
|
using Theriapolis.Core.Util;
|
||||||
|
using Theriapolis.Core.World;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the player. World-map mode: click a destination, A* the path
|
||||||
|
/// (via <see cref="WorldTravelPlanner"/>), and animate the player along
|
||||||
|
/// it while the WorldClock advances. Tactical mode: WASD step with
|
||||||
|
/// axis-separated motion (wall-sliding) and encumbrance-aware speed.
|
||||||
|
///
|
||||||
|
/// Direct logic port of <c>Theriapolis.Game/Input/PlayerController.cs</c>;
|
||||||
|
/// the Godot version takes pre-resolved <c>dx</c>/<c>dy</c> from the
|
||||||
|
/// screen instead of poking <c>InputManager</c> + <c>Camera2D</c>
|
||||||
|
/// (MonoGame types). Save-format determinism is unaffected — the only
|
||||||
|
/// output that round-trips through saves is <see cref="PlayerActor.Position"/>
|
||||||
|
/// and <see cref="WorldClock.InGameSeconds"/>, both of which are advanced
|
||||||
|
/// by identical arithmetic in both ports.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlayerController
|
||||||
|
{
|
||||||
|
private readonly PlayerActor _player;
|
||||||
|
private readonly WorldState _world;
|
||||||
|
private readonly WorldClock _clock;
|
||||||
|
private readonly WorldTravelPlanner _planner;
|
||||||
|
|
||||||
|
/// <summary>Optional callback installed by the screen once tactical
|
||||||
|
/// streaming is up. Returns whether the given tactical-tile coord
|
||||||
|
/// is walkable.</summary>
|
||||||
|
public Func<int, int, bool>? TacticalIsWalkable { get; set; }
|
||||||
|
|
||||||
|
private List<(int X, int Y)>? _path;
|
||||||
|
private int _pathIndex;
|
||||||
|
|
||||||
|
// Sub-second carry for the world clock — tactical motion is continuous,
|
||||||
|
// so a single frame may advance fewer than one in-game second; without
|
||||||
|
// this carry, slow movement would never tick the clock past 0.
|
||||||
|
private float _tacticalClockCarry;
|
||||||
|
|
||||||
|
public bool IsTraveling => _path is not null && _pathIndex < _path.Count;
|
||||||
|
|
||||||
|
public PlayerController(PlayerActor player, WorldState world, WorldClock clock)
|
||||||
|
{
|
||||||
|
_player = player;
|
||||||
|
_world = world;
|
||||||
|
_clock = clock;
|
||||||
|
_planner = new WorldTravelPlanner(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CancelTravel()
|
||||||
|
{
|
||||||
|
_path = null;
|
||||||
|
_pathIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queue a click destination as a new travel plan. Returns
|
||||||
|
/// true if a path was found.</summary>
|
||||||
|
public bool RequestTravelTo(int tileX, int tileY)
|
||||||
|
{
|
||||||
|
int sx = (int)MathF.Floor(_player.Position.X / C.WORLD_TILE_PIXELS);
|
||||||
|
int sy = (int)MathF.Floor(_player.Position.Y / C.WORLD_TILE_PIXELS);
|
||||||
|
sx = Math.Clamp(sx, 0, C.WORLD_WIDTH_TILES - 1);
|
||||||
|
sy = Math.Clamp(sy, 0, C.WORLD_HEIGHT_TILES - 1);
|
||||||
|
|
||||||
|
var path = _planner.PlanTilePath(sx, sy, tileX, tileY);
|
||||||
|
if (path is null || path.Count < 2) return false;
|
||||||
|
_path = path;
|
||||||
|
_pathIndex = 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-frame tick. <paramref name="dx"/>/<paramref name="dy"/>
|
||||||
|
/// are the pre-resolved input direction (e.g. -1/0/+1 each, from WASD);
|
||||||
|
/// ignored when <paramref name="isTacticalMode"/> is false. The screen
|
||||||
|
/// is responsible for deciding tactical vs. world-map based on camera
|
||||||
|
/// zoom and for gating input when the window isn't focused.</summary>
|
||||||
|
public void Update(float dt, float dx, float dy, bool isTacticalMode, bool isFocused)
|
||||||
|
{
|
||||||
|
if (!isTacticalMode)
|
||||||
|
UpdateWorldMap(dt);
|
||||||
|
else
|
||||||
|
UpdateTactical(dt, dx, dy, isFocused);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateWorldMap(float dt)
|
||||||
|
{
|
||||||
|
if (_path is null) return;
|
||||||
|
if (_pathIndex >= _path.Count) { _path = null; return; }
|
||||||
|
|
||||||
|
var (tx, ty) = _path[_pathIndex];
|
||||||
|
var target = WorldTravelPlanner.TileCenterToWorldPixel(tx, ty);
|
||||||
|
var curPos = _player.Position;
|
||||||
|
var diff = target - curPos;
|
||||||
|
float dist = diff.Length;
|
||||||
|
float move = _player.SpeedWorldPxPerSec * dt;
|
||||||
|
|
||||||
|
if (move >= dist)
|
||||||
|
{
|
||||||
|
int prevTileX = (int)MathF.Floor(curPos.X / C.WORLD_TILE_PIXELS);
|
||||||
|
int prevTileY = (int)MathF.Floor(curPos.Y / C.WORLD_TILE_PIXELS);
|
||||||
|
_player.Position = target;
|
||||||
|
if (dist > 1e-3f) _player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X);
|
||||||
|
float legSeconds = _planner.EstimateSecondsForLeg(prevTileX, prevTileY, tx, ty);
|
||||||
|
_clock.Advance((long)MathF.Round(legSeconds));
|
||||||
|
_pathIndex++;
|
||||||
|
if (_pathIndex >= _path.Count) _path = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var step = diff.Normalized * move;
|
||||||
|
_player.Position = curPos + step;
|
||||||
|
_player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X);
|
||||||
|
ref var dst = ref _world.TileAt(tx, ty);
|
||||||
|
float secondsThisFrame = move * _planner.SecondsPerPixel(dst);
|
||||||
|
_clock.Advance((long)MathF.Round(secondsThisFrame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTactical(float dt, float dx, float dy, bool isFocused)
|
||||||
|
{
|
||||||
|
if (!isFocused || TacticalIsWalkable is null) return;
|
||||||
|
if (dx == 0f && dy == 0f) return;
|
||||||
|
|
||||||
|
// Normalize so diagonal isn't √2 faster than cardinal.
|
||||||
|
float invLen = (dx != 0f && dy != 0f) ? 0.70710678f : 1f;
|
||||||
|
float vx = dx * invLen;
|
||||||
|
float vy = dy * invLen;
|
||||||
|
|
||||||
|
// Apply encumbrance multiplier when a Character is attached.
|
||||||
|
// Carrying ≤ 100% of capacity walks at full speed; >100% is heavy
|
||||||
|
// (×0.66); >150% is over-encumbered (×0.50).
|
||||||
|
float encMult = _player.Character is not null
|
||||||
|
? Theriapolis.Core.Rules.Stats.DerivedStats.TacticalSpeedMult(_player.Character)
|
||||||
|
: 1f;
|
||||||
|
float speed = C.TACTICAL_PLAYER_PX_PER_SEC * encMult;
|
||||||
|
float moveX = vx * speed * dt;
|
||||||
|
float moveY = vy * speed * dt;
|
||||||
|
|
||||||
|
var pos = _player.Position;
|
||||||
|
|
||||||
|
// Axis-separated motion gives wall-sliding for free: if X is blocked,
|
||||||
|
// Y still moves, and vice versa. Each axis tests the destination tile
|
||||||
|
// with a small body radius so the player doesn't visibly clip walls.
|
||||||
|
const float BodyRadius = 0.35f;
|
||||||
|
float newX = pos.X + moveX;
|
||||||
|
if (CanOccupy(newX, pos.Y, BodyRadius)) pos = new Vec2(newX, pos.Y);
|
||||||
|
float newY = pos.Y + moveY;
|
||||||
|
if (CanOccupy(pos.X, newY, BodyRadius)) pos = new Vec2(pos.X, newY);
|
||||||
|
|
||||||
|
_player.Position = pos;
|
||||||
|
_player.FacingAngleRad = MathF.Atan2(vy, vx);
|
||||||
|
|
||||||
|
// 1 tactical pixel walked = TACTICAL_STEP_SECONDS in-game seconds.
|
||||||
|
// Sub-second motion accumulates in _tacticalClockCarry so slow walking
|
||||||
|
// still ticks the clock cumulatively.
|
||||||
|
float walked = MathF.Sqrt(moveX * moveX + moveY * moveY);
|
||||||
|
float secondsThisFrame = walked * C.TACTICAL_STEP_SECONDS + _tacticalClockCarry;
|
||||||
|
long whole = (long)MathF.Floor(secondsThisFrame);
|
||||||
|
_tacticalClockCarry = secondsThisFrame - whole;
|
||||||
|
if (whole > 0) _clock.Advance(whole);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanOccupy(float x, float y, float r)
|
||||||
|
{
|
||||||
|
// Sample the four corners of the player's body AABB so we don't slip
|
||||||
|
// into walls when sliding past corners.
|
||||||
|
return TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y - r))
|
||||||
|
&& TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y - r))
|
||||||
|
&& TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y + r))
|
||||||
|
&& TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y + r));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Godot;
|
using Godot;
|
||||||
using Theriapolis.GodotHost.Platform;
|
using Theriapolis.GodotHost.Platform;
|
||||||
using Theriapolis.GodotHost.Rendering;
|
using Theriapolis.GodotHost.Rendering;
|
||||||
|
using Theriapolis.GodotHost.Scenes;
|
||||||
using Theriapolis.GodotHost.UI;
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
namespace Theriapolis.GodotHost;
|
namespace Theriapolis.GodotHost;
|
||||||
@@ -28,6 +29,19 @@ public partial class Main : Control
|
|||||||
bool runCodexTest = false;
|
bool runCodexTest = false;
|
||||||
bool runWizard = false;
|
bool runWizard = false;
|
||||||
(ulong seed, int tx, int ty)? tacticalArgs = null;
|
(ulong seed, int tx, int ty)? tacticalArgs = null;
|
||||||
|
|
||||||
|
// --dark is independent of the entry-point flags: it sets the codex
|
||||||
|
// palette default before any UI mounts so both TitleScreen and the
|
||||||
|
// wizard pick it up via CodexTheme.Build()'s no-arg overload.
|
||||||
|
for (int i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
if (args[i] == "--dark")
|
||||||
|
{
|
||||||
|
CodexTheme.DefaultPalette = CodexPalette.Dark;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < args.Length; i++)
|
for (int i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
if (args[i] == "--codex-test")
|
if (args[i] == "--codex-test")
|
||||||
@@ -128,7 +142,12 @@ public partial class Main : Control
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
|
// Default entry point — TitleScreen. M0's hello-world Label is no
|
||||||
|
// longer the boot UI; the title swaps itself for the wizard when
|
||||||
|
// "New Character" is clicked, or shuts the engine down on Quit.
|
||||||
|
foreach (Node child in GetChildren())
|
||||||
|
child.QueueFree();
|
||||||
|
AddChild(new TitleScreen());
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void _UnhandledInput(InputEvent @event)
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
|||||||
@@ -7,17 +7,3 @@ anchors_preset = 15
|
|||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
script = ExtResource("1_main")
|
script = ExtResource("1_main")
|
||||||
|
|
||||||
[node name="Label" type="Label" parent="."]
|
|
||||||
anchors_preset = 8
|
|
||||||
anchor_left = 0.5
|
|
||||||
anchor_top = 0.5
|
|
||||||
anchor_right = 0.5
|
|
||||||
anchor_bottom = 0.5
|
|
||||||
offset_left = -200.0
|
|
||||||
offset_top = -16.0
|
|
||||||
offset_right = 200.0
|
|
||||||
offset_bottom = 16.0
|
|
||||||
horizontal_alignment = 1
|
|
||||||
vertical_alignment = 1
|
|
||||||
text = "Theriapolis · Godot port · M0 · F11 toggles fullscreen"
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OS-aware save directory resolution. Direct port of
|
||||||
|
/// <c>Theriapolis.Game/Platform/SavePaths.cs</c>; deliberately uses the
|
||||||
|
/// same directories as the MonoGame build so saves are interoperable
|
||||||
|
/// across the two ports.
|
||||||
|
///
|
||||||
|
/// Locations:
|
||||||
|
/// Windows: <c>%LOCALAPPDATA%\Theriapolis\Saves\</c>
|
||||||
|
/// macOS: <c>~/Library/Application Support/Theriapolis/Saves/</c>
|
||||||
|
/// Linux: <c>$XDG_DATA_HOME/Theriapolis/saves/</c> (default
|
||||||
|
/// <c>~/.local/share/Theriapolis/saves/</c>)
|
||||||
|
/// </summary>
|
||||||
|
public static class SavePaths
|
||||||
|
{
|
||||||
|
/// <summary>Top-level Theriapolis save directory. Created on first
|
||||||
|
/// call if missing.</summary>
|
||||||
|
public static string SavesDir
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
string dir = ResolveBase();
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string SlotPath(int slot) => Path.Combine(SavesDir, $"slot_{slot:D2}.trps");
|
||||||
|
public static string AutosavePath() => Path.Combine(SavesDir, "autosave.trps");
|
||||||
|
|
||||||
|
private static string ResolveBase()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Theriapolis", "Saves");
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Library", "Application Support", "Theriapolis", "Saves");
|
||||||
|
// Linux + others: respect XDG_DATA_HOME, fall back to ~/.local/share.
|
||||||
|
string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? "";
|
||||||
|
if (string.IsNullOrEmpty(xdg))
|
||||||
|
xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
".local", "share");
|
||||||
|
return Path.Combine(xdg, "Theriapolis", "saves");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Atomic-rename file write so a crash mid-save can't
|
||||||
|
/// corrupt the slot.</summary>
|
||||||
|
public static void WriteAtomic(string path, byte[] bytes)
|
||||||
|
{
|
||||||
|
string dir = Path.GetDirectoryName(path)!;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
string tmp = path + ".tmp";
|
||||||
|
File.WriteAllBytes(tmp, bytes);
|
||||||
|
if (File.Exists(path)) File.Replace(tmp, path, destinationBackupFileName: null);
|
||||||
|
else File.Move(tmp, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slot-picker label formatting. Pulls the in-game time from
|
||||||
|
/// <see cref="SaveHeader.SlotLabel"/> (e.g. "Howlwind — Y0 Spring D5
|
||||||
|
/// (Tier 1)") and appends the wall-clock saved-at time parsed from
|
||||||
|
/// <see cref="SaveHeader.SavedAtUtc"/>, rendered in the player's local
|
||||||
|
/// timezone with a relative label when recent.
|
||||||
|
///
|
||||||
|
/// Shared between <see cref="Scenes.SaveLoadScreen"/> (load picker
|
||||||
|
/// from Title) and <see cref="Scenes.PauseMenuScreen"/>'s save picker
|
||||||
|
/// so both surfaces present the same row format.
|
||||||
|
/// </summary>
|
||||||
|
public static class SaveSlotFormat
|
||||||
|
{
|
||||||
|
/// <summary>Composed row label: "{slot} — {in-game} · saved {when}".</summary>
|
||||||
|
public static string FormatRow(string slotPrefix, SaveHeader header)
|
||||||
|
=> $"{slotPrefix} — {header.SlotLabel()} · saved {FormatSavedAt(header.SavedAtUtc)}";
|
||||||
|
|
||||||
|
/// <summary>Parses the SaveHeader's UTC saved-at timestamp and
|
||||||
|
/// renders it relative to now, in local time. Returns "<unknown>"
|
||||||
|
/// for empty / unparseable inputs so the row still shows something.</summary>
|
||||||
|
public static string FormatSavedAt(string savedAtUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(savedAtUtc)) return "<unknown>";
|
||||||
|
if (!DateTime.TryParse(
|
||||||
|
savedAtUtc, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||||
|
out var utc))
|
||||||
|
return savedAtUtc;
|
||||||
|
|
||||||
|
DateTime local = utc.ToLocalTime();
|
||||||
|
DateTime now = DateTime.Now;
|
||||||
|
if (local.Date == now.Date)
|
||||||
|
return $"today, {local:HH:mm}";
|
||||||
|
if (local.Date == now.Date.AddDays(-1))
|
||||||
|
return $"yesterday, {local:HH:mm}";
|
||||||
|
if (local.Year == now.Year)
|
||||||
|
return local.ToString("MMM d, HH:mm", CultureInfo.InvariantCulture);
|
||||||
|
return local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
using Theriapolis.Core.Rules.Character;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NPC marker — small dot tinted by allegiance. M7.2 stand-in for the
|
||||||
|
/// MonoGame NpcSprite (which adds walking-cycle animation). Counter-scaled
|
||||||
|
/// with zoom by the owner so the on-screen size stays constant.
|
||||||
|
/// </summary>
|
||||||
|
public partial class NpcMarker : Node2D
|
||||||
|
{
|
||||||
|
private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.4f;
|
||||||
|
|
||||||
|
public Allegiance Allegiance { get; set; } = Allegiance.Neutral;
|
||||||
|
|
||||||
|
public override void _Draw()
|
||||||
|
{
|
||||||
|
var fill = Allegiance switch
|
||||||
|
{
|
||||||
|
Allegiance.Hostile => new Color(0.78f, 0.18f, 0.20f),
|
||||||
|
Allegiance.Friendly => new Color(0.45f, 0.78f, 0.38f),
|
||||||
|
_ => new Color(0.70f, 0.70f, 0.68f),
|
||||||
|
};
|
||||||
|
DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f));
|
||||||
|
DrawCircle(Vector2.Zero, RadiusWorldPx * 0.80f, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Player marker — small dot with a thin facing tick. Drawn at
|
||||||
|
/// <see cref="C.PLAYER_MARKER_SCREEN_PX"/>/2 wp; the owner sets
|
||||||
|
/// <see cref="Node2D.Scale"/> = 1/zoom every frame so the on-screen size
|
||||||
|
/// stays constant across the seamless zoom range. Facing is driven by
|
||||||
|
/// the owner via <see cref="Node2D.Rotation"/>; the tick is drawn along
|
||||||
|
/// the local +X axis so rotating the node rotates the tick without
|
||||||
|
/// invalidating the cached <c>_Draw</c> commands.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PlayerMarker : Node2D
|
||||||
|
{
|
||||||
|
private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f;
|
||||||
|
private const float FacingTickPx = RadiusWorldPx * 1.4f;
|
||||||
|
|
||||||
|
private bool _showFacingTick = true;
|
||||||
|
|
||||||
|
/// <summary>When true, draws a small tick along the local +X axis
|
||||||
|
/// so the player can read facing without a full sprite. Hidden at
|
||||||
|
/// low zoom to avoid clutter. Triggers <see cref="CanvasItem.QueueRedraw"/>
|
||||||
|
/// on change.</summary>
|
||||||
|
public bool ShowFacingTick
|
||||||
|
{
|
||||||
|
get => _showFacingTick;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_showFacingTick == value) return;
|
||||||
|
_showFacingTick = value;
|
||||||
|
QueueRedraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Draw()
|
||||||
|
{
|
||||||
|
DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f));
|
||||||
|
DrawCircle(Vector2.Zero, RadiusWorldPx * 0.85f, new Color(0.86f, 0.31f, 0.24f));
|
||||||
|
|
||||||
|
if (_showFacingTick)
|
||||||
|
{
|
||||||
|
DrawLine(
|
||||||
|
new Vector2(RadiusWorldPx * 0.4f, 0),
|
||||||
|
new Vector2(FacingTickPx, 0),
|
||||||
|
new Color(1f, 0.96f, 0.86f),
|
||||||
|
width: RadiusWorldPx * 0.18f,
|
||||||
|
antialiased: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filled circle settlement marker on the world map. Sized in world-pixel
|
||||||
|
/// space; the parent <see cref="WorldRenderNode"/> hides the layer above
|
||||||
|
/// the tactical-zoom threshold so the dot doesn't clutter close-up views.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SettlementDot : Node2D
|
||||||
|
{
|
||||||
|
public float Radius { get; set; } = 8f;
|
||||||
|
public Color FillColor { get; set; } = Colors.White;
|
||||||
|
|
||||||
|
public override void _Draw() => DrawCircle(Vector2.Zero, Radius, FillColor);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
using Theriapolis.Core.Tactical;
|
||||||
|
using Theriapolis.Core.Util;
|
||||||
|
using Theriapolis.Core.World;
|
||||||
|
using Theriapolis.Core.World.Polylines;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders a generated <see cref="WorldState"/> across the seamless zoom
|
||||||
|
/// range — biome backdrop, polylines (rivers / roads / rails), bridges,
|
||||||
|
/// settlement dots, and the tactical-chunk layer that streams in close-up.
|
||||||
|
/// Owns its own <see cref="PanZoomCamera"/> so callers can read zoom and
|
||||||
|
/// drive position uniformly.
|
||||||
|
///
|
||||||
|
/// Per M7 plan §6.2: extracted from the M2+M4 <see cref="WorldView"/> demo
|
||||||
|
/// so PlayScreen and the standalone demo both mount the same renderer.
|
||||||
|
/// The chunk streamer itself is owned by the *caller* — PlayScreen needs
|
||||||
|
/// the streamer for NPC lifecycle separately from the visual layer — so
|
||||||
|
/// the caller subscribes to <c>OnChunkLoaded</c>/<c>OnChunkEvicting</c>
|
||||||
|
/// and forwards into <see cref="AddChunkNode"/>/<see cref="RemoveChunkNode"/>.
|
||||||
|
///
|
||||||
|
/// Per-frame: hides/shows the tactical and settlement layers based on
|
||||||
|
/// camera zoom, and counter-scales every Line2D width so polyline widths
|
||||||
|
/// stay visually consistent regardless of zoom.
|
||||||
|
/// </summary>
|
||||||
|
public partial class WorldRenderNode : Node2D
|
||||||
|
{
|
||||||
|
// Zoom thresholds, in Camera2D zoom units (1.0 = 1 world px per screen px,
|
||||||
|
// 32.0 = sprite-native tactical view, ~0.07 = world fits 1080p).
|
||||||
|
public const float TacticalRenderZoomMin = 4.0f;
|
||||||
|
public const float SettlementHideZoom = 2.0f;
|
||||||
|
|
||||||
|
// Polyline base widths in *screen* pixels (counter-scaled to world space
|
||||||
|
// per frame). Mirrors the differentiation in LineFeatureRenderer.cs.
|
||||||
|
private const float HighwayScreenPx = 4f;
|
||||||
|
private const float PostRoadScreenPx = 3f;
|
||||||
|
private const float DirtRoadScreenPx = 2f;
|
||||||
|
private const float RiverMajorScreenPx = 4.5f;
|
||||||
|
private const float RiverScreenPx = 3f;
|
||||||
|
private const float StreamScreenPx = 2f;
|
||||||
|
private const float RailTieScreenPx = 4f;
|
||||||
|
private const float RailLineScreenPx = 2f;
|
||||||
|
private const float BridgeScreenPx = 6f;
|
||||||
|
|
||||||
|
// Polyline colours mirror LineFeatureRenderer.cs / WorldgenDump.cs.
|
||||||
|
private static readonly Color RiverMajorColour = ColorByte(40, 100, 200);
|
||||||
|
private static readonly Color RiverColour = ColorByte(60, 120, 200);
|
||||||
|
private static readonly Color StreamColour = ColorByte(100, 150, 220);
|
||||||
|
private static readonly Color HighwayColour = ColorByte(210, 180, 80);
|
||||||
|
private static readonly Color PostRoadColour = ColorByte(180, 155, 70);
|
||||||
|
private static readonly Color DirtRoadColour = ColorByte(150, 130, 90);
|
||||||
|
private static readonly Color RailTieColour = ColorByte(120, 100, 80);
|
||||||
|
private static readonly Color RailColour = ColorByte(80, 65, 50);
|
||||||
|
private static readonly Color BridgeColour = ColorByte(160, 140, 100);
|
||||||
|
|
||||||
|
private Node2D? _tacticalLayer;
|
||||||
|
private Node2D? _polylineLayer;
|
||||||
|
private Node2D? _bridgeLayer;
|
||||||
|
private Node2D? _settlementLayer;
|
||||||
|
private PanZoomCamera? _camera;
|
||||||
|
private readonly Dictionary<ChunkCoord, TacticalChunkNode> _chunkNodes = new();
|
||||||
|
private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new();
|
||||||
|
private bool _initialised;
|
||||||
|
|
||||||
|
/// <summary>The camera owned by this node. Caller reads <c>Zoom</c> to
|
||||||
|
/// pick world-map vs. tactical UI behaviour, and sets <c>Position</c>
|
||||||
|
/// to follow the player.</summary>
|
||||||
|
public PanZoomCamera Camera => _camera!;
|
||||||
|
|
||||||
|
/// <summary>Initialise from a completed <see cref="WorldGenContext"/>.
|
||||||
|
/// Idempotent on repeat — second call is a no-op. <paramref name="initialZoom"/>
|
||||||
|
/// of 0 means "compute fit-to-viewport so the whole world is visible".</summary>
|
||||||
|
public void Initialize(WorldState world, float initialZoom = 0f)
|
||||||
|
{
|
||||||
|
if (_initialised) return;
|
||||||
|
_initialised = true;
|
||||||
|
|
||||||
|
TacticalAtlas.EnsureLoaded();
|
||||||
|
|
||||||
|
BuildBiomeSprite(world);
|
||||||
|
_tacticalLayer = AddNamedLayer("TacticalChunks");
|
||||||
|
BuildPolylines(world);
|
||||||
|
BuildBridges(world);
|
||||||
|
BuildSettlements(world);
|
||||||
|
AddCamera(initialZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Process(double delta)
|
||||||
|
{
|
||||||
|
if (!_initialised) return;
|
||||||
|
UpdateLayerVisibility();
|
||||||
|
UpdateZoomScaledNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mount the visual for a freshly-streamed chunk. Caller
|
||||||
|
/// invokes from a <c>ChunkStreamer.OnChunkLoaded</c> subscription.</summary>
|
||||||
|
public void AddChunkNode(TacticalChunk chunk)
|
||||||
|
{
|
||||||
|
if (_tacticalLayer is null) return;
|
||||||
|
if (_chunkNodes.ContainsKey(chunk.Coord)) return;
|
||||||
|
|
||||||
|
var node = new TacticalChunkNode { Name = $"Chunk{chunk.Coord.X}_{chunk.Coord.Y}" };
|
||||||
|
_tacticalLayer.AddChild(node);
|
||||||
|
node.Bind(chunk);
|
||||||
|
_chunkNodes[chunk.Coord] = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tear down a chunk visual on eviction. Caller invokes from
|
||||||
|
/// <c>ChunkStreamer.OnChunkEvicting</c>.</summary>
|
||||||
|
public void RemoveChunkNode(TacticalChunk chunk)
|
||||||
|
{
|
||||||
|
if (!_chunkNodes.TryGetValue(chunk.Coord, out var node)) return;
|
||||||
|
node.QueueFree();
|
||||||
|
_chunkNodes.Remove(chunk.Coord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Layer construction
|
||||||
|
|
||||||
|
private void BuildBiomeSprite(WorldState world)
|
||||||
|
{
|
||||||
|
int W = C.WORLD_WIDTH_TILES;
|
||||||
|
int H = C.WORLD_HEIGHT_TILES;
|
||||||
|
|
||||||
|
var palette = new Color[(int)BiomeId.Mangrove + 1];
|
||||||
|
foreach (var def in world.BiomeDefs!)
|
||||||
|
{
|
||||||
|
var (r, g, b) = def.ParsedColor();
|
||||||
|
int id = (int)ParseBiomeId(def.Id);
|
||||||
|
if (id >= 0 && id < palette.Length) palette[id] = ColorByte(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
var image = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
|
||||||
|
for (int y = 0; y < H; y++)
|
||||||
|
for (int x = 0; x < W; x++)
|
||||||
|
{
|
||||||
|
int id = (int)world.Tiles[x, y].Biome;
|
||||||
|
Color c = (id >= 0 && id < palette.Length && palette[id].A > 0f)
|
||||||
|
? palette[id]
|
||||||
|
: ColorByte(255, 0, 255);
|
||||||
|
image.SetPixel(x, y, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sprite = new Sprite2D
|
||||||
|
{
|
||||||
|
Texture = ImageTexture.CreateFromImage(image),
|
||||||
|
Centered = false,
|
||||||
|
Scale = new Vector2(C.WORLD_TILE_PIXELS, C.WORLD_TILE_PIXELS),
|
||||||
|
TextureFilter = TextureFilterEnum.Nearest,
|
||||||
|
Name = "Biome",
|
||||||
|
};
|
||||||
|
AddChild(sprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node2D AddNamedLayer(string name)
|
||||||
|
{
|
||||||
|
var n = new Node2D { Name = name };
|
||||||
|
AddChild(n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildPolylines(WorldState world)
|
||||||
|
{
|
||||||
|
_polylineLayer = AddNamedLayer("Polylines");
|
||||||
|
|
||||||
|
foreach (var road in world.Roads.OrderBy(RoadDrawRank))
|
||||||
|
{
|
||||||
|
var (color, screenPx) = road.RoadClassification switch
|
||||||
|
{
|
||||||
|
RoadType.Highway => (HighwayColour, HighwayScreenPx),
|
||||||
|
RoadType.PostRoad => (PostRoadColour, PostRoadScreenPx),
|
||||||
|
_ => (DirtRoadColour, DirtRoadScreenPx),
|
||||||
|
};
|
||||||
|
AddScaledLine(_polylineLayer, road.Points, color, screenPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var river in world.Rivers)
|
||||||
|
{
|
||||||
|
var (color, screenPx) = river.RiverClassification switch
|
||||||
|
{
|
||||||
|
RiverClass.MajorRiver => (RiverMajorColour, RiverMajorScreenPx),
|
||||||
|
RiverClass.River => (RiverColour, RiverScreenPx),
|
||||||
|
_ => (StreamColour, StreamScreenPx),
|
||||||
|
};
|
||||||
|
float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f;
|
||||||
|
AddScaledLine(_polylineLayer, river.Points, color,
|
||||||
|
Mathf.Min(screenPx * flowScale, RiverMajorScreenPx * 1.5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rail in world.Rails)
|
||||||
|
{
|
||||||
|
AddScaledLine(_polylineLayer, rail.Points, RailTieColour, RailTieScreenPx);
|
||||||
|
AddScaledLine(_polylineLayer, rail.Points, RailColour, RailLineScreenPx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildBridges(WorldState world)
|
||||||
|
{
|
||||||
|
if (world.Bridges.Count == 0) return;
|
||||||
|
_bridgeLayer = AddNamedLayer("Bridges");
|
||||||
|
|
||||||
|
foreach (var bridge in world.Bridges)
|
||||||
|
{
|
||||||
|
var line = new Line2D
|
||||||
|
{
|
||||||
|
DefaultColor = BridgeColour,
|
||||||
|
JointMode = Line2D.LineJointMode.Round,
|
||||||
|
};
|
||||||
|
line.AddPoint(new Vector2(bridge.Start.X, bridge.Start.Y));
|
||||||
|
line.AddPoint(new Vector2(bridge.End.X, bridge.End.Y));
|
||||||
|
_bridgeLayer.AddChild(line);
|
||||||
|
_scaledLines.Add((line, BridgeScreenPx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildSettlements(WorldState world)
|
||||||
|
{
|
||||||
|
if (world.Settlements.Count == 0) return;
|
||||||
|
_settlementLayer = AddNamedLayer("Settlements");
|
||||||
|
|
||||||
|
foreach (var s in world.Settlements)
|
||||||
|
{
|
||||||
|
var (colour, tileRadius) = s.Tier switch
|
||||||
|
{
|
||||||
|
1 => (ColorByte(255, 215, 0), 2.5f),
|
||||||
|
2 => (ColorByte(230, 230, 230), 1.8f),
|
||||||
|
3 => (ColorByte(150, 200, 255), 1.3f),
|
||||||
|
4 => (ColorByte(200, 200, 200), 0.8f),
|
||||||
|
_ => (ColorByte(200, 60, 60), 0.7f),
|
||||||
|
};
|
||||||
|
float radius = tileRadius * C.WORLD_TILE_PIXELS;
|
||||||
|
var dot = new SettlementDot
|
||||||
|
{
|
||||||
|
Position = new Vector2(
|
||||||
|
s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
||||||
|
s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f),
|
||||||
|
Radius = radius,
|
||||||
|
FillColor = colour,
|
||||||
|
};
|
||||||
|
_settlementLayer.AddChild(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddScaledLine(Node2D parent, IReadOnlyList<Vec2> pts, Color colour, float screenPx)
|
||||||
|
{
|
||||||
|
var line = new Line2D
|
||||||
|
{
|
||||||
|
DefaultColor = colour,
|
||||||
|
JointMode = Line2D.LineJointMode.Round,
|
||||||
|
BeginCapMode = Line2D.LineCapMode.Round,
|
||||||
|
EndCapMode = Line2D.LineCapMode.Round,
|
||||||
|
Antialiased = false,
|
||||||
|
};
|
||||||
|
for (int i = 0; i < pts.Count; i++)
|
||||||
|
line.AddPoint(new Vector2(pts[i].X, pts[i].Y));
|
||||||
|
parent.AddChild(line);
|
||||||
|
_scaledLines.Add((line, screenPx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddCamera(float initialZoom)
|
||||||
|
{
|
||||||
|
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
||||||
|
Vector2 worldSize = new(
|
||||||
|
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS,
|
||||||
|
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS);
|
||||||
|
float fitZoom = Mathf.Min(viewport.X / worldSize.X, viewport.Y / worldSize.Y) * 0.95f;
|
||||||
|
float startZoom = initialZoom > 0f ? initialZoom : fitZoom;
|
||||||
|
|
||||||
|
_camera = new PanZoomCamera
|
||||||
|
{
|
||||||
|
Position = worldSize * 0.5f, // caller can reposition immediately after Initialize
|
||||||
|
Zoom = new Vector2(startZoom, startZoom),
|
||||||
|
MinZoom = fitZoom * 0.5f,
|
||||||
|
MaxZoom = 64f,
|
||||||
|
};
|
||||||
|
AddChild(_camera);
|
||||||
|
_camera.MakeCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Per-frame updates
|
||||||
|
|
||||||
|
private void UpdateLayerVisibility()
|
||||||
|
{
|
||||||
|
if (_camera is null) return;
|
||||||
|
float zoom = _camera.Zoom.X;
|
||||||
|
bool tactical = zoom >= TacticalRenderZoomMin;
|
||||||
|
|
||||||
|
if (_tacticalLayer is not null)
|
||||||
|
_tacticalLayer.Visible = tactical;
|
||||||
|
if (_settlementLayer is not null)
|
||||||
|
_settlementLayer.Visible = zoom < SettlementHideZoom;
|
||||||
|
|
||||||
|
// Polylines and bridges are baked into the tactical chunk surface
|
||||||
|
// tiles by TacticalChunkGen.Pass2_Polylines, so re-stroking the
|
||||||
|
// Line2D overlay at tactical zoom double-draws the road and shows
|
||||||
|
// as a brown line over top of the rasterised one. Hide the line
|
||||||
|
// overlay when tactical is active.
|
||||||
|
if (_polylineLayer is not null)
|
||||||
|
_polylineLayer.Visible = !tactical;
|
||||||
|
if (_bridgeLayer is not null)
|
||||||
|
_bridgeLayer.Visible = !tactical;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateZoomScaledNodes()
|
||||||
|
{
|
||||||
|
if (_camera is null) return;
|
||||||
|
float zoom = _camera.Zoom.X;
|
||||||
|
if (zoom <= 0f) return;
|
||||||
|
float invZoom = 1f / zoom;
|
||||||
|
foreach (var (line, baseScreenPx) in _scaledLines)
|
||||||
|
line.Width = baseScreenPx * invZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
private static int RoadDrawRank(Polyline r) => r.RoadClassification switch
|
||||||
|
{
|
||||||
|
RoadType.Footpath => 0,
|
||||||
|
RoadType.DirtRoad => 1,
|
||||||
|
RoadType.PostRoad => 2,
|
||||||
|
RoadType.Highway => 3,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Color ColorByte(byte r, byte g, byte b) =>
|
||||||
|
new(r / 255f, g / 255f, b / 255f);
|
||||||
|
|
||||||
|
private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ocean" => BiomeId.Ocean,
|
||||||
|
"tundra" => BiomeId.Tundra,
|
||||||
|
"boreal" => BiomeId.Boreal,
|
||||||
|
"temperate_deciduous" => BiomeId.TemperateDeciduous,
|
||||||
|
"temperate_grassland" => BiomeId.TemperateGrassland,
|
||||||
|
"mountain_alpine" => BiomeId.MountainAlpine,
|
||||||
|
"mountain_forested" => BiomeId.MountainForested,
|
||||||
|
"subtropical_forest" => BiomeId.SubtropicalForest,
|
||||||
|
"wetland" => BiomeId.Wetland,
|
||||||
|
"coastal" => BiomeId.Coastal,
|
||||||
|
"river_valley" => BiomeId.RiverValley,
|
||||||
|
"scrubland" => BiomeId.Scrubland,
|
||||||
|
"desert_cold" => BiomeId.DesertCold,
|
||||||
|
"forest_edge" => BiomeId.ForestEdge,
|
||||||
|
"foothills" => BiomeId.Foothills,
|
||||||
|
"marsh_edge" => BiomeId.MarshEdge,
|
||||||
|
"beach" => BiomeId.Beach,
|
||||||
|
"cliff" => BiomeId.Cliff,
|
||||||
|
"tidal_flat" => BiomeId.TidalFlat,
|
||||||
|
"mangrove" => BiomeId.Mangrove,
|
||||||
|
_ => BiomeId.TemperateGrassland,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,37 +1,24 @@
|
|||||||
using Godot;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using Godot;
|
||||||
using Theriapolis.Core;
|
using Theriapolis.Core;
|
||||||
using Theriapolis.Core.Tactical;
|
using Theriapolis.Core.Tactical;
|
||||||
using Theriapolis.Core.Util;
|
using Theriapolis.Core.Util;
|
||||||
using Theriapolis.Core.World;
|
|
||||||
using Theriapolis.Core.World.Generation;
|
using Theriapolis.Core.World.Generation;
|
||||||
using Theriapolis.Core.World.Polylines;
|
|
||||||
using Theriapolis.GodotHost.Platform;
|
using Theriapolis.GodotHost.Platform;
|
||||||
|
|
||||||
namespace Theriapolis.GodotHost.Rendering;
|
namespace Theriapolis.GodotHost.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unified seamless-zoom view (CLAUDE.md "Seamless Zoom Model"). One scene
|
/// Standalone demo entry point for the M2+M4 unified seamless-zoom view.
|
||||||
/// covers world-map and tactical scales; layers fade in/out at zoom
|
/// Runs worldgen inline, spawns a placeholder player, and lets you walk
|
||||||
/// thresholds. Polyline widths and the player marker counter-scale with
|
/// around with WASD. Used by the <c>--world-map</c> and <c>--tactical</c>
|
||||||
/// zoom so they stay visually consistent across the full range.
|
/// CLI flags for headless / debug viewing without going through the
|
||||||
|
/// title → wizard → progress flow.
|
||||||
///
|
///
|
||||||
/// Layers, bottom-up:
|
/// The actual rendering — biome / polyline / settlement / chunk layers
|
||||||
/// BiomeLayer — 256x256 biome image, scaled by WORLD_TILE_PIXELS;
|
/// and the camera — lives in <see cref="WorldRenderNode"/>, which is
|
||||||
/// always visible. Acts as the backdrop past the
|
/// shared with M7's <see cref="Scenes.PlayScreen"/>. This shell just
|
||||||
/// tactical streaming radius.
|
/// owns the demo's local player position and streaming loop.
|
||||||
/// TacticalChunks — TacticalChunkNode children added on chunk load;
|
|
||||||
/// visible only when zoom > TacticalRenderZoomMin.
|
|
||||||
/// Polylines/Bridges — Line2D children; always visible. Widths counter-
|
|
||||||
/// scaled per frame.
|
|
||||||
/// Settlements — SettlementDot children; visible only when zoom
|
|
||||||
/// < SettlementHideZoom.
|
|
||||||
/// Player — Always visible; counter-scaled.
|
|
||||||
///
|
|
||||||
/// Camera follows the player at all zooms; right-drag temporarily pans
|
|
||||||
/// (PanZoomCamera handles drag input).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class WorldView : Node2D
|
public partial class WorldView : Node2D
|
||||||
{
|
{
|
||||||
@@ -40,56 +27,22 @@ public partial class WorldView : Node2D
|
|||||||
private readonly int _startWorldTileY;
|
private readonly int _startWorldTileY;
|
||||||
private readonly float _initialZoom;
|
private readonly float _initialZoom;
|
||||||
|
|
||||||
// Zoom thresholds, in Camera2D zoom units (1.0 = 1 world px per screen px,
|
|
||||||
// 32.0 = sprite-native tactical view, ~0.07 = world fits 1080p).
|
|
||||||
private const float TacticalRenderZoomMin = 4.0f;
|
|
||||||
private const float SettlementHideZoom = 2.0f;
|
|
||||||
private const float StreamRadiusZoomMin = 4.0f;
|
|
||||||
|
|
||||||
// World-pixel movement speed. 32 wp = 1 world tile, so 96 = ~3 tiles/sec.
|
// World-pixel movement speed. 32 wp = 1 world tile, so 96 = ~3 tiles/sec.
|
||||||
private const float MoveSpeedWorldPx = 96f;
|
private const float MoveSpeedWorldPx = 96f;
|
||||||
private const int StreamingBufferWorldTiles = 2;
|
private const int StreamingBufferWorldTiles = 2;
|
||||||
|
private const float StreamRadiusZoomMin = WorldRenderNode.TacticalRenderZoomMin;
|
||||||
// Polyline base widths in *screen* pixels (counter-scaled to world space
|
|
||||||
// per frame). Mirrors the differentiation in LineFeatureRenderer.cs.
|
|
||||||
private const float HighwayScreenPx = 4f;
|
|
||||||
private const float PostRoadScreenPx = 3f;
|
|
||||||
private const float DirtRoadScreenPx = 2f;
|
|
||||||
private const float RiverMajorScreenPx = 4.5f;
|
|
||||||
private const float RiverScreenPx = 3f;
|
|
||||||
private const float StreamScreenPx = 2f;
|
|
||||||
private const float RailTieScreenPx = 4f;
|
|
||||||
private const float RailLineScreenPx = 2f;
|
|
||||||
private const float BridgeScreenPx = 6f;
|
|
||||||
|
|
||||||
// Polyline colours mirror LineFeatureRenderer.cs / WorldgenDump.cs.
|
|
||||||
private static readonly Color RiverMajorColour = ColorByte(40, 100, 200);
|
|
||||||
private static readonly Color RiverColour = ColorByte(60, 120, 200);
|
|
||||||
private static readonly Color StreamColour = ColorByte(100, 150, 220);
|
|
||||||
private static readonly Color HighwayColour = ColorByte(210, 180, 80);
|
|
||||||
private static readonly Color PostRoadColour = ColorByte(180, 155, 70);
|
|
||||||
private static readonly Color DirtRoadColour = ColorByte(150, 130, 90);
|
|
||||||
private static readonly Color RailTieColour = ColorByte(120, 100, 80);
|
|
||||||
private static readonly Color RailColour = ColorByte(80, 65, 50);
|
|
||||||
private static readonly Color BridgeColour = ColorByte(160, 140, 100);
|
|
||||||
|
|
||||||
private ChunkStreamer? _streamer;
|
private ChunkStreamer? _streamer;
|
||||||
|
private WorldRenderNode? _render;
|
||||||
private Vec2 _playerPos;
|
private Vec2 _playerPos;
|
||||||
private PanZoomCamera? _camera;
|
|
||||||
private Node2D? _tacticalLayer;
|
|
||||||
private Node2D? _polylineLayer;
|
|
||||||
private Node2D? _bridgeLayer;
|
|
||||||
private Node2D? _settlementLayer;
|
|
||||||
private PlayerMarker? _playerMarker;
|
private PlayerMarker? _playerMarker;
|
||||||
private readonly Dictionary<ChunkCoord, TacticalChunkNode> _chunkNodes = new();
|
|
||||||
private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new();
|
|
||||||
|
|
||||||
public WorldView(ulong seed, int startWorldTileX = 128, int startWorldTileY = 128, float initialZoom = 0f)
|
public WorldView(ulong seed, int startWorldTileX = 128, int startWorldTileY = 128, float initialZoom = 0f)
|
||||||
{
|
{
|
||||||
_seed = seed;
|
_seed = seed;
|
||||||
_startWorldTileX = startWorldTileX;
|
_startWorldTileX = startWorldTileX;
|
||||||
_startWorldTileY = startWorldTileY;
|
_startWorldTileY = startWorldTileY;
|
||||||
_initialZoom = initialZoom; // 0 = compute fit-to-viewport
|
_initialZoom = initialZoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
@@ -109,17 +62,13 @@ public partial class WorldView : Node2D
|
|||||||
$"roads={world.Roads.Count} rails={world.Rails.Count} " +
|
$"roads={world.Roads.Count} rails={world.Rails.Count} " +
|
||||||
$"settlements={world.Settlements.Count} bridges={world.Bridges.Count}");
|
$"settlements={world.Settlements.Count} bridges={world.Bridges.Count}");
|
||||||
|
|
||||||
|
_render = new WorldRenderNode();
|
||||||
|
AddChild(_render);
|
||||||
|
_render.Initialize(world, _initialZoom);
|
||||||
|
|
||||||
_streamer = new ChunkStreamer(_seed, world, new InMemoryChunkDeltaStore());
|
_streamer = new ChunkStreamer(_seed, world, new InMemoryChunkDeltaStore());
|
||||||
_streamer.OnChunkLoaded += AddChunkNode;
|
_streamer.OnChunkLoaded += _render.AddChunkNode;
|
||||||
_streamer.OnChunkEvicting += RemoveChunkNode;
|
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
|
||||||
|
|
||||||
TacticalAtlas.EnsureLoaded();
|
|
||||||
|
|
||||||
BuildBiomeSprite(world);
|
|
||||||
_tacticalLayer = AddNamedLayer("TacticalChunks");
|
|
||||||
BuildPolylines(world);
|
|
||||||
BuildBridges(world);
|
|
||||||
BuildSettlements(world);
|
|
||||||
|
|
||||||
_playerPos = new Vec2(
|
_playerPos = new Vec2(
|
||||||
_startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
_startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
||||||
@@ -128,20 +77,19 @@ public partial class WorldView : Node2D
|
|||||||
_playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) };
|
_playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) };
|
||||||
AddChild(_playerMarker);
|
AddChild(_playerMarker);
|
||||||
|
|
||||||
AddCamera();
|
_render.Camera.Position = new Vector2(_playerPos.X, _playerPos.Y);
|
||||||
UpdateLayerVisibility();
|
|
||||||
StreamIfTactical();
|
StreamIfTactical();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void _Process(double delta)
|
public override void _Process(double delta)
|
||||||
{
|
{
|
||||||
if (_camera is null || _playerMarker is null) return;
|
if (_render is null || _playerMarker is null) return;
|
||||||
|
|
||||||
Vector2 dir = Vector2.Zero;
|
Vector2 dir = Vector2.Zero;
|
||||||
if (Input.IsKeyPressed(Key.W) || Input.IsKeyPressed(Key.Up)) dir.Y -= 1;
|
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dir.Y -= 1;
|
||||||
if (Input.IsKeyPressed(Key.S) || Input.IsKeyPressed(Key.Down)) dir.Y += 1;
|
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dir.Y += 1;
|
||||||
if (Input.IsKeyPressed(Key.A) || Input.IsKeyPressed(Key.Left)) dir.X -= 1;
|
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dir.X -= 1;
|
||||||
if (Input.IsKeyPressed(Key.D) || Input.IsKeyPressed(Key.Right)) dir.X += 1;
|
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dir.X += 1;
|
||||||
|
|
||||||
if (dir != Vector2.Zero)
|
if (dir != Vector2.Zero)
|
||||||
{
|
{
|
||||||
@@ -160,309 +108,24 @@ public partial class WorldView : Node2D
|
|||||||
|
|
||||||
var pos = new Vector2(_playerPos.X, _playerPos.Y);
|
var pos = new Vector2(_playerPos.X, _playerPos.Y);
|
||||||
_playerMarker.Position = pos;
|
_playerMarker.Position = pos;
|
||||||
_camera.Position = pos;
|
_render.Camera.Position = pos;
|
||||||
|
|
||||||
UpdateLayerVisibility();
|
// Counter-scale the marker so its on-screen size stays constant.
|
||||||
UpdateZoomScaledNodes();
|
float zoom = _render.Camera.Zoom.X;
|
||||||
}
|
if (zoom > 0f)
|
||||||
|
_playerMarker.Scale = new Vector2(1f / zoom, 1f / zoom);
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
|
||||||
// Layer construction
|
|
||||||
|
|
||||||
private void BuildBiomeSprite(WorldState world)
|
|
||||||
{
|
|
||||||
int W = C.WORLD_WIDTH_TILES;
|
|
||||||
int H = C.WORLD_HEIGHT_TILES;
|
|
||||||
|
|
||||||
var palette = new Color[(int)BiomeId.Mangrove + 1];
|
|
||||||
foreach (var def in world.BiomeDefs!)
|
|
||||||
{
|
|
||||||
var (r, g, b) = def.ParsedColor();
|
|
||||||
int id = (int)ParseBiomeId(def.Id);
|
|
||||||
if (id >= 0 && id < palette.Length) palette[id] = ColorByte(r, g, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
var image = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
|
|
||||||
for (int y = 0; y < H; y++)
|
|
||||||
for (int x = 0; x < W; x++)
|
|
||||||
{
|
|
||||||
int id = (int)world.Tiles[x, y].Biome;
|
|
||||||
Color c = (id >= 0 && id < palette.Length && palette[id].A > 0f)
|
|
||||||
? palette[id]
|
|
||||||
: ColorByte(255, 0, 255);
|
|
||||||
image.SetPixel(x, y, c);
|
|
||||||
}
|
|
||||||
|
|
||||||
var sprite = new Sprite2D
|
|
||||||
{
|
|
||||||
Texture = ImageTexture.CreateFromImage(image),
|
|
||||||
Centered = false,
|
|
||||||
Scale = new Vector2(C.WORLD_TILE_PIXELS, C.WORLD_TILE_PIXELS),
|
|
||||||
TextureFilter = TextureFilterEnum.Nearest,
|
|
||||||
Name = "Biome",
|
|
||||||
};
|
|
||||||
AddChild(sprite);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Node2D AddNamedLayer(string name)
|
|
||||||
{
|
|
||||||
var n = new Node2D { Name = name };
|
|
||||||
AddChild(n);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildPolylines(WorldState world)
|
|
||||||
{
|
|
||||||
_polylineLayer = AddNamedLayer("Polylines");
|
|
||||||
|
|
||||||
foreach (var road in world.Roads.OrderBy(RoadDrawRank))
|
|
||||||
{
|
|
||||||
var (color, screenPx) = road.RoadClassification switch
|
|
||||||
{
|
|
||||||
RoadType.Highway => (HighwayColour, HighwayScreenPx),
|
|
||||||
RoadType.PostRoad => (PostRoadColour, PostRoadScreenPx),
|
|
||||||
_ => (DirtRoadColour, DirtRoadScreenPx),
|
|
||||||
};
|
|
||||||
AddScaledLine(_polylineLayer, road.Points, color, screenPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var river in world.Rivers)
|
|
||||||
{
|
|
||||||
var (color, screenPx) = river.RiverClassification switch
|
|
||||||
{
|
|
||||||
RiverClass.MajorRiver => (RiverMajorColour, RiverMajorScreenPx),
|
|
||||||
RiverClass.River => (RiverColour, RiverScreenPx),
|
|
||||||
_ => (StreamColour, StreamScreenPx),
|
|
||||||
};
|
|
||||||
float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f;
|
|
||||||
AddScaledLine(_polylineLayer, river.Points, color,
|
|
||||||
Mathf.Min(screenPx * flowScale, RiverMajorScreenPx * 1.5f));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var rail in world.Rails)
|
|
||||||
{
|
|
||||||
AddScaledLine(_polylineLayer, rail.Points, RailTieColour, RailTieScreenPx);
|
|
||||||
AddScaledLine(_polylineLayer, rail.Points, RailColour, RailLineScreenPx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildBridges(WorldState world)
|
|
||||||
{
|
|
||||||
if (world.Bridges.Count == 0) return;
|
|
||||||
_bridgeLayer = AddNamedLayer("Bridges");
|
|
||||||
|
|
||||||
foreach (var bridge in world.Bridges)
|
|
||||||
{
|
|
||||||
var line = new Line2D
|
|
||||||
{
|
|
||||||
DefaultColor = BridgeColour,
|
|
||||||
JointMode = Line2D.LineJointMode.Round,
|
|
||||||
};
|
|
||||||
line.AddPoint(new Vector2(bridge.Start.X, bridge.Start.Y));
|
|
||||||
line.AddPoint(new Vector2(bridge.End.X, bridge.End.Y));
|
|
||||||
_bridgeLayer.AddChild(line);
|
|
||||||
_scaledLines.Add((line, BridgeScreenPx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildSettlements(WorldState world)
|
|
||||||
{
|
|
||||||
if (world.Settlements.Count == 0) return;
|
|
||||||
_settlementLayer = AddNamedLayer("Settlements");
|
|
||||||
|
|
||||||
foreach (var s in world.Settlements)
|
|
||||||
{
|
|
||||||
var (colour, tileRadius) = s.Tier switch
|
|
||||||
{
|
|
||||||
1 => (ColorByte(255, 215, 0), 2.5f),
|
|
||||||
2 => (ColorByte(230, 230, 230), 1.8f),
|
|
||||||
3 => (ColorByte(150, 200, 255), 1.3f),
|
|
||||||
4 => (ColorByte(200, 200, 200), 0.8f),
|
|
||||||
_ => (ColorByte(200, 60, 60), 0.7f),
|
|
||||||
};
|
|
||||||
float radius = tileRadius * C.WORLD_TILE_PIXELS;
|
|
||||||
var dot = new SettlementDot
|
|
||||||
{
|
|
||||||
Position = new Vector2(
|
|
||||||
s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
|
||||||
s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f),
|
|
||||||
Radius = radius,
|
|
||||||
FillColor = colour,
|
|
||||||
};
|
|
||||||
_settlementLayer.AddChild(dot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddScaledLine(Node2D parent, IReadOnlyList<Vec2> pts, Color colour, float screenPx)
|
|
||||||
{
|
|
||||||
var line = new Line2D
|
|
||||||
{
|
|
||||||
DefaultColor = colour,
|
|
||||||
JointMode = Line2D.LineJointMode.Round,
|
|
||||||
BeginCapMode = Line2D.LineCapMode.Round,
|
|
||||||
EndCapMode = Line2D.LineCapMode.Round,
|
|
||||||
Antialiased = false,
|
|
||||||
};
|
|
||||||
for (int i = 0; i < pts.Count; i++)
|
|
||||||
line.AddPoint(new Vector2(pts[i].X, pts[i].Y));
|
|
||||||
parent.AddChild(line);
|
|
||||||
_scaledLines.Add((line, screenPx));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddCamera()
|
|
||||||
{
|
|
||||||
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
|
||||||
Vector2 worldSize = new(
|
|
||||||
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS,
|
|
||||||
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS);
|
|
||||||
float fitZoom = Mathf.Min(viewport.X / worldSize.X, viewport.Y / worldSize.Y) * 0.95f;
|
|
||||||
float startZoom = _initialZoom > 0f ? _initialZoom : fitZoom;
|
|
||||||
|
|
||||||
_camera = new PanZoomCamera
|
|
||||||
{
|
|
||||||
Position = new Vector2(_playerPos.X, _playerPos.Y),
|
|
||||||
Zoom = new Vector2(startZoom, startZoom),
|
|
||||||
MinZoom = fitZoom * 0.5f,
|
|
||||||
MaxZoom = 64f,
|
|
||||||
};
|
|
||||||
AddChild(_camera);
|
|
||||||
_camera.MakeCurrent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
|
||||||
// Per-frame updates
|
|
||||||
|
|
||||||
private void UpdateLayerVisibility()
|
|
||||||
{
|
|
||||||
if (_camera is null) return;
|
|
||||||
float zoom = _camera.Zoom.X;
|
|
||||||
if (_tacticalLayer is not null)
|
|
||||||
_tacticalLayer.Visible = zoom >= TacticalRenderZoomMin;
|
|
||||||
if (_settlementLayer is not null)
|
|
||||||
_settlementLayer.Visible = zoom < SettlementHideZoom;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateZoomScaledNodes()
|
|
||||||
{
|
|
||||||
if (_camera is null) return;
|
|
||||||
float zoom = _camera.Zoom.X;
|
|
||||||
if (zoom <= 0f) return;
|
|
||||||
float invZoom = 1f / zoom;
|
|
||||||
|
|
||||||
foreach (var (line, baseScreenPx) in _scaledLines)
|
|
||||||
line.Width = baseScreenPx * invZoom;
|
|
||||||
|
|
||||||
if (_playerMarker is not null)
|
|
||||||
_playerMarker.Scale = new Vector2(invZoom, invZoom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StreamIfTactical()
|
private void StreamIfTactical()
|
||||||
{
|
{
|
||||||
if (_streamer is null) return;
|
if (_streamer is null || _render is null) return;
|
||||||
if (_camera is null || _camera.Zoom.X < StreamRadiusZoomMin)
|
if (_render.Camera.Zoom.X < StreamRadiusZoomMin) return;
|
||||||
{
|
|
||||||
// Optional: evict everything outside a small fallback set so we
|
|
||||||
// don't keep a stale tactical cache when zoomed out for a long
|
|
||||||
// time. Skipping for M4 — soft cap in the streamer handles it.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
||||||
float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _camera.Zoom.X * 0.5f;
|
float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _render.Camera.Zoom.X * 0.5f;
|
||||||
int halfExtentTiles = Mathf.CeilToInt(halfExtentWorldPx / C.WORLD_TILE_PIXELS);
|
int halfExtentTiles = Mathf.CeilToInt(halfExtentWorldPx / C.WORLD_TILE_PIXELS);
|
||||||
int radius = halfExtentTiles + StreamingBufferWorldTiles;
|
int radius = halfExtentTiles + StreamingBufferWorldTiles;
|
||||||
|
|
||||||
_streamer.EnsureLoadedAround(_playerPos, radius);
|
_streamer.EnsureLoadedAround(_playerPos, radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
|
||||||
// Chunk node lifecycle
|
|
||||||
|
|
||||||
private void AddChunkNode(TacticalChunk chunk)
|
|
||||||
{
|
|
||||||
if (_tacticalLayer is null) return;
|
|
||||||
if (_chunkNodes.ContainsKey(chunk.Coord)) return;
|
|
||||||
|
|
||||||
var node = new TacticalChunkNode { Name = $"Chunk{chunk.Coord.X}_{chunk.Coord.Y}" };
|
|
||||||
_tacticalLayer.AddChild(node);
|
|
||||||
node.Bind(chunk);
|
|
||||||
_chunkNodes[chunk.Coord] = node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveChunkNode(TacticalChunk chunk)
|
|
||||||
{
|
|
||||||
if (!_chunkNodes.TryGetValue(chunk.Coord, out var node)) return;
|
|
||||||
node.QueueFree();
|
|
||||||
_chunkNodes.Remove(chunk.Coord);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
|
||||||
// Helpers
|
|
||||||
|
|
||||||
private static int RoadDrawRank(Polyline r) => r.RoadClassification switch
|
|
||||||
{
|
|
||||||
RoadType.Footpath => 0,
|
|
||||||
RoadType.DirtRoad => 1,
|
|
||||||
RoadType.PostRoad => 2,
|
|
||||||
RoadType.Highway => 3,
|
|
||||||
_ => 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static Color ColorByte(byte r, byte g, byte b) =>
|
|
||||||
new(r / 255f, g / 255f, b / 255f);
|
|
||||||
|
|
||||||
private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"ocean" => BiomeId.Ocean,
|
|
||||||
"tundra" => BiomeId.Tundra,
|
|
||||||
"boreal" => BiomeId.Boreal,
|
|
||||||
"temperate_deciduous" => BiomeId.TemperateDeciduous,
|
|
||||||
"temperate_grassland" => BiomeId.TemperateGrassland,
|
|
||||||
"mountain_alpine" => BiomeId.MountainAlpine,
|
|
||||||
"mountain_forested" => BiomeId.MountainForested,
|
|
||||||
"subtropical_forest" => BiomeId.SubtropicalForest,
|
|
||||||
"wetland" => BiomeId.Wetland,
|
|
||||||
"coastal" => BiomeId.Coastal,
|
|
||||||
"river_valley" => BiomeId.RiverValley,
|
|
||||||
"scrubland" => BiomeId.Scrubland,
|
|
||||||
"desert_cold" => BiomeId.DesertCold,
|
|
||||||
"forest_edge" => BiomeId.ForestEdge,
|
|
||||||
"foothills" => BiomeId.Foothills,
|
|
||||||
"marsh_edge" => BiomeId.MarshEdge,
|
|
||||||
"beach" => BiomeId.Beach,
|
|
||||||
"cliff" => BiomeId.Cliff,
|
|
||||||
"tidal_flat" => BiomeId.TidalFlat,
|
|
||||||
"mangrove" => BiomeId.Mangrove,
|
|
||||||
_ => BiomeId.TemperateGrassland,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filled circle settlement marker on the world map. Sized in world-pixel
|
|
||||||
/// space (parent layer's visibility flag handles the world-vs-tactical
|
|
||||||
/// hide threshold).
|
|
||||||
/// </summary>
|
|
||||||
public partial class SettlementDot : Node2D
|
|
||||||
{
|
|
||||||
public float Radius { get; set; } = 8f;
|
|
||||||
public Color FillColor { get; set; } = Colors.White;
|
|
||||||
|
|
||||||
public override void _Draw() => DrawCircle(Vector2.Zero, Radius, FillColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Player marker. Drawn at <see cref="C.PLAYER_MARKER_SCREEN_PX"/>/2 wp;
|
|
||||||
/// parent WorldView sets <see cref="Node2D.Scale"/> = 1/zoom every frame
|
|
||||||
/// so the on-screen size stays constant (~24 px radius / 48 px diameter,
|
|
||||||
/// matching MonoGame's PlayerSprite) across the seamless zoom range.
|
|
||||||
/// </summary>
|
|
||||||
public partial class PlayerMarker : Node2D
|
|
||||||
{
|
|
||||||
private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f;
|
|
||||||
|
|
||||||
public override void _Draw()
|
|
||||||
{
|
|
||||||
DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f));
|
|
||||||
DrawCircle(Vector2.Zero, RadiusWorldPx * 0.85f, new Color(0.86f, 0.31f, 0.24f));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,14 @@ public partial class Aside : MarginContainer
|
|||||||
|
|
||||||
private void BuildDetailsGrid()
|
private void BuildDetailsGrid()
|
||||||
{
|
{
|
||||||
if (_draft!.IsHybrid)
|
string sexLabel = _draft!.Sex switch
|
||||||
|
{
|
||||||
|
"male" => "Male",
|
||||||
|
"female" => "Female",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_draft.IsHybrid)
|
||||||
{
|
{
|
||||||
// Hybrid layout: SIRE / DAM column headers above the parent
|
// Hybrid layout: SIRE / DAM column headers above the parent
|
||||||
// detail rows, then the Calling / Background row spans both
|
// detail rows, then the Calling / Background row spans both
|
||||||
@@ -102,6 +109,10 @@ public partial class Aside : MarginContainer
|
|||||||
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.DamCladeId)?.Name));
|
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.DamCladeId)?.Name));
|
||||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SireSpeciesId)?.Name));
|
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SireSpeciesId)?.Name));
|
||||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.DamSpeciesId)?.Name));
|
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.DamSpeciesId)?.Name));
|
||||||
|
// Sex is a character-level field, not per-parent — span left
|
||||||
|
// column with an empty filler in the right.
|
||||||
|
lineageGrid.AddChild(MakeCell("Sex", sexLabel));
|
||||||
|
lineageGrid.AddChild(new Control());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -109,6 +120,8 @@ public partial class Aside : MarginContainer
|
|||||||
_content.AddChild(lineageGrid);
|
_content.AddChild(lineageGrid);
|
||||||
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.CladeId)?.Name));
|
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.CladeId)?.Name));
|
||||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name));
|
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name));
|
||||||
|
lineageGrid.AddChild(MakeCell("Sex", sexLabel));
|
||||||
|
lineageGrid.AddChild(new Control());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calling + Background — last row of the lineage block, with
|
// Calling + Background — last row of the lineage block, with
|
||||||
@@ -269,10 +282,14 @@ public partial class Aside : MarginContainer
|
|||||||
// (single-pick each, per doc) plus the four universal detriments.
|
// (single-pick each, per doc) plus the four universal detriments.
|
||||||
if (_draft.IsHybrid)
|
if (_draft.IsHybrid)
|
||||||
{
|
{
|
||||||
AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.SireSpeciesId),
|
var sireSp = CodexContent.SpeciesById(_draft.SireSpeciesId);
|
||||||
|
var damSp = CodexContent.SpeciesById(_draft.DamSpeciesId);
|
||||||
|
AddPickedSpeciesPick(flow, sireSp,
|
||||||
_draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
|
_draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
|
||||||
AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.DamSpeciesId),
|
AddPickedSpeciesPick(flow, damSp,
|
||||||
_draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment);
|
_draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment);
|
||||||
|
AddVariantContent(flow, sireSp, _draft.ResolveVariantId(sireSp, "sire"));
|
||||||
|
AddVariantContent(flow, damSp, _draft.ResolveVariantId(damSp, "dam"));
|
||||||
|
|
||||||
// Universal hybrid detriments — every hybrid has all four.
|
// Universal hybrid detriments — every hybrid has all four.
|
||||||
foreach (var (name, desc) in UniversalHybridDetriments)
|
foreach (var (name, desc) in UniversalHybridDetriments)
|
||||||
@@ -280,7 +297,9 @@ public partial class Aside : MarginContainer
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.SpeciesId));
|
var sp = CodexContent.SpeciesById(_draft.SpeciesId);
|
||||||
|
AddSpeciesTraits(flow, sp);
|
||||||
|
AddVariantContent(flow, sp, _draft.ResolveVariantId(sp, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Class level-1 features.
|
// Class level-1 features.
|
||||||
@@ -362,6 +381,22 @@ public partial class Aside : MarginContainer
|
|||||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Render the resolved variant's extra traits/detriments,
|
||||||
|
/// if any. <paramref name="variantId"/> is the variant key (e.g. "male"
|
||||||
|
/// or "sheep"); empty when no resolution applies.</summary>
|
||||||
|
private static void AddVariantContent(HFlowContainer flow,
|
||||||
|
Theriapolis.Core.Data.SpeciesDef? species,
|
||||||
|
string variantId)
|
||||||
|
{
|
||||||
|
if (species is null || string.IsNullOrEmpty(variantId)) return;
|
||||||
|
var variant = System.Array.Find(species.Variants, v => v.Id == variantId);
|
||||||
|
if (variant is null) return;
|
||||||
|
foreach (var t in variant.Traits)
|
||||||
|
flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||||
|
foreach (var d in variant.Detriments)
|
||||||
|
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Hybrid: one chosen species trait + one chosen species detriment.</summary>
|
/// <summary>Hybrid: one chosen species trait + one chosen species detriment.</summary>
|
||||||
private static void AddPickedSpeciesPick(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species,
|
private static void AddPickedSpeciesPick(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species,
|
||||||
string chosenTraitId, string chosenDetrimentId)
|
string chosenTraitId, string chosenDetrimentId)
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
using Theriapolis.Core.Data;
|
||||||
|
using Theriapolis.Core.Entities;
|
||||||
|
using Theriapolis.Core.Rules.Dialogue;
|
||||||
|
using Theriapolis.Core.Rules.Reputation;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M7.5 — dialogue overlay. Pushed by <see cref="PlayScreen"/> when the
|
||||||
|
/// player presses F next to a friendly / neutral NPC. Mirrors
|
||||||
|
/// <c>Theriapolis.Game/Screens/InteractionScreen.cs</c> from the MonoGame
|
||||||
|
/// build: speaker header + bias / disposition tag + optional Scent Literacy
|
||||||
|
/// overlay; scrollback of the last <see cref="C.DIALOGUE_HISTORY_LINES"/>
|
||||||
|
/// entries; numbered option list; number-key + Esc / F input.
|
||||||
|
///
|
||||||
|
/// Effect routing after each <c>ChooseOption</c>:
|
||||||
|
/// - Drains <see cref="DialogueContext.StartQuestRequests"/> into
|
||||||
|
/// <c>playScreen.QuestEngine.Start</c> so quest journal entries
|
||||||
|
/// land in the right order (the journal UI is M8 territory but the
|
||||||
|
/// engine fires immediately).
|
||||||
|
/// - When <see cref="DialogueContext.ShopRequested"/> flips true, M7
|
||||||
|
/// surfaces a toast — the real ShopScreen lands with M8.
|
||||||
|
/// </summary>
|
||||||
|
public partial class InteractionScreen : CanvasLayer
|
||||||
|
{
|
||||||
|
private readonly NpcActor _npc;
|
||||||
|
private readonly PlayScreen _playScreen;
|
||||||
|
private DialogueRunner? _runner;
|
||||||
|
|
||||||
|
private Control _root = null!;
|
||||||
|
private VBoxContainer _historyPanel = null!;
|
||||||
|
private VBoxContainer _optionsPanel = null!;
|
||||||
|
private bool _consumedOpeningKeys;
|
||||||
|
|
||||||
|
public InteractionScreen(NpcActor npc, PlayScreen playScreen)
|
||||||
|
{
|
||||||
|
_npc = npc ?? throw new ArgumentNullException(nameof(npc));
|
||||||
|
_playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
Layer = 50;
|
||||||
|
ProcessMode = ProcessModeEnum.WhenPaused;
|
||||||
|
GetTree().Paused = true;
|
||||||
|
|
||||||
|
_runner = TryBuildRunner();
|
||||||
|
BuildLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DialogueRunner? TryBuildRunner()
|
||||||
|
{
|
||||||
|
var content = _playScreen.Content;
|
||||||
|
var pc = _playScreen.PlayerCharacter();
|
||||||
|
if (content is null || pc is null) return null;
|
||||||
|
if (string.IsNullOrEmpty(_npc.DialogueId)) return null;
|
||||||
|
if (!content.Dialogues.TryGetValue(_npc.DialogueId, out var tree)) return null;
|
||||||
|
|
||||||
|
var pos = _playScreen.PlayerPosition;
|
||||||
|
var ctx = new DialogueContext(_npc, pc, _playScreen.Reputation, _playScreen.Flags, content)
|
||||||
|
{
|
||||||
|
PlayerWorldTileX = (int)(pos.X / C.WORLD_TILE_PIXELS),
|
||||||
|
PlayerWorldTileY = (int)(pos.Y / C.WORLD_TILE_PIXELS),
|
||||||
|
WorldClockSeconds = _playScreen.ClockSeconds,
|
||||||
|
};
|
||||||
|
return new DialogueRunner(tree, ctx, _playScreen.WorldSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildLayout()
|
||||||
|
{
|
||||||
|
_root = new Control
|
||||||
|
{
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Stop,
|
||||||
|
ProcessMode = ProcessModeEnum.WhenPaused,
|
||||||
|
};
|
||||||
|
_root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||||
|
AddChild(_root);
|
||||||
|
|
||||||
|
var scrim = new ColorRect
|
||||||
|
{
|
||||||
|
Color = new Color(0, 0, 0, 0.55f),
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||||
|
_root.AddChild(scrim);
|
||||||
|
|
||||||
|
var center = new CenterContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
|
||||||
|
center.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||||
|
_root.AddChild(center);
|
||||||
|
|
||||||
|
var panel = new PanelContainer
|
||||||
|
{
|
||||||
|
ThemeTypeVariation = "Card",
|
||||||
|
Theme = CodexTheme.Build(),
|
||||||
|
CustomMinimumSize = new Vector2(760, 0),
|
||||||
|
};
|
||||||
|
center.AddChild(panel);
|
||||||
|
|
||||||
|
var margin = new MarginContainer();
|
||||||
|
margin.AddThemeConstantOverride("margin_left", 28);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", 28);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", 22);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", 22);
|
||||||
|
panel.AddChild(margin);
|
||||||
|
|
||||||
|
var col = new VBoxContainer();
|
||||||
|
col.AddThemeConstantOverride("separation", 10);
|
||||||
|
margin.AddChild(col);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
col.AddChild(BuildHeader());
|
||||||
|
|
||||||
|
// Spacer
|
||||||
|
col.AddChild(new Control { CustomMinimumSize = new Vector2(0, 4) });
|
||||||
|
|
||||||
|
// History
|
||||||
|
_historyPanel = new VBoxContainer { CustomMinimumSize = new Vector2(680, 0) };
|
||||||
|
_historyPanel.AddThemeConstantOverride("separation", 4);
|
||||||
|
col.AddChild(_historyPanel);
|
||||||
|
|
||||||
|
// Options
|
||||||
|
_optionsPanel = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||||
|
_optionsPanel.AddThemeConstantOverride("separation", 4);
|
||||||
|
col.AddChild(_optionsPanel);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "(1-9 to choose · Esc to leave · F also closes)",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control BuildHeader()
|
||||||
|
{
|
||||||
|
var header = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter };
|
||||||
|
header.AddThemeConstantOverride("separation", 2);
|
||||||
|
|
||||||
|
header.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = _npc.DisplayName,
|
||||||
|
ThemeTypeVariation = "H2",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
string roleLine = FormatRoleLine(_npc.RoleTag);
|
||||||
|
if (!string.IsNullOrEmpty(roleLine))
|
||||||
|
{
|
||||||
|
header.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = roleLine,
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = _playScreen.Content;
|
||||||
|
var pc = _playScreen.PlayerCharacter();
|
||||||
|
if (content is not null && pc is not null)
|
||||||
|
{
|
||||||
|
var br = EffectiveDisposition.Breakdown(
|
||||||
|
_npc, pc, _playScreen.Reputation, content,
|
||||||
|
_playScreen.World, _playScreen.WorldSeed);
|
||||||
|
string profile = content.BiasProfiles.TryGetValue(_npc.BiasProfileId, out var bp)
|
||||||
|
? bp.Name : _npc.BiasProfileId;
|
||||||
|
header.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = $"[{profile}] · {DispositionLabels.DisplayName(br.Label)} {br.Total:+#;-#;0}",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
string? scentLine = ScentReadingFor(_npc, pc);
|
||||||
|
if (scentLine is not null)
|
||||||
|
{
|
||||||
|
header.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = scentLine,
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Refresh()
|
||||||
|
{
|
||||||
|
ClearChildren(_historyPanel);
|
||||||
|
ClearChildren(_optionsPanel);
|
||||||
|
|
||||||
|
if (_runner is null)
|
||||||
|
{
|
||||||
|
_historyPanel.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "(They have nothing to say yet.)",
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
});
|
||||||
|
_historyPanel.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "— no dialogue tree authored for this NPC. "
|
||||||
|
+ "Stock trees ship as content fills in.",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
});
|
||||||
|
var close = MakeOptionButton("1. Goodbye");
|
||||||
|
close.Pressed += Close;
|
||||||
|
_optionsPanel.AddChild(close);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render history — last C.DIALOGUE_HISTORY_LINES entries.
|
||||||
|
int start = Math.Max(0, _runner.History.Count - C.DIALOGUE_HISTORY_LINES);
|
||||||
|
for (int i = start; i < _runner.History.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = _runner.History[i];
|
||||||
|
string prefix = entry.Speaker switch
|
||||||
|
{
|
||||||
|
DialogueSpeaker.Pc => " > ",
|
||||||
|
DialogueSpeaker.Narration => " ",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
Color color = entry.Speaker switch
|
||||||
|
{
|
||||||
|
DialogueSpeaker.Npc => new Color(0.86f, 0.86f, 0.78f),
|
||||||
|
DialogueSpeaker.Pc => new Color(0.67f, 0.78f, 0.86f),
|
||||||
|
DialogueSpeaker.Narration => new Color(0.63f, 0.71f, 0.55f),
|
||||||
|
_ => Colors.White,
|
||||||
|
};
|
||||||
|
var line = new Label
|
||||||
|
{
|
||||||
|
Text = prefix + entry.Text,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
CustomMinimumSize = new Vector2(680, 0),
|
||||||
|
};
|
||||||
|
line.AddThemeColorOverride("font_color", color);
|
||||||
|
_historyPanel.AddChild(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_runner.IsOver)
|
||||||
|
{
|
||||||
|
var close = MakeOptionButton("1. (close)");
|
||||||
|
close.Pressed += Close;
|
||||||
|
_optionsPanel.AddChild(close);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render options. Number by *display* index (visible options only).
|
||||||
|
int displayN = 0;
|
||||||
|
foreach (var (origIdx, opt) in _runner.VisibleOptions())
|
||||||
|
{
|
||||||
|
displayN++;
|
||||||
|
int captured = origIdx;
|
||||||
|
string label = $"{displayN}. {opt.Text}";
|
||||||
|
if (opt.SkillCheck is { } sc)
|
||||||
|
label = $"{displayN}. [{sc.Skill.ToUpperInvariant()} DC {sc.Dc}] {opt.Text}";
|
||||||
|
var btn = MakeOptionButton(label);
|
||||||
|
btn.Pressed += () => OnOptionPicked(captured);
|
||||||
|
_optionsPanel.AddChild(btn);
|
||||||
|
if (displayN >= C.DIALOGUE_MAX_OPTIONS_PER_NODE) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOptionPicked(int origIndex)
|
||||||
|
{
|
||||||
|
if (_runner is null) return;
|
||||||
|
_runner.ChooseOption(origIndex);
|
||||||
|
|
||||||
|
// M7.6 / Phase 6 M4 — dialogue's start_quest effects buffer quest
|
||||||
|
// ids on the runner context. Drain them into the live engine
|
||||||
|
// before refreshing so journal entries print in the right order.
|
||||||
|
if (_runner.Context.StartQuestRequests.Count > 0)
|
||||||
|
{
|
||||||
|
var qctx = _playScreen.BuildQuestContextForDialogue();
|
||||||
|
if (qctx is not null)
|
||||||
|
{
|
||||||
|
foreach (var qid in _runner.Context.StartQuestRequests)
|
||||||
|
_playScreen.QuestEngine.Start(qid, qctx);
|
||||||
|
}
|
||||||
|
_runner.Context.StartQuestRequests.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Refresh();
|
||||||
|
|
||||||
|
// open_shop effect — M8 stub. Toast acknowledges the request and
|
||||||
|
// clears the flag so re-entry doesn't loop on the same node.
|
||||||
|
if (_runner.Context.ShopRequested)
|
||||||
|
{
|
||||||
|
_runner.Context.ShopRequested = false;
|
||||||
|
_playScreen.Toast($"Shop ships with M8 — {_npc.DisplayName} waits patiently.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is not InputEventKey { Pressed: true } key) return;
|
||||||
|
if (key.Echo) return;
|
||||||
|
|
||||||
|
// Belt-and-braces: PlayScreen.AddChild(this) happens during _Process,
|
||||||
|
// so the F-press that opened this overlay shouldn't reach _Input
|
||||||
|
// here (different frame). The flag absorbs the rare case where it
|
||||||
|
// does.
|
||||||
|
if (!_consumedOpeningKeys)
|
||||||
|
{
|
||||||
|
_consumedOpeningKeys = true;
|
||||||
|
if (key.Keycode is Key.F or Key.Escape) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key.Keycode)
|
||||||
|
{
|
||||||
|
case Key.Escape:
|
||||||
|
case Key.F:
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
Close();
|
||||||
|
return;
|
||||||
|
case Key.Enter:
|
||||||
|
case Key.KpEnter:
|
||||||
|
if (_runner is { IsOver: true })
|
||||||
|
{
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number keys 1..9 (top-row and numpad). Godot's Key enum has a
|
||||||
|
// long underlying type (so it can hold Unicode + modifier bits) —
|
||||||
|
// cast the arithmetic result to int.
|
||||||
|
int picked = key.Keycode switch
|
||||||
|
{
|
||||||
|
>= Key.Key1 and <= Key.Key9 => (int)(key.Keycode - Key.Key1) + 1,
|
||||||
|
>= Key.Kp1 and <= Key.Kp9 => (int)(key.Keycode - Key.Kp1) + 1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if (picked > 0 && _runner is not null && !_runner.IsOver)
|
||||||
|
{
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
HandleNumberPick(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleNumberPick(int displayN)
|
||||||
|
{
|
||||||
|
if (_runner is null) return;
|
||||||
|
int seen = 0;
|
||||||
|
foreach (var (origIdx, _) in _runner.VisibleOptions())
|
||||||
|
{
|
||||||
|
seen++;
|
||||||
|
if (seen == displayN)
|
||||||
|
{
|
||||||
|
OnOptionPicked(origIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close()
|
||||||
|
{
|
||||||
|
GetTree().Paused = false;
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
private static Button MakeOptionButton(string text)
|
||||||
|
{
|
||||||
|
return new Button
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
CustomMinimumSize = new Vector2(680, 40),
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter,
|
||||||
|
Alignment = HorizontalAlignment.Left,
|
||||||
|
FocusMode = Control.FocusModeEnum.None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ClearChildren(Node node)
|
||||||
|
{
|
||||||
|
foreach (Node child in node.GetChildren()) child.QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatRoleLine(string roleTag)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(roleTag)) return "";
|
||||||
|
int dot = roleTag.LastIndexOf('.');
|
||||||
|
if (dot < 0) return TitleCase(roleTag);
|
||||||
|
string anchor = roleTag[..dot];
|
||||||
|
string role = roleTag[(dot + 1)..];
|
||||||
|
return $"{TitleCase(role)} of {TitleCase(anchor)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TitleCase(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(raw)) return "";
|
||||||
|
Span<char> buf = stackalloc char[raw.Length];
|
||||||
|
bool capNext = true;
|
||||||
|
for (int i = 0; i < raw.Length; i++)
|
||||||
|
{
|
||||||
|
char c = raw[i];
|
||||||
|
if (c == '_' || c == '.') { buf[i] = ' '; capNext = true; continue; }
|
||||||
|
buf[i] = capNext ? char.ToUpperInvariant(c) : c;
|
||||||
|
capNext = false;
|
||||||
|
}
|
||||||
|
return new string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Phase 6.5 M1 — Scent Literacy overlay. Returns null when
|
||||||
|
/// the PC doesn't have the feature, so the header skips the line.</summary>
|
||||||
|
private static string? ScentReadingFor(NpcActor npc, Theriapolis.Core.Rules.Character.Character pc)
|
||||||
|
{
|
||||||
|
bool hasFeature = pc.LearnedFeatureIds.Contains("scent_literacy")
|
||||||
|
|| pc.ClassDef.Id == "scent_broker";
|
||||||
|
if (!hasFeature) return null;
|
||||||
|
|
||||||
|
string clade = npc.Resident?.Clade ?? npc.Template?.Behavior ?? "unknown";
|
||||||
|
string species = npc.Resident?.Species ?? "—";
|
||||||
|
int hpPct = npc.MaxHp > 0
|
||||||
|
? (int)Math.Round(100.0 * npc.CurrentHp / npc.MaxHp)
|
||||||
|
: 100;
|
||||||
|
string hp = hpPct == 100 ? "—" : $"{hpPct}%";
|
||||||
|
|
||||||
|
int tagCount = pc.LearnedFeatureIds.Contains("master_nose") ? 3 : 1;
|
||||||
|
var tags = npc.ComputeScentTags(tagCount);
|
||||||
|
string tagSuffix = "";
|
||||||
|
if (tags.Count > 0)
|
||||||
|
{
|
||||||
|
var rendered = new List<string>(tags.Count);
|
||||||
|
foreach (var t in tags) rendered.Add("⚠ " + t.DisplayName());
|
||||||
|
tagSuffix = " · " + string.Join(" · ", rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"⊙ Scent: {Capitalize(clade)} ({Capitalize(species)}) · HP {hp}{tagSuffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Capitalize(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return s;
|
||||||
|
return char.ToUpperInvariant(s[0]) + s[1..].Replace('_', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
|
using Theriapolis.Core.Rules.Character;
|
||||||
|
using Theriapolis.GodotHost.Platform;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M7.4 — pause overlay. Pushed by PlayScreen when Esc is pressed.
|
||||||
|
/// Halts the game clock via <c>GetTree().Paused = true</c> so the
|
||||||
|
/// player + streamer + controller all freeze; the overlay itself
|
||||||
|
/// runs with <c>ProcessModeEnum.WhenPaused</c> so it stays
|
||||||
|
/// responsive to input.
|
||||||
|
///
|
||||||
|
/// Two sub-states: <see cref="BuildMain"/> shows the main menu
|
||||||
|
/// (Resume / Level Up / Save Game / Quicksave / Quit). Clicking
|
||||||
|
/// "Save Game" flips to <see cref="BuildSlotPicker"/> — a per-slot
|
||||||
|
/// row list that calls back into <see cref="PlayScreen.SaveTo"/>.
|
||||||
|
/// Esc backs out of the slot picker to main, then closes the
|
||||||
|
/// overlay on a second press.
|
||||||
|
///
|
||||||
|
/// Save-from-pause shows a status line inside the panel (separate
|
||||||
|
/// from PlayScreen's save-flash toast, which is suppressed while
|
||||||
|
/// the tree is paused).
|
||||||
|
/// </summary>
|
||||||
|
public partial class PauseMenuScreen : CanvasLayer
|
||||||
|
{
|
||||||
|
private readonly PlayScreen _playScreen;
|
||||||
|
private Control _root = null!;
|
||||||
|
private PanelContainer _panel = null!;
|
||||||
|
private VBoxContainer _content = null!;
|
||||||
|
private Label _statusLabel = null!;
|
||||||
|
private bool _showingSlots;
|
||||||
|
// Edge-detection for the Esc that opened the overlay — without this
|
||||||
|
// the same press both opens AND closes the menu on the next frame.
|
||||||
|
private bool _consumedOpeningEsc;
|
||||||
|
|
||||||
|
public PauseMenuScreen(PlayScreen playScreen)
|
||||||
|
{
|
||||||
|
_playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
Layer = 50;
|
||||||
|
ProcessMode = ProcessModeEnum.WhenPaused;
|
||||||
|
GetTree().Paused = true;
|
||||||
|
|
||||||
|
_root = new Control
|
||||||
|
{
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Stop,
|
||||||
|
ProcessMode = ProcessModeEnum.WhenPaused,
|
||||||
|
};
|
||||||
|
_root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||||
|
AddChild(_root);
|
||||||
|
|
||||||
|
// Half-opaque scrim so the world reads as backgrounded.
|
||||||
|
var scrim = new ColorRect
|
||||||
|
{
|
||||||
|
Color = new Color(0, 0, 0, 0.55f),
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||||
|
_root.AddChild(scrim);
|
||||||
|
|
||||||
|
var center = new CenterContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
|
||||||
|
center.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||||
|
_root.AddChild(center);
|
||||||
|
|
||||||
|
// Apply the codex theme on the panel root so the cascade reaches
|
||||||
|
// every button (overlays mount outside the PlayScreen Control tree,
|
||||||
|
// so the parent theme doesn't cascade automatically).
|
||||||
|
_panel = new PanelContainer
|
||||||
|
{
|
||||||
|
ThemeTypeVariation = "Card",
|
||||||
|
Theme = CodexTheme.Build(),
|
||||||
|
CustomMinimumSize = new Vector2(360, 0),
|
||||||
|
};
|
||||||
|
center.AddChild(_panel);
|
||||||
|
|
||||||
|
var margin = new MarginContainer();
|
||||||
|
margin.AddThemeConstantOverride("margin_left", 28);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", 28);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", 20);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", 20);
|
||||||
|
_panel.AddChild(margin);
|
||||||
|
|
||||||
|
_content = new VBoxContainer();
|
||||||
|
_content.AddThemeConstantOverride("separation", 10);
|
||||||
|
margin.AddChild(_content);
|
||||||
|
|
||||||
|
BuildMain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildMain()
|
||||||
|
{
|
||||||
|
_showingSlots = false;
|
||||||
|
ClearContent();
|
||||||
|
|
||||||
|
_content.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "PAUSED",
|
||||||
|
ThemeTypeVariation = "H2",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
AddSpacer(8);
|
||||||
|
|
||||||
|
var resume = MakeMenuButton("Resume", primary: true);
|
||||||
|
resume.Pressed += Close;
|
||||||
|
_content.AddChild(resume);
|
||||||
|
|
||||||
|
// Level-up affordance — only when eligible. Disabled in M7 with a
|
||||||
|
// tooltip; the actual LevelUpScreen ships with M8.
|
||||||
|
var pc = _playScreen.PlayerCharacter();
|
||||||
|
if (pc is not null && LevelUpFlow.CanLevelUp(pc))
|
||||||
|
{
|
||||||
|
var lvlBtn = MakeMenuButton($"★ Level Up ({pc.Level} → {pc.Level + 1})", primary: false);
|
||||||
|
lvlBtn.Disabled = true;
|
||||||
|
lvlBtn.TooltipText = "Level-up screen ships with M8.";
|
||||||
|
_content.AddChild(lvlBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saveBtn = MakeMenuButton("Save Game", primary: false);
|
||||||
|
saveBtn.Pressed += BuildSlotPicker;
|
||||||
|
_content.AddChild(saveBtn);
|
||||||
|
|
||||||
|
var quickSave = MakeMenuButton("Quicksave (autosave slot)", primary: false);
|
||||||
|
quickSave.Pressed += OnQuicksave;
|
||||||
|
_content.AddChild(quickSave);
|
||||||
|
|
||||||
|
var quitBtn = MakeMenuButton("Quit to Title", primary: false);
|
||||||
|
quitBtn.Pressed += OnQuitToTitle;
|
||||||
|
_content.AddChild(quitBtn);
|
||||||
|
|
||||||
|
AddSpacer(6);
|
||||||
|
|
||||||
|
_statusLabel = new Label
|
||||||
|
{
|
||||||
|
Text = " ",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
};
|
||||||
|
_content.AddChild(_statusLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildSlotPicker()
|
||||||
|
{
|
||||||
|
_showingSlots = true;
|
||||||
|
ClearContent();
|
||||||
|
|
||||||
|
_content.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "SAVE TO SLOT",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
AddSpacer(4);
|
||||||
|
|
||||||
|
for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
|
||||||
|
{
|
||||||
|
int slotNum = i;
|
||||||
|
string path = SavePaths.SlotPath(slotNum);
|
||||||
|
string prefix = $"Slot {slotNum:D2}";
|
||||||
|
string label = prefix;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
var header = SaveCodec.DeserializeHeaderOnly(bytes);
|
||||||
|
label = SaveSlotFormat.FormatRow(prefix, header);
|
||||||
|
}
|
||||||
|
catch { label += " — <unreadable>"; }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
label += " — <empty>";
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
CustomMinimumSize = new Vector2(0, 36),
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||||
|
Alignment = HorizontalAlignment.Left,
|
||||||
|
};
|
||||||
|
btn.Pressed += () => OnSaveToSlot(slotNum, path);
|
||||||
|
_content.AddChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpacer(6);
|
||||||
|
var back = MakeMenuButton("← Back", primary: false);
|
||||||
|
back.Pressed += BuildMain;
|
||||||
|
_content.AddChild(back);
|
||||||
|
|
||||||
|
_statusLabel = new Label
|
||||||
|
{
|
||||||
|
Text = " ",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
};
|
||||||
|
_content.AddChild(_statusLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnQuicksave()
|
||||||
|
{
|
||||||
|
bool ok = _playScreen.SaveTo(SavePaths.AutosavePath());
|
||||||
|
ShowStatus(ok ? "Quicksaved." : "Quicksave failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSaveToSlot(int slotNum, string path)
|
||||||
|
{
|
||||||
|
bool ok = _playScreen.SaveTo(path);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
BuildMain();
|
||||||
|
ShowStatus($"Saved to slot {slotNum:D2}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowStatus("Save failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnQuitToTitle()
|
||||||
|
{
|
||||||
|
// Autosave on quit-to-title, matching MonoGame behaviour. A failed
|
||||||
|
// autosave doesn't block the quit — better to let the user leave
|
||||||
|
// than trap them with an angry dialog.
|
||||||
|
_playScreen.SaveTo(SavePaths.AutosavePath());
|
||||||
|
|
||||||
|
GetTree().Paused = false;
|
||||||
|
var playParent = _playScreen.GetParent();
|
||||||
|
if (playParent is not null)
|
||||||
|
{
|
||||||
|
foreach (Node child in playParent.GetChildren())
|
||||||
|
child.QueueFree();
|
||||||
|
playParent.AddChild(new TitleScreen());
|
||||||
|
}
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close()
|
||||||
|
{
|
||||||
|
GetTree().Paused = false;
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is not InputEventKey { Pressed: true, Keycode: Key.Escape } key) return;
|
||||||
|
if (key.Echo) return;
|
||||||
|
|
||||||
|
// The same physical Esc press that *opened* this overlay produces
|
||||||
|
// exactly one event. We're routed via _UnhandledInput on the layer,
|
||||||
|
// which dispatches AFTER PlayScreen.Input has run + consumed the
|
||||||
|
// event with SetInputAsHandled — so this branch fires on the NEXT
|
||||||
|
// Esc press. The _consumedOpeningEsc guard is a belt-and-braces
|
||||||
|
// for the case where input routing skips the consumed flag.
|
||||||
|
if (!_consumedOpeningEsc)
|
||||||
|
{
|
||||||
|
_consumedOpeningEsc = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
if (_showingSlots) BuildMain();
|
||||||
|
else Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Layout helpers
|
||||||
|
|
||||||
|
private void ClearContent()
|
||||||
|
{
|
||||||
|
foreach (Node child in _content.GetChildren()) child.QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSpacer(int height)
|
||||||
|
=> _content.AddChild(new Control { CustomMinimumSize = new Vector2(0, height) });
|
||||||
|
|
||||||
|
private static Button MakeMenuButton(string text, bool primary)
|
||||||
|
{
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FocusMode = Control.FocusModeEnum.None,
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||||
|
CustomMinimumSize = new Vector2(0, 40),
|
||||||
|
};
|
||||||
|
if (primary) btn.ThemeTypeVariation = "PrimaryButton";
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowStatus(string text)
|
||||||
|
{
|
||||||
|
if (_statusLabel is not null) _statusLabel.Text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
using Theriapolis.Core.Data;
|
||||||
|
using Theriapolis.Core.Entities;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
|
using Theriapolis.Core.Rules.Combat;
|
||||||
|
using Theriapolis.Core.Rules.Quests;
|
||||||
|
using Theriapolis.Core.Rules.Reputation;
|
||||||
|
using Theriapolis.Core.Tactical;
|
||||||
|
using Theriapolis.Core.Time;
|
||||||
|
using Theriapolis.Core.Util;
|
||||||
|
using Theriapolis.Core.World;
|
||||||
|
using Theriapolis.Core.World.Generation;
|
||||||
|
using Theriapolis.Core.World.Settlements;
|
||||||
|
using Theriapolis.GodotHost.Input;
|
||||||
|
using Theriapolis.GodotHost.Platform;
|
||||||
|
using Theriapolis.GodotHost.Rendering;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M7.2 + M7.3 — the play screen. Wraps <see cref="WorldRenderNode"/>
|
||||||
|
/// with the game-state layer: player actor, world clock, chunk
|
||||||
|
/// streamer, NPC markers, player controller, save layer, and a
|
||||||
|
/// top-left HUD overlay. Click on the world map to travel; WASD to
|
||||||
|
/// step at tactical zoom. F5 quicksaves; Esc returns to title (M7.4
|
||||||
|
/// will replace this with a pause menu).
|
||||||
|
///
|
||||||
|
/// Save round-trip (M7.3): <see cref="SaveTo"/> wraps
|
||||||
|
/// <see cref="SaveCodec.Serialize"/> + <see cref="SavePaths.WriteAtomic"/>.
|
||||||
|
/// <see cref="ApplyRestoredBody"/> on init consumes
|
||||||
|
/// <see cref="GameSession.PendingRestore"/> set by the
|
||||||
|
/// <see cref="SaveLoadScreen"/> hand-off; replaces the new-game spawn.
|
||||||
|
/// The save format is owned by Core and untouched — saves written by
|
||||||
|
/// the MonoGame build load here byte-identically and vice versa.
|
||||||
|
///
|
||||||
|
/// M7 sub-milestone status:
|
||||||
|
/// M7.4 (pause menu) — Esc still does quit-to-title for now.
|
||||||
|
/// M7.5 (interact prompt) — F-to-talk not yet wired.
|
||||||
|
/// M7.6 (encounter trigger stub) — hostile detection not yet wired.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PlayScreen : Control
|
||||||
|
{
|
||||||
|
private const float ClickSlopPixels = 4f;
|
||||||
|
|
||||||
|
// Composed Core systems
|
||||||
|
private WorldGenContext _ctx = null!;
|
||||||
|
private ContentResolver _content = null!;
|
||||||
|
private InMemoryChunkDeltaStore _deltas = null!;
|
||||||
|
private ChunkStreamer _streamer = null!;
|
||||||
|
private ActorManager _actors = null!;
|
||||||
|
private WorldClock _clock = null!;
|
||||||
|
private PlayerController _controller = null!;
|
||||||
|
private AnchorRegistry _anchorRegistry = null!;
|
||||||
|
private readonly PlayerReputation _reputation = new();
|
||||||
|
private readonly Dictionary<string, int> _flags = new();
|
||||||
|
private readonly QuestEngine _questEngine = new();
|
||||||
|
private QuestContext? _questCtx;
|
||||||
|
|
||||||
|
// M7.5 — interact candidate cached per tick. Cleared when no
|
||||||
|
// friendly/neutral NPC is in range; the HUD shows "[F] Talk to ..."
|
||||||
|
// while non-null.
|
||||||
|
private NpcActor? _interactCandidate;
|
||||||
|
// Edge-detect F for the talk handler so a held key doesn't fire twice.
|
||||||
|
private bool _fWasDown;
|
||||||
|
// M7.6 — most recent hostile NPC id that tripped the encounter trigger.
|
||||||
|
// Edge-detection: only fires the stub once per *fresh* hostile entering
|
||||||
|
// range, so walking next to the same wolf doesn't spam autosaves.
|
||||||
|
private int _lastHostileTriggerId;
|
||||||
|
|
||||||
|
// M7.3 — save round-trip plumbing
|
||||||
|
private readonly Dictionary<ChunkCoord, HashSet<int>> _killedByChunk = new();
|
||||||
|
// Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD
|
||||||
|
// push. Captured by load, picked up after chunks load; M8 will turn
|
||||||
|
// this into an actual push to the combat screen.
|
||||||
|
private EncounterState? _pendingEncounterRestore;
|
||||||
|
private float _saveFlashTimer;
|
||||||
|
private string _saveFlashText = "";
|
||||||
|
|
||||||
|
// Godot tree
|
||||||
|
private WorldRenderNode _render = null!;
|
||||||
|
private PlayerMarker _playerMarker = null!;
|
||||||
|
private readonly Dictionary<int, NpcMarker> _npcMarkers = new();
|
||||||
|
private Label _hudLabel = null!;
|
||||||
|
private PanelContainer _hudPanel = null!;
|
||||||
|
private Label _cursorDebugLabel = null!;
|
||||||
|
private Label? _saveFlashLabel;
|
||||||
|
// Reused per-frame builders — avoid GC pressure on hot _Process path.
|
||||||
|
// Holding a key produces auto-repeat InputEventKey objects that the C#
|
||||||
|
// GC must release before engine shutdown asserts on empty bindings;
|
||||||
|
// reducing per-frame allocations buys headroom for those collections.
|
||||||
|
private readonly System.Text.StringBuilder _cursorSb = new(256);
|
||||||
|
private readonly System.Text.StringBuilder _hudSb = new(256);
|
||||||
|
|
||||||
|
// Click-vs-drag state (left-click only; PanZoomCamera handles
|
||||||
|
// middle/right-drag pan independently).
|
||||||
|
private Vector2 _mouseDownPos;
|
||||||
|
private int _mouseDownTileX, _mouseDownTileY;
|
||||||
|
private bool _mouseDownTracked;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
if (session.Ctx is null)
|
||||||
|
{
|
||||||
|
GD.PushError("[play] No WorldGenContext on session — falling back to title.");
|
||||||
|
BackToTitle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ctx = session.Ctx;
|
||||||
|
|
||||||
|
Theme = CodexTheme.Build();
|
||||||
|
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
// World render layer — biome + polylines + settlements + camera.
|
||||||
|
_render = new WorldRenderNode();
|
||||||
|
AddChild(_render);
|
||||||
|
_render.Initialize(_ctx.World);
|
||||||
|
|
||||||
|
// Core systems.
|
||||||
|
_content = new ContentResolver(
|
||||||
|
new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir));
|
||||||
|
_deltas = new InMemoryChunkDeltaStore();
|
||||||
|
_streamer = new ChunkStreamer(
|
||||||
|
_ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
|
||||||
|
_streamer.OnChunkLoaded += _render.AddChunkNode;
|
||||||
|
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
|
||||||
|
_streamer.OnChunkLoaded += HandleChunkLoaded;
|
||||||
|
_streamer.OnChunkEvicting += HandleChunkEvicting;
|
||||||
|
|
||||||
|
_clock = new WorldClock();
|
||||||
|
_actors = new ActorManager();
|
||||||
|
_anchorRegistry = new AnchorRegistry();
|
||||||
|
_anchorRegistry.RegisterAllAnchors(_ctx.World);
|
||||||
|
|
||||||
|
// Phase 6 M4 — quest context wraps content/actors/rep/flags/clock/
|
||||||
|
// world for the quest engine. Round-trips through the save body.
|
||||||
|
_questCtx = new QuestContext(
|
||||||
|
_content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World);
|
||||||
|
|
||||||
|
// Spawn or restore the player. Restore wins when a load was queued.
|
||||||
|
if (session.PendingRestore is not null)
|
||||||
|
{
|
||||||
|
ApplyRestoredBody(session.PendingRestore);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var spawn = ChooseSpawn(_ctx.World);
|
||||||
|
if (session.PendingCharacter is not null)
|
||||||
|
{
|
||||||
|
var p = _actors.SpawnPlayer(spawn, session.PendingCharacter);
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.PendingName))
|
||||||
|
p.Name = session.PendingName;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_actors.SpawnPlayer(spawn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
||||||
|
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
||||||
|
|
||||||
|
// Set the initial zoom BEFORE building the player marker so the
|
||||||
|
// counter-scale below picks a sane scale on the spawn frame.
|
||||||
|
_render.Camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
|
||||||
|
SetInitialZoom();
|
||||||
|
|
||||||
|
// Player marker.
|
||||||
|
_playerMarker = new PlayerMarker
|
||||||
|
{
|
||||||
|
Position = new Vector2(_actors.Player.Position.X, _actors.Player.Position.Y),
|
||||||
|
Rotation = _actors.Player.FacingAngleRad,
|
||||||
|
Scale = CounterScaleVec(),
|
||||||
|
};
|
||||||
|
AddChild(_playerMarker);
|
||||||
|
BuildHud();
|
||||||
|
|
||||||
|
// M7.5/M8 will pick up _pendingEncounterRestore here once the
|
||||||
|
// combat HUD screen exists. For now we keep the snapshot on the
|
||||||
|
// body so a re-save preserves it across the Godot↔MonoGame round
|
||||||
|
// trip, but we don't attempt to resume combat.
|
||||||
|
if (_pendingEncounterRestore is not null)
|
||||||
|
GD.Print("[play] Loaded save has an active encounter — "
|
||||||
|
+ "combat HUD ships with M8; encounter preserved through save round-trip.");
|
||||||
|
|
||||||
|
// Clear pending so a quit-to-title doesn't see stale data.
|
||||||
|
session.PendingCharacter = null;
|
||||||
|
session.PendingName = "Wanderer";
|
||||||
|
session.PendingRestore = null;
|
||||||
|
session.PendingHeader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Process(double delta)
|
||||||
|
{
|
||||||
|
if (_actors?.Player is null || _render is null) return;
|
||||||
|
float dt = (float)delta;
|
||||||
|
|
||||||
|
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
|
||||||
|
|
||||||
|
// WASD is context-sensitive: tactical mode steps the player,
|
||||||
|
// world-map mode pans the camera. Same keys, intent depends on zoom.
|
||||||
|
float wasdX = 0f, wasdY = 0f;
|
||||||
|
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) wasdY -= 1f;
|
||||||
|
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) wasdY += 1f;
|
||||||
|
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) wasdX -= 1f;
|
||||||
|
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) wasdX += 1f;
|
||||||
|
|
||||||
|
// Controller always ticks (path-follow runs even when WASD is idle).
|
||||||
|
// Pass step input only in tactical mode.
|
||||||
|
float stepX = tactical ? wasdX : 0f;
|
||||||
|
float stepY = tactical ? wasdY : 0f;
|
||||||
|
_controller.Update(dt, stepX, stepY, tactical, isFocused: true);
|
||||||
|
|
||||||
|
// World-map WASD pan. Skip while traveling — the follow logic below
|
||||||
|
// re-centres the camera on the player and would clobber the pan.
|
||||||
|
// Speed scales inversely with zoom so the on-screen pan rate feels
|
||||||
|
// consistent at any zoom level (matches MonoGame's 400 px/sec).
|
||||||
|
if (!tactical && !_controller.IsTraveling && (wasdX != 0f || wasdY != 0f))
|
||||||
|
{
|
||||||
|
const float PanScreenPxPerSec = 400f;
|
||||||
|
float invLen = (wasdX != 0f && wasdY != 0f) ? 0.70710678f : 1f;
|
||||||
|
float panSpeed = PanScreenPxPerSec / Mathf.Max(_render.Camera.Zoom.X, 0.01f);
|
||||||
|
_render.Camera.Position += new Vector2(wasdX * invLen, wasdY * invLen) * panSpeed * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the player marker from Core state. Rotation drives the
|
||||||
|
// facing tick via the transform — auto-property setters on a
|
||||||
|
// PlayerMarker field would skip QueueRedraw and the cached
|
||||||
|
// _Draw commands would stay stuck at the initial angle.
|
||||||
|
var p = _actors.Player;
|
||||||
|
_playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
|
||||||
|
_playerMarker.Rotation = p.FacingAngleRad;
|
||||||
|
|
||||||
|
// Camera follow when traveling or in tactical (matches MonoGame).
|
||||||
|
if (_controller.IsTraveling || tactical)
|
||||||
|
_render.Camera.Position = _playerMarker.Position;
|
||||||
|
|
||||||
|
// Stream tactical chunks around the player when at tactical zoom.
|
||||||
|
if (tactical)
|
||||||
|
_streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES);
|
||||||
|
|
||||||
|
// M7.5 — friendly / neutral interact candidate. Only computed in
|
||||||
|
// tactical mode; world-map scale doesn't surface NPC interactions.
|
||||||
|
if (tactical)
|
||||||
|
_interactCandidate = EncounterTrigger.FindInteractCandidate(_actors);
|
||||||
|
else
|
||||||
|
_interactCandidate = null;
|
||||||
|
|
||||||
|
// M7.6 — hostile encounter stub. The real combat HUD ships with M8;
|
||||||
|
// for now, an autosave + console log + toast on each fresh hostile
|
||||||
|
// entering range gives the player a heads-up and ensures M8 has a
|
||||||
|
// valid snapshot to wire combat-restore into. Edge-detected by
|
||||||
|
// NPC id so movement past the same hostile doesn't refire.
|
||||||
|
if (tactical)
|
||||||
|
{
|
||||||
|
var hostile = EncounterTrigger.FindHostileTrigger(_actors);
|
||||||
|
if (hostile is not null)
|
||||||
|
{
|
||||||
|
if (hostile.Id != _lastHostileTriggerId)
|
||||||
|
{
|
||||||
|
_lastHostileTriggerId = hostile.Id;
|
||||||
|
string tpl = hostile.Template?.Id ?? "<resident>";
|
||||||
|
GD.Print($"[encounter] Would start fight with {hostile.DisplayName} "
|
||||||
|
+ $"(allegiance={hostile.Allegiance}, template={tpl})");
|
||||||
|
FlashSavedToast($"Combat HUD lands with M8 — encounter logged: {hostile.DisplayName}");
|
||||||
|
// NB: deliberately do NOT autosave here, even though the
|
||||||
|
// doc proposes it. SaveTo → CaptureBody → FlushAll evicts
|
||||||
|
// every loaded chunk, which despawns NPCs and respawns
|
||||||
|
// them on the next tactical tick with fresh actor ids —
|
||||||
|
// breaking _lastHostileTriggerId's edge detection and
|
||||||
|
// looping the stub. M8 owns combat-start autosave; at
|
||||||
|
// that point the combat HUD is pushed *before* FlushAll
|
||||||
|
// happens, so the encounter snapshot covers the live
|
||||||
|
// state and the loop can't form.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lastHostileTriggerId = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lastHostileTriggerId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// F-press → push dialogue overlay. Edge-detect so a held key doesn't
|
||||||
|
// re-open the screen while the previous one is still up.
|
||||||
|
bool fNow = Godot.Input.IsKeyPressed(Key.F);
|
||||||
|
bool fJustDown = fNow && !_fWasDown;
|
||||||
|
_fWasDown = fNow;
|
||||||
|
if (fJustDown && _interactCandidate is not null)
|
||||||
|
{
|
||||||
|
AddChild(new InteractionScreen(_interactCandidate, this));
|
||||||
|
_interactCandidate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter-scale markers so on-screen size stays constant.
|
||||||
|
float zoom = _render.Camera.Zoom.X;
|
||||||
|
if (zoom > 0f)
|
||||||
|
{
|
||||||
|
float inv = 1f / zoom;
|
||||||
|
_playerMarker.Scale = new Vector2(inv, inv);
|
||||||
|
_playerMarker.ShowFacingTick = tactical;
|
||||||
|
foreach (var marker in _npcMarkers.Values)
|
||||||
|
marker.Scale = new Vector2(inv, inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save-flash toast decay.
|
||||||
|
if (_saveFlashTimer > 0f)
|
||||||
|
{
|
||||||
|
_saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt);
|
||||||
|
if (_saveFlashLabel is not null)
|
||||||
|
{
|
||||||
|
_saveFlashLabel.Text = _saveFlashText;
|
||||||
|
_saveFlashLabel.Modulate = new Color(1, 1, 1, Mathf.Min(1f, _saveFlashTimer / 0.5f));
|
||||||
|
_saveFlashLabel.Visible = _saveFlashTimer > 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_saveFlashLabel is not null && _saveFlashLabel.Visible)
|
||||||
|
{
|
||||||
|
_saveFlashLabel.Visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateHud(tactical);
|
||||||
|
UpdateCursorDebug(tactical);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is not InputEventMouseButton mb || mb.ButtonIndex != MouseButton.Left)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (mb.Pressed)
|
||||||
|
{
|
||||||
|
_mouseDownPos = mb.Position;
|
||||||
|
var worldPos = ScreenToWorld(mb.Position);
|
||||||
|
_mouseDownTileX = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
|
||||||
|
_mouseDownTileY = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
|
||||||
|
_mouseDownTracked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_mouseDownTracked) return;
|
||||||
|
_mouseDownTracked = false;
|
||||||
|
bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
|
||||||
|
if (!wasClick) return;
|
||||||
|
|
||||||
|
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
|
||||||
|
if (!tactical && InBounds(_mouseDownTileX, _mouseDownTileY))
|
||||||
|
{
|
||||||
|
_controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Input(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is not InputEventKey { Pressed: true } key) return;
|
||||||
|
if (key.Echo) return;
|
||||||
|
// Skip key events while the game is paused — the pause overlay
|
||||||
|
// owns input handling for itself; PlayScreen shouldn't see Esc/F5
|
||||||
|
// again until the overlay closes.
|
||||||
|
if (GetTree().Paused) return;
|
||||||
|
|
||||||
|
switch (key.Keycode)
|
||||||
|
{
|
||||||
|
case Key.F5:
|
||||||
|
SaveTo(SavePaths.AutosavePath());
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
break;
|
||||||
|
case Key.Escape:
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
AddChild(new PauseMenuScreen(this));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Read-only accessor for the live player Character — used
|
||||||
|
/// by <see cref="PauseMenuScreen"/> to surface the level-up affordance
|
||||||
|
/// when eligible.</summary>
|
||||||
|
public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
|
||||||
|
=> _actors?.Player?.Character;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// M7.5 accessors — let InteractionScreen build a DialogueContext +
|
||||||
|
// DialogueRunner from PlayScreen-owned aggregates without copying them.
|
||||||
|
|
||||||
|
internal Theriapolis.Core.Rules.Reputation.PlayerReputation Reputation => _reputation;
|
||||||
|
internal Dictionary<string, int> Flags => _flags;
|
||||||
|
internal QuestEngine QuestEngine => _questEngine;
|
||||||
|
internal WorldState World => _ctx.World;
|
||||||
|
internal ulong WorldSeed => _ctx.World.WorldSeed;
|
||||||
|
internal long ClockSeconds => _clock.InGameSeconds;
|
||||||
|
internal Vec2 PlayerPosition => _actors?.Player?.Position ?? new Vec2(0, 0);
|
||||||
|
internal ContentResolver? Content => _content;
|
||||||
|
|
||||||
|
/// <summary>Hand back a quest context wired to current actors/rep/flags
|
||||||
|
/// — used by InteractionScreen to fire start_quest effects after a
|
||||||
|
/// dialogue option resolves.</summary>
|
||||||
|
internal QuestContext? BuildQuestContextForDialogue()
|
||||||
|
{
|
||||||
|
if (_content is null || _questCtx is null) return null;
|
||||||
|
_questCtx.PlayerCharacter = _actors?.Player?.Character;
|
||||||
|
return _questCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Surfaced to the toast layer so InteractionScreen can flash
|
||||||
|
/// "Shop ships with M8" without poking PlayScreen's private save-flash
|
||||||
|
/// machinery directly. M8 will swap this for a real ShopScreen push.</summary>
|
||||||
|
public void Toast(string text) => FlashSavedToast(text);
|
||||||
|
|
||||||
|
private Vector2 ScreenToWorld(Vector2 screenPos)
|
||||||
|
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
|
||||||
|
|
||||||
|
private static bool InBounds(int x, int y)
|
||||||
|
=> (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Chunk → NPC lifecycle (Phase 5 M5)
|
||||||
|
|
||||||
|
private void HandleChunkLoaded(TacticalChunk chunk)
|
||||||
|
{
|
||||||
|
if (_content is null) return;
|
||||||
|
_killedByChunk.TryGetValue(chunk.Coord, out var killed);
|
||||||
|
for (int i = 0; i < chunk.Spawns.Count; i++)
|
||||||
|
{
|
||||||
|
// Skip slots the player previously killed — they don't respawn
|
||||||
|
// on chunk reload until the save is wiped.
|
||||||
|
if (killed is not null && killed.Contains(i)) continue;
|
||||||
|
var spawn = chunk.Spawns[i];
|
||||||
|
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
|
||||||
|
|
||||||
|
if (spawn.Kind == SpawnKind.Resident)
|
||||||
|
{
|
||||||
|
var resident = ResidentInstantiator.Spawn(
|
||||||
|
_ctx.World.WorldSeed, chunk, i, spawn,
|
||||||
|
_ctx.World, _content, _actors, _anchorRegistry);
|
||||||
|
if (resident is not null) MountNpcMarker(resident);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var template = NpcInstantiator.PickTemplate(spawn.Kind, chunk.DangerZone, _content.Npcs);
|
||||||
|
if (template is null) continue;
|
||||||
|
|
||||||
|
int tx = chunk.OriginX + spawn.LocalX;
|
||||||
|
int ty = chunk.OriginY + spawn.LocalY;
|
||||||
|
var npc = _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
|
||||||
|
MountNpcMarker(npc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleChunkEvicting(TacticalChunk chunk)
|
||||||
|
{
|
||||||
|
var toRemove = new List<int>();
|
||||||
|
foreach (var npc in _actors.Npcs)
|
||||||
|
{
|
||||||
|
if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
|
||||||
|
{
|
||||||
|
toRemove.Add(npc.Id);
|
||||||
|
if (!string.IsNullOrEmpty(npc.RoleTag))
|
||||||
|
_anchorRegistry.UnregisterRole(npc.RoleTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (int id in toRemove)
|
||||||
|
{
|
||||||
|
_actors.RemoveActor(id);
|
||||||
|
if (_npcMarkers.Remove(id, out var marker))
|
||||||
|
marker.QueueFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MountNpcMarker(NpcActor npc)
|
||||||
|
{
|
||||||
|
// Stamp the counter-scale at construction time. NPCs spawn from
|
||||||
|
// OnChunkLoaded inside _Process, *after* the per-frame counter-scale
|
||||||
|
// loop has already iterated _npcMarkers. Without an initial scale,
|
||||||
|
// the new marker would render at Scale=(1,1) for one frame — at
|
||||||
|
// tactical zoom 32 that's a ~307 screen-pixel-radius red blob.
|
||||||
|
var marker = new NpcMarker
|
||||||
|
{
|
||||||
|
Position = new Vector2(npc.Position.X, npc.Position.Y),
|
||||||
|
Allegiance = npc.Allegiance,
|
||||||
|
Scale = CounterScaleVec(),
|
||||||
|
};
|
||||||
|
AddChild(marker);
|
||||||
|
_npcMarkers[npc.Id] = marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector2 CounterScaleVec()
|
||||||
|
{
|
||||||
|
if (_render is null) return Vector2.One;
|
||||||
|
float zoom = _render.Camera.Zoom.X;
|
||||||
|
if (zoom <= 0f) return Vector2.One;
|
||||||
|
float inv = 1f / zoom;
|
||||||
|
return new Vector2(inv, inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// M7.3 — Save / Load
|
||||||
|
|
||||||
|
/// <summary>Write the current state to the given slot path (atomic).</summary>
|
||||||
|
public bool SaveTo(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var header = BuildHeader();
|
||||||
|
var body = CaptureBody();
|
||||||
|
var bytes = SaveCodec.Serialize(header, body);
|
||||||
|
SavePaths.WriteAtomic(path, bytes);
|
||||||
|
FlashSavedToast($"Saved to {Path.GetFileName(path)}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FlashSavedToast($"Save failed: {ex.Message}");
|
||||||
|
GD.PushError($"[save] {ex}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SaveHeader BuildHeader()
|
||||||
|
{
|
||||||
|
var h = new SaveHeader
|
||||||
|
{
|
||||||
|
WorldSeedHex = $"0x{_ctx.World.WorldSeed:X}",
|
||||||
|
PlayerName = _actors.Player!.Name,
|
||||||
|
PlayerTier = _actors.Player.HighestTierReached,
|
||||||
|
InGameSeconds = _clock.InGameSeconds,
|
||||||
|
SavedAtUtc = DateTime.UtcNow.ToString("u"),
|
||||||
|
};
|
||||||
|
foreach (var kv in _ctx.World.StageHashes)
|
||||||
|
h.StageHashes[kv.Key] = $"0x{kv.Value:X}";
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build a save body from the current PlayScreen state. Mirrors
|
||||||
|
/// the MonoGame source's <c>CaptureBody</c> field-by-field so the
|
||||||
|
/// resulting byte stream is interoperable across builds.</summary>
|
||||||
|
private SaveBody CaptureBody()
|
||||||
|
{
|
||||||
|
// Mid-combat snapshot — null in M7 (combat HUD doesn't exist yet),
|
||||||
|
// but the field is preserved so a save loaded from MonoGame with
|
||||||
|
// an active encounter round-trips back through Godot intact.
|
||||||
|
EncounterState? activeEnc = _pendingEncounterRestore;
|
||||||
|
|
||||||
|
// Push every loaded chunk through eviction so any in-memory deltas
|
||||||
|
// land in the store before we read it.
|
||||||
|
_streamer.FlushAll();
|
||||||
|
|
||||||
|
var body = new SaveBody
|
||||||
|
{
|
||||||
|
Player = _actors.Player!.CaptureState(),
|
||||||
|
Clock = _clock.CaptureState(),
|
||||||
|
};
|
||||||
|
if (_actors.Player.Character is not null)
|
||||||
|
body.PlayerCharacter = CharacterCodec.Capture(_actors.Player.Character);
|
||||||
|
foreach (var kv in _deltas.All)
|
||||||
|
body.ModifiedChunks[kv.Key] = kv.Value;
|
||||||
|
|
||||||
|
foreach (var kv in _killedByChunk)
|
||||||
|
body.NpcRoster.ChunkDeltas.Add(new NpcChunkDelta
|
||||||
|
{
|
||||||
|
ChunkX = kv.Key.X,
|
||||||
|
ChunkY = kv.Key.Y,
|
||||||
|
KilledSpawnIndices = kv.Value.ToArray(),
|
||||||
|
});
|
||||||
|
|
||||||
|
body.ActiveEncounter = activeEnc;
|
||||||
|
body.ReputationState = ReputationCodec.Capture(_reputation);
|
||||||
|
body.Flags = new Dictionary<string, int>(_flags);
|
||||||
|
body.QuestEngineState = QuestCodec.Capture(_questEngine);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restore PlayScreen state from a deserialised body. Caller
|
||||||
|
/// must have already set <c>_ctx</c>, <c>_content</c>, <c>_streamer</c>,
|
||||||
|
/// <c>_actors</c>, <c>_clock</c>, and <c>_anchorRegistry</c> — this is
|
||||||
|
/// invoked from <see cref="_Ready"/> after those are wired but before
|
||||||
|
/// the player marker is created.</summary>
|
||||||
|
private void ApplyRestoredBody(SaveBody body)
|
||||||
|
{
|
||||||
|
var player = _actors.RestorePlayer(body.Player);
|
||||||
|
_clock.RestoreState(body.Clock);
|
||||||
|
|
||||||
|
foreach (var kv in body.ModifiedChunks)
|
||||||
|
_deltas.Put(kv.Key, kv.Value);
|
||||||
|
|
||||||
|
foreach (var d in body.ModifiedWorldTiles)
|
||||||
|
{
|
||||||
|
ref var t = ref _ctx.World.TileAt(d.X, d.Y);
|
||||||
|
t.Biome = (BiomeId)d.NewBiome;
|
||||||
|
t.Features = (FeatureFlags)d.NewFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.PlayerCharacter is not null)
|
||||||
|
player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content);
|
||||||
|
|
||||||
|
_killedByChunk.Clear();
|
||||||
|
foreach (var d in body.NpcRoster.ChunkDeltas)
|
||||||
|
{
|
||||||
|
var coord = new ChunkCoord(d.ChunkX, d.ChunkY);
|
||||||
|
_killedByChunk[coord] = new HashSet<int>(d.KilledSpawnIndices);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer the mid-combat encounter restore until M8 wires the combat
|
||||||
|
// HUD — but keep the body so a re-save round-trips byte-identical.
|
||||||
|
_pendingEncounterRestore = body.ActiveEncounter;
|
||||||
|
|
||||||
|
// Reputation aggregate — mutate the existing instance in place so
|
||||||
|
// consumers holding a reference (future ReputationScreen / dialogue
|
||||||
|
// runner) keep working.
|
||||||
|
var restoredRep = ReputationCodec.Restore(body.ReputationState);
|
||||||
|
_reputation.Factions.Clear();
|
||||||
|
foreach (var (k, v) in restoredRep.Factions.Standings) _reputation.Factions.Set(k, v);
|
||||||
|
_reputation.Personal.Clear();
|
||||||
|
foreach (var (k, v) in restoredRep.Personal) _reputation.Personal[k] = v;
|
||||||
|
_reputation.Ledger.Clear();
|
||||||
|
foreach (var ev in restoredRep.Ledger.Entries) _reputation.Ledger.Append(ev);
|
||||||
|
|
||||||
|
_flags.Clear();
|
||||||
|
foreach (var (k, v) in body.Flags) _flags[k] = v;
|
||||||
|
|
||||||
|
QuestCodec.Restore(_questEngine, body.QuestEngineState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlashSavedToast(string text)
|
||||||
|
{
|
||||||
|
_saveFlashText = text;
|
||||||
|
_saveFlashTimer = 2.5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Spawn + initial-zoom helpers
|
||||||
|
|
||||||
|
private static Vec2 ChooseSpawn(WorldState w)
|
||||||
|
{
|
||||||
|
var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
|
||||||
|
if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
|
||||||
|
var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
|
||||||
|
if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
|
||||||
|
return new Vec2(
|
||||||
|
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
|
||||||
|
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetInitialZoom()
|
||||||
|
{
|
||||||
|
Vector2 viewport = GetViewport().GetVisibleRect().Size;
|
||||||
|
float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
|
||||||
|
targetZoom = Mathf.Clamp(targetZoom,
|
||||||
|
_render.Camera.MinZoom,
|
||||||
|
WorldRenderNode.TacticalRenderZoomMin * 0.95f);
|
||||||
|
_render.Camera.Zoom = new Vector2(targetZoom, targetZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// HUD overlay (top-left panel, codex-styled) + save-flash toast
|
||||||
|
|
||||||
|
private void BuildHud()
|
||||||
|
{
|
||||||
|
var hudLayer = new CanvasLayer { Layer = 50, Name = "Hud" };
|
||||||
|
AddChild(hudLayer);
|
||||||
|
|
||||||
|
_hudPanel = new PanelContainer
|
||||||
|
{
|
||||||
|
ThemeTypeVariation = "Card",
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
OffsetLeft = 12, OffsetTop = 12,
|
||||||
|
OffsetRight = 420, OffsetBottom = 220,
|
||||||
|
};
|
||||||
|
hudLayer.AddChild(_hudPanel);
|
||||||
|
|
||||||
|
var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
margin.AddThemeConstantOverride("margin_left", 12);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", 8);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", 12);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", 8);
|
||||||
|
_hudPanel.AddChild(margin);
|
||||||
|
|
||||||
|
_hudLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "…",
|
||||||
|
ThemeTypeVariation = "CardBody",
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
margin.AddChild(_hudLabel);
|
||||||
|
|
||||||
|
// Cursor-debug panel — top-right counterpart to the player-status
|
||||||
|
// panel. Shows tile coords, biome, feature flags, settlement,
|
||||||
|
// tactical-tile surface/deco, and any NPC under the mouse.
|
||||||
|
var cursorPanel = new PanelContainer
|
||||||
|
{
|
||||||
|
ThemeTypeVariation = "Card",
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
AnchorLeft = 1, AnchorRight = 1,
|
||||||
|
OffsetLeft = -460, OffsetTop = 12, OffsetRight = -12, OffsetBottom = 260,
|
||||||
|
};
|
||||||
|
hudLayer.AddChild(cursorPanel);
|
||||||
|
|
||||||
|
var cursorMargin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
cursorMargin.AddThemeConstantOverride("margin_left", 12);
|
||||||
|
cursorMargin.AddThemeConstantOverride("margin_top", 8);
|
||||||
|
cursorMargin.AddThemeConstantOverride("margin_right", 12);
|
||||||
|
cursorMargin.AddThemeConstantOverride("margin_bottom", 8);
|
||||||
|
cursorPanel.AddChild(cursorMargin);
|
||||||
|
|
||||||
|
_cursorDebugLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "CURSOR",
|
||||||
|
ThemeTypeVariation = "CardBody",
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
cursorMargin.AddChild(_cursorDebugLabel);
|
||||||
|
|
||||||
|
// Save-flash toast, mounted bottom-center on the same canvas
|
||||||
|
// layer. Hidden by default; FlashSavedToast pops it in.
|
||||||
|
_saveFlashLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
AnchorLeft = 0.5f, AnchorRight = 0.5f,
|
||||||
|
AnchorTop = 1.0f, AnchorBottom = 1.0f,
|
||||||
|
OffsetLeft = -180, OffsetRight = 180,
|
||||||
|
OffsetTop = -56, OffsetBottom = -28,
|
||||||
|
Visible = false,
|
||||||
|
};
|
||||||
|
hudLayer.AddChild(_saveFlashLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Top-right debug panel — what is under the mouse this
|
||||||
|
/// frame. World/tile coords, biome, feature flags, the settlement
|
||||||
|
/// whose footprint contains the tile, the tactical surface + deco
|
||||||
|
/// + walkability when zoomed in, and any NPC within hit radius.</summary>
|
||||||
|
private void UpdateCursorDebug(bool tactical)
|
||||||
|
{
|
||||||
|
var screenPos = GetViewport().GetMousePosition();
|
||||||
|
var worldPos = ScreenToWorld(screenPos);
|
||||||
|
int tx = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
|
||||||
|
int ty = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
|
||||||
|
|
||||||
|
var sb = _cursorSb;
|
||||||
|
sb.Clear();
|
||||||
|
sb.Append("CURSOR world (").Append((int)worldPos.X).Append(", ")
|
||||||
|
.Append((int)worldPos.Y).Append(") tile (")
|
||||||
|
.Append(tx).Append(", ").Append(ty).Append(')').AppendLine();
|
||||||
|
|
||||||
|
if ((uint)tx < C.WORLD_WIDTH_TILES && (uint)ty < C.WORLD_HEIGHT_TILES)
|
||||||
|
{
|
||||||
|
ref var t = ref _ctx.World.TileAt(tx, ty);
|
||||||
|
sb.Append(" Biome: ").Append(t.Biome).AppendLine();
|
||||||
|
if (t.Features != FeatureFlags.None)
|
||||||
|
sb.Append(" Flags: ").Append(t.Features).AppendLine();
|
||||||
|
|
||||||
|
// Copy SettlementId out of the ref local before the lambda
|
||||||
|
// capture below — `ref var t` can't escape into a closure.
|
||||||
|
int settlementId = t.SettlementId;
|
||||||
|
if (settlementId != 0)
|
||||||
|
{
|
||||||
|
var settle = _ctx.World.Settlements.FirstOrDefault(s => s.Id == settlementId);
|
||||||
|
if (settle is not null)
|
||||||
|
{
|
||||||
|
sb.Append(" Settlement: ").Append(settle.Name)
|
||||||
|
.Append(" (Tier ").Append(settle.Tier).Append(')').AppendLine();
|
||||||
|
if (!settle.IsPoi)
|
||||||
|
sb.Append(" ").Append(settle.Economy)
|
||||||
|
.Append(" · ").Append(settle.Governance)
|
||||||
|
.Append(" · pop ").Append(settle.Population).AppendLine();
|
||||||
|
else if (settle.PoiType != PoiType.None)
|
||||||
|
sb.Append(" PoI: ").Append(settle.PoiType).AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tactical)
|
||||||
|
{
|
||||||
|
int tacticalX = (int)Mathf.Floor(worldPos.X);
|
||||||
|
int tacticalY = (int)Mathf.Floor(worldPos.Y);
|
||||||
|
var tt = _streamer.SampleTile(tacticalX, tacticalY);
|
||||||
|
string move = !tt.IsWalkable ? "blocked"
|
||||||
|
: tt.SlowsMovement ? "slow" : "walkable";
|
||||||
|
string deco = tt.Deco == TacticalDeco.None ? "—" : tt.Deco.ToString();
|
||||||
|
sb.Append(" Tactical (").Append(tacticalX).Append(", ").Append(tacticalY).Append(')').AppendLine();
|
||||||
|
sb.Append(" Surface: ").Append(tt.Surface)
|
||||||
|
.Append(" (v").Append(tt.Variant).Append(") Deco: ").Append(deco).AppendLine();
|
||||||
|
sb.Append(" Move: ").Append(move).AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(" <off-world>").AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge under cursor (point-on-segment test — cheap, ≤ a few dozen bridges).
|
||||||
|
const float BridgeHitPx = 6f;
|
||||||
|
foreach (var bridge in _ctx.World.Bridges)
|
||||||
|
{
|
||||||
|
if (DistancePointToSegmentSq(worldPos.X, worldPos.Y,
|
||||||
|
bridge.Start.X, bridge.Start.Y, bridge.End.X, bridge.End.Y) < BridgeHitPx * BridgeHitPx)
|
||||||
|
{
|
||||||
|
sb.Append(" Bridge over road ").Append(bridge.RoadId).AppendLine();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NPC under cursor (within marker hit radius).
|
||||||
|
const float NpcHitPx = 12f;
|
||||||
|
float closestSq = NpcHitPx * NpcHitPx;
|
||||||
|
NpcActor? hovered = null;
|
||||||
|
foreach (var npc in _actors.Npcs)
|
||||||
|
{
|
||||||
|
float ddx = npc.Position.X - worldPos.X;
|
||||||
|
float ddy = npc.Position.Y - worldPos.Y;
|
||||||
|
float distSq = ddx * ddx + ddy * ddy;
|
||||||
|
if (distSq < closestSq) { closestSq = distSq; hovered = npc; }
|
||||||
|
}
|
||||||
|
if (hovered is not null)
|
||||||
|
{
|
||||||
|
string tag = !string.IsNullOrEmpty(hovered.RoleTag)
|
||||||
|
? hovered.RoleTag
|
||||||
|
: (hovered.Template?.Id ?? "<resident>");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append("NPC: ").Append(hovered.DisplayName)
|
||||||
|
.Append(" [").Append(tag).Append(']').AppendLine();
|
||||||
|
sb.Append(" Allegiance: ").Append(hovered.Allegiance)
|
||||||
|
.Append(" HP ").Append(hovered.CurrentHp).Append('/').Append(hovered.MaxHp).AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursorDebugLabel.Text = sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float DistancePointToSegmentSq(float px, float py,
|
||||||
|
float ax, float ay, float bx, float by)
|
||||||
|
{
|
||||||
|
float vx = bx - ax, vy = by - ay;
|
||||||
|
float wx = px - ax, wy = py - ay;
|
||||||
|
float c1 = vx * wx + vy * wy;
|
||||||
|
if (c1 <= 0f) return wx * wx + wy * wy;
|
||||||
|
float c2 = vx * vx + vy * vy;
|
||||||
|
if (c2 <= c1) { float ex = px - bx, ey = py - by; return ex * ex + ey * ey; }
|
||||||
|
float t = c1 / c2;
|
||||||
|
float qx = ax + t * vx, qy = ay + t * vy;
|
||||||
|
float dx = px - qx, dy = py - qy;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateHud(bool tactical)
|
||||||
|
{
|
||||||
|
var p = _actors.Player!;
|
||||||
|
int ptx = (int)Mathf.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
|
||||||
|
int pty = (int)Mathf.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
|
||||||
|
int cx = Mathf.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1);
|
||||||
|
int cy = Mathf.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1);
|
||||||
|
ref var t = ref _ctx.World.TileAt(cx, cy);
|
||||||
|
|
||||||
|
string charBlock = "";
|
||||||
|
if (p.Character is { } pc)
|
||||||
|
{
|
||||||
|
int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
|
||||||
|
charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
string viewBlock = tactical
|
||||||
|
? "View: Tactical (WASD to step)"
|
||||||
|
: "View: World Map (WASD to pan · click a tile to travel)";
|
||||||
|
|
||||||
|
string status = _controller.IsTraveling
|
||||||
|
? "Traveling…"
|
||||||
|
: tactical
|
||||||
|
? "Mouse-wheel out to leave tactical."
|
||||||
|
: "Mouse-wheel in for tactical.";
|
||||||
|
|
||||||
|
string interactBlock = _interactCandidate is { } npc
|
||||||
|
? $"\n[F] Talk to {npc.DisplayName}"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
_hudLabel.Text =
|
||||||
|
charBlock +
|
||||||
|
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
|
||||||
|
$"Player: ({ptx}, {pty}) {t.Biome}\n" +
|
||||||
|
$"{viewBlock}\n" +
|
||||||
|
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
|
||||||
|
$"{status}\n" +
|
||||||
|
"F5 quicksaves · Esc opens pause" + interactBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Quit path
|
||||||
|
|
||||||
|
private void BackToTitle()
|
||||||
|
{
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
session.ClearPending();
|
||||||
|
session.Ctx = null;
|
||||||
|
|
||||||
|
var parent = GetParent();
|
||||||
|
if (parent is null) return;
|
||||||
|
foreach (Node sibling in parent.GetChildren())
|
||||||
|
if (sibling != this) sibling.QueueFree();
|
||||||
|
parent.AddChild(new TitleScreen());
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using Godot;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M7.1 placeholder for the play screen. WorldGenProgressScreen swaps
|
||||||
|
/// here on success; M7.2 will replace this with the real PlayScreen
|
||||||
|
/// (walking character, chunk-streamed tactical view, HUD, save layer).
|
||||||
|
///
|
||||||
|
/// Reads <see cref="GameSession.Ctx"/> and <see cref="GameSession.PendingCharacter"/>
|
||||||
|
/// so the play-test confirms the M7.1 hand-off chain end-to-end:
|
||||||
|
/// Title → Wizard → CharacterAssembler → WorldGenProgress → here.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PlayScreenStub : Control
|
||||||
|
{
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
Theme = CodexTheme.Build();
|
||||||
|
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(bg);
|
||||||
|
bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
MoveChild(bg, 0);
|
||||||
|
|
||||||
|
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(center);
|
||||||
|
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var col = new VBoxContainer { CustomMinimumSize = new Vector2(640, 0) };
|
||||||
|
col.AddThemeConstantOverride("separation", 14);
|
||||||
|
center.AddChild(col);
|
||||||
|
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "PLAYSCREEN STUB · M7.1",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "World generation complete.",
|
||||||
|
ThemeTypeVariation = "H2",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
var ctx = session.Ctx;
|
||||||
|
if (ctx is not null)
|
||||||
|
{
|
||||||
|
var w = ctx.World;
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = $"Seed 0x{w.WorldSeed:X} · rivers {w.Rivers.Count} "
|
||||||
|
+ $"roads {w.Roads.Count} rails {w.Rails.Count} "
|
||||||
|
+ $"settlements {w.Settlements.Count} bridges {w.Bridges.Count}",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "(No WorldGenContext on session — this stub was entered out-of-band.)",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var character = session.PendingCharacter;
|
||||||
|
if (character is not null)
|
||||||
|
{
|
||||||
|
string hybridTag = character.Hybrid is not null ? "yes" : "no";
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = $"Character: {session.PendingName} · HP {character.MaxHp} "
|
||||||
|
+ $"· class {character.ClassDef.Id} · hybrid: {hybridTag} "
|
||||||
|
+ $"· skills: {character.SkillProficiencies.Count}",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "(No character attached — load path will fill this in once M7.3 ships.)",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "PlayScreen with walking character + chunk-streamed tactical view lands in M7.2.",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
});
|
||||||
|
|
||||||
|
var titleBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "← Title",
|
||||||
|
CustomMinimumSize = new Vector2(220, 44),
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
|
||||||
|
};
|
||||||
|
titleBtn.Pressed += BackToTitle;
|
||||||
|
col.AddChild(titleBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToTitle()
|
||||||
|
{
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
session.ClearPending();
|
||||||
|
session.Ctx = null;
|
||||||
|
|
||||||
|
var parent = GetParent();
|
||||||
|
if (parent is null) return;
|
||||||
|
foreach (Node sibling in parent.GetChildren())
|
||||||
|
if (sibling != this) sibling.QueueFree();
|
||||||
|
parent.AddChild(new TitleScreen());
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
|
using Theriapolis.GodotHost.Platform;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M7.3 — slot picker for *load*. Pushed by TitleScreen's "Continue"
|
||||||
|
/// when at least one compatible save exists. Lists the autosave row
|
||||||
|
/// followed by slots 1..<see cref="C.SAVE_SLOT_COUNT"/>; reads each
|
||||||
|
/// slot's header (cheap) for the label and disables incompatible /
|
||||||
|
/// unreadable rows.
|
||||||
|
///
|
||||||
|
/// On slot click: deserialise, stash the body + header + seed on
|
||||||
|
/// <see cref="GameSession"/>, swap to <see cref="WorldGenProgressScreen"/>
|
||||||
|
/// which will hand off to <see cref="PlayScreen"/> with the
|
||||||
|
/// restore-from-save path.
|
||||||
|
///
|
||||||
|
/// Save-from-pause (write) is M7.4 territory and intentionally lives
|
||||||
|
/// in a separate widget — keeps each picker single-purpose.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SaveLoadScreen : Control
|
||||||
|
{
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
Theme = CodexTheme.Build();
|
||||||
|
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(bg);
|
||||||
|
bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
MoveChild(bg, 0);
|
||||||
|
|
||||||
|
BuildUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildUI()
|
||||||
|
{
|
||||||
|
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(center);
|
||||||
|
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var col = new VBoxContainer { CustomMinimumSize = new Vector2(520, 0) };
|
||||||
|
col.AddThemeConstantOverride("separation", 10);
|
||||||
|
center.AddChild(col);
|
||||||
|
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "LOAD GAME",
|
||||||
|
ThemeTypeVariation = "H2",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Autosave row first; numbered slots after.
|
||||||
|
AddSlotRow(col, "Autosave", SavePaths.AutosavePath());
|
||||||
|
for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
|
||||||
|
AddSlotRow(col, $"Slot {i:D2}", SavePaths.SlotPath(i));
|
||||||
|
|
||||||
|
var spacer = new Control { CustomMinimumSize = new Vector2(0, 12) };
|
||||||
|
col.AddChild(spacer);
|
||||||
|
|
||||||
|
var back = new Button
|
||||||
|
{
|
||||||
|
Text = "← Back",
|
||||||
|
CustomMinimumSize = new Vector2(220, 40),
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
|
||||||
|
};
|
||||||
|
back.Pressed += BackToTitle;
|
||||||
|
col.AddChild(back);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSlotRow(VBoxContainer parent, string label, string path)
|
||||||
|
{
|
||||||
|
string text;
|
||||||
|
bool clickable = false;
|
||||||
|
bool exists = File.Exists(path);
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
var header = SaveCodec.DeserializeHeaderOnly(bytes);
|
||||||
|
if (SaveCodec.IsCompatible(header))
|
||||||
|
{
|
||||||
|
text = SaveSlotFormat.FormatRow(label, header);
|
||||||
|
clickable = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
text = $"{label} — <v{header.Version}: {SaveCodec.IncompatibilityReason(header)}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
text = $"{label} — <unreadable: {ex.Message}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
text = $"{label} — <empty>";
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
CustomMinimumSize = new Vector2(0, 40),
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||||
|
Disabled = !clickable,
|
||||||
|
Alignment = HorizontalAlignment.Left,
|
||||||
|
};
|
||||||
|
if (clickable) btn.Pressed += () => LoadSlot(path);
|
||||||
|
parent.AddChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadSlot(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
var (header, body) = SaveCodec.Deserialize(bytes);
|
||||||
|
if (!SaveCodec.IsCompatible(header))
|
||||||
|
{
|
||||||
|
GD.PushError($"[saveload] Refused incompatible save at {path}: "
|
||||||
|
+ SaveCodec.IncompatibilityReason(header));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
session.Seed = header.ParseSeed();
|
||||||
|
session.PendingRestore = body;
|
||||||
|
session.PendingHeader = header;
|
||||||
|
session.PendingCharacter = null; // restore path supplies it via body
|
||||||
|
|
||||||
|
// Swap Title → WorldGenProgress (which will swap to PlayScreen
|
||||||
|
// once the pipeline finishes and stage-hash drift is checked).
|
||||||
|
var parent = GetParent();
|
||||||
|
if (parent is null) return;
|
||||||
|
foreach (Node sibling in parent.GetChildren())
|
||||||
|
if (sibling != this) sibling.QueueFree();
|
||||||
|
parent.AddChild(new WorldGenProgressScreen());
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
GD.PushError($"[saveload] Failed to load {path}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToTitle()
|
||||||
|
{
|
||||||
|
var parent = GetParent();
|
||||||
|
if (parent is null) return;
|
||||||
|
foreach (Node sibling in parent.GetChildren())
|
||||||
|
if (sibling != this) sibling.QueueFree();
|
||||||
|
parent.AddChild(new TitleScreen());
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
|
||||||
|
{
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
BackToTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,9 +43,8 @@ public partial class StepBackground : VBoxContainer, IStep
|
|||||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
});
|
});
|
||||||
|
|
||||||
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
_grid.AddThemeConstantOverride("h_separation", 16);
|
_grid.AddThemeConstantOverride("v_separation", 12);
|
||||||
_grid.AddThemeConstantOverride("v_separation", 16);
|
|
||||||
AddChild(_grid);
|
AddChild(_grid);
|
||||||
|
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -67,7 +66,7 @@ public partial class StepBackground : VBoxContainer, IStep
|
|||||||
bool selected = _draft.BackgroundId == bg.Id;
|
bool selected = _draft.BackgroundId == bg.Id;
|
||||||
|
|
||||||
var card = CodexCard.Make();
|
var card = CodexCard.Make();
|
||||||
card.CustomMinimumSize = new Vector2(200, 0);
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
CodexCard.SetSelected(card, selected);
|
CodexCard.SetSelected(card, selected);
|
||||||
|
|
||||||
card.GuiInput += (InputEvent e) =>
|
card.GuiInput += (InputEvent e) =>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
{
|
{
|
||||||
private CharacterDraft _draft = null!;
|
private CharacterDraft _draft = null!;
|
||||||
private Button _hybridToggle = null!;
|
private Button _hybridToggle = null!;
|
||||||
|
private Button _sexMaleBtn = null!;
|
||||||
|
private Button _sexFemaleBtn = null!;
|
||||||
private VBoxContainer _purebredSection = null!;
|
private VBoxContainer _purebredSection = null!;
|
||||||
private VBoxContainer _hybridSection = null!;
|
private VBoxContainer _hybridSection = null!;
|
||||||
private OptionButton _dominantToggle = null!;
|
private OptionButton _dominantToggle = null!;
|
||||||
@@ -40,8 +42,12 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
private Label _damTraitHeader = null!;
|
private Label _damTraitHeader = null!;
|
||||||
|
|
||||||
private readonly Dictionary<string, PanelContainer> _purebredCards = new();
|
private readonly Dictionary<string, PanelContainer> _purebredCards = new();
|
||||||
private readonly Dictionary<string, PanelContainer> _sireCards = new();
|
private readonly Dictionary<string, PanelContainer> _hybridCards = new();
|
||||||
private readonly Dictionary<string, PanelContainer> _damCards = new();
|
// Per-card sire/dam toggle buttons in the hybrid grid header. Mutated
|
||||||
|
// in place via SetPressedNoSignal during Refresh — same Free()-defer
|
||||||
|
// hazard as the lineage bonus rows.
|
||||||
|
private readonly Dictionary<string, Button> _sireToggles = new();
|
||||||
|
private readonly Dictionary<string, Button> _damToggles = new();
|
||||||
|
|
||||||
// Bonus rows are mutated in place too: when the clade hasn't changed
|
// Bonus rows are mutated in place too: when the clade hasn't changed
|
||||||
// since the last build, we only flip ButtonPressed states. Rebuilding
|
// since the last build, we only flip ButtonPressed states. Rebuilding
|
||||||
@@ -83,6 +89,27 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sex picker — required for every character. For purebreds with
|
||||||
|
// sex-axis variants (Elk, Lion) this drives variant resolution;
|
||||||
|
// for hybrids it's identity-only since sire/dam are male/female
|
||||||
|
// by parent-role definition.
|
||||||
|
var sexRow = new HBoxContainer();
|
||||||
|
sexRow.AddThemeConstantOverride("separation", 8);
|
||||||
|
AddChild(sexRow);
|
||||||
|
sexRow.AddChild(new Label { Text = "SEX", ThemeTypeVariation = "Eyebrow" });
|
||||||
|
_sexMaleBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "Male", ToggleMode = true, FocusMode = Control.FocusModeEnum.None,
|
||||||
|
};
|
||||||
|
_sexFemaleBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "Female", ToggleMode = true, FocusMode = Control.FocusModeEnum.None,
|
||||||
|
};
|
||||||
|
_sexMaleBtn.Pressed += () => OnSexPicked("male");
|
||||||
|
_sexFemaleBtn.Pressed += () => OnSexPicked("female");
|
||||||
|
sexRow.AddChild(_sexMaleBtn);
|
||||||
|
sexRow.AddChild(_sexFemaleBtn);
|
||||||
|
|
||||||
// Toggle Button (not CheckBox) so the inverted-on-press button style
|
// Toggle Button (not CheckBox) so the inverted-on-press button style
|
||||||
// from the codex theme handles selection visually — no checkbox glyph
|
// from the codex theme handles selection visually — no checkbox glyph
|
||||||
// needed, the bg colour shift is the affordance.
|
// needed, the bg colour shift is the affordance.
|
||||||
@@ -111,15 +138,24 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
_hybridSection.AddThemeConstantOverride("separation", 16);
|
_hybridSection.AddThemeConstantOverride("separation", 16);
|
||||||
AddChild(_hybridSection);
|
AddChild(_hybridSection);
|
||||||
|
|
||||||
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
// One unified hybrid grid: every clade card carries Sire/Dam toggle
|
||||||
var sireGrid = MakeGrid();
|
// buttons in its header. The same card can become either parent;
|
||||||
_hybridSection.AddChild(sireGrid);
|
// picking Sire on a card currently set as Dam clears the Dam pick
|
||||||
PopulateGrid(sireGrid, _sireCards, id => OnLineageCladePicked("sire", id));
|
// (and vice versa) atomically.
|
||||||
|
_hybridSection.AddChild(new Label
|
||||||
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
{
|
||||||
var damGrid = MakeGrid();
|
Text = "Mark one clade as Sire (paternal) and another as Dam (maternal). "
|
||||||
_hybridSection.AddChild(damGrid);
|
+ "A single clade cannot be both.",
|
||||||
PopulateGrid(damGrid, _damCards, id => OnLineageCladePicked("dam", id));
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
});
|
||||||
|
var hybridGrid = MakeGrid();
|
||||||
|
_hybridSection.AddChild(hybridGrid);
|
||||||
|
foreach (var clade in CodexContent.Clades)
|
||||||
|
{
|
||||||
|
var card = BuildHybridCard(clade);
|
||||||
|
_hybridCards[clade.Id] = card;
|
||||||
|
hybridGrid.AddChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
// Lineage bonus pickers — hybrids pick ONE ability mod from each
|
// Lineage bonus pickers — hybrids pick ONE ability mod from each
|
||||||
// parent clade. Stacking on the same ability is allowed; mods sum.
|
// parent clade. Stacking on the same ability is allowed; mods sum.
|
||||||
@@ -181,9 +217,11 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
|
|
||||||
private static GridContainer MakeGrid()
|
private static GridContainer MakeGrid()
|
||||||
{
|
{
|
||||||
var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
// Single-column layout: each card spans the wizard's content width
|
||||||
grid.AddThemeConstantOverride("h_separation", 16);
|
// and surfaces the clade's description text. Establishes the
|
||||||
grid.AddThemeConstantOverride("v_separation", 16);
|
// world's tone before mechanics — the trade is more scrolling.
|
||||||
|
var grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
|
grid.AddThemeConstantOverride("v_separation", 12);
|
||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +262,9 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
_draft.Patch(patch);
|
_draft.Patch(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnSexPicked(string sex) =>
|
||||||
|
_draft.Patch(new Godot.Collections.Dictionary { { "sex", sex } });
|
||||||
|
|
||||||
private void OnDominantSelected(long index)
|
private void OnDominantSelected(long index)
|
||||||
{
|
{
|
||||||
string newDominant = index == 0 ? "sire" : "dam";
|
string newDominant = index == 0 ? "sire" : "dam";
|
||||||
@@ -259,9 +300,13 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
_purebredSection.Visible = !hybrid;
|
_purebredSection.Visible = !hybrid;
|
||||||
_hybridSection.Visible = hybrid;
|
_hybridSection.Visible = hybrid;
|
||||||
|
|
||||||
|
bool isMale = _draft.Sex == "male";
|
||||||
|
bool isFemale = _draft.Sex == "female";
|
||||||
|
if (_sexMaleBtn.ButtonPressed != isMale) _sexMaleBtn.SetPressedNoSignal(isMale);
|
||||||
|
if (_sexFemaleBtn.ButtonPressed != isFemale) _sexFemaleBtn.SetPressedNoSignal(isFemale);
|
||||||
|
|
||||||
UpdateSelection(_purebredCards, _draft.CladeId);
|
UpdateSelection(_purebredCards, _draft.CladeId);
|
||||||
UpdateSelection(_sireCards, _draft.SireCladeId);
|
UpdateHybridCards();
|
||||||
UpdateSelection(_damCards, _draft.DamCladeId);
|
|
||||||
|
|
||||||
int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0;
|
int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0;
|
||||||
if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx);
|
if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx);
|
||||||
@@ -349,7 +394,14 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
_draft.Patch(patch);
|
_draft.Patch(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLineageCladePicked(string lineage, string cladeId)
|
/// <summary>
|
||||||
|
/// Build the patch dict that records "<paramref name="lineage"/>'s
|
||||||
|
/// clade is now <paramref name="cladeId"/>" — clearing the dependent
|
||||||
|
/// fields (species, lineage bonus, clade traits) so they don't carry
|
||||||
|
/// stale picks from the prior clade. Returned without applying so the
|
||||||
|
/// caller can merge multiple lineage patches into one atomic Patch.
|
||||||
|
/// </summary>
|
||||||
|
private Godot.Collections.Dictionary BuildLineageCladePatch(string lineage, string cladeId)
|
||||||
{
|
{
|
||||||
var patch = new Godot.Collections.Dictionary
|
var patch = new Godot.Collections.Dictionary
|
||||||
{
|
{
|
||||||
@@ -363,17 +415,57 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
patch[lineage + "_chosen_species_trait"] = "";
|
patch[lineage + "_chosen_species_trait"] = "";
|
||||||
patch[lineage + "_chosen_species_detriment"] = "";
|
patch[lineage + "_chosen_species_detriment"] = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clade swap invalidates the previously-picked lineage bonus
|
|
||||||
// (different clade has different mod options) and the clade
|
|
||||||
// trait picks.
|
|
||||||
patch[lineage + "_chosen_ability"] = "";
|
patch[lineage + "_chosen_ability"] = "";
|
||||||
patch[lineage + "_chosen_clade_traits"] = new Godot.Collections.Array<string>();
|
patch[lineage + "_chosen_clade_traits"] = new Godot.Collections.Array<string>();
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User toggled Sire or Dam on a hybrid card. If the toggle was just
|
||||||
|
/// turned ON, record the pick (and clear the OTHER lineage if it was
|
||||||
|
/// already pointing at this same clade — a clade can't be both
|
||||||
|
/// parents). If turned OFF, clear that lineage.
|
||||||
|
/// </summary>
|
||||||
|
private void OnHybridParentPressed(string lineage, string cladeId)
|
||||||
|
{
|
||||||
|
string currentForLineage = lineage == "sire" ? _draft.SireCladeId : _draft.DamCladeId;
|
||||||
|
bool wasOn = currentForLineage == cladeId;
|
||||||
|
string newCladeId = wasOn ? "" : cladeId;
|
||||||
|
|
||||||
|
var patch = BuildLineageCladePatch(lineage, newCladeId);
|
||||||
|
|
||||||
|
// If the new pick collides with the OTHER parent, clear it.
|
||||||
|
if (!string.IsNullOrEmpty(newCladeId))
|
||||||
|
{
|
||||||
|
string otherLineage = lineage == "sire" ? "dam" : "sire";
|
||||||
|
string otherCladeId = lineage == "sire" ? _draft.DamCladeId : _draft.SireCladeId;
|
||||||
|
if (otherCladeId == newCladeId)
|
||||||
|
{
|
||||||
|
var otherPatch = BuildLineageCladePatch(otherLineage, "");
|
||||||
|
foreach (var key in otherPatch.Keys) patch[(string)key] = otherPatch[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ClearBackgroundIfInvalidated(patch);
|
ClearBackgroundIfInvalidated(patch);
|
||||||
_draft.Patch(patch);
|
_draft.Patch(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateHybridCards()
|
||||||
|
{
|
||||||
|
foreach (var (id, card) in _hybridCards)
|
||||||
|
{
|
||||||
|
bool isSire = id == _draft.SireCladeId;
|
||||||
|
bool isDam = id == _draft.DamCladeId;
|
||||||
|
// The card itself gets a "selected" highlight when either
|
||||||
|
// toggle is on, so it pops visually from the unmarked clades.
|
||||||
|
CodexCard.SetSelected(card, isSire || isDam);
|
||||||
|
if (_sireToggles.TryGetValue(id, out var sireBtn) && sireBtn.ButtonPressed != isSire)
|
||||||
|
sireBtn.SetPressedNoSignal(isSire);
|
||||||
|
if (_damToggles.TryGetValue(id, out var damBtn) && damBtn.ButtonPressed != isDam)
|
||||||
|
damBtn.SetPressedNoSignal(isDam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnLineageBonusPicked(string lineage, string ability)
|
private void OnLineageBonusPicked(string lineage, string ability)
|
||||||
{
|
{
|
||||||
_draft.Patch(new Godot.Collections.Dictionary
|
_draft.Patch(new Godot.Collections.Dictionary
|
||||||
@@ -499,7 +591,7 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
private PanelContainer BuildCard(CladeDef clade, System.Action<string> onClick)
|
private PanelContainer BuildCard(CladeDef clade, System.Action<string> onClick)
|
||||||
{
|
{
|
||||||
var card = CodexCard.Make();
|
var card = CodexCard.Make();
|
||||||
card.CustomMinimumSize = new Vector2(200, 0);
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
card.GuiInput += (InputEvent e) =>
|
card.GuiInput += (InputEvent e) =>
|
||||||
{
|
{
|
||||||
if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
||||||
@@ -513,6 +605,106 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
box.AddChild(new Label { Text = clade.Name, ThemeTypeVariation = "CardName" });
|
box.AddChild(new Label { Text = clade.Name, ThemeTypeVariation = "CardName" });
|
||||||
box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
|
box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(clade.Description))
|
||||||
|
{
|
||||||
|
box.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = clade.Description,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clade.AbilityMods.Count > 0)
|
||||||
|
{
|
||||||
|
var modsRow = new HBoxContainer();
|
||||||
|
modsRow.AddThemeConstantOverride("separation", 8);
|
||||||
|
box.AddChild(modsRow);
|
||||||
|
foreach (var (k, v) in clade.AbilityMods)
|
||||||
|
modsRow.AddChild(new Label { Text = $"{k} {(v >= 0 ? "+" : "")}{v}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clade.Traits.Length > 0 || clade.Detriments.Length > 0)
|
||||||
|
{
|
||||||
|
var chips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass };
|
||||||
|
chips.AddThemeConstantOverride("h_separation", 6);
|
||||||
|
chips.AddThemeConstantOverride("v_separation", 4);
|
||||||
|
box.AddChild(chips);
|
||||||
|
foreach (var t in clade.Traits)
|
||||||
|
chips.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||||
|
foreach (var d in clade.Detriments)
|
||||||
|
chips.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hybrid-mode clade card. Same body as BuildCard but with Sire and
|
||||||
|
/// Dam toggle buttons stacked at the right edge of the title row, so a
|
||||||
|
/// single grid replaces the old two-stacked-grid layout. Body click is
|
||||||
|
/// disabled — only the toggles change selection.
|
||||||
|
/// </summary>
|
||||||
|
private PanelContainer BuildHybridCard(CladeDef clade)
|
||||||
|
{
|
||||||
|
var card = CodexCard.Make();
|
||||||
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
|
||||||
|
var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
|
||||||
|
box.AddThemeConstantOverride("separation", 6);
|
||||||
|
card.AddChild(box);
|
||||||
|
|
||||||
|
// Header HBox: title VBox (expand fill) + Sire/Dam toggle column.
|
||||||
|
var header = new HBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||||
|
header.AddThemeConstantOverride("separation", 12);
|
||||||
|
box.AddChild(header);
|
||||||
|
|
||||||
|
var titleCol = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||||
|
titleCol.AddThemeConstantOverride("separation", 2);
|
||||||
|
header.AddChild(titleCol);
|
||||||
|
titleCol.AddChild(new Label { Text = clade.Name, ThemeTypeVariation = "CardName" });
|
||||||
|
titleCol.AddChild(new Label { Text = clade.Kind.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
|
||||||
|
|
||||||
|
var toggleCol = new HBoxContainer
|
||||||
|
{
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ShrinkBegin,
|
||||||
|
};
|
||||||
|
toggleCol.AddThemeConstantOverride("separation", 6);
|
||||||
|
header.AddChild(toggleCol);
|
||||||
|
|
||||||
|
string capturedId = clade.Id;
|
||||||
|
var sireBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "Sire",
|
||||||
|
ToggleMode = true,
|
||||||
|
FocusMode = Control.FocusModeEnum.None,
|
||||||
|
CustomMinimumSize = new Vector2(64, 0),
|
||||||
|
};
|
||||||
|
sireBtn.Pressed += () => OnHybridParentPressed("sire", capturedId);
|
||||||
|
toggleCol.AddChild(sireBtn);
|
||||||
|
_sireToggles[clade.Id] = sireBtn;
|
||||||
|
|
||||||
|
var damBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "Dam",
|
||||||
|
ToggleMode = true,
|
||||||
|
FocusMode = Control.FocusModeEnum.None,
|
||||||
|
CustomMinimumSize = new Vector2(64, 0),
|
||||||
|
};
|
||||||
|
damBtn.Pressed += () => OnHybridParentPressed("dam", capturedId);
|
||||||
|
toggleCol.AddChild(damBtn);
|
||||||
|
_damToggles[clade.Id] = damBtn;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(clade.Description))
|
||||||
|
{
|
||||||
|
box.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = clade.Description,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (clade.AbilityMods.Count > 0)
|
if (clade.AbilityMods.Count > 0)
|
||||||
{
|
{
|
||||||
var modsRow = new HBoxContainer();
|
var modsRow = new HBoxContainer();
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ public partial class StepClass : VBoxContainer, IStep
|
|||||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
});
|
});
|
||||||
|
|
||||||
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
// Single-column layout matches StepClade / StepSpecies — each card
|
||||||
_grid.AddThemeConstantOverride("h_separation", 16);
|
// spans the wizard's content width so the description text fits
|
||||||
_grid.AddThemeConstantOverride("v_separation", 16);
|
// comfortably and the calling's tone lands before the mechanics.
|
||||||
|
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
|
_grid.AddThemeConstantOverride("v_separation", 12);
|
||||||
AddChild(_grid);
|
AddChild(_grid);
|
||||||
|
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -68,7 +70,7 @@ public partial class StepClass : VBoxContainer, IStep
|
|||||||
bool selected = _draft.ClassId == cls.Id;
|
bool selected = _draft.ClassId == cls.Id;
|
||||||
|
|
||||||
var card = CodexCard.Make();
|
var card = CodexCard.Make();
|
||||||
card.CustomMinimumSize = new Vector2(200, 0);
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
CodexCard.SetSelected(card, selected);
|
CodexCard.SetSelected(card, selected);
|
||||||
|
|
||||||
card.GuiInput += (InputEvent e) =>
|
card.GuiInput += (InputEvent e) =>
|
||||||
@@ -97,6 +99,16 @@ public partial class StepClass : VBoxContainer, IStep
|
|||||||
ThemeTypeVariation = "CardMeta",
|
ThemeTypeVariation = "CardMeta",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(cls.Description))
|
||||||
|
{
|
||||||
|
box.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = cls.Description,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (cls.Saves.Length > 0)
|
if (cls.Saves.Length > 0)
|
||||||
{
|
{
|
||||||
var savesRow = new HBoxContainer();
|
var savesRow = new HBoxContainer();
|
||||||
|
|||||||
@@ -127,15 +127,27 @@ public partial class StepReview : VBoxContainer, IStep
|
|||||||
{
|
{
|
||||||
if (WizardValidation.FirstIncomplete(_draft) != -1) return;
|
if (WizardValidation.FirstIncomplete(_draft) != -1) return;
|
||||||
|
|
||||||
// Persist the draft so a future load path can pick it up.
|
// Persist the draft so a future load path can resume editing.
|
||||||
const string SavePath = "user://character.tres";
|
const string DraftPath = "user://character.tres";
|
||||||
var err = ResourceSaver.Save(_draft, SavePath);
|
var saveErr = ResourceSaver.Save(_draft, DraftPath);
|
||||||
if (err != Error.Ok)
|
if (saveErr != Error.Ok)
|
||||||
GD.PushWarning($"[review] ResourceSaver.Save failed: {err}");
|
GD.PushWarning($"[review] ResourceSaver.Save failed: {saveErr}");
|
||||||
else
|
else
|
||||||
GD.Print($"[review] Saved character draft to {SavePath}");
|
GD.Print($"[review] Saved character draft to {DraftPath}");
|
||||||
|
|
||||||
GD.Print($"[review] Confirmed: {Summarise(_draft)}");
|
// The actual handoff: build the runtime Character via Core's
|
||||||
|
// CharacterBuilder. Failure here is a content/wiring bug, not a
|
||||||
|
// user error — the wizard's validation should have caught everything
|
||||||
|
// the builder rejects. Surface the message and stay on this step.
|
||||||
|
if (!CharacterAssembler.TryBuild(_draft, out var built, out string buildError))
|
||||||
|
{
|
||||||
|
GD.PushError($"[review] Character build failed: {buildError}");
|
||||||
|
_confirmStatus.Text = $"Could not finalize character — {buildError}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GD.Print($"[review] Confirmed: {Summarise(_draft)} → HP={built!.MaxHp}, "
|
||||||
|
+ $"hybrid={built.Hybrid is not null}, skills={built.SkillProficiencies.Count}");
|
||||||
|
|
||||||
EmitSignal(SignalName.CharacterConfirmed, _draft);
|
EmitSignal(SignalName.CharacterConfirmed, _draft);
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ namespace Theriapolis.GodotHost.Scenes.Steps;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Step II — Species. Direct port of <c>StepSpecies</c> in
|
/// Step II — Species. Direct port of <c>StepSpecies</c> in
|
||||||
/// <c>src/steps.jsx</c> plus the Phase 6.5 hybrid extension: when
|
/// <c>src/steps.jsx</c> plus the Phase 6.5 hybrid extension. Hybrid mode
|
||||||
/// <see cref="CharacterDraft.IsHybrid"/> is true the step shows two
|
/// uses a single unified grid (M6.16) — sire and dam species lists are
|
||||||
/// stacked grids, one filtered by SireCladeId and one by DamCladeId.
|
/// merged, and each card carries either a Sire or Dam toggle in its
|
||||||
|
/// header matching the clade it belongs to (sire/dam clades are
|
||||||
|
/// guaranteed disjoint by StepClade's parent-conflict rule).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class StepSpecies : VBoxContainer, IStep
|
public partial class StepSpecies : VBoxContainer, IStep
|
||||||
{
|
{
|
||||||
@@ -17,8 +19,7 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
private VBoxContainer _purebredSection = null!;
|
private VBoxContainer _purebredSection = null!;
|
||||||
private VBoxContainer _hybridSection = null!;
|
private VBoxContainer _hybridSection = null!;
|
||||||
private GridContainer _purebredGrid = null!;
|
private GridContainer _purebredGrid = null!;
|
||||||
private GridContainer _sireGrid = null!;
|
private GridContainer _hybridGrid = null!;
|
||||||
private GridContainer _damGrid = null!;
|
|
||||||
|
|
||||||
// Phase B species trait + detriment pickers — single-pick per lineage.
|
// Phase B species trait + detriment pickers — single-pick per lineage.
|
||||||
private VBoxContainer _pickSection = null!;
|
private VBoxContainer _pickSection = null!;
|
||||||
@@ -66,13 +67,14 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
_hybridSection.AddThemeConstantOverride("separation", 16);
|
_hybridSection.AddThemeConstantOverride("separation", 16);
|
||||||
AddChild(_hybridSection);
|
AddChild(_hybridSection);
|
||||||
|
|
||||||
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
_hybridSection.AddChild(new Label
|
||||||
_sireGrid = MakeGrid();
|
{
|
||||||
_hybridSection.AddChild(_sireGrid);
|
Text = "Pick one species per parent lineage. Sire's clade species "
|
||||||
|
+ "are listed first, then Dam's — click any card to pick it.",
|
||||||
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
_damGrid = MakeGrid();
|
});
|
||||||
_hybridSection.AddChild(_damGrid);
|
_hybridGrid = MakeGrid();
|
||||||
|
_hybridSection.AddChild(_hybridGrid);
|
||||||
|
|
||||||
// Phase B species pickers: one trait + one detriment per parent.
|
// Phase B species pickers: one trait + one detriment per parent.
|
||||||
_pickSection = new VBoxContainer();
|
_pickSection = new VBoxContainer();
|
||||||
@@ -103,9 +105,10 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
|
|
||||||
private static GridContainer MakeGrid()
|
private static GridContainer MakeGrid()
|
||||||
{
|
{
|
||||||
var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
// Single-column layout: each card spans the wizard's content width
|
||||||
grid.AddThemeConstantOverride("h_separation", 16);
|
// and surfaces the species' description text. Matches StepClade.
|
||||||
grid.AddThemeConstantOverride("v_separation", 16);
|
var grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
|
grid.AddThemeConstantOverride("v_separation", 12);
|
||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +121,7 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
|
|
||||||
if (hybrid)
|
if (hybrid)
|
||||||
{
|
{
|
||||||
RefreshGrid(_sireGrid, _draft.SireCladeId, _draft.SireSpeciesId,
|
RefreshHybridGrid();
|
||||||
spId => OnLineageSpeciesPicked("sire", spId));
|
|
||||||
RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId,
|
|
||||||
spId => OnLineageSpeciesPicked("dam", spId));
|
|
||||||
|
|
||||||
SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire",
|
SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire",
|
||||||
_draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
|
_draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
|
||||||
@@ -130,27 +130,25 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId,
|
RefreshPurebredGrid();
|
||||||
spId => _draft.Patch(new Godot.Collections.Dictionary { { "species_id", spId } }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLineageSpeciesPicked(string lineage, string speciesId)
|
private void OnPurebredSpeciesPicked(string speciesId) =>
|
||||||
{
|
_draft.Patch(new Godot.Collections.Dictionary { { "species_id", speciesId } });
|
||||||
|
|
||||||
|
private void OnLineageSpeciesPicked(string lineage, string speciesId) =>
|
||||||
_draft.Patch(new Godot.Collections.Dictionary
|
_draft.Patch(new Godot.Collections.Dictionary
|
||||||
{
|
{
|
||||||
{ lineage + "_species_id", speciesId },
|
{ lineage + "_species_id", speciesId },
|
||||||
// Species swap invalidates the previously-picked species trait/detriment.
|
// Species swap invalidates the previously-picked trait/detriment.
|
||||||
{ lineage + "_chosen_species_trait", "" },
|
{ lineage + "_chosen_species_trait", "" },
|
||||||
{ lineage + "_chosen_species_detriment", "" },
|
{ lineage + "_chosen_species_detriment", "" },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mutate-in-place sync for the species-pick column (one trait button
|
/// Mutate-in-place sync for the species-pick column (one trait button
|
||||||
/// group + one detriment button group, radio-style). Same Free()-defer
|
/// group + one detriment button group, radio-style).
|
||||||
/// hazard as the bonus rows in StepClade — only rebuild when the
|
|
||||||
/// species id changes.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void SyncSpeciesPicks(VBoxContainer col, ref string builtFor,
|
private void SyncSpeciesPicks(VBoxContainer col, ref string builtFor,
|
||||||
string lineage, string speciesId,
|
string lineage, string speciesId,
|
||||||
@@ -255,18 +253,47 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
_draft.Patch(new Godot.Collections.Dictionary { { field, traitId } });
|
_draft.Patch(new Godot.Collections.Dictionary { { field, traitId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RefreshGrid(GridContainer grid, string cladeId, string selectedSpecies, System.Action<string> onClick)
|
private void RefreshPurebredGrid()
|
||||||
|
{
|
||||||
|
foreach (var c in _purebredGrid.GetChildren()) c.Free();
|
||||||
|
if (string.IsNullOrEmpty(_draft.CladeId)) return;
|
||||||
|
foreach (var sp in CodexContent.SpeciesOfClade(_draft.CladeId))
|
||||||
|
_purebredGrid.AddChild(BuildCard(sp, sp.Id == _draft.SpeciesId,
|
||||||
|
spId => OnPurebredSpeciesPicked(spId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hybrid mode: rebuild the unified grid with sire-clade species
|
||||||
|
/// followed by dam-clade species. Sire and dam clades are
|
||||||
|
/// guaranteed distinct by StepClade's parent-conflict rule, so the
|
||||||
|
/// species lists are disjoint — each card unambiguously belongs to
|
||||||
|
/// one lineage. Full rebuild on every Refresh is safe because Bind
|
||||||
|
/// installs Refresh as a deferred callback.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshHybridGrid()
|
||||||
|
{
|
||||||
|
foreach (var c in _hybridGrid.GetChildren()) c.Free();
|
||||||
|
AddHybridLineageBlock("sire", _draft.SireCladeId, _draft.SireSpeciesId);
|
||||||
|
AddHybridLineageBlock("dam", _draft.DamCladeId, _draft.DamSpeciesId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddHybridLineageBlock(string lineage, string cladeId, string selectedSpeciesId)
|
||||||
{
|
{
|
||||||
foreach (var c in grid.GetChildren()) c.Free();
|
|
||||||
if (string.IsNullOrEmpty(cladeId)) return;
|
if (string.IsNullOrEmpty(cladeId)) return;
|
||||||
|
var clade = CodexContent.Clade(cladeId);
|
||||||
|
string headerLabel = (lineage == "sire" ? "SIRE" : "DAM")
|
||||||
|
+ (clade is null ? "" : " — " + clade.Name.ToUpperInvariant());
|
||||||
|
_hybridGrid.AddChild(new Label { Text = headerLabel, ThemeTypeVariation = "Eyebrow" });
|
||||||
|
|
||||||
foreach (var sp in CodexContent.SpeciesOfClade(cladeId))
|
foreach (var sp in CodexContent.SpeciesOfClade(cladeId))
|
||||||
grid.AddChild(BuildCard(sp, sp.Id == selectedSpecies, onClick));
|
_hybridGrid.AddChild(BuildCard(sp, sp.Id == selectedSpeciesId,
|
||||||
|
spId => OnLineageSpeciesPicked(lineage, spId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Control BuildCard(SpeciesDef sp, bool selected, System.Action<string> onClick)
|
private static Control BuildCard(SpeciesDef sp, bool selected, System.Action<string> onClick)
|
||||||
{
|
{
|
||||||
var card = CodexCard.Make();
|
var card = CodexCard.Make();
|
||||||
card.CustomMinimumSize = new Vector2(200, 0);
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
CodexCard.SetSelected(card, selected);
|
CodexCard.SetSelected(card, selected);
|
||||||
|
|
||||||
card.GuiInput += (InputEvent e) =>
|
card.GuiInput += (InputEvent e) =>
|
||||||
@@ -286,6 +313,16 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
ThemeTypeVariation = "CardMeta",
|
ThemeTypeVariation = "CardMeta",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(sp.Description))
|
||||||
|
{
|
||||||
|
box.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = sp.Description,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (sp.AbilityMods.Count > 0)
|
if (sp.AbilityMods.Count > 0)
|
||||||
{
|
{
|
||||||
var modsRow = new HBoxContainer();
|
var modsRow = new HBoxContainer();
|
||||||
|
|||||||
@@ -50,9 +50,8 @@ public partial class StepSubclass : VBoxContainer, IStep
|
|||||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
});
|
});
|
||||||
|
|
||||||
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
_grid.AddThemeConstantOverride("h_separation", 16);
|
_grid.AddThemeConstantOverride("v_separation", 12);
|
||||||
_grid.AddThemeConstantOverride("v_separation", 16);
|
|
||||||
AddChild(_grid);
|
AddChild(_grid);
|
||||||
|
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -78,7 +77,7 @@ public partial class StepSubclass : VBoxContainer, IStep
|
|||||||
bool selected = _draft.SubclassId == sub.Id;
|
bool selected = _draft.SubclassId == sub.Id;
|
||||||
|
|
||||||
var card = CodexCard.Make();
|
var card = CodexCard.Make();
|
||||||
card.CustomMinimumSize = new Vector2(200, 0);
|
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
CodexCard.SetSelected(card, selected);
|
CodexCard.SetSelected(card, selected);
|
||||||
|
|
||||||
card.GuiInput += (InputEvent e) =>
|
card.GuiInput += (InputEvent e) =>
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using Godot;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry-point screen — vertical button stack on a parchment field with the
|
||||||
|
/// codex title and a version label. Per port-plan §M6, exists primarily to
|
||||||
|
/// validate the design system in a non-trivial composition before the player
|
||||||
|
/// reaches character creation.
|
||||||
|
///
|
||||||
|
/// Button actions:
|
||||||
|
/// New Character — swap self for the Wizard scene under the Main parent
|
||||||
|
/// (siblings cleared so the wizard fills the viewport).
|
||||||
|
/// Continue — disabled until <see cref="CharacterAssembler.PersistedStatePath"/>
|
||||||
|
/// exists; full pickup lands with the M7 play loop.
|
||||||
|
/// Quit — shut down the engine.
|
||||||
|
/// </summary>
|
||||||
|
public partial class TitleScreen : Control
|
||||||
|
{
|
||||||
|
private const string VersionLabel = "PORT / GODOT · M7.6";
|
||||||
|
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
Theme = CodexTheme.Build();
|
||||||
|
|
||||||
|
// Backing panel so the parchment Bg fills the viewport (the Control
|
||||||
|
// itself paints nothing). Same pattern as Wizard.cs.
|
||||||
|
// Note: SetAnchorsAndOffsetsPreset is required (not just AnchorRight =
|
||||||
|
// 1) because Godot's anchor setters preserve visual position by
|
||||||
|
// adjusting offsets — manual anchor edits leave the control at 0×0.
|
||||||
|
var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(bg);
|
||||||
|
bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
MoveChild(bg, 0);
|
||||||
|
|
||||||
|
// Centered title + button stack column.
|
||||||
|
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(center);
|
||||||
|
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var col = new VBoxContainer { CustomMinimumSize = new Vector2(360, 0) };
|
||||||
|
col.AddThemeConstantOverride("separation", 28);
|
||||||
|
center.AddChild(col);
|
||||||
|
|
||||||
|
var titleBlock = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
||||||
|
titleBlock.AddThemeConstantOverride("separation", 4);
|
||||||
|
col.AddChild(titleBlock);
|
||||||
|
titleBlock.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "THERIAPOLIS",
|
||||||
|
ThemeTypeVariation = "CodexTitle",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
titleBlock.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "CODEX OF BECOMING",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
var buttonStack = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
|
buttonStack.AddThemeConstantOverride("separation", 12);
|
||||||
|
col.AddChild(buttonStack);
|
||||||
|
|
||||||
|
var newBtn = MakeMenuButton("New Character", primary: true);
|
||||||
|
newBtn.Pressed += OnNewCharacter;
|
||||||
|
buttonStack.AddChild(newBtn);
|
||||||
|
|
||||||
|
var continueBtn = MakeMenuButton("Continue", primary: false);
|
||||||
|
continueBtn.Disabled = !AnyCompatibleSaveExists();
|
||||||
|
continueBtn.Pressed += OnContinue;
|
||||||
|
buttonStack.AddChild(continueBtn);
|
||||||
|
|
||||||
|
var quitBtn = MakeMenuButton("Quit", primary: false);
|
||||||
|
quitBtn.Pressed += OnQuit;
|
||||||
|
buttonStack.AddChild(quitBtn);
|
||||||
|
|
||||||
|
// Version chip in the bottom-right corner — small mono Eyebrow tag,
|
||||||
|
// sits over the parchment field at a comfortable margin.
|
||||||
|
var versionLabel = new Label
|
||||||
|
{
|
||||||
|
Text = VersionLabel,
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
AnchorLeft = 1, AnchorRight = 1,
|
||||||
|
AnchorTop = 1, AnchorBottom = 1,
|
||||||
|
OffsetLeft = -180, OffsetTop = -28,
|
||||||
|
OffsetRight = -16, OffsetBottom = -10,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
};
|
||||||
|
AddChild(versionLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Button MakeMenuButton(string text, bool primary)
|
||||||
|
{
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FocusMode = FocusModeEnum.None,
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||||
|
CustomMinimumSize = new Vector2(0, 44),
|
||||||
|
};
|
||||||
|
if (primary) btn.ThemeTypeVariation = "PrimaryButton";
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNewCharacter()
|
||||||
|
{
|
||||||
|
var packed = ResourceLoader.Load<PackedScene>(WizardScenePath);
|
||||||
|
if (packed is null)
|
||||||
|
{
|
||||||
|
GD.PushError($"[title] Failed to load {WizardScenePath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var parent = GetParent();
|
||||||
|
if (parent is null) return;
|
||||||
|
// Clear siblings so the wizard fills the viewport, then swap in.
|
||||||
|
foreach (Node sibling in parent.GetChildren())
|
||||||
|
if (sibling != this) sibling.QueueFree();
|
||||||
|
var wizardNode = packed.Instantiate();
|
||||||
|
parent.AddChild(wizardNode);
|
||||||
|
if (wizardNode is Wizard wizard)
|
||||||
|
{
|
||||||
|
// "← Title" back-button (visible on step 0) emits BackToTitle.
|
||||||
|
wizard.BackToTitle += () => SwapBackToTitle(parent);
|
||||||
|
// M7.1 — Confirm & Begin in StepReview is forwarded by the
|
||||||
|
// wizard as CharacterConfirmed. Stash the built character on
|
||||||
|
// GameSession and hand off to WorldGenProgressScreen.
|
||||||
|
wizard.CharacterConfirmed += draft => SwapToWorldGen(parent, draft);
|
||||||
|
}
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SwapBackToTitle(Node parent)
|
||||||
|
{
|
||||||
|
foreach (Node child in parent.GetChildren()) child.QueueFree();
|
||||||
|
parent.AddChild(new TitleScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>M7.1 hand-off: snapshot the built character + chosen
|
||||||
|
/// name onto <see cref="GameSession"/>, default the seed (a seed-entry
|
||||||
|
/// UI lands later), and swap to <see cref="WorldGenProgressScreen"/>.</summary>
|
||||||
|
private static void SwapToWorldGen(Node parent, UI.CharacterDraft draft)
|
||||||
|
{
|
||||||
|
var session = GameSession.From(parent);
|
||||||
|
// CharacterAssembler.LastBuilt is populated by StepReview's
|
||||||
|
// OnConfirmPressed → TryBuild call immediately before the
|
||||||
|
// CharacterConfirmed signal fires.
|
||||||
|
session.PendingCharacter = CharacterAssembler.LastBuilt;
|
||||||
|
session.PendingName = string.IsNullOrWhiteSpace(draft.CharacterName)
|
||||||
|
? "Wanderer"
|
||||||
|
: draft.CharacterName;
|
||||||
|
session.Seed = 12345UL; // default for M7; seed-entry UI is M8+.
|
||||||
|
session.PendingRestore = null;
|
||||||
|
session.PendingHeader = null;
|
||||||
|
|
||||||
|
foreach (Node child in parent.GetChildren()) child.QueueFree();
|
||||||
|
parent.AddChild(new WorldGenProgressScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnContinue()
|
||||||
|
{
|
||||||
|
var parent = GetParent();
|
||||||
|
if (parent is null) return;
|
||||||
|
foreach (Node sibling in parent.GetChildren())
|
||||||
|
if (sibling != this) sibling.QueueFree();
|
||||||
|
parent.AddChild(new SaveLoadScreen());
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnQuit() => GetTree().Quit();
|
||||||
|
|
||||||
|
/// <summary>True iff at least one slot under <see cref="Platform.SavePaths.SavesDir"/>
|
||||||
|
/// has a header that <see cref="Theriapolis.Core.Persistence.SaveCodec.IsCompatible"/>
|
||||||
|
/// accepts. Cheap: <see cref="Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly"/>
|
||||||
|
/// reads only the JSON prefix, not the binary body.</summary>
|
||||||
|
private static bool AnyCompatibleSaveExists()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string dir = Platform.SavePaths.SavesDir;
|
||||||
|
if (!System.IO.Directory.Exists(dir)) return false;
|
||||||
|
foreach (var path in System.IO.Directory.EnumerateFiles(dir, "*.trps"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = System.IO.File.ReadAllBytes(path);
|
||||||
|
var header = Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly(bytes);
|
||||||
|
if (Theriapolis.Core.Persistence.SaveCodec.IsCompatible(header)) return true;
|
||||||
|
}
|
||||||
|
catch { /* skip broken slot */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* defensive */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,11 @@ public static class CodexCard
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a PanelContainer with ThemeTypeVariation = "Card" plus
|
/// Creates a PanelContainer with ThemeTypeVariation = "Card" plus
|
||||||
/// hover signal wiring. The MouseEntered/MouseExited handlers update
|
/// hover signal wiring. MouseEntered marks hover; MouseExited defers
|
||||||
/// the hover meta and re-apply the right stylebox.
|
/// a recheck against the card's global rect so moving the cursor
|
||||||
|
/// from the card body onto an inner Button (which captures the
|
||||||
|
/// parent's MouseExited via mouse-filter Stop) does not clear the
|
||||||
|
/// hover state — the cursor is still visually within the card.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PanelContainer Make()
|
public static PanelContainer Make()
|
||||||
{
|
{
|
||||||
@@ -32,20 +35,52 @@ public static class CodexCard
|
|||||||
MouseFilter = Control.MouseFilterEnum.Stop,
|
MouseFilter = Control.MouseFilterEnum.Stop,
|
||||||
};
|
};
|
||||||
card.MouseEntered += () => SetHover(card, true);
|
card.MouseEntered += () => SetHover(card, true);
|
||||||
card.MouseExited += () => SetHover(card, false);
|
card.MouseExited += () =>
|
||||||
|
Callable.From(() => RecheckHover(card)).CallDeferred();
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void RecheckHover(PanelContainer card)
|
||||||
|
{
|
||||||
|
if (!GodotObject.IsInstanceValid(card)) return;
|
||||||
|
// Hover stays true as long as the cursor is anywhere within the
|
||||||
|
// card's rect (including over any child control). Drop only when
|
||||||
|
// the cursor has truly left the card area.
|
||||||
|
bool stillOver = card.GetGlobalRect().HasPoint(card.GetGlobalMousePosition());
|
||||||
|
SetHover(card, stillOver);
|
||||||
|
}
|
||||||
|
|
||||||
public static void SetSelected(PanelContainer card, bool selected)
|
public static void SetSelected(PanelContainer card, bool selected)
|
||||||
{
|
{
|
||||||
card.SetMeta(SelectedMeta, selected);
|
card.SetMeta(SelectedMeta, selected);
|
||||||
Apply(card);
|
ApplyOrDefer(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetHover(PanelContainer card, bool hover)
|
private static void SetHover(PanelContainer card, bool hover)
|
||||||
{
|
{
|
||||||
card.SetMeta(HoverMeta, hover);
|
card.SetMeta(HoverMeta, hover);
|
||||||
Apply(card);
|
ApplyOrDefer(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply now if the card is already in the scene tree, otherwise defer
|
||||||
|
/// until end-of-frame so the parent theme cascade is reachable. Step
|
||||||
|
/// builders call SetSelected on a freshly-created card before
|
||||||
|
/// AddChild — the theme isn't visible at that point and HasThemeStylebox
|
||||||
|
/// returns false, which previously meant the override silently dropped
|
||||||
|
/// and only re-attached when MouseEntered later re-ran Apply.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyOrDefer(PanelContainer card)
|
||||||
|
{
|
||||||
|
if (card.IsInsideTree())
|
||||||
|
{
|
||||||
|
Apply(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Callable.From(() =>
|
||||||
|
{
|
||||||
|
if (GodotObject.IsInstanceValid(card)) Apply(card);
|
||||||
|
}).CallDeferred();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Apply(PanelContainer card)
|
private static void Apply(PanelContainer card)
|
||||||
|
|||||||
@@ -46,18 +46,33 @@ public partial class PopoverLayer : CanvasLayer
|
|||||||
{
|
{
|
||||||
Layer = 100;
|
Layer = 100;
|
||||||
BuildPopover();
|
BuildPopover();
|
||||||
|
// Theme inheritance walks Control descendants only — CanvasLayer is
|
||||||
|
// a plain Node, so it breaks the propagation chain from the Wizard
|
||||||
|
// Control above. _Ready is bottom-up, so the parent Wizard hasn't
|
||||||
|
// assigned its codex theme yet — defer the lookup until parent's
|
||||||
|
// _Ready has run, then pull its theme onto the popup directly.
|
||||||
|
CallDeferred(MethodName.InheritParentTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InheritParentTheme()
|
||||||
|
{
|
||||||
|
if (GetParent() is Control parentControl && parentControl.Theme is not null)
|
||||||
|
_popup.Theme = parentControl.Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildPopover()
|
private void BuildPopover()
|
||||||
{
|
{
|
||||||
// Ignore so clicks/scroll/hover all pass through to whatever's
|
// Ignore so clicks/scroll/hover all pass through to whatever's
|
||||||
// beneath. The popover is purely a visual readout; the chip
|
// beneath. The popover is purely a visual readout; the chip
|
||||||
// owns the lifecycle entirely.
|
// owns the lifecycle entirely. ThemeTypeVariation pulls the
|
||||||
|
// CodexPopover stylebox (parchment bg2 + gild border + rounded
|
||||||
|
// corners + soft shadow) defined in CodexTheme.
|
||||||
_popup = new PanelContainer
|
_popup = new PanelContainer
|
||||||
{
|
{
|
||||||
Visible = false,
|
Visible = false,
|
||||||
MouseFilter = Control.MouseFilterEnum.Ignore,
|
MouseFilter = Control.MouseFilterEnum.Ignore,
|
||||||
ZIndex = 100,
|
ZIndex = 100,
|
||||||
|
ThemeTypeVariation = "CodexPopover",
|
||||||
};
|
};
|
||||||
AddChild(_popup);
|
AddChild(_popup);
|
||||||
|
|
||||||
@@ -66,19 +81,29 @@ public partial class PopoverLayer : CanvasLayer
|
|||||||
_popup.AddChild(v);
|
_popup.AddChild(v);
|
||||||
|
|
||||||
var nameRow = new HBoxContainer();
|
var nameRow = new HBoxContainer();
|
||||||
nameRow.AddThemeConstantOverride("separation", 8);
|
nameRow.AddThemeConstantOverride("separation", 10);
|
||||||
v.AddChild(nameRow);
|
v.AddChild(nameRow);
|
||||||
|
|
||||||
_titleLabel = new Label();
|
// Display-serif title at H3 size (20px) — pulls the trait name
|
||||||
|
// out of the body copy below.
|
||||||
|
_titleLabel = new Label { ThemeTypeVariation = "H3" };
|
||||||
nameRow.AddChild(_titleLabel);
|
nameRow.AddChild(_titleLabel);
|
||||||
|
|
||||||
_tagLabel = new Label { Visible = false };
|
// Mono uppercase tag label, vertically centred against the title.
|
||||||
|
_tagLabel = new Label
|
||||||
|
{
|
||||||
|
Visible = false,
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
|
||||||
|
};
|
||||||
nameRow.AddChild(_tagLabel);
|
nameRow.AddChild(_tagLabel);
|
||||||
|
|
||||||
_descLabel = new Label
|
_descLabel = new Label
|
||||||
{
|
{
|
||||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
CustomMinimumSize = new Vector2(220, 0),
|
CustomMinimumSize = new Vector2(220, 0),
|
||||||
|
ThemeTypeVariation = "CardBody",
|
||||||
};
|
};
|
||||||
v.AddChild(_descLabel);
|
v.AddChild(_descLabel);
|
||||||
}
|
}
|
||||||
@@ -91,10 +116,18 @@ public partial class PopoverLayer : CanvasLayer
|
|||||||
_tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
|
_tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
|
||||||
: (detriment ? "DETRIMENT" : "");
|
: (detriment ? "DETRIMENT" : "");
|
||||||
|
|
||||||
// M6.3 default-theme tint: detriment popover gets a red modulate so
|
// Detriment popover swaps to the seal-bordered stylebox via
|
||||||
// it reads visually distinct from a regular trait. The proper
|
// override; non-detriment clears the override so the default
|
||||||
// codex StyleBox swap lands in the theming pass.
|
// CodexPopover panel takes effect again.
|
||||||
_popup.Modulate = detriment ? new Color(1f, 0.78f, 0.78f) : Colors.White;
|
if (detriment && _popup.HasThemeStylebox("panel_detriment", "CodexPopover"))
|
||||||
|
{
|
||||||
|
var box = _popup.GetThemeStylebox("panel_detriment", "CodexPopover");
|
||||||
|
_popup.AddThemeStyleboxOverride("panel", box);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_popup.RemoveThemeStyleboxOverride("panel");
|
||||||
|
}
|
||||||
|
|
||||||
_popup.Visible = true;
|
_popup.Visible = true;
|
||||||
_popup.ResetSize();
|
_popup.ResetSize();
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ public partial class Wizard : Control
|
|||||||
{
|
{
|
||||||
[Signal] public delegate void BackToTitleEventHandler();
|
[Signal] public delegate void BackToTitleEventHandler();
|
||||||
|
|
||||||
|
/// <summary>Forwarded from <c>StepReview.CharacterConfirmed</c> so
|
||||||
|
/// the wizard's owner (TitleScreen / Main) can hand off to the
|
||||||
|
/// WorldGenProgressScreen without reaching into the step tree.</summary>
|
||||||
|
[Signal] public delegate void CharacterConfirmedEventHandler(UI.CharacterDraft draft);
|
||||||
|
|
||||||
private static readonly string[] StepKeys =
|
private static readonly string[] StepKeys =
|
||||||
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
|
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
|
||||||
private static readonly string[] StepNames =
|
private static readonly string[] StepNames =
|
||||||
@@ -117,6 +122,13 @@ public partial class Wizard : Control
|
|||||||
_activeStep = instance;
|
_activeStep = instance;
|
||||||
instance.Bind(Character);
|
instance.Bind(Character);
|
||||||
_stepHost.AddChild((Control)instance);
|
_stepHost.AddChild((Control)instance);
|
||||||
|
|
||||||
|
// Forward the final-step confirmation upward so TitleScreen
|
||||||
|
// (or whatever shell owns the wizard) can swap to M7.1's
|
||||||
|
// WorldGenProgressScreen without coupling to the step tree.
|
||||||
|
if (instance is Steps.StepReview review)
|
||||||
|
review.CharacterConfirmed += draft =>
|
||||||
|
EmitSignal(SignalName.CharacterConfirmed, draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateChrome();
|
UpdateChrome();
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
|
using Theriapolis.Core.World.Generation;
|
||||||
|
using Theriapolis.GodotHost.Platform;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.Scenes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M7.1 — runs the 23-stage worldgen pipeline on a background thread
|
||||||
|
/// and shows per-stage progress. Transitions to <see cref="PlayScreenStub"/>
|
||||||
|
/// (which M7.2 will replace with the real PlayScreen) on completion.
|
||||||
|
///
|
||||||
|
/// Mirrors <c>Theriapolis.Game/Screens/WorldGenProgressScreen.cs</c>:
|
||||||
|
/// same volatile-field hand-off between the worker and the UI thread,
|
||||||
|
/// same soft stage-hash warning when restoring from a saved header.
|
||||||
|
///
|
||||||
|
/// Inputs (from <see cref="GameSession"/>):
|
||||||
|
/// - <c>Seed</c> — required.
|
||||||
|
/// - <c>PendingHeader</c> — present when restoring from save; triggers
|
||||||
|
/// the post-gen stage-hash diff against <c>WorldState.StageHashes</c>.
|
||||||
|
///
|
||||||
|
/// Outputs:
|
||||||
|
/// - <c>session.Ctx</c> set on success; consumed by the next screen.
|
||||||
|
///
|
||||||
|
/// Escape during generation: cancel the worker (honoured at the next
|
||||||
|
/// stage boundary), return to Title.
|
||||||
|
/// </summary>
|
||||||
|
public partial class WorldGenProgressScreen : Control
|
||||||
|
{
|
||||||
|
private WorldGenContext? _ctx;
|
||||||
|
private Task? _genTask;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private volatile float _progress;
|
||||||
|
private volatile string _stageName = "Initialising…";
|
||||||
|
private volatile bool _complete;
|
||||||
|
private volatile string? _error;
|
||||||
|
|
||||||
|
private Label _titleLabel = null!;
|
||||||
|
private ProgressBar _progressBar = null!;
|
||||||
|
private Label _stageLabel = null!;
|
||||||
|
private bool _transitioned;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
Theme = CodexTheme.Build();
|
||||||
|
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
// Backing panel so the dark palette Bg fills the viewport (the
|
||||||
|
// Control itself paints nothing). Mirrors TitleScreen.cs.
|
||||||
|
var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(bg);
|
||||||
|
bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
MoveChild(bg, 0);
|
||||||
|
|
||||||
|
BuildUI();
|
||||||
|
StartGeneration();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildUI()
|
||||||
|
{
|
||||||
|
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(center);
|
||||||
|
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var col = new VBoxContainer { CustomMinimumSize = new Vector2(480, 0) };
|
||||||
|
col.AddThemeConstantOverride("separation", 14);
|
||||||
|
center.AddChild(col);
|
||||||
|
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "FORGING THE WORLD",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
_titleLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"Seed 0x{session.Seed:X}",
|
||||||
|
ThemeTypeVariation = "H2",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
};
|
||||||
|
col.AddChild(_titleLabel);
|
||||||
|
|
||||||
|
_progressBar = new ProgressBar
|
||||||
|
{
|
||||||
|
MinValue = 0,
|
||||||
|
MaxValue = 1,
|
||||||
|
Step = 0.001,
|
||||||
|
ShowPercentage = true,
|
||||||
|
CustomMinimumSize = new Vector2(0, 22),
|
||||||
|
};
|
||||||
|
col.AddChild(_progressBar);
|
||||||
|
|
||||||
|
_stageLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Starting…",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
};
|
||||||
|
col.AddChild(_stageLabel);
|
||||||
|
|
||||||
|
col.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "Esc to cancel · returns to title.",
|
||||||
|
ThemeTypeVariation = "Eyebrow",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartGeneration()
|
||||||
|
{
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var token = _cts.Token;
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
ulong seed = session.Seed;
|
||||||
|
string dataDir = ContentPaths.DataDir;
|
||||||
|
|
||||||
|
_genTask = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = new WorldGenContext(seed, dataDir)
|
||||||
|
{
|
||||||
|
ProgressCallback = (name, frac) =>
|
||||||
|
{
|
||||||
|
_stageName = name;
|
||||||
|
_progress = frac;
|
||||||
|
},
|
||||||
|
Log = msg => GD.Print($"[worldgen] {msg}"),
|
||||||
|
};
|
||||||
|
WorldGenerator.RunAll(ctx);
|
||||||
|
if (token.IsCancellationRequested) return;
|
||||||
|
_ctx = ctx;
|
||||||
|
_complete = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var inner = ex is AggregateException ae ? ae.Flatten().InnerException ?? ex : ex;
|
||||||
|
_error = inner.ToString();
|
||||||
|
}
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Process(double delta)
|
||||||
|
{
|
||||||
|
if (_transitioned) return;
|
||||||
|
if (_error is not null)
|
||||||
|
{
|
||||||
|
ShowError(_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_complete && _ctx is not null)
|
||||||
|
{
|
||||||
|
_transitioned = true;
|
||||||
|
Transition();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_progressBar.Value = _progress;
|
||||||
|
_stageLabel.Text = _stageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Transition()
|
||||||
|
{
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
if (session.PendingHeader is not null)
|
||||||
|
CompareStageHashes(session.PendingHeader);
|
||||||
|
|
||||||
|
session.Ctx = _ctx;
|
||||||
|
|
||||||
|
// M7.2 — the real PlayScreen. PlayScreenStub is kept around as
|
||||||
|
// a fallback for any future code path that hasn't been wired up
|
||||||
|
// (e.g. mid-development load flows), but the live hand-off lands
|
||||||
|
// in the play view.
|
||||||
|
SwapTo(new PlayScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwapTo(Node next)
|
||||||
|
{
|
||||||
|
var parent = GetParent();
|
||||||
|
if (parent is null) return;
|
||||||
|
foreach (Node sibling in parent.GetChildren())
|
||||||
|
if (sibling != this) sibling.QueueFree();
|
||||||
|
parent.AddChild(next);
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowError(string error)
|
||||||
|
{
|
||||||
|
_stageLabel.Text = "ERROR — press Escape to return to title";
|
||||||
|
_progressBar.Value = 0;
|
||||||
|
// Crop to the first line + 100 chars so the title label stays legible.
|
||||||
|
int newline = error.IndexOf('\n');
|
||||||
|
string headline = newline > 0 ? error[..newline] : error;
|
||||||
|
_titleLabel.Text = headline.Length > 100 ? headline[..100] + "…" : headline;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string logPath = ProjectSettings.GlobalizePath("user://worldgen_error.log");
|
||||||
|
File.WriteAllText(logPath, $"[{DateTime.Now:u}] WorldGen ERROR\n{error}\n");
|
||||||
|
GD.PushError($"[worldgen] Wrote {logPath}");
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
BackToTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _ExitTree()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToTitle()
|
||||||
|
{
|
||||||
|
var session = GameSession.From(this);
|
||||||
|
session.ClearPending();
|
||||||
|
session.Ctx = null;
|
||||||
|
SwapTo(new TitleScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompareStageHashes(SaveHeader savedHeader)
|
||||||
|
{
|
||||||
|
if (_ctx is null) return;
|
||||||
|
int mismatches = 0;
|
||||||
|
foreach (var kv in _ctx.World.StageHashes)
|
||||||
|
{
|
||||||
|
if (!savedHeader.StageHashes.TryGetValue(kv.Key, out var sv)) continue;
|
||||||
|
string current = $"0x{kv.Value:X}";
|
||||||
|
if (!string.Equals(sv, current, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
mismatches++;
|
||||||
|
GD.PushWarning($"[save-migration] Stage '{kv.Key}' hash drift: saved={sv}, current={current}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mismatches > 0)
|
||||||
|
GD.PushWarning($"[save-migration] {mismatches} stage(s) drifted; loading anyway (soft).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Godot;
|
||||||
|
using Theriapolis.Core.Data;
|
||||||
|
using Theriapolis.Core.Items;
|
||||||
|
using Theriapolis.Core.Persistence;
|
||||||
|
using Theriapolis.Core.Rules.Character;
|
||||||
|
using Theriapolis.Core.Rules.Stats;
|
||||||
|
using Theriapolis.GodotHost.Platform;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges the wizard's <see cref="CharacterDraft"/> (Godot Resource) into a
|
||||||
|
/// runtime <see cref="Character"/> via <see cref="CharacterBuilder"/>. Used by
|
||||||
|
/// StepReview's "Confirm & Begin" handoff.
|
||||||
|
///
|
||||||
|
/// Holds the most-recently-built character in <see cref="LastBuilt"/> so a
|
||||||
|
/// future PlayScreen / world-load step can pick it up without re-running the
|
||||||
|
/// builder, and writes the captured <see cref="PlayerCharacterState"/> to
|
||||||
|
/// <c>user://character.json</c> for cold-restart resumability before the M7
|
||||||
|
/// save format lands.
|
||||||
|
/// </summary>
|
||||||
|
public static class CharacterAssembler
|
||||||
|
{
|
||||||
|
/// <summary>The most recently confirmed character, or null if none.</summary>
|
||||||
|
public static Character? LastBuilt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Path used for the resumability dump.</summary>
|
||||||
|
public const string PersistedStatePath = "user://character.json";
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, ItemDef>? _items;
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, ItemDef> Items()
|
||||||
|
{
|
||||||
|
if (_items is not null) return _items;
|
||||||
|
var loader = new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir);
|
||||||
|
var arr = loader.LoadItems();
|
||||||
|
var dict = new Dictionary<string, ItemDef>(arr.Length);
|
||||||
|
foreach (var it in arr) dict[it.Id] = it;
|
||||||
|
_items = dict;
|
||||||
|
return _items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the runtime <see cref="Character"/> from the draft. Returns false
|
||||||
|
/// with a populated <paramref name="error"/> when the draft is incomplete
|
||||||
|
/// or content lookups fail; on success <paramref name="character"/> holds
|
||||||
|
/// the built object and <see cref="LastBuilt"/> is updated.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryBuild(CharacterDraft draft, out Character? character, out string error)
|
||||||
|
{
|
||||||
|
character = null;
|
||||||
|
error = "";
|
||||||
|
|
||||||
|
var builder = new CharacterBuilder
|
||||||
|
{
|
||||||
|
BaseAbilities = ToAbilityScores(draft.StatAssign),
|
||||||
|
Name = string.IsNullOrWhiteSpace(draft.CharacterName) ? "Wanderer" : draft.CharacterName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Class + background lookups are shared by purebred and hybrid paths.
|
||||||
|
var classDef = CodexContent.Class(draft.ClassId);
|
||||||
|
if (classDef is null) { error = $"Unknown class id '{draft.ClassId}'."; return false; }
|
||||||
|
builder.ClassDef = classDef;
|
||||||
|
|
||||||
|
var bgDef = CodexContent.Background(draft.BackgroundId);
|
||||||
|
if (bgDef is null) { error = $"Unknown background id '{draft.BackgroundId}'."; return false; }
|
||||||
|
builder.Background = bgDef;
|
||||||
|
|
||||||
|
foreach (Variant v in draft.ChosenSkills)
|
||||||
|
{
|
||||||
|
string raw = (string)v;
|
||||||
|
try { builder.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); }
|
||||||
|
catch (System.ArgumentException) { error = $"Unknown skill id '{raw}'."; return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Character built;
|
||||||
|
if (draft.IsHybrid)
|
||||||
|
{
|
||||||
|
builder.IsHybridOrigin = true;
|
||||||
|
|
||||||
|
var sireClade = CodexContent.Clade(draft.SireCladeId);
|
||||||
|
var sireSp = CodexContent.SpeciesById(draft.SireSpeciesId);
|
||||||
|
var damClade = CodexContent.Clade(draft.DamCladeId);
|
||||||
|
var damSp = CodexContent.SpeciesById(draft.DamSpeciesId);
|
||||||
|
if (sireClade is null || sireSp is null || damClade is null || damSp is null)
|
||||||
|
{
|
||||||
|
error = "Hybrid lineage incomplete (sire/dam clade or species missing).";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.HybridSireClade = sireClade;
|
||||||
|
builder.HybridSireSpecies = sireSp;
|
||||||
|
builder.HybridDamClade = damClade;
|
||||||
|
builder.HybridDamSpecies = damSp;
|
||||||
|
builder.HybridDominantParent =
|
||||||
|
draft.DominantParent == "dam" ? ParentLineage.Dam : ParentLineage.Sire;
|
||||||
|
// Hybrid lineage-bonus picks: the wizard's StepClade picker
|
||||||
|
// records one chosen ability per parent. The builder applies
|
||||||
|
// exactly those (no full ability_mods spread, no species mods)
|
||||||
|
// so the built Character matches the Aside's preview math.
|
||||||
|
builder.HybridSireChosenAbility = draft.SireChosenAbility ?? "";
|
||||||
|
builder.HybridDamChosenAbility = draft.DamChosenAbility ?? "";
|
||||||
|
|
||||||
|
if (!builder.TryBuildHybrid(Items(), out var hybridChar, out error)) return false;
|
||||||
|
built = hybridChar!;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var clade = CodexContent.Clade(draft.CladeId);
|
||||||
|
var sp = CodexContent.SpeciesById(draft.SpeciesId);
|
||||||
|
if (clade is null || sp is null)
|
||||||
|
{
|
||||||
|
error = "Lineage incomplete (clade or species missing).";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
builder.Clade = clade;
|
||||||
|
builder.Species = sp;
|
||||||
|
|
||||||
|
try { built = builder.Build(Items()); }
|
||||||
|
catch (System.InvalidOperationException ex) { error = ex.Message; return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subclass selection happens at level 3 mechanically, but the wizard
|
||||||
|
// locks the choice in at L1 — stamp it onto the character so the L3
|
||||||
|
// unlock has the player's pick already recorded.
|
||||||
|
if (!string.IsNullOrEmpty(draft.SubclassId)) built.SubclassId = draft.SubclassId;
|
||||||
|
|
||||||
|
character = built;
|
||||||
|
LastBuilt = built;
|
||||||
|
PersistState(built);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Capture the live <see cref="Character"/> into a flat
|
||||||
|
/// <see cref="PlayerCharacterState"/> and write it as JSON to
|
||||||
|
/// <see cref="PersistedStatePath"/>. Lets a future cold-load path
|
||||||
|
/// recover the confirmed character before the M7 save format lands.
|
||||||
|
/// </summary>
|
||||||
|
private static void PersistState(Character c)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snap = CharacterCodec.Capture(c);
|
||||||
|
string globalPath = ProjectSettings.GlobalizePath(PersistedStatePath);
|
||||||
|
string? dir = Path.GetDirectoryName(globalPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
string json = JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(globalPath, json);
|
||||||
|
GD.Print($"[character] Persisted state to {PersistedStatePath}");
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
GD.PushWarning($"[character] PersistState failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbilityScores ToAbilityScores(Godot.Collections.Dictionary statAssign)
|
||||||
|
{
|
||||||
|
int Get(string key) => statAssign.ContainsKey(key) ? (int)statAssign[key] : 10;
|
||||||
|
return new AbilityScores(
|
||||||
|
Get("STR"), Get("DEX"), Get("CON"),
|
||||||
|
Get("INT"), Get("WIS"), Get("CHA"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,23 @@ public partial class CharacterDraft : Resource
|
|||||||
[Export] public string SubclassId { get; set; } = "";
|
[Export] public string SubclassId { get; set; } = "";
|
||||||
[Export] public string BackgroundId { get; set; } = "";
|
[Export] public string BackgroundId { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>"male" or "female". Required for every character. For
|
||||||
|
/// purebreds with sex-axis variants (Elk, Lion), drives variant
|
||||||
|
/// resolution. For hybrids, sire variant always resolves to "male"
|
||||||
|
/// and dam variant to "female" by parent-role definition — the
|
||||||
|
/// character's own Sex remains an identity field.</summary>
|
||||||
|
[Export] public string Sex { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Lineage-axis variant id picked for the purebred species
|
||||||
|
/// (e.g. "sheep" or "goat" for Ram-Folk). Empty if the picked species
|
||||||
|
/// has no lineage variant.</summary>
|
||||||
|
[Export] public string SpeciesVariant { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Hybrid: lineage-axis variant for sire's species.</summary>
|
||||||
|
[Export] public string SireSpeciesVariant { get; set; } = "";
|
||||||
|
/// <summary>Hybrid: lineage-axis variant for dam's species.</summary>
|
||||||
|
[Export] public string DamSpeciesVariant { get; set; } = "";
|
||||||
|
|
||||||
// ── Phase 6.5 hybrid origin ────────────────────────────────────────
|
// ── Phase 6.5 hybrid origin ────────────────────────────────────────
|
||||||
/// <summary>True when the PC is a hybrid (two parent lineages).</summary>
|
/// <summary>True when the PC is a hybrid (two parent lineages).</summary>
|
||||||
[Export] public bool IsHybrid { get; set; }
|
[Export] public bool IsHybrid { get; set; }
|
||||||
@@ -67,6 +84,36 @@ public partial class CharacterDraft : Resource
|
|||||||
public int CladeTraitLimit(string lineage) =>
|
public int CladeTraitLimit(string lineage) =>
|
||||||
lineage == DominantParent ? 2 : 1;
|
lineage == DominantParent ? 2 : 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the active variant id for a species. <paramref name="role"/>
|
||||||
|
/// is "" for purebred, "sire" or "dam" for hybrid lineages. Returns
|
||||||
|
/// empty when the species has no variants or the relevant pick/sex is
|
||||||
|
/// missing.
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveVariantId(Theriapolis.Core.Data.SpeciesDef? species, string role)
|
||||||
|
{
|
||||||
|
if (species is null || string.IsNullOrEmpty(species.VariantAxis)) return "";
|
||||||
|
return species.VariantAxis switch
|
||||||
|
{
|
||||||
|
// Sex-axis: purebred uses character Sex; hybrid lineages are
|
||||||
|
// pinned by parent role (sire = male, dam = female).
|
||||||
|
"sex" => role switch
|
||||||
|
{
|
||||||
|
"sire" => "male",
|
||||||
|
"dam" => "female",
|
||||||
|
_ => Sex,
|
||||||
|
},
|
||||||
|
// Lineage-axis: explicit per-species pick.
|
||||||
|
"lineage" => role switch
|
||||||
|
{
|
||||||
|
"sire" => SireSpeciesVariant,
|
||||||
|
"dam" => DamSpeciesVariant,
|
||||||
|
_ => SpeciesVariant,
|
||||||
|
},
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the "active" clade for downstream steps (Class / Subclass
|
/// Resolves the "active" clade for downstream steps (Class / Subclass
|
||||||
/// / Background). Purebred uses <see cref="CladeId"/>; hybrids use
|
/// / Background). Purebred uses <see cref="CladeId"/>; hybrids use
|
||||||
@@ -149,6 +196,10 @@ public partial class CharacterDraft : Resource
|
|||||||
case "dam_chosen_species_trait": DamChosenSpeciesTrait = (string)patch[key]; break;
|
case "dam_chosen_species_trait": DamChosenSpeciesTrait = (string)patch[key]; break;
|
||||||
case "sire_chosen_species_detriment":SireChosenSpeciesDetriment = (string)patch[key]; break;
|
case "sire_chosen_species_detriment":SireChosenSpeciesDetriment = (string)patch[key]; break;
|
||||||
case "dam_chosen_species_detriment": DamChosenSpeciesDetriment = (string)patch[key]; break;
|
case "dam_chosen_species_detriment": DamChosenSpeciesDetriment = (string)patch[key]; break;
|
||||||
|
case "sex": Sex = (string)patch[key]; break;
|
||||||
|
case "species_variant": SpeciesVariant = (string)patch[key]; break;
|
||||||
|
case "sire_species_variant": SireSpeciesVariant = (string)patch[key]; break;
|
||||||
|
case "dam_species_variant": DamSpeciesVariant = (string)patch[key]; break;
|
||||||
default:
|
default:
|
||||||
GD.PushWarning($"[CharacterDraft] unknown patch key: {k}");
|
GD.PushWarning($"[CharacterDraft] unknown patch key: {k}");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -29,7 +29,15 @@ public static class CodexTheme
|
|||||||
private static FontFile? _mono;
|
private static FontFile? _mono;
|
||||||
private static bool _fontsLoaded;
|
private static bool _fontsLoaded;
|
||||||
|
|
||||||
public static Theme Build() => Build(CodexPalette.Parchment);
|
/// <summary>
|
||||||
|
/// Palette used by the no-arg <see cref="Build()"/>. Set this before any
|
||||||
|
/// UI mounts to swap the active codex palette globally — e.g. Main reads
|
||||||
|
/// the <c>--dark</c> command-line flag and assigns <see cref="CodexPalette.Dark"/>
|
||||||
|
/// here. Defaults to <see cref="CodexPalette.Parchment"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static CodexPalette DefaultPalette { get; set; } = CodexPalette.Parchment;
|
||||||
|
|
||||||
|
public static Theme Build() => Build(DefaultPalette);
|
||||||
|
|
||||||
public static Theme Build(CodexPalette palette)
|
public static Theme Build(CodexPalette palette)
|
||||||
{
|
{
|
||||||
@@ -101,17 +109,30 @@ public static class CodexTheme
|
|||||||
var cardSelected = (StyleBoxFlat)card.Duplicate();
|
var cardSelected = (StyleBoxFlat)card.Duplicate();
|
||||||
cardSelected.BorderColor = p.Seal;
|
cardSelected.BorderColor = p.Seal;
|
||||||
cardSelected.SetBorderWidthAll(3);
|
cardSelected.SetBorderWidthAll(3);
|
||||||
cardSelected.ShadowColor = WithAlpha(p.Seal, 0.6f);
|
// Drop shadow: directional (light from upper-left) and sized so the
|
||||||
cardSelected.ShadowSize = 14;
|
// shadow's bottom edge stays clear of the next card. Card grids
|
||||||
cardSelected.ShadowOffset = new Vector2(0, 14);
|
// separate cards by 12px (v_separation in StepClade / StepSpecies /
|
||||||
|
// StepClass / etc.) — offset.y + size ≤ 11 keeps a 1px-minimum gap
|
||||||
|
// before the next card so the shadow reads as a shadow on the
|
||||||
|
// surface below, not as a smudge between cards.
|
||||||
|
cardSelected.ShadowColor = WithAlpha(p.Seal, 0.55f);
|
||||||
|
cardSelected.ShadowSize = 6;
|
||||||
|
cardSelected.ShadowOffset = new Vector2(4, 4);
|
||||||
theme.SetStylebox("panel_selected", "Card", cardSelected);
|
theme.SetStylebox("panel_selected", "Card", cardSelected);
|
||||||
|
|
||||||
// Popover frame — gild border + soft shadow. Matches .trait-hint.
|
// Popover frame — gild border + soft shadow + rounded corners.
|
||||||
|
// Matches .trait-hint, with a softer corner radius than the rest of
|
||||||
|
// the codex (cards/buttons use 2px sharp) so the floating reveal
|
||||||
|
// reads as a friendlier secondary surface.
|
||||||
theme.SetTypeVariation("CodexPopover", "PanelContainer");
|
theme.SetTypeVariation("CodexPopover", "PanelContainer");
|
||||||
var popover = new StyleBoxFlat
|
var popover = new StyleBoxFlat
|
||||||
{
|
{
|
||||||
BgColor = p.Bg2,
|
BgColor = p.Bg2,
|
||||||
BorderColor = p.Gild,
|
BorderColor = p.Gild,
|
||||||
|
CornerRadiusTopLeft = 14,
|
||||||
|
CornerRadiusTopRight = 14,
|
||||||
|
CornerRadiusBottomLeft = 14,
|
||||||
|
CornerRadiusBottomRight = 14,
|
||||||
ContentMarginLeft = 16,
|
ContentMarginLeft = 16,
|
||||||
ContentMarginRight = 16,
|
ContentMarginRight = 16,
|
||||||
ContentMarginTop = 14,
|
ContentMarginTop = 14,
|
||||||
@@ -120,11 +141,14 @@ public static class CodexTheme
|
|||||||
ShadowSize = 18,
|
ShadowSize = 18,
|
||||||
ShadowOffset = new Vector2(0, 12),
|
ShadowOffset = new Vector2(0, 12),
|
||||||
};
|
};
|
||||||
popover.SetBorderWidthAll(1);
|
popover.SetBorderWidthAll(2);
|
||||||
theme.SetStylebox("panel", "CodexPopover", popover);
|
theme.SetStylebox("panel", "CodexPopover", popover);
|
||||||
|
|
||||||
|
// Detriment swap — seal-red border drawn at 3px so the warning reads
|
||||||
|
// unambiguously against the parchment bg even at a glance.
|
||||||
var popoverDetriment = (StyleBoxFlat)popover.Duplicate();
|
var popoverDetriment = (StyleBoxFlat)popover.Duplicate();
|
||||||
popoverDetriment.BorderColor = p.Seal;
|
popoverDetriment.BorderColor = p.Seal;
|
||||||
|
popoverDetriment.SetBorderWidthAll(3);
|
||||||
theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment);
|
theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment);
|
||||||
|
|
||||||
// Pill — small trait/skill chip. Mirrors .trait-chips .t-name from
|
// Pill — small trait/skill chip. Mirrors .trait-chips .t-name from
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
namespace Theriapolis.GodotHost.UI;
|
namespace Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All 18 skills with display labels, governing ability, and codex
|
/// All 30 skills with display labels, governing ability, and codex
|
||||||
/// flavor descriptions. Entries are keyed by their snake_case JSON id —
|
/// flavor descriptions. Entries are keyed by their snake_case JSON id —
|
||||||
/// the same string that appears in <c>class.skill_options</c> and
|
/// the same string that appears in <c>class.skill_options</c> and
|
||||||
/// <c>background.skill_proficiencies</c> in <c>Content/Data</c>.
|
/// <c>background.skill_proficiencies</c> in <c>Content/Data</c>.
|
||||||
///
|
///
|
||||||
/// Labels and ability mapping mirror Theriapolis.Core.Rules.Stats.SkillId;
|
/// Labels and ability mapping mirror Theriapolis.Core.Rules.Stats.SkillId.
|
||||||
/// descriptions are ported verbatim from <c>src/data.jsx</c>'s
|
/// The 18 d20-baseline descriptions are ported from the React prototype's
|
||||||
/// <c>SKILL_DESC</c> table in the React prototype. If the JSON schema
|
/// <c>SKILL_DESC</c> table; the 12 M6.18 expansions (5 per ability total)
|
||||||
/// gains a description field later, swap to a data-driven lookup.
|
/// are Theriapolis-specific and authored against the design canon.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SkillsCatalog
|
public static class SkillsCatalog
|
||||||
{
|
{
|
||||||
@@ -25,8 +25,24 @@ public static class SkillsCatalog
|
|||||||
"Knowledge of the older magics: scent-sorcery, blood-sigil, the half-forgotten rites that pre-date the Covenant of Claws."),
|
"Knowledge of the older magics: scent-sorcery, blood-sigil, the half-forgotten rites that pre-date the Covenant of Claws."),
|
||||||
new("athletics", "Athletics", "STR",
|
new("athletics", "Athletics", "STR",
|
||||||
"Raw physical effort. Climbing scaffold, swimming the foul canal, hauling a packmate from the pit, breaking a hold."),
|
"Raw physical effort. Climbing scaffold, swimming the foul canal, hauling a packmate from the pit, breaking a hold."),
|
||||||
|
new("brawl", "Brawl", "STR",
|
||||||
|
"Bare-fanged unarmed violence — claws, teeth, horns, hooves. The coliseum tradition and the back-alley one. Distinct from Athletics (the running body) and Intimidation (the threat unspoken) — this is the application of natural-weapon force."),
|
||||||
|
new("build_read", "Build-Read", "STR",
|
||||||
|
"Sizing up another creature's physical capabilities at a glance. Cross-clade body diversity makes this nontrivial — a Mustelid's frame lies about its strength, a Cervid's stillness lies about its speed. The dockside boxer's eye for who can fight."),
|
||||||
new("deception", "Deception", "CHA",
|
new("deception", "Deception", "CHA",
|
||||||
"Speaking convincingly past your scent. The art of the false posture, the planted rumor, the answer that is technically true."),
|
"Speaking convincingly past your scent. The art of the false posture, the planted rumor, the answer that is technically true."),
|
||||||
|
new("driving", "Driving", "DEX",
|
||||||
|
"Vehicle and mount control under pressure. Caravan-work, Hare-courier carts, riding a clademorphic beast over broken ground, navigating a contested street at speed."),
|
||||||
|
new("endurance", "Endurance", "CON",
|
||||||
|
"Sustained applied effort. The act of doing hard work for a long time — forced marches, all-night hauls, rowing the canal-shift, the long chase. What you put out, hour after hour."),
|
||||||
|
new("force", "Force", "STR",
|
||||||
|
"Applied violence on objects. Doors, chains, shutters, locked chests, walls that shouldn't be there anymore. The Bulwark's quiet talent and the Claw-Wright's last resort."),
|
||||||
|
new("fortitude", "Fortitude", "CON",
|
||||||
|
"Resisting what you swallow. Ingested poison and contagion, spoiled rations, foreign cuisine, the Goat-Folk welcome of rotted cheese, the dockside whiskey flight, the ritual draught you cannot politely refuse."),
|
||||||
|
new("hardiness", "Hardiness", "CON",
|
||||||
|
"Withstanding external conditions. Temperature extremes, smoke and dust, thin mountain air, the slow attrition of weather. The Polar Bear-Folk's cold tolerance, the Coyote-Folk's heat-of-the-day shift, the herd-wall holding under blizzard."),
|
||||||
|
new("haulage", "Haulage", "STR",
|
||||||
|
"Bearing weight under distance. Porter discipline, stretcher-craft, shouldering an unconscious packmate up a switchback. The Bovid wagon-train's daily reality and the battlefield medic's quiet requirement."),
|
||||||
new("history", "History", "INT",
|
new("history", "History", "INT",
|
||||||
"The long memory of Theriapolis — the Imperium's fall, the Compact's ratification, which clade owes which other a centuries-old debt."),
|
"The long memory of Theriapolis — the Imperium's fall, the Compact's ratification, which clade owes which other a centuries-old debt."),
|
||||||
new("insight", "Insight", "WIS",
|
new("insight", "Insight", "WIS",
|
||||||
@@ -35,10 +51,16 @@ public static class SkillsCatalog
|
|||||||
"Bared-teeth diplomacy. The threat made plain enough that violence is not required to extract compliance."),
|
"Bared-teeth diplomacy. The threat made plain enough that violence is not required to extract compliance."),
|
||||||
new("investigation", "Investigation", "INT",
|
new("investigation", "Investigation", "INT",
|
||||||
"Methodical search and inference: scene-reading, document-sifting, the patient accumulation of small facts into a verdict."),
|
"Methodical search and inference: scene-reading, document-sifting, the patient accumulation of small facts into a verdict."),
|
||||||
|
new("lung_craft", "Lung-Craft", "CON",
|
||||||
|
"Sustained vocalization and breath-control. The physical basis of Muzzle-Speaker oratory, the diver's stillness underwater, the smoke-tolerant veteran's projection across a coliseum without shredding the throat."),
|
||||||
|
new("marksmanship", "Marksmanship", "DEX",
|
||||||
|
"Accurate ranged weapon use — bow, crossbow, sling, javelin, thrown blade. The skill that lets prey-clade militias hold a wall against predator charge, and the Shadow-Pelt's clean answer at distance."),
|
||||||
new("medicine", "Medicine", "WIS",
|
new("medicine", "Medicine", "WIS",
|
||||||
"Field surgery, poultice-craft, knowing which clade tolerates which tincture. Stabilizing the dying without finishing them."),
|
"Field surgery, poultice-craft, knowing which clade tolerates which tincture. Stabilizing the dying without finishing them."),
|
||||||
new("nature", "Nature", "INT",
|
new("nature", "Nature", "INT",
|
||||||
"Knowledge of the wild outside the city wall — terrain, weather, plant-lore, and the unsigned beasts that observe no Covenant."),
|
"Knowledge of the wild outside the city wall — terrain, weather, plant-lore, and the unsigned beasts that observe no Covenant."),
|
||||||
|
new("pain_tolerance", "Pain Tolerance", "CON",
|
||||||
|
"Function while wounded. Holding the line bleeding, finishing the sentence with a knife in your shoulder, performing surgery on yourself. The hybrid medical-mistrust skill — the one you use when the doctor isn't an option."),
|
||||||
new("perception", "Perception", "WIS",
|
new("perception", "Perception", "WIS",
|
||||||
"Awareness through every sense your clade gives you: ear-cock, scent-prickle, the half-glimpsed shape at the edge of vision."),
|
"Awareness through every sense your clade gives you: ear-cock, scent-prickle, the half-glimpsed shape at the edge of vision."),
|
||||||
new("performance", "Performance", "CHA",
|
new("performance", "Performance", "CHA",
|
||||||
@@ -47,6 +69,8 @@ public static class SkillsCatalog
|
|||||||
"Open-handed argument. The case made on its merits, the appeal to mutual benefit, the patient construction of agreement."),
|
"Open-handed argument. The case made on its merits, the appeal to mutual benefit, the patient construction of agreement."),
|
||||||
new("religion", "Religion", "INT",
|
new("religion", "Religion", "INT",
|
||||||
"The hymn-cycles of the Cervid liturgy, the Compact's sacred clauses, the small household rites your clade keeps without thinking."),
|
"The hymn-cycles of the Cervid liturgy, the Compact's sacred clauses, the small household rites your clade keeps without thinking."),
|
||||||
|
new("scent_speak", "Scent-Speak", "CHA",
|
||||||
|
"Deliberate pheromone communication — the unspoken half of every Theriapolis conversation. Broadcasting calm, threat, deference, lineage, trust. The Scent-Broker's craft, the passer's nightmare, the perfumer's living. Distinct from Insight (which reads the scent) — this is the active emission."),
|
||||||
new("sleight_of_hand", "Sleight of Hand", "DEX",
|
new("sleight_of_hand", "Sleight of Hand", "DEX",
|
||||||
"Quiet fingers — pickpocketing, palmed coins, the swap performed under another's nose. Useful in markets and courtrooms alike."),
|
"Quiet fingers — pickpocketing, palmed coins, the swap performed under another's nose. Useful in markets and courtrooms alike."),
|
||||||
new("stealth", "Stealth", "DEX",
|
new("stealth", "Stealth", "DEX",
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public partial class CodexStepper : HBoxContainer
|
|||||||
{
|
{
|
||||||
var underline = new ColorRect
|
var underline = new ColorRect
|
||||||
{
|
{
|
||||||
Color = TryGetThemeColor("font_color", "Gild") ?? new Color("#b48a3c"),
|
Color = CodexTheme.DefaultPalette.Gild,
|
||||||
MouseFilter = MouseFilterEnum.Ignore,
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
};
|
};
|
||||||
underline.AnchorTop = 1.0f;
|
underline.AnchorTop = 1.0f;
|
||||||
@@ -124,16 +124,20 @@ public partial class CodexStepper : HBoxContainer
|
|||||||
// Default theme colours come from the StepperNum/StepperName variations
|
// Default theme colours come from the StepperNum/StepperName variations
|
||||||
// (ink-mute). State overrides bring active steps to ink and complete
|
// (ink-mute). State overrides bring active steps to ink and complete
|
||||||
// to seal-red. Locked uses the dim default plus reduced opacity.
|
// to seal-red. Locked uses the dim default plus reduced opacity.
|
||||||
|
// Pull the colours from CodexTheme.DefaultPalette so the stepper
|
||||||
|
// tracks the active palette (parchment vs dark) instead of forcing
|
||||||
|
// the parchment values regardless.
|
||||||
|
var palette = CodexTheme.DefaultPalette;
|
||||||
Color? numColor = state switch
|
Color? numColor = state switch
|
||||||
{
|
{
|
||||||
StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")),
|
StepState.Active => palette.Ink,
|
||||||
StepState.Complete => TryGetGlobalThemeColor("Seal", new Color("#7a1f12")),
|
StepState.Complete => palette.Seal,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
Color? nameColor = state switch
|
Color? nameColor = state switch
|
||||||
{
|
{
|
||||||
StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")),
|
StepState.Active => palette.Ink,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (numColor.HasValue) num.AddThemeColorOverride("font_color", numColor.Value);
|
if (numColor.HasValue) num.AddThemeColorOverride("font_color", numColor.Value);
|
||||||
@@ -149,14 +153,6 @@ public partial class CodexStepper : HBoxContainer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Color? TryGetGlobalThemeColor(string name, Color fallback) => fallback;
|
|
||||||
|
|
||||||
private Color? TryGetThemeColor(string property, string variation)
|
|
||||||
{
|
|
||||||
if (HasThemeColor(property, variation)) return GetThemeColor(property, variation);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Roman(int n) => n switch
|
private static string Roman(int n) => n switch
|
||||||
{
|
{
|
||||||
1 => "I",
|
1 => "I",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public static class WizardValidation
|
|||||||
|
|
||||||
private static string? ValidateClade(CharacterDraft draft)
|
private static string? ValidateClade(CharacterDraft draft)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrEmpty(draft.Sex)) return "Pick a sex.";
|
||||||
if (draft.IsHybrid)
|
if (draft.IsHybrid)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(draft.SireCladeId)) return "Pick a sire clade.";
|
if (string.IsNullOrEmpty(draft.SireCladeId)) return "Pick a sire clade.";
|
||||||
@@ -85,9 +86,23 @@ public static class WizardValidation
|
|||||||
&& string.IsNullOrEmpty(draft.DamChosenSpeciesDetriment))
|
&& string.IsNullOrEmpty(draft.DamChosenSpeciesDetriment))
|
||||||
return "Pick a dam species detriment.";
|
return "Pick a dam species detriment.";
|
||||||
|
|
||||||
|
// Lineage-axis variants: each parent species needs an
|
||||||
|
// explicit lineage pick when applicable.
|
||||||
|
if (sireSp is not null && sireSp.VariantAxis == "lineage"
|
||||||
|
&& string.IsNullOrEmpty(draft.SireSpeciesVariant))
|
||||||
|
return "Pick a sire species lineage.";
|
||||||
|
if (damSp is not null && damSp.VariantAxis == "lineage"
|
||||||
|
&& string.IsNullOrEmpty(draft.DamSpeciesVariant))
|
||||||
|
return "Pick a dam species lineage.";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return string.IsNullOrEmpty(draft.SpeciesId) ? "Pick a species." : null;
|
if (string.IsNullOrEmpty(draft.SpeciesId)) return "Pick a species.";
|
||||||
|
var purebredSp = CodexContent.SpeciesById(draft.SpeciesId);
|
||||||
|
if (purebredSp is not null && purebredSp.VariantAxis == "lineage"
|
||||||
|
&& string.IsNullOrEmpty(draft.SpeciesVariant))
|
||||||
|
return "Pick a species lineage.";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ValidateSkills(CharacterDraft draft)
|
private static string? ValidateSkills(CharacterDraft draft)
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ window/size/mode=3
|
|||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
|
[autoload]
|
||||||
|
|
||||||
|
; M7.1 — cross-scene state (seed, post-worldgen ctx, pending character,
|
||||||
|
; pending save snapshot). See GameSession.cs and the M7 plan §4.3.
|
||||||
|
GameSession="*res://GameSession.cs"
|
||||||
|
|
||||||
[dotnet]
|
[dotnet]
|
||||||
|
|
||||||
project/assembly_name="Theriapolis.Godot"
|
project/assembly_name="Theriapolis.Godot"
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ public sealed class ContentLoadTests
|
|||||||
[InlineData("rabbit", -1, 2, 0, 0, 1, 0)]
|
[InlineData("rabbit", -1, 2, 0, 0, 1, 0)]
|
||||||
[InlineData("hare", -1, 2, 1, 0, 0, 0)]
|
[InlineData("hare", -1, 2, 1, 0, 0, 0)]
|
||||||
[InlineData("bull", 2, 0, 1, 0, 0, 0)]
|
[InlineData("bull", 2, 0, 1, 0, 0, 0)]
|
||||||
[InlineData("ram", 1, 0, 1, 0, 1, 0)]
|
[InlineData("sheep", 1, 0, 1, 0, 1, 0)]
|
||||||
|
[InlineData("goat", 1, 0, 1, 0, 1, 0)]
|
||||||
[InlineData("bison", 1, 0, 2, 0, 0, 0)]
|
[InlineData("bison", 1, 0, 2, 0, 0, 0)]
|
||||||
public void Clade_Plus_Species_AbilityMods_MatchQuickRefTable(
|
public void Clade_Plus_Species_AbilityMods_MatchQuickRefTable(
|
||||||
string speciesId, int str, int dex, int con, int @int, int wis, int cha)
|
string speciesId, int str, int dex, int con, int @int, int wis, int cha)
|
||||||
|
|||||||
@@ -93,27 +93,60 @@ public sealed class HybridCharacterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildHybrid_BlendsAbilityMods()
|
public void TryBuildHybrid_AppliesChosenAbilityFromEachParentClade()
|
||||||
{
|
{
|
||||||
// Wolf-Folk Sire:
|
// Hybrid PCs take ONE ability mod from each parent clade — the
|
||||||
// canidae clade: +1 CON, +1 WIS
|
// wizard's StepClade picker records the choices, and the builder
|
||||||
// wolf species: +1 STR
|
// applies exactly those. Species mods don't apply for hybrids.
|
||||||
// × Rabbit-Folk Dam:
|
//
|
||||||
// leporidae clade: -1 STR, +2 DEX
|
// Wolf-Folk Sire (canidae: +1 CON, +1 WIS) — sire picks CON.
|
||||||
// rabbit species: +1 WIS
|
// Rabbit-Folk Dam (leporidae: -1 STR, +2 DEX) — dam picks DEX.
|
||||||
// Base 10 across the board.
|
// Base 10 across the board.
|
||||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||||
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||||
|
b.HybridSireChosenAbility = "CON";
|
||||||
|
b.HybridDamChosenAbility = "DEX";
|
||||||
|
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal(10, c!.Abilities.STR); // no -1 (dam didn't pick STR), no +1 (no species mods)
|
||||||
|
Assert.Equal(12, c.Abilities.DEX); // dam pick: +2
|
||||||
|
Assert.Equal(11, c.Abilities.CON); // sire pick: +1
|
||||||
|
Assert.Equal(10, c.Abilities.WIS); // no +1 (sire picked CON, not WIS)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryBuildHybrid_StacksWhenBothParentsPickSameAbility()
|
||||||
|
{
|
||||||
|
// Canidae gives +1 CON, ursidae gives +2 CON. If both parents pick
|
||||||
|
// CON, both bonuses apply additively.
|
||||||
|
var b = NewBuilderWithClassAndSkills();
|
||||||
|
b.HybridSireClade = _content.Clades["canidae"];
|
||||||
|
b.HybridSireSpecies = _content.Species["wolf"];
|
||||||
|
b.HybridDamClade = _content.Clades["ursidae"];
|
||||||
|
b.HybridDamSpecies = _content.Species["brown_bear"];
|
||||||
|
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||||
|
b.HybridSireChosenAbility = "CON";
|
||||||
|
b.HybridDamChosenAbility = "CON";
|
||||||
|
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal(13, c!.Abilities.CON); // 10 + 1 (canid) + 2 (ursid)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryBuildHybrid_NoBonusWhenPickIsEmpty()
|
||||||
|
{
|
||||||
|
// Defensive: empty pick = no bonus from that side. Headless tests
|
||||||
|
// and old saves leave the field blank; the builder should not
|
||||||
|
// silently fall back to the old "apply everything" rule.
|
||||||
|
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||||
|
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||||
|
// No sire or dam pick set.
|
||||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||||
Assert.True(ok);
|
Assert.True(ok);
|
||||||
// Net STR: 10 + 1 (wolf) - 1 (leporid) = 10.
|
|
||||||
// Net DEX: 10 + 2 (leporid) = 12.
|
|
||||||
// Net CON: 10 + 1 (canid) = 11.
|
|
||||||
// Net WIS: 10 + 1 (canid) + 1 (rabbit) = 12.
|
|
||||||
Assert.Equal(10, c!.Abilities.STR);
|
Assert.Equal(10, c!.Abilities.STR);
|
||||||
Assert.Equal(12, c.Abilities.DEX);
|
Assert.Equal(10, c.Abilities.DEX);
|
||||||
Assert.Equal(11, c.Abilities.CON);
|
Assert.Equal(10, c.Abilities.CON);
|
||||||
Assert.Equal(12, c.Abilities.WIS);
|
Assert.Equal(10, c.Abilities.WIS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cross-clade pairings smoke ────────────────────────────────────────
|
// ── Cross-clade pairings smoke ────────────────────────────────────────
|
||||||
@@ -124,7 +157,7 @@ public sealed class HybridCharacterTests
|
|||||||
[InlineData("ursidae", "brown_bear", "bovidae", "bull")]
|
[InlineData("ursidae", "brown_bear", "bovidae", "bull")]
|
||||||
[InlineData("felidae", "leopard", "cervidae", "deer")]
|
[InlineData("felidae", "leopard", "cervidae", "deer")]
|
||||||
[InlineData("mustelidae","badger", "leporidae", "rabbit")]
|
[InlineData("mustelidae","badger", "leporidae", "rabbit")]
|
||||||
[InlineData("bovidae", "ram", "cervidae", "elk")]
|
[InlineData("bovidae", "sheep", "cervidae", "elk")]
|
||||||
[InlineData("leporidae", "rabbit", "felidae", "housecat")]
|
[InlineData("leporidae", "rabbit", "felidae", "housecat")]
|
||||||
public void TryBuildHybrid_AllCrossCladeCombinationsValid(
|
public void TryBuildHybrid_AllCrossCladeCombinationsValid(
|
||||||
string sireClade, string sireSpecies, string damClade, string damSpecies)
|
string sireClade, string sireSpecies, string damClade, string damSpecies)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user