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