M6.16: Unified hybrid species grid + codex-styled hover popover

StepSpecies hybrid mode now uses one grid combining sire-clade species
(under a "SIRE — <Clade>" eyebrow) and dam-clade species (under
"DAM — <Clade>"). Cards are click-to-select like the purebred path —
since clades are guaranteed disjoint by StepClade's parent-conflict
rule, the lineage is implicit from the species' clade and no per-card
toggles are needed.

Hover popover now picks up the codex theme: parchment Bg2 panel with a
gild border, rounded 14px corners, and soft drop shadow; H3 display
serif title, mono Eyebrow tag, CardBody description. Detriment popovers
swap to a 3px seal-red border via the panel_detriment stylebox override
(replaces the old red Modulate hack).

Theme propagation fix: CanvasLayer breaks Godot's Control theme
inheritance, so the popup was rendering on Godot defaults. _Ready
defers a lookup of the parent Control's theme and assigns it directly
to the popup so the codex parchment + Cormorant/CrimsonPro fonts
actually resolve.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-06 21:26:38 -07:00
parent 479899d3d1
commit 0ab4715aee
3 changed files with 97 additions and 26 deletions
+44 -16
View File
@@ -7,9 +7,11 @@ namespace Theriapolis.GodotHost.Scenes.Steps;
/// <summary> /// <summary>
/// Step II — Species. Direct port of <c>StepSpecies</c> in /// Step II — Species. Direct port of <c>StepSpecies</c> in
/// <c>src/steps.jsx</c> plus the Phase 6.5 hybrid extension: when /// <c>src/steps.jsx</c> plus the Phase 6.5 hybrid extension. Hybrid mode
/// <see cref="CharacterDraft.IsHybrid"/> is true the step shows two /// uses a single unified grid (M6.16) — sire and dam species lists are
/// stacked grids, one filtered by SireCladeId and one by DamCladeId. /// 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).
/// </summary> /// </summary>
public partial class StepSpecies : VBoxContainer, IStep public partial class StepSpecies : VBoxContainer, IStep
{ {
@@ -17,8 +19,7 @@ public partial class StepSpecies : VBoxContainer, IStep
private VBoxContainer _purebredSection = null!; private VBoxContainer _purebredSection = null!;
private VBoxContainer _hybridSection = null!; private VBoxContainer _hybridSection = null!;
private GridContainer _purebredGrid = null!; private GridContainer _purebredGrid = null!;
private GridContainer _sireGrid = null!; private GridContainer _hybridGrid = null!;
private GridContainer _damGrid = null!;
// Phase B species trait + detriment pickers — single-pick per lineage. // Phase B species trait + detriment pickers — single-pick per lineage.
private VBoxContainer _pickSection = null!; private VBoxContainer _pickSection = null!;
@@ -77,13 +78,14 @@ public partial class StepSpecies : VBoxContainer, IStep
_hybridSection.AddThemeConstantOverride("separation", 16); _hybridSection.AddThemeConstantOverride("separation", 16);
AddChild(_hybridSection); AddChild(_hybridSection);
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" }); _hybridSection.AddChild(new Label
_sireGrid = MakeGrid(); {
_hybridSection.AddChild(_sireGrid); Text = "Pick one species per parent lineage. Sire's clade species "
+ "are listed first, then Dam's — click any card to pick it.",
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" }); AutowrapMode = TextServer.AutowrapMode.WordSmart,
_damGrid = MakeGrid(); });
_hybridSection.AddChild(_damGrid); _hybridGrid = MakeGrid();
_hybridSection.AddChild(_hybridGrid);
// Phase B species pickers: one trait + one detriment per parent. // Phase B species pickers: one trait + one detriment per parent.
_pickSection = new VBoxContainer(); _pickSection = new VBoxContainer();
@@ -130,10 +132,7 @@ public partial class StepSpecies : VBoxContainer, IStep
if (hybrid) if (hybrid)
{ {
RefreshGrid(_sireGrid, _draft.SireCladeId, _draft.SireSpeciesId, RefreshHybridGrid();
spId => OnLineageSpeciesPicked("sire", spId));
RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId,
spId => OnLineageSpeciesPicked("dam", spId));
SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire", SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire",
_draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment); _draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
@@ -398,6 +397,35 @@ public partial class StepSpecies : VBoxContainer, IStep
grid.AddChild(BuildCard(sp, sp.Id == selectedSpecies, onClick)); grid.AddChild(BuildCard(sp, sp.Id == selectedSpecies, onClick));
} }
/// <summary>
/// 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 and a click on the card commits the pick. Full
/// rebuild on every Refresh is safe because Bind installs Refresh
/// as a deferred callback.
/// </summary>
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<string> onClick) private static Control BuildCard(SpeciesDef sp, bool selected, System.Action<string> onClick)
{ {
var card = CodexCard.Make(); var card = CodexCard.Make();
@@ -46,18 +46,33 @@ public partial class PopoverLayer : CanvasLayer
{ {
Layer = 100; Layer = 100;
BuildPopover(); BuildPopover();
// Theme inheritance walks Control descendants only — CanvasLayer is
// a plain Node, so it breaks the propagation chain from the Wizard
// Control above. _Ready is bottom-up, so the parent Wizard hasn't
// assigned its codex theme yet — defer the lookup until parent's
// _Ready has run, then pull its theme onto the popup directly.
CallDeferred(MethodName.InheritParentTheme);
}
private void InheritParentTheme()
{
if (GetParent() is Control parentControl && parentControl.Theme is not null)
_popup.Theme = parentControl.Theme;
} }
private void BuildPopover() private void BuildPopover()
{ {
// Ignore so clicks/scroll/hover all pass through to whatever's // Ignore so clicks/scroll/hover all pass through to whatever's
// beneath. The popover is purely a visual readout; the chip // beneath. The popover is purely a visual readout; the chip
// owns the lifecycle entirely. // owns the lifecycle entirely. ThemeTypeVariation pulls the
// CodexPopover stylebox (parchment bg2 + gild border + rounded
// corners + soft shadow) defined in CodexTheme.
_popup = new PanelContainer _popup = new PanelContainer
{ {
Visible = false, Visible = false,
MouseFilter = Control.MouseFilterEnum.Ignore, MouseFilter = Control.MouseFilterEnum.Ignore,
ZIndex = 100, ZIndex = 100,
ThemeTypeVariation = "CodexPopover",
}; };
AddChild(_popup); AddChild(_popup);
@@ -66,19 +81,29 @@ public partial class PopoverLayer : CanvasLayer
_popup.AddChild(v); _popup.AddChild(v);
var nameRow = new HBoxContainer(); var nameRow = new HBoxContainer();
nameRow.AddThemeConstantOverride("separation", 8); nameRow.AddThemeConstantOverride("separation", 10);
v.AddChild(nameRow); v.AddChild(nameRow);
_titleLabel = new Label(); // Display-serif title at H3 size (20px) — pulls the trait name
// out of the body copy below.
_titleLabel = new Label { ThemeTypeVariation = "H3" };
nameRow.AddChild(_titleLabel); nameRow.AddChild(_titleLabel);
_tagLabel = new Label { Visible = false }; // Mono uppercase tag label, vertically centred against the title.
_tagLabel = new Label
{
Visible = false,
ThemeTypeVariation = "Eyebrow",
VerticalAlignment = VerticalAlignment.Center,
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
};
nameRow.AddChild(_tagLabel); nameRow.AddChild(_tagLabel);
_descLabel = new Label _descLabel = new Label
{ {
AutowrapMode = TextServer.AutowrapMode.WordSmart, AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(220, 0), CustomMinimumSize = new Vector2(220, 0),
ThemeTypeVariation = "CardBody",
}; };
v.AddChild(_descLabel); v.AddChild(_descLabel);
} }
@@ -91,10 +116,18 @@ public partial class PopoverLayer : CanvasLayer
_tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant() _tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
: (detriment ? "DETRIMENT" : ""); : (detriment ? "DETRIMENT" : "");
// M6.3 default-theme tint: detriment popover gets a red modulate so // Detriment popover swaps to the seal-bordered stylebox via
// it reads visually distinct from a regular trait. The proper // override; non-detriment clears the override so the default
// codex StyleBox swap lands in the theming pass. // CodexPopover panel takes effect again.
_popup.Modulate = detriment ? new Color(1f, 0.78f, 0.78f) : Colors.White; if (detriment && _popup.HasThemeStylebox("panel_detriment", "CodexPopover"))
{
var box = _popup.GetThemeStylebox("panel_detriment", "CodexPopover");
_popup.AddThemeStyleboxOverride("panel", box);
}
else
{
_popup.RemoveThemeStyleboxOverride("panel");
}
_popup.Visible = true; _popup.Visible = true;
_popup.ResetSize(); _popup.ResetSize();
+12 -2
View File
@@ -106,12 +106,19 @@ public static class CodexTheme
cardSelected.ShadowOffset = new Vector2(0, 14); cardSelected.ShadowOffset = new Vector2(0, 14);
theme.SetStylebox("panel_selected", "Card", cardSelected); theme.SetStylebox("panel_selected", "Card", cardSelected);
// Popover frame — gild border + soft shadow. Matches .trait-hint. // Popover frame — gild border + soft shadow + rounded corners.
// Matches .trait-hint, with a softer corner radius than the rest of
// the codex (cards/buttons use 2px sharp) so the floating reveal
// reads as a friendlier secondary surface.
theme.SetTypeVariation("CodexPopover", "PanelContainer"); theme.SetTypeVariation("CodexPopover", "PanelContainer");
var popover = new StyleBoxFlat var popover = new StyleBoxFlat
{ {
BgColor = p.Bg2, BgColor = p.Bg2,
BorderColor = p.Gild, BorderColor = p.Gild,
CornerRadiusTopLeft = 14,
CornerRadiusTopRight = 14,
CornerRadiusBottomLeft = 14,
CornerRadiusBottomRight = 14,
ContentMarginLeft = 16, ContentMarginLeft = 16,
ContentMarginRight = 16, ContentMarginRight = 16,
ContentMarginTop = 14, ContentMarginTop = 14,
@@ -120,11 +127,14 @@ public static class CodexTheme
ShadowSize = 18, ShadowSize = 18,
ShadowOffset = new Vector2(0, 12), ShadowOffset = new Vector2(0, 12),
}; };
popover.SetBorderWidthAll(1); popover.SetBorderWidthAll(2);
theme.SetStylebox("panel", "CodexPopover", popover); theme.SetStylebox("panel", "CodexPopover", popover);
// Detriment swap — seal-red border drawn at 3px so the warning reads
// unambiguously against the parchment bg even at a glance.
var popoverDetriment = (StyleBoxFlat)popover.Duplicate(); var popoverDetriment = (StyleBoxFlat)popover.Duplicate();
popoverDetriment.BorderColor = p.Seal; popoverDetriment.BorderColor = p.Seal;
popoverDetriment.SetBorderWidthAll(3);
theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment); theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment);
// Pill — small trait/skill chip. Mirrors .trait-chips .t-name from // Pill — small trait/skill chip. Mirrors .trait-chips .t-name from