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:
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user