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:
@@ -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,24 +70,21 @@ 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) =>
|
||||
{
|
||||
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.
|
||||
@@ -101,23 +99,12 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
{ "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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Godot;
|
||||
|
||||
namespace Theriapolis.GodotHost.Scenes.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Shared overlay layer that owns one reusable trait popover panel.
|
||||
/// Per GODOT_PORTING_GUIDE.md §6.1 — TraitChip triggers ask
|
||||
/// <see cref="Instance"/> to show the popover at their global rect; the
|
||||
/// popover stays open while either the trigger or the popover itself is
|
||||
/// hovered (80 ms grace via close timer).
|
||||
///
|
||||
/// One PopoverLayer per scene; lives as a CanvasLayer child of
|
||||
/// Wizard.tscn so popovers float above every step's content. Mirrors
|
||||
/// <c>src/trait-hint.jsx</c>'s viewport-clamp / flip-above behaviour.
|
||||
/// </summary>
|
||||
public partial class PopoverLayer : CanvasLayer
|
||||
{
|
||||
public static PopoverLayer? Instance { get; private set; }
|
||||
|
||||
private const float GracePeriodSec = 0.08f;
|
||||
private const float ArrowOffsetPx = 6f;
|
||||
private const int ViewportPadPx = 8;
|
||||
|
||||
private PanelContainer _popup = null!;
|
||||
private Label _titleLabel = null!;
|
||||
private Label _tagLabel = null!;
|
||||
private Label _descLabel = null!;
|
||||
private Timer _closeTimer = null!;
|
||||
|
||||
public override void _EnterTree()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
Layer = 100;
|
||||
BuildPopover();
|
||||
}
|
||||
|
||||
private void BuildPopover()
|
||||
{
|
||||
_popup = new PanelContainer
|
||||
{
|
||||
Visible = false,
|
||||
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||
ZIndex = 100,
|
||||
};
|
||||
_popup.MouseEntered += CancelClose;
|
||||
_popup.MouseExited += ScheduleClose;
|
||||
AddChild(_popup);
|
||||
|
||||
var v = new VBoxContainer { CustomMinimumSize = new Vector2(240, 0) };
|
||||
v.AddThemeConstantOverride("separation", 6);
|
||||
_popup.AddChild(v);
|
||||
|
||||
var nameRow = new HBoxContainer();
|
||||
nameRow.AddThemeConstantOverride("separation", 8);
|
||||
v.AddChild(nameRow);
|
||||
|
||||
_titleLabel = new Label();
|
||||
nameRow.AddChild(_titleLabel);
|
||||
|
||||
_tagLabel = new Label { Visible = false };
|
||||
nameRow.AddChild(_tagLabel);
|
||||
|
||||
_descLabel = new Label
|
||||
{
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
CustomMinimumSize = new Vector2(220, 0),
|
||||
};
|
||||
v.AddChild(_descLabel);
|
||||
|
||||
_closeTimer = new Timer { OneShot = true, WaitTime = GracePeriodSec };
|
||||
_closeTimer.Timeout += HidePopover;
|
||||
AddChild(_closeTimer);
|
||||
}
|
||||
|
||||
public void ShowFor(Control trigger, string title, string description, string tag, bool detriment)
|
||||
{
|
||||
CancelClose();
|
||||
_titleLabel.Text = title;
|
||||
_descLabel.Text = description;
|
||||
_tagLabel.Visible = !string.IsNullOrEmpty(tag) || detriment;
|
||||
_tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
|
||||
: (detriment ? "DETRIMENT" : "");
|
||||
|
||||
// M6.3 default-theme tint: detriment popover gets a red modulate so
|
||||
// it reads visually distinct from a regular trait. The proper
|
||||
// codex StyleBox swap lands in the theming pass.
|
||||
_popup.Modulate = detriment ? new Color(1f, 0.78f, 0.78f) : Colors.White;
|
||||
|
||||
_popup.Visible = true;
|
||||
_popup.ResetSize();
|
||||
Reposition(trigger);
|
||||
}
|
||||
|
||||
public void ScheduleClose() => _closeTimer.Start();
|
||||
public void CancelClose() => _closeTimer.Stop();
|
||||
|
||||
private void HidePopover() => _popup.Visible = false;
|
||||
|
||||
private void Reposition(Control trigger)
|
||||
{
|
||||
var trigRect = trigger.GetGlobalRect();
|
||||
var popSize = _popup.GetCombinedMinimumSize();
|
||||
var vp = trigger.GetViewportRect().Size;
|
||||
|
||||
// Default: under trigger, left-aligned with trigger.
|
||||
float left = trigRect.Position.X;
|
||||
float top = trigRect.End.Y + ArrowOffsetPx;
|
||||
|
||||
// Flip above if no room below and there's room above.
|
||||
if (top + popSize.Y + ViewportPadPx > vp.Y &&
|
||||
trigRect.Position.Y - ArrowOffsetPx - popSize.Y >= ViewportPadPx)
|
||||
{
|
||||
top = trigRect.Position.Y - ArrowOffsetPx - popSize.Y;
|
||||
}
|
||||
|
||||
// Clamp horizontally + vertically to keep popover in-viewport.
|
||||
left = Mathf.Clamp(left, ViewportPadPx, vp.X - popSize.X - ViewportPadPx);
|
||||
top = Mathf.Clamp(top, ViewportPadPx, vp.Y - popSize.Y - ViewportPadPx);
|
||||
_popup.Position = new Vector2(left, top);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Godot;
|
||||
|
||||
namespace Theriapolis.GodotHost.Scenes.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Small chip widget. The hover trigger for a trait/skill/detriment.
|
||||
/// Per GODOT_PORTING_GUIDE.md §6.2: on mouse-enter, asks the shared
|
||||
/// <see cref="PopoverLayer"/> to show the popover at this chip's global
|
||||
/// rect; on exit, schedules close (popover stays alive if the cursor
|
||||
/// then enters the popover itself).
|
||||
///
|
||||
/// Lightweight — every clade trait list, every skill row, every bonus
|
||||
/// pill spawns several of these. The actual popover panel is a single
|
||||
/// reused instance owned by PopoverLayer, not one per chip.
|
||||
/// </summary>
|
||||
public partial class TraitChip : PanelContainer
|
||||
{
|
||||
[Export] public string TraitName { get; set; } = "";
|
||||
[Export] public string Description { get; set; } = "";
|
||||
[Export] public string Tag { get; set; } = "";
|
||||
[Export] public bool Detriment { get; set; }
|
||||
|
||||
private Label _label = null!;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Stop;
|
||||
_label = new Label
|
||||
{
|
||||
Text = TraitName,
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
};
|
||||
AddChild(_label);
|
||||
MouseEntered += OnHoverEntered;
|
||||
MouseExited += OnHoverExited;
|
||||
}
|
||||
|
||||
public void SetTrait(string name, string description, string tag = "", bool detriment = false)
|
||||
{
|
||||
TraitName = name;
|
||||
Description = description;
|
||||
Tag = tag;
|
||||
Detriment = detriment;
|
||||
if (_label is not null) _label.Text = name;
|
||||
}
|
||||
|
||||
private void OnHoverEntered()
|
||||
{
|
||||
PopoverLayer.Instance?.ShowFor(this, TraitName, Description, Tag, Detriment);
|
||||
}
|
||||
|
||||
private void OnHoverExited()
|
||||
{
|
||||
PopoverLayer.Instance?.ScheduleClose();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://wizard6m6v1"]
|
||||
[gd_scene load_steps=5 format=3 uid="uid://wizard6m6v1"]
|
||||
|
||||
[ext_resource type="Script" path="res://Scenes/Wizard.cs" id="1_wizard"]
|
||||
[ext_resource type="Script" path="res://UI/Widgets/CodexStepper.cs" id="2_stepper"]
|
||||
[ext_resource type="PackedScene" path="res://Scenes/Aside.tscn" id="3_aside"]
|
||||
[ext_resource type="Script" path="res://Scenes/Widgets/PopoverLayer.cs" id="4_popover"]
|
||||
|
||||
[node name="Wizard" type="Control"]
|
||||
anchors_preset = 15
|
||||
@@ -85,3 +86,6 @@ text = "1 / 8"
|
||||
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
|
||||
unique_name_in_owner = true
|
||||
text = "Next ›"
|
||||
|
||||
[node name="PopoverLayer" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("4_popover")
|
||||
|
||||
Reference in New Issue
Block a user