using Godot; using System.Collections.Generic; using Theriapolis.GodotHost.Scenes.Widgets; using Theriapolis.GodotHost.UI; namespace Theriapolis.GodotHost.Scenes.Steps; /// /// Step VII — Skills. Direct port of StepSkills in /// src/steps.jsx: 18 skills laid out in 6 ability groups /// (STR / DEX / CON / INT / WIS / CHA). Background-granted skills are /// pre-checked and locked; the user picks class.SkillsChoose /// more from class.SkillOptions. Hover any skill row → popover /// with the description. /// /// State: /// - "locked" → granted by background, can't toggle (✓ shown) /// - "checked" → user-picked from class options (✓ shown) /// - "available" → in class options, unchecked /// - "unavailable" → not in class options, dimmed and unclickable /// public partial class StepSkills : VBoxContainer, IStep { private CharacterDraft _draft = null!; private Label _countLabel = null!; private GridContainer _groupsGrid = null!; public void Bind(CharacterDraft draft) { _draft = draft; _draft.Changed += () => Callable.From(Refresh).CallDeferred(); Build(); } public string? Validate() => WizardValidation.Validate(6, _draft); private void Build() { AddThemeConstantOverride("separation", 16); var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); intro.AddChild(new Label { Text = "FOLIO VII · SKILLS", ThemeTypeVariation = "Eyebrow" }); intro.AddChild(new Label { Text = "Choose Your Skills", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Your background grants two skills automatically (sealed). From your " + "calling's offered list, choose the rest. Hover any skill for its codex " + "reading.", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); _countLabel = new Label { Text = "0 / 0 chosen", ThemeTypeVariation = "Meta" }; AddChild(_countLabel); _groupsGrid = new GridContainer { Columns = 2, SizeFlagsHorizontal = SizeFlags.ExpandFill, }; _groupsGrid.AddThemeConstantOverride("h_separation", 18); _groupsGrid.AddThemeConstantOverride("v_separation", 18); AddChild(_groupsGrid); Refresh(); } private void Refresh() { if (_groupsGrid is null) return; var cls = CodexContent.Class(_draft.ClassId); var bg = CodexContent.Background(_draft.BackgroundId); int required = cls?.SkillsChoose ?? 0; var lockedFromBg = new HashSet(bg?.SkillProficiencies ?? System.Array.Empty()); var classOptions = new HashSet(cls?.SkillOptions ?? System.Array.Empty()); var chosen = new HashSet(_draft.ChosenSkills); _countLabel.Text = cls is null ? "Pick a calling first." : $"{chosen.Count} / {required} chosen +{lockedFromBg.Count} sealed by background"; foreach (var c in _groupsGrid.GetChildren()) c.QueueFree(); if (cls is null) return; foreach (var ability in SkillsCatalog.Abilities) _groupsGrid.AddChild(BuildAbilityGroup(ability, lockedFromBg, classOptions, chosen, required)); } private Control BuildAbilityGroup(string ability, HashSet lockedFromBg, HashSet classOptions, HashSet chosen, int required) { var panel = new PanelContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill, ThemeTypeVariation = "Card", }; var col = new VBoxContainer(); col.AddThemeConstantOverride("separation", 4); panel.AddChild(col); // Header — full ability name + abbreviation. var header = new HBoxContainer(); header.AddThemeConstantOverride("separation", 8); col.AddChild(header); header.AddChild(new Label { Text = SkillsCatalog.AbilityFullName[ability], ThemeTypeVariation = "H3" }); var spacer = new Control { SizeFlagsHorizontal = SizeFlags.ExpandFill }; header.AddChild(spacer); header.AddChild(new Label { Text = ability, ThemeTypeVariation = "Eyebrow" }); foreach (var s in SkillsCatalog.ByAbility(ability)) col.AddChild(BuildSkillRow(s, lockedFromBg, classOptions, chosen, required)); return panel; } private Control BuildSkillRow(SkillsCatalog.SkillEntry skill, HashSet lockedFromBg, HashSet classOptions, HashSet chosen, int required) { bool fromBg = lockedFromBg.Contains(skill.JsonId); bool fromClass = classOptions.Contains(skill.JsonId); bool isChecked = chosen.Contains(skill.JsonId); bool clickable = fromClass && !fromBg; var row = new PanelContainer { MouseFilter = MouseFilterEnum.Stop, ThemeTypeVariation = "SkillRow", }; // Visual state: dim unavailable rows, gild-tint background-locked, // brighten chosen. Theming pass will replace Modulate with proper // styleboxes per state. if (fromBg) row.Modulate = new Color(1f, 0.95f, 0.7f); else if (!fromClass) row.Modulate = new Color(1f, 1f, 1f, 0.4f); else if (isChecked) row.Modulate = new Color(0.95f, 1f, 0.95f); var rowH = new HBoxContainer { MouseFilter = MouseFilterEnum.Pass }; rowH.AddThemeConstantOverride("separation", 8); row.AddChild(rowH); // Fixed-width slot for the checkbox so the name doesn't jump // horizontally when toggling — "[✓]" and "[ ]" render at slightly // different widths in proportional fonts. var checkbox = new Label { Text = (fromBg || isChecked) ? "[✓]" : (clickable ? "[ ]" : "[—]"), CustomMinimumSize = new Vector2(36, 0), HorizontalAlignment = HorizontalAlignment.Center, MouseFilter = MouseFilterEnum.Ignore, }; rowH.AddChild(checkbox); // Skill name — the only hover trigger for the popover. MouseFilter // = Stop so MouseEntered/Exited fire on this label specifically; // GuiInput handles click here too so toggling works whether you // click the name or the row's other areas. var nameLabel = new Label { Text = skill.Label, SizeFlagsHorizontal = SizeFlags.ExpandFill, MouseFilter = MouseFilterEnum.Stop, }; rowH.AddChild(nameLabel); nameLabel.MouseEntered += () => PopoverLayer.Instance?.ShowFor(nameLabel, skill.Label, skill.Description, skill.Ability, false); nameLabel.MouseExited += () => PopoverLayer.Instance?.ScheduleClose(); if (clickable) { nameLabel.GuiInput += (InputEvent e) => { if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) Toggle(skill.JsonId, isChecked, required); }; } rowH.AddChild(new Label { Text = fromBg ? "BG" : (fromClass ? "CLASS" : "—"), MouseFilter = MouseFilterEnum.Ignore, }); // Click anywhere else on the row → also toggles. Hover here doesn't // trigger the popover; only the name label does. if (clickable) { row.GuiInput += (InputEvent e) => { if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) Toggle(skill.JsonId, isChecked, required); }; } return row; } private void Toggle(string skillId, bool isCurrentlyChecked, int required) { var newChosen = new Godot.Collections.Array(_draft.ChosenSkills); if (isCurrentlyChecked) { newChosen.Remove(skillId); } else if (newChosen.Count < required) { newChosen.Add(skillId); } else { return; // already at the cap; no-op } _draft.Patch(new Godot.Collections.Dictionary { { "chosen_skills", newChosen }, }); } }