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: when /// is true the step shows two /// stacked grids, one filtered by SireCladeId and one by DamCladeId. /// public partial class StepSpecies : VBoxContainer, IStep { private CharacterDraft _draft = null!; private VBoxContainer _purebredSection = null!; private VBoxContainer _hybridSection = null!; private GridContainer _purebredGrid = null!; private GridContainer _sireGrid = null!; private GridContainer _damGrid = 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 = ""; // Lineage-axis variant picker for purebred path (Ram-Folk sheep/goat etc.). // Hybrid path embeds its own lineage picker into the per-parent col. private VBoxContainer _purebredVariantSection = null!; private string _purebredVariantBuiltFor = ""; 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); // Lineage picker (Ram-Folk sheep/goat). Visible only when the // selected species has VariantAxis == "lineage". _purebredVariantSection = new VBoxContainer { Visible = false }; _purebredVariantSection.AddThemeConstantOverride("separation", 6); _purebredSection.AddChild(_purebredVariantSection); _hybridSection = new VBoxContainer(); _hybridSection.AddThemeConstantOverride("separation", 16); AddChild(_hybridSection); _hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" }); _sireGrid = MakeGrid(); _hybridSection.AddChild(_sireGrid); _hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" }); _damGrid = MakeGrid(); _hybridSection.AddChild(_damGrid); // 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() { var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; grid.AddThemeConstantOverride("h_separation", 16); grid.AddThemeConstantOverride("v_separation", 16); return grid; } private void Refresh() { if (_purebredGrid is null) return; bool hybrid = _draft.IsHybrid; _purebredSection.Visible = !hybrid; _hybridSection.Visible = hybrid; if (hybrid) { RefreshGrid(_sireGrid, _draft.SireCladeId, _draft.SireSpeciesId, spId => OnLineageSpeciesPicked("sire", spId)); RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId, spId => OnLineageSpeciesPicked("dam", spId)); SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire", _draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment); SyncSpeciesPicks(_damPickCol, ref _damPicksBuiltFor, "dam", _draft.DamSpeciesId, _draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment); } else { RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId, spId => OnPurebredSpeciesPicked(spId)); SyncPurebredVariant(); } } private void OnPurebredSpeciesPicked(string speciesId) { _draft.Patch(new Godot.Collections.Dictionary { { "species_id", speciesId }, // Species swap invalidates lineage variant. { "species_variant", "" }, }); } private void OnLineageSpeciesPicked(string lineage, string speciesId) { _draft.Patch(new Godot.Collections.Dictionary { { lineage + "_species_id", speciesId }, // Species swap invalidates the previously-picked species trait/detriment + variant. { lineage + "_chosen_species_trait", "" }, { lineage + "_chosen_species_detriment", "" }, { lineage + "_species_variant", "" }, }); } /// Sync the purebred lineage picker row. Visible iff the /// picked species declares a lineage-axis variant. private void SyncPurebredVariant() { var sp = CodexContent.SpeciesById(_draft.SpeciesId); bool show = sp is not null && sp.VariantAxis == "lineage" && sp.Variants.Length > 0; _purebredVariantSection.Visible = show; if (!show) { _purebredVariantBuiltFor = ""; foreach (var c in _purebredVariantSection.GetChildren()) c.Free(); return; } if (_purebredVariantBuiltFor == _draft.SpeciesId) { // Same species — just update which lineage button is pressed. foreach (var child in _purebredVariantSection.GetChildren()) { if (child is Button btn) { bool want = btn.Name == _draft.SpeciesVariant; if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want); } } return; } foreach (var c in _purebredVariantSection.GetChildren()) c.Free(); _purebredVariantBuiltFor = _draft.SpeciesId; _purebredVariantSection.AddChild(new Label { Text = "LINEAGE", ThemeTypeVariation = "Eyebrow" }); var row = new HBoxContainer(); row.AddThemeConstantOverride("separation", 8); _purebredVariantSection.AddChild(row); foreach (var v in sp!.Variants) { string captured = v.Id; var btn = new Button { Text = v.Name, ToggleMode = true, ButtonPressed = v.Id == _draft.SpeciesVariant, FocusMode = Control.FocusModeEnum.None, Name = v.Id, }; var btnRef = btn; btn.Pressed += () => _draft.Patch(new Godot.Collections.Dictionary { { "species_variant", btnRef.ButtonPressed ? captured : "" }, }); // Hover popover summarizes the variant's traits + detriments. string capturedName = v.Name; string capturedDesc = SummarizeVariant(v); btn.MouseEntered += () => PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, "lineage", false); btn.MouseExited += () => PopoverLayer.Instance?.ScheduleClose(); row.AddChild(btn); } } private static string SummarizeVariant(Theriapolis.Core.Data.SpeciesVariantDef v) { var parts = new System.Collections.Generic.List(); foreach (var t in v.Traits) parts.Add($"• {t.Name}: {t.Description}"); foreach (var d in v.Detriments) parts.Add($"• {d.Name} (detriment): {d.Description}"); return parts.Count == 0 ? "(no extra traits)" : string.Join("\n", parts); } /// /// Mutate-in-place sync for the species-pick column (one trait button /// group + one detriment button group, radio-style; plus a lineage /// picker when the species declares a lineage-axis variant). Same /// Free()-defer hazard as the bonus rows in StepClade — only rebuild /// when the species id changes. /// 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); UpdateRadioGroup(col, "lineage", lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant); 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; } // Lineage picker first when applicable, so the player picks // lineage before reading the trait/detriment list (variant // content layers on top). if (sp.VariantAxis == "lineage" && sp.Variants.Length > 0) { col.AddChild(new Label { Text = "Lineage", ThemeTypeVariation = "Eyebrow" }); string currentVariant = lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant; BuildRadioGroup(col, "lineage", lineage, VariantsAsTraits(sp.Variants), currentVariant, (lin, id) => _draft.Patch(new Godot.Collections.Dictionary { { lin + "_species_variant", id }, }), isDetriment: false); } 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); } } /// /// Adapter — BuildRadioGroup operates on TraitDef[]; project the variant /// list into TraitDef-shape so it can drive the same radio renderer. /// Description summarises the variant's contents for the hover popover. /// private static Theriapolis.Core.Data.TraitDef[] VariantsAsTraits( Theriapolis.Core.Data.SpeciesVariantDef[] variants) { var arr = new Theriapolis.Core.Data.TraitDef[variants.Length]; for (int i = 0; i < variants.Length; i++) { arr[i] = new Theriapolis.Core.Data.TraitDef { Id = variants[i].Id, Name = variants[i].Name, Description = SummarizeVariant(variants[i]), }; } return arr; } 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 static void RefreshGrid(GridContainer grid, string cladeId, string selectedSpecies, System.Action onClick) { foreach (var c in grid.GetChildren()) c.Free(); if (string.IsNullOrEmpty(cladeId)) return; foreach (var sp in CodexContent.SpeciesOfClade(cladeId)) grid.AddChild(BuildCard(sp, sp.Id == selectedSpecies, onClick)); } private static Control BuildCard(SpeciesDef sp, bool selected, System.Action onClick) { var card = CodexCard.Make(); card.CustomMinimumSize = new Vector2(200, 0); 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 (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; } }