M6.3: Trait popovers — shared PopoverLayer + TraitChip triggers

Per GODOT_PORTING_GUIDE.md §6 (and §12 build order — popovers before
the easy card-grid steps because traits/skills/bonuses surface them
everywhere). One reusable popover panel; lightweight chip triggers.

Scenes/Widgets/PopoverLayer.cs:
  CanvasLayer added once as a child of Wizard.tscn. Owns one
  PanelContainer + close Timer; static Instance for chip-side access.
  ShowFor(trigger, ...) populates and positions the popover at the
  trigger's global rect with viewport clamp + flip-above logic
  (mirrors src/trait-hint.jsx). 80 ms grace period when moving from
  trigger to popover so the popover stays open across the gap.
  Detriment popovers get a red Modulate as a placeholder for the
  seal-coloured StyleBox the theming pass will install.

Scenes/Widgets/TraitChip.cs:
  Lightweight PanelContainer + Label trigger. On MouseEntered asks
  PopoverLayer.Instance to show; on MouseExited schedules close.
  Pill styling deferred to theming (default Godot panel for now;
  TraitChip / TraitChipDetriment styleboxes will land alongside
  the parchment Theme pass).

Wizard.tscn:
  PopoverLayer added as a top-level CanvasLayer child so popovers
  float above every step's content regardless of where the trigger
  is in the tree.

Steps/StepClade.cs:
  Replaces the placeholder "{n} traits, {m} detriments" line with an
  HFlowContainer of TraitChip per trait + per detriment. Hover any
  chip → popover shows name + description (+ DETRIMENT tag for the
  detriment chips).

  Also: cards switched from Button to PanelContainer for content-
  driven height. Button isn't a Container, so its intrinsic min
  size didn't aggregate from the inner vbox — at higher trait
  counts the chips overflowed into the cards below. PanelContainer
  is a Container, so the card grows with its content. GuiInput
  handles the click-to-select; selected state shown via Modulate
  tint until the proper StyleBox swap lands in theming.

Closes M6.3. Per guide §12, next is M6.4 — easy card-grid steps
(Species / Calling / Subclass / History) variations on the StepClade
pattern, then StepSkills, then StepReview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-02 20:57:02 -07:00
parent 4d3db17a89
commit ba3ebe7ff3
4 changed files with 244 additions and 48 deletions
+53 -47
View File
@@ -1,5 +1,6 @@
using Godot;
using Theriapolis.Core.Data;
using Theriapolis.GodotHost.Scenes.Widgets;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes.Steps;
@@ -69,55 +70,41 @@ public partial class StepClade : VBoxContainer, IStep
{
bool selected = _draft.CladeId == clade.Id;
var btn = new Button
// 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.
var card = new PanelContainer
{
Text = "",
Flat = false,
ToggleMode = true,
ButtonPressed = selected,
FocusMode = Control.FocusModeEnum.None,
// 200 wide so 3 cards + separators (≈ 632) + page margins +
// Aside fit inside ≥ 1024-px viewports (the smaller-screen
// floor we want to support). Height held at 200 so the inner
// labels render at readable size without a content-driven
// height collapse (Button isn't a Container, so child vbox
// height doesn't bubble up to the button's intrinsic min size).
CustomMinimumSize = new Vector2(200, 200),
ClipText = false,
Alignment = HorizontalAlignment.Left,
CustomMinimumSize = new Vector2(200, 0),
MouseFilter = MouseFilterEnum.Stop,
};
btn.Pressed += () =>
if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); // gild tint placeholder until theming
card.GuiInput += (InputEvent e) =>
{
// 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))
if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
{
speciesId = s.Id;
break;
// 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 },
});
}
_draft.Patch(new Godot.Collections.Dictionary
{
{ "clade_id", clade.Id },
{ "species_id", speciesId },
});
};
// Label content stacked inside the button via an anchored VBoxContainer
// (Button isn't a Container, so we anchor the vbox to fill the button's
// rect and let the children flow within it).
var box = new VBoxContainer
{
MouseFilter = MouseFilterEnum.Ignore,
};
box.AnchorRight = 1f;
box.AnchorBottom = 1f;
box.OffsetLeft = 12;
box.OffsetTop = 12;
box.OffsetRight = -12;
box.OffsetBottom = -12;
var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
box.AddThemeConstantOverride("separation", 6);
btn.AddChild(box);
card.AddChild(box);
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
box.AddChild(new Label
@@ -139,15 +126,34 @@ public partial class StepClade : VBoxContainer, IStep
});
}
if (clade.Traits.Length > 0)
if (clade.Traits.Length > 0 || clade.Detriments.Length > 0)
{
box.AddChild(new Label
var chips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass };
chips.AddThemeConstantOverride("h_separation", 6);
chips.AddThemeConstantOverride("v_separation", 4);
box.AddChild(chips);
foreach (var t in clade.Traits)
{
Text = $"{clade.Traits.Length} traits, {clade.Detriments.Length} detriments",
MouseFilter = MouseFilterEnum.Ignore,
});
var chip = new TraitChip
{
TraitName = t.Name,
Description = t.Description,
};
chips.AddChild(chip);
}
foreach (var d in clade.Detriments)
{
var chip = new TraitChip
{
TraitName = d.Name,
Description = d.Description,
Detriment = true,
};
chips.AddChild(chip);
}
}
return btn;
return card;
}
}