diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index 84b0894..5f477e1 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -42,8 +42,12 @@ public partial class StepClade : VBoxContainer, IStep private Label _damTraitHeader = null!; private readonly Dictionary _purebredCards = new(); - private readonly Dictionary _sireCards = new(); - private readonly Dictionary _damCards = new(); + private readonly Dictionary _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 _sireToggles = new(); + private readonly Dictionary _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) + /// + /// Build the patch dict that records "'s + /// clade is now " — 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. + /// + 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(); + return patch; + } + + /// + /// 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. + /// + 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; } + + /// + /// 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. + /// + 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; + } }