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 Godot;
|
||||||
using Theriapolis.Core.Data;
|
using Theriapolis.Core.Data;
|
||||||
|
using Theriapolis.GodotHost.Scenes.Widgets;
|
||||||
using Theriapolis.GodotHost.UI;
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
namespace Theriapolis.GodotHost.Scenes.Steps;
|
namespace Theriapolis.GodotHost.Scenes.Steps;
|
||||||
@@ -69,24 +70,21 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
{
|
{
|
||||||
bool selected = _draft.CladeId == clade.Id;
|
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 = "",
|
CustomMinimumSize = new Vector2(200, 0),
|
||||||
Flat = false,
|
MouseFilter = MouseFilterEnum.Stop,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
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:
|
// Default species for the new clade — match React app.jsx:
|
||||||
// when clade changes, species defaults to first species in clade.
|
// when clade changes, species defaults to first species in clade.
|
||||||
@@ -101,23 +99,12 @@ public partial class StepClade : VBoxContainer, IStep
|
|||||||
{ "clade_id", clade.Id },
|
{ "clade_id", clade.Id },
|
||||||
{ "species_id", speciesId },
|
{ "species_id", speciesId },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Label content stacked inside the button via an anchored VBoxContainer
|
var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
|
||||||
// (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;
|
|
||||||
box.AddThemeConstantOverride("separation", 6);
|
box.AddThemeConstantOverride("separation", 6);
|
||||||
btn.AddChild(box);
|
card.AddChild(box);
|
||||||
|
|
||||||
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
|
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
|
||||||
box.AddChild(new Label
|
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",
|
var chip = new TraitChip
|
||||||
MouseFilter = MouseFilterEnum.Ignore,
|
{
|
||||||
});
|
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://Scenes/Wizard.cs" id="1_wizard"]
|
||||||
[ext_resource type="Script" path="res://UI/Widgets/CodexStepper.cs" id="2_stepper"]
|
[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="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"]
|
[node name="Wizard" type="Control"]
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
@@ -85,3 +86,6 @@ text = "1 / 8"
|
|||||||
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
|
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
text = "Next ›"
|
text = "Next ›"
|
||||||
|
|
||||||
|
[node name="PopoverLayer" type="CanvasLayer" parent="."]
|
||||||
|
script = ExtResource("4_popover")
|
||||||
|
|||||||
Reference in New Issue
Block a user