M6.15: Unified hybrid clade grid with Sire/Dam toggle headers

Replace the two stacked sire/dam clade grids in StepClade hybrid
mode with one unified grid where each card carries Sire and Dam
toggle buttons side-by-side in its header (right edge of the title
row). A single clade can become either parent; picking Sire on a
card currently set as Dam atomically clears the Dam pick (and vice
versa). Card body click is disabled in hybrid mode — only the
toggles change selection.

Mechanics: BuildLineageCladePatch extracts the per-lineage clear
logic from the old OnLineageCladePicked into a patch builder so
OnHybridParentPressed can compose multi-lineage patches into one
atomic Patch call (the cross-clear case applies both lineage
updates in a single Changed signal). UpdateHybridCards walks the
unified card dictionary and syncs both toggle states + the card's
selected highlight.

Drops the old _sireCards / _damCards dictionaries and the
OnLineageCladePicked callback, since hybrid mode no longer routes
through the per-grid click handlers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-04 22:01:29 -07:00
parent 66055f9549
commit 479899d3d1
+167 -18
View File
@@ -42,8 +42,12 @@ public partial class StepClade : VBoxContainer, IStep
private Label _damTraitHeader = null!;
private readonly Dictionary<string, PanelContainer> _purebredCards = new();
private readonly Dictionary<string, PanelContainer> _sireCards = new();
private readonly Dictionary<string, PanelContainer> _damCards = new();
private readonly Dictionary<string, PanelContainer> _hybridCards = 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
// since the last build, we only flip ButtonPressed states. Rebuilding
@@ -134,15 +138,24 @@ public partial class StepClade : VBoxContainer, IStep
_hybridSection.AddThemeConstantOverride("separation", 16);
AddChild(_hybridSection);
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" });
var sireGrid = MakeGrid();
_hybridSection.AddChild(sireGrid);
PopulateGrid(sireGrid, _sireCards, id => OnLineageCladePicked("sire", id));
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" });
var damGrid = MakeGrid();
_hybridSection.AddChild(damGrid);
PopulateGrid(damGrid, _damCards, id => OnLineageCladePicked("dam", id));
// One unified hybrid grid: every clade card carries Sire/Dam toggle
// buttons in its header. The same card can become either parent;
// picking Sire on a card currently set as Dam clears the Dam pick
// (and vice versa) atomically.
_hybridSection.AddChild(new Label
{
Text = "Mark one clade as Sire (paternal) and another as Dam (maternal). "
+ "A single clade cannot be both.",
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
// parent clade. Stacking on the same ability is allowed; mods sum.
@@ -293,8 +306,7 @@ public partial class StepClade : VBoxContainer, IStep
if (_sexFemaleBtn.ButtonPressed != isFemale) _sexFemaleBtn.SetPressedNoSignal(isFemale);
UpdateSelection(_purebredCards, _draft.CladeId);
UpdateSelection(_sireCards, _draft.SireCladeId);
UpdateSelection(_damCards, _draft.DamCladeId);
UpdateHybridCards();
int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0;
if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx);
@@ -382,7 +394,14 @@ public partial class StepClade : VBoxContainer, IStep
_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
{
@@ -396,17 +415,57 @@ public partial class StepClade : VBoxContainer, IStep
patch[lineage + "_chosen_species_trait"] = "";
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_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);
_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)
{
_draft.Patch(new Godot.Collections.Dictionary
@@ -579,4 +638,94 @@ public partial class StepClade : VBoxContainer, IStep
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)
{
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;
}
}