using Godot; using Theriapolis.Core.Data; using Theriapolis.GodotHost.Scenes.Widgets; using Theriapolis.GodotHost.UI; namespace Theriapolis.GodotHost.Scenes.Steps; /// /// Step II — Species. Direct port of StepSpecies in /// src/steps.jsx plus the Phase 6.5 hybrid extension. Hybrid mode /// uses a single unified grid (M6.16) — sire and dam species lists are /// merged, and each card carries either a Sire or Dam toggle in its /// header matching the clade it belongs to (sire/dam clades are /// guaranteed disjoint by StepClade's parent-conflict rule). /// public partial class StepSpecies : VBoxContainer, IStep { private CharacterDraft _draft = null!; private VBoxContainer _purebredSection = null!; private VBoxContainer _hybridSection = null!; private GridContainer _purebredGrid = null!; private GridContainer _hybridGrid = null!; // Phase B species trait + detriment pickers — single-pick per lineage. private VBoxContainer _pickSection = null!; private VBoxContainer _sirePickCol = null!; private VBoxContainer _damPickCol = null!; private string _sirePicksBuiltFor = ""; private string _damPicksBuiltFor = ""; public void Bind(CharacterDraft draft) { _draft = draft; // Defer Refresh so it runs after the click callback that triggered // Changed completes — Free() called on a card mid-callback returns // without freeing, leaving a stale duplicate in the grid. _draft.Changed += () => Callable.From(Refresh).CallDeferred(); Build(); } public string? Validate() => WizardValidation.Validate(1, _draft); private void Build() { AddThemeConstantOverride("separation", 16); var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); intro.AddChild(new Label { Text = "FOLIO II · SPECIES", ThemeTypeVariation = "Eyebrow" }); intro.AddChild(new Label { Text = "Choose a Species", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Refine your line. Species inherits the clade's traits and adds its " + "own — body size, base movement speed, and species-specific abilities. " + "Hybrid characters pick one species per parent lineage.", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); _purebredSection = new VBoxContainer(); _purebredSection.AddThemeConstantOverride("separation", 6); AddChild(_purebredSection); _purebredGrid = MakeGrid(); _purebredSection.AddChild(_purebredGrid); _hybridSection = new VBoxContainer(); _hybridSection.AddThemeConstantOverride("separation", 16); AddChild(_hybridSection); _hybridSection.AddChild(new Label { Text = "Pick one species per parent lineage. Sire's clade species " + "are listed first, then Dam's — click any card to pick it.", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); _hybridGrid = MakeGrid(); _hybridSection.AddChild(_hybridGrid); // Phase B species pickers: one trait + one detriment per parent. _pickSection = new VBoxContainer(); _pickSection.AddThemeConstantOverride("separation", 8); _hybridSection.AddChild(_pickSection); _pickSection.AddChild(new Label { Text = "SPECIES TRAITS & DETRIMENTS", ThemeTypeVariation = "Eyebrow" }); _pickSection.AddChild(new Label { Text = "Pick one species trait and one species detriment from each parent. " + "GMs may rule that traits express partially in hybrids.", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); var pickGrid = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; pickGrid.AddThemeConstantOverride("separation", 16); _pickSection.AddChild(pickGrid); _sirePickCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; _sirePickCol.AddThemeConstantOverride("separation", 6); pickGrid.AddChild(_sirePickCol); _damPickCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; _damPickCol.AddThemeConstantOverride("separation", 6); pickGrid.AddChild(_damPickCol); Refresh(); } private static GridContainer MakeGrid() { // Single-column layout: each card spans the wizard's content width // and surfaces the species' description text. Matches StepClade. var grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill }; grid.AddThemeConstantOverride("v_separation", 12); return grid; } private void Refresh() { if (_purebredGrid is null) return; bool hybrid = _draft.IsHybrid; _purebredSection.Visible = !hybrid; _hybridSection.Visible = hybrid; if (hybrid) { RefreshHybridGrid(); SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire", _draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment); SyncSpeciesPicks(_damPickCol, ref _damPicksBuiltFor, "dam", _draft.DamSpeciesId, _draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment); } else { RefreshPurebredGrid(); } } private void OnPurebredSpeciesPicked(string speciesId) => _draft.Patch(new Godot.Collections.Dictionary { { "species_id", speciesId } }); private void OnLineageSpeciesPicked(string lineage, string speciesId) => _draft.Patch(new Godot.Collections.Dictionary { { lineage + "_species_id", speciesId }, // Species swap invalidates the previously-picked trait/detriment. { lineage + "_chosen_species_trait", "" }, { lineage + "_chosen_species_detriment", "" }, }); /// /// Mutate-in-place sync for the species-pick column (one trait button /// group + one detriment button group, radio-style). /// private void SyncSpeciesPicks(VBoxContainer col, ref string builtFor, string lineage, string speciesId, string chosenTrait, string chosenDetriment) { if (speciesId == builtFor) { UpdateRadioGroup(col, "trait", chosenTrait); UpdateRadioGroup(col, "detriment", chosenDetriment); return; } foreach (var c in col.GetChildren()) c.Free(); builtFor = speciesId; var sp = CodexContent.SpeciesById(speciesId); col.AddChild(new Label { Text = lineage.ToUpperInvariant() + (sp is null ? "" : " — " + sp.Name), ThemeTypeVariation = "Eyebrow", }); if (sp is null) { col.AddChild(new Label { Text = "(pick a species above)" }); return; } col.AddChild(new Label { Text = "Trait", ThemeTypeVariation = "Eyebrow" }); BuildRadioGroup(col, "trait", lineage, sp.Traits, chosenTrait, (lin, id) => OnSpeciesPickToggled(lin, "trait", id), isDetriment: false); col.AddChild(new Label { Text = "Detriment", ThemeTypeVariation = "Eyebrow" }); if (sp.Detriments.Length == 0) { col.AddChild(new Label { Text = "(none)" }); } else { BuildRadioGroup(col, "detriment", lineage, sp.Detriments, chosenDetriment, (lin, id) => OnSpeciesPickToggled(lin, "detriment", id), isDetriment: true); } } private static void BuildRadioGroup(VBoxContainer parent, string kind, string lineage, Theriapolis.Core.Data.TraitDef[] options, string selected, System.Action onPicked, bool isDetriment) { var holder = new VBoxContainer { Name = $"{kind}_group" }; holder.AddThemeConstantOverride("separation", 4); parent.AddChild(holder); foreach (var t in options) { string captured = t.Id; string capturedName = t.Name; string capturedDesc = t.Description; var btn = new Button { Text = t.Name, ToggleMode = true, ButtonPressed = t.Id == selected, FocusMode = Control.FocusModeEnum.None, SizeFlagsHorizontal = SizeFlags.ExpandFill, Name = t.Id, }; // Single Pressed signal handles both directions: if the // button is now pressed (its own click flipped it on), // commit the pick; if it's now off, clear the field. // Reading btn.ButtonPressed lets us avoid the stale-closure // bug from capturing `selected` at build time. var btnRef = btn; btn.Pressed += () => onPicked(lineage, btnRef.ButtonPressed ? captured : ""); btn.MouseEntered += () => PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, isDetriment ? "" : "trait", isDetriment); btn.MouseExited += () => PopoverLayer.Instance?.ScheduleClose(); holder.AddChild(btn); } } private static void UpdateRadioGroup(VBoxContainer col, string kind, string selected) { // Find the holder by Name. Bail silently if missing — a species // with no detriments has no detriment_group node. var holder = col.GetNodeOrNull($"{kind}_group"); if (holder is null) return; foreach (var child in holder.GetChildren()) { if (child is Button btn) { bool want = btn.Name == selected; if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want); } } } private void OnSpeciesPickToggled(string lineage, string kind, string traitId) { string field = lineage + "_chosen_species_" + kind; _draft.Patch(new Godot.Collections.Dictionary { { field, traitId } }); } private void RefreshPurebredGrid() { foreach (var c in _purebredGrid.GetChildren()) c.Free(); if (string.IsNullOrEmpty(_draft.CladeId)) return; foreach (var sp in CodexContent.SpeciesOfClade(_draft.CladeId)) _purebredGrid.AddChild(BuildCard(sp, sp.Id == _draft.SpeciesId, spId => OnPurebredSpeciesPicked(spId))); } /// /// Hybrid mode: rebuild the unified grid with sire-clade species /// followed by dam-clade species. Sire and dam clades are /// guaranteed distinct by StepClade's parent-conflict rule, so the /// species lists are disjoint — each card unambiguously belongs to /// one lineage. Full rebuild on every Refresh is safe because Bind /// installs Refresh as a deferred callback. /// private void RefreshHybridGrid() { foreach (var c in _hybridGrid.GetChildren()) c.Free(); AddHybridLineageBlock("sire", _draft.SireCladeId, _draft.SireSpeciesId); AddHybridLineageBlock("dam", _draft.DamCladeId, _draft.DamSpeciesId); } private void AddHybridLineageBlock(string lineage, string cladeId, string selectedSpeciesId) { if (string.IsNullOrEmpty(cladeId)) return; var clade = CodexContent.Clade(cladeId); string headerLabel = (lineage == "sire" ? "SIRE" : "DAM") + (clade is null ? "" : " — " + clade.Name.ToUpperInvariant()); _hybridGrid.AddChild(new Label { Text = headerLabel, ThemeTypeVariation = "Eyebrow" }); foreach (var sp in CodexContent.SpeciesOfClade(cladeId)) _hybridGrid.AddChild(BuildCard(sp, sp.Id == selectedSpeciesId, spId => OnLineageSpeciesPicked(lineage, spId))); } private static Control BuildCard(SpeciesDef sp, bool selected, System.Action onClick) { var card = CodexCard.Make(); card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; CodexCard.SetSelected(card, selected); card.GuiInput += (InputEvent e) => { if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) onClick(sp.Id); }; var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass }; box.AddThemeConstantOverride("separation", 6); card.AddChild(box); box.AddChild(new Label { Text = sp.Name, ThemeTypeVariation = "CardName" }); box.AddChild(new Label { Text = $"{sp.Size.ToUpperInvariant()} · {sp.BaseSpeedFt} FT/TURN", ThemeTypeVariation = "CardMeta", }); if (!string.IsNullOrEmpty(sp.Description)) { box.AddChild(new Label { Text = sp.Description, AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = Control.MouseFilterEnum.Pass, }); } if (sp.AbilityMods.Count > 0) { var modsRow = new HBoxContainer(); modsRow.AddThemeConstantOverride("separation", 8); box.AddChild(modsRow); foreach (var (k, v) in sp.AbilityMods) modsRow.AddChild(new Label { Text = $"{k} {(v >= 0 ? "+" : "")}{v}" }); } if (sp.Traits.Length > 0 || sp.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 sp.Traits) chips.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); foreach (var d in sp.Detriments) chips.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } return card; } }