using Godot; using System.Collections.Generic; using Theriapolis.Core.Data; using Theriapolis.GodotHost.Scenes.Widgets; using Theriapolis.GodotHost.UI; namespace Theriapolis.GodotHost.Scenes.Steps; /// /// Step I — Clade. Direct port of StepClade in /// src/steps.jsx, plus the Phase 6.5 hybrid-origin extension /// per port plan §10: a Hybrid toggle splits the picker into Sire + /// Dam grids, each independent. The dominant-parent radio drives /// for downstream steps. /// /// Cards are built once and mutated in place (Modulate update only) on /// selection change — tearing down + rebuilding inside the click /// callback chain caused duplicates because Free() defers when the /// freed node is currently mid-signal. /// public partial class StepClade : VBoxContainer, IStep { private CharacterDraft _draft = null!; private CheckBox _hybridToggle = null!; private VBoxContainer _purebredSection = null!; private VBoxContainer _hybridSection = null!; private OptionButton _dominantToggle = null!; private readonly Dictionary _purebredCards = new(); private readonly Dictionary _sireCards = new(); private readonly Dictionary _damCards = new(); public void Bind(CharacterDraft draft) { _draft = draft; _draft.Changed += Refresh; Build(); } public string? Validate() => WizardValidation.Validate(0, _draft); private void Build() { AddThemeConstantOverride("separation", 16); var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); intro.AddChild(new Label { Text = "FOLIO I · CLADE" }); intro.AddChild(new Label { Text = "Choose a Clade" }); intro.AddChild(new Label { Text = "The broad mammalian family of your line. Clade defines the largest " + "strokes — predator or prey, communal or solitary, scent-driven or " + "sight-driven. Hybrid characters blend two lineages.", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); var toggleRow = new HBoxContainer(); toggleRow.AddThemeConstantOverride("separation", 12); AddChild(toggleRow); _hybridToggle = new CheckBox { Text = "Hybrid Origin (two parent lineages)" }; _hybridToggle.Toggled += OnHybridToggled; toggleRow.AddChild(_hybridToggle); // Purebred section _purebredSection = new VBoxContainer(); _purebredSection.AddThemeConstantOverride("separation", 6); AddChild(_purebredSection); var purebredGrid = MakeGrid(); _purebredSection.AddChild(purebredGrid); PopulateGrid(purebredGrid, _purebredCards, OnPurebredCladePicked); // Hybrid section _hybridSection = new VBoxContainer(); _hybridSection.AddThemeConstantOverride("separation", 16); AddChild(_hybridSection); _hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage" }); var sireGrid = MakeGrid(); _hybridSection.AddChild(sireGrid); PopulateGrid(sireGrid, _sireCards, id => OnLineageCladePicked("sire", id)); _hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage" }); var damGrid = MakeGrid(); _hybridSection.AddChild(damGrid); PopulateGrid(damGrid, _damCards, id => OnLineageCladePicked("dam", id)); var dominantRow = new HBoxContainer(); dominantRow.AddThemeConstantOverride("separation", 8); _hybridSection.AddChild(dominantRow); dominantRow.AddChild(new Label { Text = "DOMINANT LINEAGE" }); _dominantToggle = new OptionButton(); _dominantToggle.AddItem("Sire", 0); _dominantToggle.AddItem("Dam", 1); _dominantToggle.ItemSelected += OnDominantSelected; dominantRow.AddChild(_dominantToggle); Refresh(); } private static GridContainer MakeGrid() { var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; grid.AddThemeConstantOverride("h_separation", 16); grid.AddThemeConstantOverride("v_separation", 16); return grid; } private void PopulateGrid(GridContainer grid, Dictionary cardMap, System.Action onClick) { foreach (var clade in CodexContent.Clades) { var card = BuildCard(clade, onClick); cardMap[clade.Id] = card; grid.AddChild(card); } } private void OnHybridToggled(bool pressed) { var patch = new Godot.Collections.Dictionary { { "is_hybrid", pressed } }; if (pressed) { patch["clade_id"] = ""; patch["species_id"] = ""; } else { patch["sire_clade_id"] = ""; patch["sire_species_id"] = ""; patch["dam_clade_id"] = ""; patch["dam_species_id"] = ""; } ClearBackgroundIfInvalidated(patch); _draft.Patch(patch); } private void OnDominantSelected(long index) { _draft.Patch(new Godot.Collections.Dictionary { { "dominant_parent", index == 0 ? "sire" : "dam" }, }); } private void Refresh() { if (_hybridToggle is null) return; bool hybrid = _draft.IsHybrid; if (_hybridToggle.ButtonPressed != hybrid) _hybridToggle.SetPressedNoSignal(hybrid); _purebredSection.Visible = !hybrid; _hybridSection.Visible = hybrid; UpdateSelection(_purebredCards, _draft.CladeId); UpdateSelection(_sireCards, _draft.SireCladeId); UpdateSelection(_damCards, _draft.DamCladeId); int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0; if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx); } private static void UpdateSelection(Dictionary cards, string selectedId) { foreach (var (id, card) in cards) card.Modulate = id == selectedId ? new Color(1f, 0.95f, 0.85f) : Colors.White; } private void OnPurebredCladePicked(string cladeId) { string speciesId = _draft.SpeciesId; var sp = CodexContent.SpeciesById(speciesId); if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase)) speciesId = ""; var patch = new Godot.Collections.Dictionary { { "clade_id", cladeId }, { "species_id", speciesId }, }; ClearBackgroundIfInvalidated(patch); _draft.Patch(patch); } private void OnLineageCladePicked(string lineage, string cladeId) { var patch = new Godot.Collections.Dictionary { { lineage + "_clade_id", cladeId }, }; string currentSpecies = lineage == "sire" ? _draft.SireSpeciesId : _draft.DamSpeciesId; var sp = CodexContent.SpeciesById(currentSpecies); if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase)) patch[lineage + "_species_id"] = ""; ClearBackgroundIfInvalidated(patch); _draft.Patch(patch); } /// /// Clade changes can make a previously-valid background unavailable /// (e.g. picking Felidae while Pack-Raised is selected). Build the /// hypothetical post-patch state via Resource.Duplicate, run it /// through BackgroundAvailability, and clear background_id in the /// patch if the rule no longer matches. /// private void ClearBackgroundIfInvalidated(Godot.Collections.Dictionary patch) { if (string.IsNullOrEmpty(_draft.BackgroundId)) return; var future = (CharacterDraft)_draft.Duplicate(); // Apply the same patch we're about to commit, in-place on the copy. future.Patch(patch); if (!BackgroundAvailability.IsAvailable(_draft.BackgroundId, future)) patch["background_id"] = ""; } private PanelContainer BuildCard(CladeDef clade, System.Action onClick) { var card = new PanelContainer { CustomMinimumSize = new Vector2(200, 0), MouseFilter = MouseFilterEnum.Stop, }; card.GuiInput += (InputEvent e) => { if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) onClick(clade.Id); }; var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass }; box.AddThemeConstantOverride("separation", 6); card.AddChild(box); box.AddChild(new Label { Text = clade.Name }); box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant() }); 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; } }