diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index b6f1c8f..533113d 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -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; } } diff --git a/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs b/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs new file mode 100644 index 0000000..288b5f5 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs @@ -0,0 +1,130 @@ +using Godot; + +namespace Theriapolis.GodotHost.Scenes.Widgets; + +/// +/// Shared overlay layer that owns one reusable trait popover panel. +/// Per GODOT_PORTING_GUIDE.md §6.1 — TraitChip triggers ask +/// 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 +/// src/trait-hint.jsx's viewport-clamp / flip-above behaviour. +/// +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); + } +} diff --git a/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs b/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs new file mode 100644 index 0000000..44ccf0e --- /dev/null +++ b/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs @@ -0,0 +1,56 @@ +using Godot; + +namespace Theriapolis.GodotHost.Scenes.Widgets; + +/// +/// Small chip widget. The hover trigger for a trait/skill/detriment. +/// Per GODOT_PORTING_GUIDE.md §6.2: on mouse-enter, asks the shared +/// 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. +/// +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(); + } +} diff --git a/Theriapolis.Godot/Scenes/Wizard.tscn b/Theriapolis.Godot/Scenes/Wizard.tscn index 458b4e2..153ff83 100644 --- a/Theriapolis.Godot/Scenes/Wizard.tscn +++ b/Theriapolis.Godot/Scenes/Wizard.tscn @@ -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")