M6.13: Sex picker + species variant schema

Adds character Sex (male/female) as a top-level CharacterDraft field
required for every character, and species variants as a layer on top
of the base species record. Two variant axes:

  - "sex": auto-resolves from CharacterDraft.Sex for purebreds; for
           hybrids, pinned by parent role (sire = male, dam = female)
           by definition. No picker needed beyond Step 0.
  - "lineage": explicit per-species pick (Ram-Folk's sheep/goat). Has
               its own picker on Step 1 (purebred path under the grid;
               hybrid path embedded into the per-parent pick column).

Schema (Theriapolis.Core/Data):
  - SpeciesDef gains VariantAxis (string) and Variants (array of
    SpeciesVariantDef { Id, Name, Traits, Detriments }).
  - JSON content not yet populated — that's M6.14.

CharacterDraft adds:
  - Sex (required by Step 0 validation)
  - SpeciesVariant / SireSpeciesVariant / DamSpeciesVariant
  - ResolveVariantId(species, role) that returns the active variant
    id for a given context — used by Aside to layer variant traits
    onto the base species traits.

Step 0 (StepClade): sex picker row above the hybrid toggle. Two
toggle buttons radio-style.

Step 1 (StepSpecies): purebred path renders a lineage picker below
the grid when the picked species has VariantAxis == "lineage";
hybrid path embeds a lineage picker at the top of each parent's
pick column. Hover popovers summarise each variant's contents.

Validation: Sex is required at Step 0. Lineage variant required at
Step 1 for any picked species (purebred or per-hybrid-parent) with
VariantAxis == "lineage".

Aside: AddVariantContent layers the resolved variant's extra
traits/detriments onto the base species rendering, for both purebred
and hybrid paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-04 21:03:56 -07:00
parent 44b2ec111f
commit e1fb988969
6 changed files with 301 additions and 9 deletions
@@ -22,6 +22,8 @@ public partial class StepClade : VBoxContainer, IStep
{
private CharacterDraft _draft = null!;
private Button _hybridToggle = null!;
private Button _sexMaleBtn = null!;
private Button _sexFemaleBtn = null!;
private VBoxContainer _purebredSection = null!;
private VBoxContainer _hybridSection = null!;
private OptionButton _dominantToggle = null!;
@@ -83,6 +85,27 @@ public partial class StepClade : VBoxContainer, IStep
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
// from the codex theme handles selection visually — no checkbox glyph
// needed, the bg colour shift is the affordance.
@@ -224,6 +247,9 @@ public partial class StepClade : VBoxContainer, IStep
_draft.Patch(patch);
}
private void OnSexPicked(string sex) =>
_draft.Patch(new Godot.Collections.Dictionary { { "sex", sex } });
private void OnDominantSelected(long index)
{
string newDominant = index == 0 ? "sire" : "dam";
@@ -259,6 +285,11 @@ public partial class StepClade : VBoxContainer, IStep
_purebredSection.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(_sireCards, _draft.SireCladeId);
UpdateSelection(_damCards, _draft.DamCladeId);