From ce87eb11ada25f200010d6b0b2688a64a62bea1c Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sat, 2 May 2026 22:24:33 -0700 Subject: [PATCH] M6.4: Card-grid steps + hybrid origin + clade-restricted backgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per GODOT_PORTING_GUIDE.md §12, the four "easy" card-grid steps land together (Species / Calling / Subclass / History), plus three real features that emerged during testing: cross-step validation gating, hybrid origin, and clade-restricted background availability. New step files (Scenes/Steps/): StepSpecies.cs — cards filtered by clade; for hybrids shows two stacked grids (Sire / Dam). StepClass.cs — all classes; class change clears chosen skills and the previously-selected subclass. StepSubclass.cs — subclasses filtered by ClassDef.SubclassIds. StepBackground.cs — backgrounds filtered by hybrid + clade rules (see below). UI/WizardValidation.cs (new): Static per-step validators against CharacterDraft. Replaces the per-instance Validate() route on the wizard side — Wizard now computes the lock state for every step in the flow, not just the current one. Mirrors app.jsx's firstIncomplete rule exactly. Bug it fixes: previously the wizard checked only the current step's validity, so picking a clade let you skip directly to Abilities without picking species/calling/etc. UI/CharacterDraft.cs: Phase 6.5 hybrid fields — IsHybrid, SireCladeId, SireSpeciesId, DamCladeId, DamSpeciesId, DominantParent. EffectiveCladeId / EffectiveSpeciesId resolve to the dominant parent's lineage when hybrid; downstream steps don't need to care which path. Helpers HasClade(id) and HasAnyCladeOfKind(kind) feed the background availability rules. StepClade.cs: Hybrid toggle splits the picker into Sire + Dam grids with a Dominant Lineage radio. Validation refuses same-clade Sire+Dam. Switched to build-once + mutate-in-place: cards are created once during Build(), Refresh just updates Modulate per selection state. Tearing down + rebuilding inside the click callback caused duplicates because Free() defers when the freed node is mid-signal. StepBackground.cs: Availability rules table — predicates per restricted background id. Hybrid-only: passer, hybrid_underground, former_chattel. Clade-restricted: warren_runner (Leporidae), pack_raised (Canidae), herd_city_born (any prey clade). Hybrids match if either parent satisfies the rule. Other steps (Species/Class/Subclass/Background): Refresh dispatched via Callable.From(Refresh).CallDeferred() so the rebuild runs after the click handler completes — same Free()-during- signal bug as StepClade hit, fixed via deferral instead of mutate- in-place because the card lists are dynamic (clade- / class- / hybrid-flag-dependent). Wizard.cs: - RebuildStepperStates uses WizardValidation.FirstIncomplete to lock every step past the first unsatisfied one. - OnStepperClicked checks every step in [0..target-1]. - UpdateChrome's banner uses WizardValidation for the active step. - Scroll preservation moved here (snapshot before step.Refresh fires, restore in _Process); StepStats's local copy removed. Wizard.tscn: Scroll node marked unique_name_in_owner so Wizard can grab it. PopoverLayer's TraitChip is reused throughout the new step cards. Aside.cs: Hybrid-aware summary — shows "Sire (dominant)" / "Dam" lineage rows when IsHybrid; otherwise the existing Clade / Species rows. Verified end-to-end: - Walk Clade → Species → Calling → Subclass → History → Abilities - Stepper locks every step past first unsatisfied - Hybrid toggle works both directions, dominant changes lineage - Hybrid-only and clade-restricted backgrounds appear / disappear based on lineage - Scroll position preserved across selections - Drag-drop still works on Abilities Closes M6.4. Per guide §12, next is M6.5 — StepSkills (class-driven choice list with TraitChip per skill). Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/Scenes/Aside.cs | 26 +- .../Scenes/Steps/StepBackground.cs | 150 ++++++++++++ Theriapolis.Godot/Scenes/Steps/StepClade.cs | 231 ++++++++++++------ Theriapolis.Godot/Scenes/Steps/StepClass.cs | 137 +++++++++++ Theriapolis.Godot/Scenes/Steps/StepSpecies.cs | 155 ++++++++++++ Theriapolis.Godot/Scenes/Steps/StepStats.cs | 41 +--- .../Scenes/Steps/StepSubclass.cs | 131 ++++++++++ Theriapolis.Godot/Scenes/Wizard.cs | 92 +++++-- Theriapolis.Godot/Scenes/Wizard.tscn | 1 + Theriapolis.Godot/UI/CharacterDraft.cs | 74 +++++- Theriapolis.Godot/UI/WizardValidation.cs | 79 ++++++ 11 files changed, 969 insertions(+), 148 deletions(-) create mode 100644 Theriapolis.Godot/Scenes/Steps/StepBackground.cs create mode 100644 Theriapolis.Godot/Scenes/Steps/StepClass.cs create mode 100644 Theriapolis.Godot/Scenes/Steps/StepSpecies.cs create mode 100644 Theriapolis.Godot/Scenes/Steps/StepSubclass.cs create mode 100644 Theriapolis.Godot/UI/WizardValidation.cs diff --git a/Theriapolis.Godot/Scenes/Aside.cs b/Theriapolis.Godot/Scenes/Aside.cs index 75227b9..8b6e62b 100644 --- a/Theriapolis.Godot/Scenes/Aside.cs +++ b/Theriapolis.Godot/Scenes/Aside.cs @@ -40,13 +40,35 @@ public partial class Aside : MarginContainer foreach (var c in _content.GetChildren()) c.QueueFree(); _content.AddChild(new Label { Text = "SUMMARY" }); - AddBlock("Clade", CodexContent.Clade(_draft.CladeId)?.Name); - AddBlock("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name); + + if (_draft.IsHybrid) + { + AddBlock("Origin", "Hybrid"); + AddBlock(_draft.DominantParent == "sire" ? "Sire (dominant)" : "Sire", + FormatLineage(_draft.SireCladeId, _draft.SireSpeciesId)); + AddBlock(_draft.DominantParent == "dam" ? "Dam (dominant)" : "Dam", + FormatLineage(_draft.DamCladeId, _draft.DamSpeciesId)); + } + else + { + AddBlock("Clade", CodexContent.Clade(_draft.CladeId)?.Name); + AddBlock("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name); + } + AddBlock("Calling", CodexContent.Class(_draft.ClassId)?.Name); AddBlock("Background", CodexContent.Background(_draft.BackgroundId)?.Name); AddBlock("Name", string.IsNullOrEmpty(_draft.CharacterName) ? null : _draft.CharacterName); } + private static string? FormatLineage(string cladeId, string speciesId) + { + var clade = CodexContent.Clade(cladeId); + var species = CodexContent.SpeciesById(speciesId); + if (clade is null && species is null) return null; + if (species is not null) return $"{species.Name} ({clade?.Name ?? cladeId})"; + return clade?.Name; + } + private void AddBlock(string label, string? value) { var v = new VBoxContainer(); diff --git a/Theriapolis.Godot/Scenes/Steps/StepBackground.cs b/Theriapolis.Godot/Scenes/Steps/StepBackground.cs new file mode 100644 index 0000000..c0b4991 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Steps/StepBackground.cs @@ -0,0 +1,150 @@ +using Godot; +using Theriapolis.Core.Data; +using Theriapolis.GodotHost.Scenes.Widgets; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes.Steps; + +/// +/// Step V — History. Direct port of StepBackground in +/// src/steps.jsx: card per background showing flavor, granted +/// skill / tool proficiencies, and a unique feature with a description +/// popover. +/// +public partial class StepBackground : VBoxContainer, IStep +{ + private CharacterDraft _draft = null!; + private GridContainer _grid = null!; + + public void Bind(CharacterDraft draft) + { + _draft = draft; + _draft.Changed += () => Callable.From(Refresh).CallDeferred(); + Build(); + } + + public string? Validate() => + string.IsNullOrEmpty(_draft?.BackgroundId) ? "Pick a history." : null; + + private void Build() + { + AddThemeConstantOverride("separation", 16); + + var intro = new VBoxContainer(); + intro.AddThemeConstantOverride("separation", 6); + AddChild(intro); + intro.AddChild(new Label { Text = "FOLIO V · HISTORY" }); + intro.AddChild(new Label { Text = "Choose a History" }); + intro.AddChild(new Label + { + Text = "Where your character came from before the wandering began. " + + "History grants extra skill and tool proficiencies and a unique " + + "feature that resolves in-fiction more often than in combat.", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + + _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _grid.AddThemeConstantOverride("h_separation", 16); + _grid.AddThemeConstantOverride("v_separation", 16); + AddChild(_grid); + + Refresh(); + } + + /// + /// Background-availability rules. The backgrounds.json schema doesn't + /// carry restriction fields — the gating lives in flavor text only — + /// so they're hardcoded here. Each predicate returns true when the + /// background is available to the given draft; missing entries are + /// universally available. + /// + /// If backgrounds.json ever gains structured restriction fields, + /// swap these out for a property-driven check. + /// + private static readonly System.Collections.Generic.Dictionary> AvailabilityRules = new() + { + // Hybrid-only backgrounds — flavor text explicitly hybrid. + { "passer", d => d.IsHybrid }, + { "hybrid_underground", d => d.IsHybrid }, + { "former_chattel", d => d.IsHybrid }, + + // Clade-restricted backgrounds. + { "warren_runner", d => d.HasClade("leporidae") }, + { "pack_raised", d => d.HasClade("canidae") }, + { "herd_city_born", d => d.HasAnyCladeOfKind("prey") }, + }; + + private void Refresh() + { + if (_grid is null) return; + foreach (var c in _grid.GetChildren()) c.QueueFree(); + foreach (var bg in CodexContent.Backgrounds) + { + if (AvailabilityRules.TryGetValue(bg.Id, out var rule) && !rule(_draft)) + continue; + _grid.AddChild(BuildCard(bg)); + } + } + + private Control BuildCard(BackgroundDef bg) + { + bool selected = _draft.BackgroundId == bg.Id; + + var card = new PanelContainer + { + CustomMinimumSize = new Vector2(200, 0), + MouseFilter = MouseFilterEnum.Stop, + }; + if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + + card.GuiInput += (InputEvent e) => + { + if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) + _draft.Patch(new Godot.Collections.Dictionary { { "background_id", bg.Id } }); + }; + + var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass }; + box.AddThemeConstantOverride("separation", 6); + card.AddChild(box); + + box.AddChild(new Label { Text = bg.Name }); + if (!string.IsNullOrEmpty(bg.Flavor)) + { + box.AddChild(new Label + { + Text = bg.Flavor, + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + } + + // Skill + tool prof chips. + if (bg.SkillProficiencies.Length > 0 || bg.ToolProficiencies.Length > 0) + { + var profs = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass }; + profs.AddThemeConstantOverride("h_separation", 6); + profs.AddThemeConstantOverride("v_separation", 4); + box.AddChild(profs); + + foreach (var s in bg.SkillProficiencies) + profs.AddChild(new TraitChip { TraitName = s, Description = "Skill proficiency", Tag = "skill" }); + foreach (var t in bg.ToolProficiencies) + profs.AddChild(new TraitChip { TraitName = t, Description = "Tool proficiency", Tag = "tool" }); + } + + // Background feature — chip whose popover shows the description. + if (!string.IsNullOrEmpty(bg.FeatureName)) + { + var featRow = new HBoxContainer(); + featRow.AddThemeConstantOverride("separation", 6); + box.AddChild(featRow); + featRow.AddChild(new Label { Text = "FEATURE" }); + featRow.AddChild(new TraitChip + { + TraitName = bg.FeatureName, + Description = bg.FeatureDescription, + }); + } + + return card; + } +} diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index 533113d..8fb4ba5 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -1,4 +1,5 @@ using Godot; +using System.Collections.Generic; using Theriapolis.Core.Data; using Theriapolis.GodotHost.Scenes.Widgets; using Theriapolis.GodotHost.UI; @@ -7,16 +8,27 @@ namespace Theriapolis.GodotHost.Scenes.Steps; /// /// Step I — Clade. Direct port of StepClade in -/// src/steps.jsx: intro paragraph, then a card grid with one -/// card per clade. Click selects via . +/// 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. /// -/// Default theme only at this layer (per GODOT_PORTING_GUIDE.md §12 build -/// order); the parchment look lands in the final theming pass. +/// 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 GridContainer _grid = 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) { @@ -25,7 +37,7 @@ public partial class StepClade : VBoxContainer, IStep Build(); } - public string? Validate() => string.IsNullOrEmpty(_draft?.CladeId) ? "Pick a clade." : null; + public string? Validate() => WizardValidation.Validate(0, _draft); private void Build() { @@ -40,90 +52,173 @@ public partial class StepClade : VBoxContainer, IStep { Text = "The broad mammalian family of your line. Clade defines the largest " + "strokes — predator or prey, communal or solitary, scent-driven or " - + "sight-driven. Each clade carries inherited traits and limits that " - + "no character escapes.", + + "sight-driven. Hybrid characters blend two lineages.", AutowrapMode = TextServer.AutowrapMode.WordSmart, - CustomMinimumSize = new Vector2(0, 0), }); - _grid = new GridContainer - { - Columns = 3, - SizeFlagsHorizontal = SizeFlags.ExpandFill, - }; - _grid.AddThemeConstantOverride("h_separation", 16); - _grid.AddThemeConstantOverride("v_separation", 16); - AddChild(_grid); + 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 void Refresh() + private static GridContainer MakeGrid() { - if (_grid is null) return; - foreach (var c in _grid.GetChildren()) c.QueueFree(); - foreach (var clade in CodexContent.Clades) - _grid.AddChild(BuildCard(clade)); + var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + grid.AddThemeConstantOverride("h_separation", 16); + grid.AddThemeConstantOverride("v_separation", 16); + return grid; } - private Control BuildCard(CladeDef clade) + private void PopulateGrid(GridContainer grid, Dictionary cardMap, System.Action onClick) { - bool selected = _draft.CladeId == clade.Id; + foreach (var clade in CodexContent.Clades) + { + var card = BuildCard(clade, onClick); + cardMap[clade.Id] = card; + grid.AddChild(card); + } + } - // PanelContainer (a Container subclass) so the card height is - // driven by its inner VBoxContainer's content. Switching from - // Button avoids the issue where Button's intrinsic min size - // doesn't aggregate from non-Button children, causing chips to - // overflow into the cards below at high trait counts. + 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"] = ""; + } + _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 = ""; + _draft.Patch(new Godot.Collections.Dictionary + { + { "clade_id", cladeId }, + { "species_id", speciesId }, + }); + } + + 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"] = ""; + _draft.Patch(patch); + } + + private PanelContainer BuildCard(CladeDef clade, System.Action onClick) + { var card = new PanelContainer { CustomMinimumSize = new Vector2(200, 0), MouseFilter = MouseFilterEnum.Stop, }; - if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); // gild tint placeholder until theming - card.GuiInput += (InputEvent e) => { if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) - { - // Default species for the new clade — match React app.jsx: - // when clade changes, species defaults to first species in clade. - string speciesId = ""; - foreach (var s in CodexContent.SpeciesOfClade(clade.Id)) - { - speciesId = s.Id; - break; - } - _draft.Patch(new Godot.Collections.Dictionary - { - { "clade_id", clade.Id }, - { "species_id", speciesId }, - }); - } + onClick(clade.Id); }; var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass }; box.AddThemeConstantOverride("separation", 6); card.AddChild(box); - box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore }); - box.AddChild(new Label - { - Text = clade.Kind.ToUpperInvariant(), - MouseFilter = MouseFilterEnum.Ignore, - }); + box.AddChild(new Label { Text = clade.Name }); + box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant() }); if (clade.AbilityMods.Count > 0) { - var modsRow = new HBoxContainer { MouseFilter = MouseFilterEnum.Ignore }; + 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}", - MouseFilter = MouseFilterEnum.Ignore, - }); + modsRow.AddChild(new Label { Text = $"{k} {(v >= 0 ? "+" : "")}{v}" }); } if (clade.Traits.Length > 0 || clade.Detriments.Length > 0) @@ -132,26 +227,10 @@ public partial class StepClade : VBoxContainer, IStep chips.AddThemeConstantOverride("h_separation", 6); chips.AddThemeConstantOverride("v_separation", 4); box.AddChild(chips); - foreach (var t in clade.Traits) - { - var chip = new TraitChip - { - TraitName = t.Name, - Description = t.Description, - }; - chips.AddChild(chip); - } + chips.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); foreach (var d in clade.Detriments) - { - var chip = new TraitChip - { - TraitName = d.Name, - Description = d.Description, - Detriment = true, - }; - chips.AddChild(chip); - } + chips.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } return card; diff --git a/Theriapolis.Godot/Scenes/Steps/StepClass.cs b/Theriapolis.Godot/Scenes/Steps/StepClass.cs new file mode 100644 index 0000000..76ec1d3 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Steps/StepClass.cs @@ -0,0 +1,137 @@ +using Godot; +using System.Linq; +using Theriapolis.Core.Data; +using Theriapolis.GodotHost.Scenes.Widgets; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes.Steps; + +/// +/// Step III — Calling. Direct port of StepClass in +/// src/steps.jsx: card per class showing hit die, primary +/// abilities, saves, and level-1 features (subclass-selection +/// stubs filtered out per the React prototype's contract). Class +/// change clears chosen skills and the previously-chosen subclass. +/// +public partial class StepClass : VBoxContainer, IStep +{ + private CharacterDraft _draft = null!; + private GridContainer _grid = null!; + + public void Bind(CharacterDraft draft) + { + _draft = draft; + // Defer Refresh so it runs after the click callback that triggered + // Changed completes (avoids the Free()-during-signal duplicate bug). + _draft.Changed += () => Callable.From(Refresh).CallDeferred(); + Build(); + } + + public string? Validate() => + string.IsNullOrEmpty(_draft?.ClassId) ? "Pick a calling." : null; + + private void Build() + { + AddThemeConstantOverride("separation", 16); + + var intro = new VBoxContainer(); + intro.AddThemeConstantOverride("separation", 6); + AddChild(intro); + intro.AddChild(new Label { Text = "FOLIO III · CALLING" }); + intro.AddChild(new Label { Text = "Choose a Calling" }); + intro.AddChild(new Label + { + Text = "Your character's path — fighter, hunter, scholar, or something stranger. " + + "The calling fixes your hit die, primary abilities, saving-throw " + + "proficiencies, and the level-1 feature set you start play with.", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + + _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _grid.AddThemeConstantOverride("h_separation", 16); + _grid.AddThemeConstantOverride("v_separation", 16); + AddChild(_grid); + + Refresh(); + } + + private void Refresh() + { + if (_grid is null) return; + foreach (var c in _grid.GetChildren()) c.QueueFree(); + foreach (var cls in CodexContent.Classes) + _grid.AddChild(BuildCard(cls)); + } + + private Control BuildCard(ClassDef cls) + { + bool selected = _draft.ClassId == cls.Id; + + var card = new PanelContainer + { + CustomMinimumSize = new Vector2(200, 0), + MouseFilter = MouseFilterEnum.Stop, + }; + if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + + card.GuiInput += (InputEvent e) => + { + if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) + { + // Class change: reset subclass + chosen skills, mirroring + // app.jsx's useEffect on classId. + _draft.Patch(new Godot.Collections.Dictionary + { + { "class_id", cls.Id }, + { "subclass_id", "" }, + { "chosen_skills", new Godot.Collections.Array() }, + }); + } + }; + + var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass }; + box.AddThemeConstantOverride("separation", 6); + card.AddChild(box); + + box.AddChild(new Label { Text = cls.Name }); + box.AddChild(new Label + { + Text = $"d{cls.HitDie} · {string.Join("/", cls.PrimaryAbility)}", + }); + + if (cls.Saves.Length > 0) + { + var savesRow = new HBoxContainer(); + savesRow.AddThemeConstantOverride("separation", 6); + box.AddChild(savesRow); + savesRow.AddChild(new Label { Text = "SAVES" }); + foreach (var s in cls.Saves) + savesRow.AddChild(new Label { Text = s }); + } + + // Level-1 features. Filter out stubs and subclass-selection markers + // (the React prototype hides the subclass picker on the class card). + var lvl1 = cls.LevelTable.FirstOrDefault(e => e.Level == 1); + if (lvl1?.Features.Length > 0) + { + var featChips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass }; + featChips.AddThemeConstantOverride("h_separation", 6); + featChips.AddThemeConstantOverride("v_separation", 4); + box.AddChild(featChips); + + foreach (var fid in lvl1.Features) + { + if (!cls.FeatureDefinitions.TryGetValue(fid, out var fd)) continue; + if (fd.Kind == "stub" || fid.StartsWith("subclass_")) continue; + featChips.AddChild(new TraitChip + { + TraitName = fd.Name, + Description = fd.Description, + Tag = fd.Kind, + }); + } + } + + return card; + } +} diff --git a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs new file mode 100644 index 0000000..e6021b8 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs @@ -0,0 +1,155 @@ +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!; + + 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" }); + intro.AddChild(new Label { Text = "Choose a Species" }); + 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 = "SIRE — Paternal Lineage" }); + _sireGrid = MakeGrid(); + _hybridSection.AddChild(_sireGrid); + + _hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage" }); + _damGrid = MakeGrid(); + _hybridSection.AddChild(_damGrid); + + Refresh(); + } + + private static GridContainer MakeGrid() + { + var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + 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 => _draft.Patch(new Godot.Collections.Dictionary { { "sire_species_id", spId } })); + RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId, + spId => _draft.Patch(new Godot.Collections.Dictionary { { "dam_species_id", spId } })); + } + else + { + RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId, + spId => _draft.Patch(new Godot.Collections.Dictionary { { "species_id", spId } })); + } + } + + 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 = new PanelContainer + { + CustomMinimumSize = new Vector2(200, 0), + MouseFilter = MouseFilterEnum.Stop, + }; + if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + + 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 }); + box.AddChild(new Label { Text = $"{sp.Size.ToUpperInvariant()} · {sp.BaseSpeedFt} FT/TURN" }); + + 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; + } +} diff --git a/Theriapolis.Godot/Scenes/Steps/StepStats.cs b/Theriapolis.Godot/Scenes/Steps/StepStats.cs index 73ee87b..fc0cad7 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepStats.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepStats.cs @@ -37,9 +37,6 @@ public partial class StepStats : VBoxContainer, IStep private Button _rollBtn = null!; private Button _rerollBtn = null!; private Button _autoBtn = null!; - private ScrollContainer? _scroll; - private int _savedScroll = -1; - private bool _scrollPending; public void Bind(CharacterDraft draft) { @@ -61,7 +58,7 @@ public partial class StepStats : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO V · ABILITIES" }); + intro.AddChild(new Label { Text = "FOLIO VI · ABILITIES" }); intro.AddChild(new Label { Text = "Assign your Ability Scores" }); intro.AddChild(new Label { @@ -139,15 +136,8 @@ public partial class StepStats : VBoxContainer, IStep private void Refresh() { if (_pool is null) return; - - // Tearing down + rebuilding child token trees triggers a layout - // pass that resets the parent ScrollContainer's vertical scroll. - // Snapshot the scroll position and restore it after the new layout - // settles. The restore goes through a method (not SetDeferred on - // the property) so it runs reliably after layout finishes — the - // method is queued via CallDeferred at the end of Refresh. - _scroll = FindAncestorScroll(); - _savedScroll = _scroll?.ScrollVertical ?? -1; + // Scroll preservation now handled centrally by Wizard.UpdateChrome + // / Wizard._Process, which snapshots before this Refresh fires. // Toolbar reflects current method and assignment state. bool isRoll = _draft.StatMethod == "roll"; @@ -197,31 +187,6 @@ public partial class StepStats : VBoxContainer, IStep } } - // Restore scroll on the next _Process frame — after layout has fully - // converged. CallDeferred / SetDeferred / CreateTimer all proved - // racy because the ScrollContainer re-clamps scroll on later layout - // passes triggered by tree mutations elsewhere. - if (_savedScroll >= 0 && _scroll is not null) _scrollPending = true; - } - - public override void _Process(double delta) - { - if (_scrollPending && _scroll is not null && IsInstanceValid(_scroll)) - { - _scroll.ScrollVertical = _savedScroll; - } - _scrollPending = false; - } - - private ScrollContainer? FindAncestorScroll() - { - Node? n = GetParent(); - while (n is not null) - { - if (n is ScrollContainer sc) return sc; - n = n.GetParent(); - } - return null; } // ────────────────────────────────────────────────────────────────────── diff --git a/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs b/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs new file mode 100644 index 0000000..3326a09 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs @@ -0,0 +1,131 @@ +using Godot; +using System.Linq; +using Theriapolis.Core.Data; +using Theriapolis.GodotHost.Scenes.Widgets; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes.Steps; + +/// +/// Step IV — Subclass. New vs the React prototype (which is pre-Phase +/// 6.5); per port plan §10, dedicated step rather than inline picker. +/// Cards filtered by the chosen class's SubclassIds. Selecting +/// commits via Patch(subclass_id). +/// +public partial class StepSubclass : VBoxContainer, IStep +{ + private CharacterDraft _draft = null!; + private GridContainer _grid = null!; + + public void Bind(CharacterDraft draft) + { + _draft = draft; + _draft.Changed += () => Callable.From(Refresh).CallDeferred(); + Build(); + } + + public string? Validate() + { + if (string.IsNullOrEmpty(_draft?.SubclassId)) return "Pick a subclass."; + var sub = System.Array.Find(CodexContent.Subclasses, s => s.Id == _draft.SubclassId); + if (sub is null || !string.Equals(sub.ClassId, _draft.ClassId, System.StringComparison.OrdinalIgnoreCase)) + return "Selected subclass doesn't belong to the current calling."; + return null; + } + + private void Build() + { + AddThemeConstantOverride("separation", 16); + + var intro = new VBoxContainer(); + intro.AddThemeConstantOverride("separation", 6); + AddChild(intro); + intro.AddChild(new Label { Text = "FOLIO IV · SUBCLASS" }); + intro.AddChild(new Label { Text = "Choose a Subclass" }); + intro.AddChild(new Label + { + Text = "Specialization within your calling. Subclass features unlock at " + + "level 3 and beyond, but the choice is locked in now — only subclasses " + + "available to your chosen calling are shown.", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + + _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _grid.AddThemeConstantOverride("h_separation", 16); + _grid.AddThemeConstantOverride("v_separation", 16); + AddChild(_grid); + + Refresh(); + } + + private void Refresh() + { + if (_grid is null) return; + foreach (var c in _grid.GetChildren()) c.QueueFree(); + + var cls = CodexContent.Class(_draft.ClassId); + if (cls is null) return; + + foreach (var subId in cls.SubclassIds) + { + var sub = System.Array.Find(CodexContent.Subclasses, s => s.Id == subId); + if (sub is not null) _grid.AddChild(BuildCard(sub)); + } + } + + private Control BuildCard(SubclassDef sub) + { + bool selected = _draft.SubclassId == sub.Id; + + var card = new PanelContainer + { + CustomMinimumSize = new Vector2(200, 0), + MouseFilter = MouseFilterEnum.Stop, + }; + if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + + card.GuiInput += (InputEvent e) => + { + if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) + _draft.Patch(new Godot.Collections.Dictionary { { "subclass_id", sub.Id } }); + }; + + var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass }; + box.AddThemeConstantOverride("separation", 6); + card.AddChild(box); + + box.AddChild(new Label { Text = sub.Name }); + if (!string.IsNullOrEmpty(sub.Flavor)) + { + box.AddChild(new Label + { + Text = sub.Flavor, + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + } + + // Level-3 features (the first unlock for any subclass). + var l3 = sub.LevelFeatures.FirstOrDefault(e => e.Level == 3); + if (l3?.Features.Length > 0) + { + var featChips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass }; + featChips.AddThemeConstantOverride("h_separation", 6); + featChips.AddThemeConstantOverride("v_separation", 4); + box.AddChild(featChips); + + foreach (var fid in l3.Features) + { + if (!sub.FeatureDefinitions.TryGetValue(fid, out var fd)) continue; + if (fd.Kind == "stub") continue; + featChips.AddChild(new TraitChip + { + TraitName = fd.Name, + Description = fd.Description, + Tag = "L3 · " + fd.Kind, + }); + } + } + + return card; + } +} diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs index 4a273f1..e50ea2d 100644 --- a/Theriapolis.Godot/Scenes/Wizard.cs +++ b/Theriapolis.Godot/Scenes/Wizard.cs @@ -32,18 +32,26 @@ public partial class Wizard : Control private Button _backBtn = null!; private Button _nextBtn = null!; + // Scroll preservation: snapshot scroll position when the draft changes + // (which fires before the active step's Refresh tears down + rebuilds + // child nodes), then restore on the next _Process frame so the user + // doesn't get punted to the top of the page when selecting a card. + private ScrollContainer? _scroll; + private int _savedScroll = -1; + private bool _scrollPending; + private int _step; private Steps.IStep? _activeStep; private static readonly System.Type?[] StepTypes = { - typeof(Steps.StepClade), // 0 Clade — implemented - null, // 1 Species - null, // 2 Calling - null, // 3 Subclass - null, // 4 History - typeof(Steps.StepStats), // 5 Abilities — implemented (M6.2) - null, // 6 Skills - null, // 7 Sign + typeof(Steps.StepClade), // 0 Clade + typeof(Steps.StepSpecies), // 1 Species + typeof(Steps.StepClass), // 2 Calling + typeof(Steps.StepSubclass), // 3 Subclass + typeof(Steps.StepBackground), // 4 History + typeof(Steps.StepStats), // 5 Abilities + null, // 6 Skills — M6.5 + null, // 7 Sign — M6.6 }; public override void _Ready() @@ -57,6 +65,7 @@ public partial class Wizard : Control _navProgress = GetNode