M6.17: Variant content + Sheep/Goat split + calling lore + uniform card layout
Species variants populated against the M6.13 schema: - Lion-Folk sex axis: Mane Guard (male) / Huntress Reflexes (female, +5 ft speed + advantage on initiative). - Elk-Folk sex axis: Antler Combat with 10 ft reach when full rack (male, retains seasonal Antler Drag) / Kick (female, prone on crit). Base traits restored to doc canon: Herd Coordination (Help → +3) + Endurance Runner (40 ft + advantage CON vs forced march); base speed bumped 30 → 40; new base detriment Herd Instinct. Ram-Folk replaced with separate Sheep-Folk + Goat-Folk species rather than a lineage-axis variant on a single Ram entry. Bovidae now has 4 species. The lineage-axis toggle UI in StepSpecies BuildCard rolled back; the schema stays for sex-axis (Lion/Elk) which auto-resolves. ContentLoadTests + HybridCharacterTests updated; Size.cs comment too. Calling lore: ClassDef gains Description; classes.json populated for all 8 callings with the doc's italic blockquote + paragraph profile. StepClass surfaces the description on the card. Card layout uniformity: StepClass / StepSubclass / StepBackground all switched to single-column ExpandFill grids (matching StepClade / StepSpecies). Each card now spans the wizard's content width so the description and feature chips have room to breathe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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"],
|
||||||
@@ -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"],
|
||||||
@@ -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"],
|
||||||
@@ -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"],
|
||||||
@@ -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"],
|
||||||
@@ -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"],
|
||||||
@@ -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"],
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -63,12 +63,30 @@
|
|||||||
"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": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -204,12 +222,34 @@
|
|||||||
"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.",
|
"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": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -300,17 +340,35 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ram",
|
"id": "sheep",
|
||||||
"clade_id": "bovidae",
|
"clade_id": "bovidae",
|
||||||
"name": "Ram-Folk",
|
"name": "Sheep-Folk",
|
||||||
"description": "\"Climbers, thinkers, and the reason every mountain fortress in the world was built by someone with horns.\"\n\nStocky and compact, lower center of gravity than bull-folk — powerful especially through the hindquarters. Wool (sheep lineage) or coarse hair (goat). Spiral or sweeping horns, cloven hooves, horizontal-slit pupils — distinctive and unsettling to other Clades.",
|
"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." },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
private string _sirePicksBuiltFor = "";
|
private string _sirePicksBuiltFor = "";
|
||||||
private string _damPicksBuiltFor = "";
|
private string _damPicksBuiltFor = "";
|
||||||
|
|
||||||
// Lineage-axis variant picker for purebred path (Ram-Folk sheep/goat etc.).
|
|
||||||
// Hybrid path embeds its own lineage picker into the per-parent col.
|
|
||||||
private VBoxContainer _purebredVariantSection = null!;
|
|
||||||
private string _purebredVariantBuiltFor = "";
|
|
||||||
|
|
||||||
public void Bind(CharacterDraft draft)
|
public void Bind(CharacterDraft draft)
|
||||||
{
|
{
|
||||||
_draft = draft;
|
_draft = draft;
|
||||||
@@ -68,12 +63,6 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
_purebredGrid = MakeGrid();
|
_purebredGrid = MakeGrid();
|
||||||
_purebredSection.AddChild(_purebredGrid);
|
_purebredSection.AddChild(_purebredGrid);
|
||||||
|
|
||||||
// Lineage picker (Ram-Folk sheep/goat). Visible only when the
|
|
||||||
// selected species has VariantAxis == "lineage".
|
|
||||||
_purebredVariantSection = new VBoxContainer { Visible = false };
|
|
||||||
_purebredVariantSection.AddThemeConstantOverride("separation", 6);
|
|
||||||
_purebredSection.AddChild(_purebredVariantSection);
|
|
||||||
|
|
||||||
_hybridSection = new VBoxContainer();
|
_hybridSection = new VBoxContainer();
|
||||||
_hybridSection.AddThemeConstantOverride("separation", 16);
|
_hybridSection.AddThemeConstantOverride("separation", 16);
|
||||||
AddChild(_hybridSection);
|
AddChild(_hybridSection);
|
||||||
@@ -141,111 +130,25 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId,
|
RefreshPurebredGrid();
|
||||||
spId => OnPurebredSpeciesPicked(spId));
|
|
||||||
SyncPurebredVariant();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPurebredSpeciesPicked(string speciesId)
|
private void OnPurebredSpeciesPicked(string speciesId) =>
|
||||||
{
|
_draft.Patch(new Godot.Collections.Dictionary { { "species_id", speciesId } });
|
||||||
_draft.Patch(new Godot.Collections.Dictionary
|
|
||||||
{
|
|
||||||
{ "species_id", speciesId },
|
|
||||||
// Species swap invalidates lineage variant.
|
|
||||||
{ "species_variant", "" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLineageSpeciesPicked(string lineage, string 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 + variant.
|
// Species swap invalidates the previously-picked trait/detriment.
|
||||||
{ lineage + "_chosen_species_trait", "" },
|
{ lineage + "_chosen_species_trait", "" },
|
||||||
{ lineage + "_chosen_species_detriment", "" },
|
{ lineage + "_chosen_species_detriment", "" },
|
||||||
{ lineage + "_species_variant", "" },
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sync the purebred lineage picker row. Visible iff the
|
|
||||||
/// picked species declares a lineage-axis variant.</summary>
|
|
||||||
private void SyncPurebredVariant()
|
|
||||||
{
|
|
||||||
var sp = CodexContent.SpeciesById(_draft.SpeciesId);
|
|
||||||
bool show = sp is not null && sp.VariantAxis == "lineage" && sp.Variants.Length > 0;
|
|
||||||
_purebredVariantSection.Visible = show;
|
|
||||||
if (!show)
|
|
||||||
{
|
|
||||||
_purebredVariantBuiltFor = "";
|
|
||||||
foreach (var c in _purebredVariantSection.GetChildren()) c.Free();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_purebredVariantBuiltFor == _draft.SpeciesId)
|
|
||||||
{
|
|
||||||
// Same species — just update which lineage button is pressed.
|
|
||||||
foreach (var child in _purebredVariantSection.GetChildren())
|
|
||||||
{
|
|
||||||
if (child is Button btn)
|
|
||||||
{
|
|
||||||
bool want = btn.Name == _draft.SpeciesVariant;
|
|
||||||
if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var c in _purebredVariantSection.GetChildren()) c.Free();
|
|
||||||
_purebredVariantBuiltFor = _draft.SpeciesId;
|
|
||||||
|
|
||||||
_purebredVariantSection.AddChild(new Label { Text = "LINEAGE", ThemeTypeVariation = "Eyebrow" });
|
|
||||||
var row = new HBoxContainer();
|
|
||||||
row.AddThemeConstantOverride("separation", 8);
|
|
||||||
_purebredVariantSection.AddChild(row);
|
|
||||||
foreach (var v in sp!.Variants)
|
|
||||||
{
|
|
||||||
string captured = v.Id;
|
|
||||||
var btn = new Button
|
|
||||||
{
|
|
||||||
Text = v.Name,
|
|
||||||
ToggleMode = true,
|
|
||||||
ButtonPressed = v.Id == _draft.SpeciesVariant,
|
|
||||||
FocusMode = Control.FocusModeEnum.None,
|
|
||||||
Name = v.Id,
|
|
||||||
};
|
|
||||||
var btnRef = btn;
|
|
||||||
btn.Pressed += () =>
|
|
||||||
_draft.Patch(new Godot.Collections.Dictionary
|
|
||||||
{
|
|
||||||
{ "species_variant", btnRef.ButtonPressed ? captured : "" },
|
|
||||||
});
|
|
||||||
// Hover popover summarizes the variant's traits + detriments.
|
|
||||||
string capturedName = v.Name;
|
|
||||||
string capturedDesc = SummarizeVariant(v);
|
|
||||||
btn.MouseEntered += () =>
|
|
||||||
PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, "lineage", false);
|
|
||||||
btn.MouseExited += () =>
|
|
||||||
PopoverLayer.Instance?.ScheduleClose();
|
|
||||||
row.AddChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SummarizeVariant(Theriapolis.Core.Data.SpeciesVariantDef v)
|
|
||||||
{
|
|
||||||
var parts = new System.Collections.Generic.List<string>();
|
|
||||||
foreach (var t in v.Traits) parts.Add($"• {t.Name}: {t.Description}");
|
|
||||||
foreach (var d in v.Detriments) parts.Add($"• {d.Name} (detriment): {d.Description}");
|
|
||||||
return parts.Count == 0 ? "(no extra traits)" : string.Join("\n", parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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; plus a lineage
|
/// group + one detriment button group, radio-style).
|
||||||
/// picker when the species declares a lineage-axis variant). Same
|
|
||||||
/// Free()-defer 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,8 +158,6 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
{
|
{
|
||||||
UpdateRadioGroup(col, "trait", chosenTrait);
|
UpdateRadioGroup(col, "trait", chosenTrait);
|
||||||
UpdateRadioGroup(col, "detriment", chosenDetriment);
|
UpdateRadioGroup(col, "detriment", chosenDetriment);
|
||||||
UpdateRadioGroup(col, "lineage",
|
|
||||||
lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,22 +176,6 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lineage picker first when applicable, so the player picks
|
|
||||||
// lineage before reading the trait/detriment list (variant
|
|
||||||
// content layers on top).
|
|
||||||
if (sp.VariantAxis == "lineage" && sp.Variants.Length > 0)
|
|
||||||
{
|
|
||||||
col.AddChild(new Label { Text = "Lineage", ThemeTypeVariation = "Eyebrow" });
|
|
||||||
string currentVariant = lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant;
|
|
||||||
BuildRadioGroup(col, "lineage", lineage, VariantsAsTraits(sp.Variants),
|
|
||||||
currentVariant,
|
|
||||||
(lin, id) => _draft.Patch(new Godot.Collections.Dictionary
|
|
||||||
{
|
|
||||||
{ lin + "_species_variant", id },
|
|
||||||
}),
|
|
||||||
isDetriment: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
col.AddChild(new Label { Text = "Trait", ThemeTypeVariation = "Eyebrow" });
|
col.AddChild(new Label { Text = "Trait", ThemeTypeVariation = "Eyebrow" });
|
||||||
BuildRadioGroup(col, "trait", lineage, sp.Traits, chosenTrait,
|
BuildRadioGroup(col, "trait", lineage, sp.Traits, chosenTrait,
|
||||||
(lin, id) => OnSpeciesPickToggled(lin, "trait", id), isDetriment: false);
|
(lin, id) => OnSpeciesPickToggled(lin, "trait", id), isDetriment: false);
|
||||||
@@ -307,27 +192,6 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adapter — BuildRadioGroup operates on TraitDef[]; project the variant
|
|
||||||
/// list into TraitDef-shape so it can drive the same radio renderer.
|
|
||||||
/// Description summarises the variant's contents for the hover popover.
|
|
||||||
/// </summary>
|
|
||||||
private static Theriapolis.Core.Data.TraitDef[] VariantsAsTraits(
|
|
||||||
Theriapolis.Core.Data.SpeciesVariantDef[] variants)
|
|
||||||
{
|
|
||||||
var arr = new Theriapolis.Core.Data.TraitDef[variants.Length];
|
|
||||||
for (int i = 0; i < variants.Length; i++)
|
|
||||||
{
|
|
||||||
arr[i] = new Theriapolis.Core.Data.TraitDef
|
|
||||||
{
|
|
||||||
Id = variants[i].Id,
|
|
||||||
Name = variants[i].Name,
|
|
||||||
Description = SummarizeVariant(variants[i]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void BuildRadioGroup(VBoxContainer parent, string kind, string lineage,
|
private static void BuildRadioGroup(VBoxContainer parent, string kind, string lineage,
|
||||||
Theriapolis.Core.Data.TraitDef[] options, string selected,
|
Theriapolis.Core.Data.TraitDef[] options, string selected,
|
||||||
System.Action<string, string> onPicked, bool isDetriment)
|
System.Action<string, string> onPicked, bool isDetriment)
|
||||||
@@ -389,12 +253,13 @@ 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 grid.GetChildren()) c.Free();
|
foreach (var c in _purebredGrid.GetChildren()) c.Free();
|
||||||
if (string.IsNullOrEmpty(cladeId)) return;
|
if (string.IsNullOrEmpty(_draft.CladeId)) return;
|
||||||
foreach (var sp in CodexContent.SpeciesOfClade(cladeId))
|
foreach (var sp in CodexContent.SpeciesOfClade(_draft.CladeId))
|
||||||
grid.AddChild(BuildCard(sp, sp.Id == selectedSpecies, onClick));
|
_purebredGrid.AddChild(BuildCard(sp, sp.Id == _draft.SpeciesId,
|
||||||
|
spId => OnPurebredSpeciesPicked(spId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -402,9 +267,8 @@ public partial class StepSpecies : VBoxContainer, IStep
|
|||||||
/// followed by dam-clade species. Sire and dam clades are
|
/// followed by dam-clade species. Sire and dam clades are
|
||||||
/// guaranteed distinct by StepClade's parent-conflict rule, so the
|
/// guaranteed distinct by StepClade's parent-conflict rule, so the
|
||||||
/// species lists are disjoint — each card unambiguously belongs to
|
/// species lists are disjoint — each card unambiguously belongs to
|
||||||
/// one lineage and a click on the card commits the pick. Full
|
/// one lineage. Full rebuild on every Refresh is safe because Bind
|
||||||
/// rebuild on every Refresh is safe because Bind installs Refresh
|
/// installs Refresh as a deferred callback.
|
||||||
/// as a deferred callback.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshHybridGrid()
|
private void RefreshHybridGrid()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -124,7 +124,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user