diff --git a/Theriapolis.Godot/Scenes/Aside.cs b/Theriapolis.Godot/Scenes/Aside.cs index 8b6e62b..a7553e0 100644 --- a/Theriapolis.Godot/Scenes/Aside.cs +++ b/Theriapolis.Godot/Scenes/Aside.cs @@ -1,14 +1,22 @@ using Godot; +using System.Collections.Generic; +using Theriapolis.GodotHost.Scenes.Widgets; using Theriapolis.GodotHost.UI; namespace Theriapolis.GodotHost.Scenes; /// -/// Right-rail summary of the in-progress character. Single -/// rebuilds every section per -/// GODOT_PORTING_GUIDE.md §10 — the panel is small enough that full -/// rebuild is cheap and partial-update logic isn't worth it. Connect -/// the draft via ; the Wizard does this on _Ready. +/// Right-rail summary of the in-progress character. Sections, top-down: +/// 1. Name (or placeholder until Step VIII). +/// 2. Lineage details — 2-column grid: +/// purebred: Clade | Species, then Calling | Background. +/// hybrid: Sire | Dam (column headers); each parent's +/// clade and species below; Calling | Background. +/// 3. Attributes — final score + d20 modifier per ability. +/// 4. Pills — traits + skills selected so far, with hover popovers. +/// +/// One Refresh() rebuild per draft change; the panel is small enough +/// that partial-update logic isn't worth the complexity. /// public partial class Aside : MarginContainer { @@ -22,9 +30,21 @@ public partial class Aside : MarginContainer AddThemeConstantOverride("margin_top", 18); AddThemeConstantOverride("margin_bottom", 18); - _content = new VBoxContainer(); + // Wrap content in a ScrollContainer so the Aside's intrinsic + // height stays bounded by the panel's allocated size — without + // this, an over-tall summary (lots of pills) forces the parent + // Layout to expand and pushes the navbar off the viewport. + var scroll = new ScrollContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + }; + AddChild(scroll); + + _content = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; _content.AddThemeConstantOverride("separation", 18); - AddChild(_content); + scroll.AddChild(_content); } public void SetDraft(CharacterDraft draft) @@ -39,42 +59,277 @@ public partial class Aside : MarginContainer if (_draft is null || _content is null) return; foreach (var c in _content.GetChildren()) c.QueueFree(); - _content.AddChild(new Label { Text = "SUMMARY" }); + BuildName(); + BuildDetailsGrid(); + BuildAttributes(); + BuildPills(); + } - if (_draft.IsHybrid) + // ────────────────────────────────────────────────────────────────────── + // Section 1 — Name + + private void BuildName() + { + var name = string.IsNullOrEmpty(_draft!.CharacterName) ? "Unnamed" : _draft.CharacterName; + _content.AddChild(new Label { - AddBlock("Origin", "Hybrid"); - AddBlock(_draft.DominantParent == "sire" ? "Sire (dominant)" : "Sire", - FormatLineage(_draft.SireCladeId, _draft.SireSpeciesId)); - AddBlock(_draft.DominantParent == "dam" ? "Dam (dominant)" : "Dam", - FormatLineage(_draft.DamCladeId, _draft.DamSpeciesId)); + Text = name, + HorizontalAlignment = HorizontalAlignment.Center, + }); + _content.AddChild(new HSeparator()); + } + + // ────────────────────────────────────────────────────────────────────── + // Section 2 — Lineage details (2-column grid) + + private void BuildDetailsGrid() + { + if (_draft!.IsHybrid) + { + // Hybrid layout: SIRE / DAM column headers above the parent + // detail rows, then the Calling / Background row spans both + // halves of the same kind of grid. + var headers = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + headers.AddThemeConstantOverride("separation", 12); + _content.AddChild(headers); + headers.AddChild(MakeColumnHeader("SIRE" + (_draft.DominantParent == "sire" ? " ★" : ""))); + headers.AddChild(MakeColumnHeader("DAM" + (_draft.DominantParent == "dam" ? " ★" : ""))); + + var lineageGrid = MakeFullWidthGrid(); + _content.AddChild(lineageGrid); + lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.SireCladeId)?.Name)); + lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.DamCladeId)?.Name)); + lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SireSpeciesId)?.Name)); + lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.DamSpeciesId)?.Name)); } else { - AddBlock("Clade", CodexContent.Clade(_draft.CladeId)?.Name); - AddBlock("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name); + var lineageGrid = MakeFullWidthGrid(); + _content.AddChild(lineageGrid); + lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.CladeId)?.Name)); + lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name)); } - AddBlock("Calling", CodexContent.Class(_draft.ClassId)?.Name); - AddBlock("Background", CodexContent.Background(_draft.BackgroundId)?.Name); - AddBlock("Name", string.IsNullOrEmpty(_draft.CharacterName) ? null : _draft.CharacterName); + // Calling + Background — last row of the lineage block, with + // Subclass tucked underneath Calling in the same column. + var callingGrid = MakeFullWidthGrid(); + _content.AddChild(callingGrid); + callingGrid.AddChild(MakeCell("Calling", CodexContent.Class(_draft.ClassId)?.Name)); + callingGrid.AddChild(MakeCell("Background", CodexContent.Background(_draft.BackgroundId)?.Name)); + + var subclassDef = System.Array.Find(CodexContent.Subclasses, s => s.Id == _draft.SubclassId); + callingGrid.AddChild(MakeCell("Subclass", subclassDef?.Name)); + callingGrid.AddChild(new Control()); // empty cell to align grid + + _content.AddChild(new HSeparator()); } - private static string? FormatLineage(string cladeId, string speciesId) + private static GridContainer MakeFullWidthGrid() { - var clade = CodexContent.Clade(cladeId); - var species = CodexContent.SpeciesById(speciesId); - if (clade is null && species is null) return null; - if (species is not null) return $"{species.Name} ({clade?.Name ?? cladeId})"; - return clade?.Name; + var grid = new GridContainer { Columns = 2, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + grid.AddThemeConstantOverride("h_separation", 12); + grid.AddThemeConstantOverride("v_separation", 8); + return grid; } - private void AddBlock(string label, string? value) + private static Control MakeColumnHeader(string label) { - var v = new VBoxContainer(); - v.AddThemeConstantOverride("separation", 4); - _content.AddChild(v); - v.AddChild(new Label { Text = label.ToUpperInvariant() }); - v.AddChild(new Label { Text = value ?? "—" }); + // Centered label + underline; sized to take half the parent + // HBoxContainer width so SIRE and DAM align over their data + // columns. + var col = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + col.AddThemeConstantOverride("separation", 2); + col.AddChild(new Label + { + Text = label, + HorizontalAlignment = HorizontalAlignment.Center, + }); + col.AddChild(new HSeparator()); + return col; + } + + private static Control MakeCell(string label, string? value) + { + var v = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + v.AddThemeConstantOverride("separation", 2); + + // Smaller font on the label tag — keeps the row compact in the + // narrow side rail. + var lbl = new Label { Text = label.ToUpperInvariant() }; + lbl.AddThemeFontSizeOverride("font_size", 11); + v.AddChild(lbl); + + // Autowrap on the value so long names ("Hybrid Underground") + // wrap rather than push the whole panel wider than its alloc. + var val = new Label + { + Text = string.IsNullOrEmpty(value) ? "—" : value, + AutowrapMode = TextServer.AutowrapMode.WordSmart, + CustomMinimumSize = new Vector2(0, 0), + }; + v.AddChild(val); + return v; + } + + // ────────────────────────────────────────────────────────────────────── + // Section 3 — Attributes (final score + modifier) + + private void BuildAttributes() + { + _content.AddChild(new Label { Text = "ATTRIBUTES" }); + + // Self-contained sub-panel so the attributes table never widens + // beyond the Aside's own rect. Columns: ab | bonus | final | d20. + // Cells have explicit min widths but DON'T ExpandFill, so the + // grid's total width is bounded by the cell mins instead of + // taking whatever parent width is available. + var grid = new GridContainer { Columns = 4 }; + grid.AddThemeConstantOverride("h_separation", 6); + grid.AddThemeConstantOverride("v_separation", 6); + _content.AddChild(grid); + + foreach (var ab in SkillsCatalog.Abilities) + { + int baseScore = AbilityCalc.BaseScore(ab, _draft!); + int bonus = AbilityCalc.TotalBonus(ab, _draft!); + int final = baseScore + bonus; + int dMod = AbilityCalc.D20Modifier(final); + + grid.AddChild(new Label + { + Text = ab, + CustomMinimumSize = new Vector2(36, 0), + }); + + // Bonus chip — only render when non-zero. +0 entries get an + // empty Control so the column stays aligned without the + // panel chrome of an empty TraitChip pushing the row wider. + if (bonus != 0) + { + grid.AddChild(new Widgets.TraitChip + { + TraitName = AbilityCalc.FormatSigned(bonus), + Description = AbilityCalc.FormatBreakdown(AbilityCalc.Sources(ab, _draft!)), + Tag = "bonus", + }); + } + else + { + grid.AddChild(new Control { CustomMinimumSize = new Vector2(40, 0) }); + } + + grid.AddChild(new Label + { + Text = baseScore == 0 ? "—" : final.ToString(), + HorizontalAlignment = HorizontalAlignment.Right, + CustomMinimumSize = new Vector2(36, 0), + }); + + grid.AddChild(new Label + { + Text = baseScore == 0 ? "" : AbilityCalc.FormatSigned(dMod), + HorizontalAlignment = HorizontalAlignment.Right, + CustomMinimumSize = new Vector2(36, 0), + }); + } + + _content.AddChild(new HSeparator()); + } + + // ────────────────────────────────────────────────────────────────────── + // Section 4 — Pills (traits, skills, features chosen so far) + + private void BuildPills() + { + _content.AddChild(new Label { Text = "TRAITS · FEATS · SKILLS" }); + + var flow = new HFlowContainer(); + flow.AddThemeConstantOverride("h_separation", 6); + flow.AddThemeConstantOverride("v_separation", 6); + _content.AddChild(flow); + + // Clade traits (purebred = single clade; hybrid = both). + if (_draft!.IsHybrid) + { + AddCladeTraits(flow, CodexContent.Clade(_draft.SireCladeId)); + AddCladeTraits(flow, CodexContent.Clade(_draft.DamCladeId)); + } + else + { + AddCladeTraits(flow, CodexContent.Clade(_draft.CladeId)); + } + + // Species traits. + var sp = CodexContent.SpeciesById(_draft.EffectiveSpeciesId); + if (sp is not null) + { + foreach (var t in sp.Traits) + flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); + foreach (var d in sp.Detriments) + flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); + } + + // Class level-1 features. + var cls = CodexContent.Class(_draft.ClassId); + if (cls is not null) + { + var lvl1 = System.Array.Find(cls.LevelTable, e => e.Level == 1); + if (lvl1 is not null) + { + foreach (var fid in lvl1.Features) + { + if (!cls.FeatureDefinitions.TryGetValue(fid, out var fd)) continue; + if (fd.Kind == "stub" || fid.StartsWith("subclass_")) continue; + flow.AddChild(new TraitChip + { + TraitName = fd.Name, + Description = fd.Description, + Tag = fd.Kind, + }); + } + } + } + + // Background feature + granted skills. + var bg = CodexContent.Background(_draft.BackgroundId); + if (bg is not null && !string.IsNullOrEmpty(bg.FeatureName)) + { + flow.AddChild(new TraitChip + { + TraitName = bg.FeatureName, + Description = bg.FeatureDescription, + Tag = "history", + }); + } + + // Skills — background-locked first, then user-chosen class skills. + if (bg is not null) + { + foreach (var skillId in bg.SkillProficiencies) + AddSkillChip(flow, skillId, "BG"); + } + foreach (var skillId in _draft.ChosenSkills) + AddSkillChip(flow, skillId, "skill"); + } + + private static void AddCladeTraits(HFlowContainer flow, Theriapolis.Core.Data.CladeDef? clade) + { + if (clade is null) return; + foreach (var t in clade.Traits) + flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); + foreach (var d in clade.Detriments) + flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); + } + + private static void AddSkillChip(HFlowContainer flow, string skillId, string tag) + { + var s = SkillsCatalog.Get(skillId); + if (s is null) return; + flow.AddChild(new TraitChip + { + TraitName = s.Label, + Description = s.Description, + Tag = tag, + }); } } diff --git a/Theriapolis.Godot/Scenes/Aside.tscn b/Theriapolis.Godot/Scenes/Aside.tscn index 34c42b1..c039459 100644 --- a/Theriapolis.Godot/Scenes/Aside.tscn +++ b/Theriapolis.Godot/Scenes/Aside.tscn @@ -4,6 +4,6 @@ [node name="Aside" type="MarginContainer"] unique_name_in_owner = true -custom_minimum_size = Vector2(320, 0) +custom_minimum_size = Vector2(360, 0) size_flags_horizontal = 0 script = ExtResource("1_aside") diff --git a/Theriapolis.Godot/Scenes/Steps/StepBackground.cs b/Theriapolis.Godot/Scenes/Steps/StepBackground.cs index c0b4991..e8be81a 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepBackground.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepBackground.cs @@ -43,7 +43,7 @@ public partial class StepBackground : VBoxContainer, IStep AutowrapMode = TextServer.AutowrapMode.WordSmart, }); - _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; _grid.AddThemeConstantOverride("h_separation", 16); _grid.AddThemeConstantOverride("v_separation", 16); AddChild(_grid); @@ -51,37 +51,13 @@ public partial class StepBackground : VBoxContainer, IStep Refresh(); } - /// - /// Background-availability rules. The backgrounds.json schema doesn't - /// carry restriction fields — the gating lives in flavor text only — - /// so they're hardcoded here. Each predicate returns true when the - /// background is available to the given draft; missing entries are - /// universally available. - /// - /// If backgrounds.json ever gains structured restriction fields, - /// swap these out for a property-driven check. - /// - private static readonly System.Collections.Generic.Dictionary> AvailabilityRules = new() - { - // Hybrid-only backgrounds — flavor text explicitly hybrid. - { "passer", d => d.IsHybrid }, - { "hybrid_underground", d => d.IsHybrid }, - { "former_chattel", d => d.IsHybrid }, - - // Clade-restricted backgrounds. - { "warren_runner", d => d.HasClade("leporidae") }, - { "pack_raised", d => d.HasClade("canidae") }, - { "herd_city_born", d => d.HasAnyCladeOfKind("prey") }, - }; - private void Refresh() { if (_grid is null) return; foreach (var c in _grid.GetChildren()) c.QueueFree(); foreach (var bg in CodexContent.Backgrounds) { - if (AvailabilityRules.TryGetValue(bg.Id, out var rule) && !rule(_draft)) - continue; + if (!BackgroundAvailability.IsAvailable(bg.Id, _draft)) continue; _grid.AddChild(BuildCard(bg)); } } diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index 8fb4ba5..779df89 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -101,7 +101,7 @@ public partial class StepClade : VBoxContainer, IStep private static GridContainer MakeGrid() { - var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; grid.AddThemeConstantOverride("h_separation", 16); grid.AddThemeConstantOverride("v_separation", 16); return grid; @@ -132,6 +132,7 @@ public partial class StepClade : VBoxContainer, IStep patch["dam_clade_id"] = ""; patch["dam_species_id"] = ""; } + ClearBackgroundIfInvalidated(patch); _draft.Patch(patch); } @@ -172,11 +173,13 @@ public partial class StepClade : VBoxContainer, IStep var sp = CodexContent.SpeciesById(speciesId); if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase)) speciesId = ""; - _draft.Patch(new Godot.Collections.Dictionary + var patch = new Godot.Collections.Dictionary { { "clade_id", cladeId }, { "species_id", speciesId }, - }); + }; + ClearBackgroundIfInvalidated(patch); + _draft.Patch(patch); } private void OnLineageCladePicked(string lineage, string cladeId) @@ -189,9 +192,28 @@ public partial class StepClade : VBoxContainer, IStep var sp = CodexContent.SpeciesById(currentSpecies); if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase)) patch[lineage + "_species_id"] = ""; + ClearBackgroundIfInvalidated(patch); _draft.Patch(patch); } + /// + /// Clade changes can make a previously-valid background unavailable + /// (e.g. picking Felidae while Pack-Raised is selected). Build the + /// hypothetical post-patch state via Resource.Duplicate, run it + /// through BackgroundAvailability, and clear background_id in the + /// patch if the rule no longer matches. + /// + private void ClearBackgroundIfInvalidated(Godot.Collections.Dictionary patch) + { + if (string.IsNullOrEmpty(_draft.BackgroundId)) return; + + var future = (CharacterDraft)_draft.Duplicate(); + // Apply the same patch we're about to commit, in-place on the copy. + future.Patch(patch); + if (!BackgroundAvailability.IsAvailable(_draft.BackgroundId, future)) + patch["background_id"] = ""; + } + private PanelContainer BuildCard(CladeDef clade, System.Action onClick) { var card = new PanelContainer diff --git a/Theriapolis.Godot/Scenes/Steps/StepClass.cs b/Theriapolis.Godot/Scenes/Steps/StepClass.cs index 76ec1d3..ee49233 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClass.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClass.cs @@ -47,7 +47,7 @@ public partial class StepClass : VBoxContainer, IStep AutowrapMode = TextServer.AutowrapMode.WordSmart, }); - _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; _grid.AddThemeConstantOverride("h_separation", 16); _grid.AddThemeConstantOverride("v_separation", 16); AddChild(_grid); diff --git a/Theriapolis.Godot/Scenes/Steps/StepSkills.cs b/Theriapolis.Godot/Scenes/Steps/StepSkills.cs new file mode 100644 index 0000000..72873c4 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Steps/StepSkills.cs @@ -0,0 +1,233 @@ +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 Label _classBgLabel = 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" }); + intro.AddChild(new Label { Text = "Choose Your Skills" }); + 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, + }); + + var meta = new HBoxContainer(); + meta.AddThemeConstantOverride("separation", 24); + AddChild(meta); + _countLabel = new Label { Text = "0 / 0 chosen" }; + meta.AddChild(_countLabel); + var spacer = new Control { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + meta.AddChild(spacer); + _classBgLabel = new Label { Text = "" }; + meta.AddChild(_classBgLabel); + + _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 = $"{chosen.Count} / {required} chosen +{lockedFromBg.Count} sealed by background"; + _classBgLabel.Text = (cls is null ? "Pick a calling first." : + $"Calling: {cls.Name} · History: {bg?.Name ?? "—"}").ToUpperInvariant(); + + 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 }; + 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] }); + var spacer = new Control { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + header.AddChild(spacer); + header.AddChild(new Label { Text = ability }); + + 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, + }; + + // 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 }, + }); + } +} diff --git a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs index e6021b8..14bf1a8 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs @@ -72,7 +72,7 @@ public partial class StepSpecies : VBoxContainer, IStep private static GridContainer MakeGrid() { - var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; grid.AddThemeConstantOverride("h_separation", 16); grid.AddThemeConstantOverride("v_separation", 16); return grid; diff --git a/Theriapolis.Godot/Scenes/Steps/StepStats.cs b/Theriapolis.Godot/Scenes/Steps/StepStats.cs index fc0cad7..6f6da53 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepStats.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepStats.cs @@ -33,6 +33,9 @@ public partial class StepStats : VBoxContainer, IStep private CharacterDraft _draft = null!; private AbilityPool _pool = null!; private readonly Dictionary _slots = new(); + private readonly Dictionary _bonusChips = new(); + private readonly Dictionary _finalLabels = new(); + private readonly Dictionary _dModLabels = new(); private Button _arrayBtn = null!; private Button _rollBtn = null!; private Button _rerollBtn = null!; @@ -125,6 +128,34 @@ public partial class StepStats : VBoxContainer, IStep => HandleSlotDrop(payload, captured); _slots[captured] = slot; row.AddChild(slot); + + // Bonus chip — hover for the per-source breakdown. + var bonus = new TraitChip { TraitName = "+0", Description = "" }; + bonus.CustomMinimumSize = new Vector2(56, 0); + _bonusChips[captured] = bonus; + row.AddChild(bonus); + + // Final score (= base + total bonus). + var finalLbl = new Label + { + Text = "—", + CustomMinimumSize = new Vector2(48, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + }; + _finalLabels[captured] = finalLbl; + row.AddChild(finalLbl); + + // d20 modifier from final score. + var dModLbl = new Label + { + Text = "", + CustomMinimumSize = new Vector2(48, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + }; + _dModLabels[captured] = dModLbl; + row.AddChild(dModLbl); } Refresh(); @@ -161,6 +192,23 @@ public partial class StepStats : VBoxContainer, IStep _pool.AddChild(token); } + // Bonus / final / d20 mod per ability — updated in place from the + // computed mod sources so the row stays stable across drops. + foreach (var ab in Abilities) + { + int bonus = AbilityCalc.TotalBonus(ab, _draft); + int baseScore = AbilityCalc.BaseScore(ab, _draft); + int final = baseScore + bonus; + int dMod = AbilityCalc.D20Modifier(final); + + _bonusChips[ab].SetTrait( + AbilityCalc.FormatSigned(bonus), + AbilityCalc.FormatBreakdown(AbilityCalc.Sources(ab, _draft)), + tag: "bonus"); + _finalLabels[ab].Text = baseScore == 0 ? "—" : final.ToString(); + _dModLabels[ab].Text = baseScore == 0 ? "" : AbilityCalc.FormatSigned(dMod); + } + // Slots: token if assigned, otherwise dash placeholder. foreach (var (ab, slot) in _slots) { @@ -265,12 +313,16 @@ public partial class StepStats : VBoxContainer, IStep { if (_draft.StatPool.Count == 0) return; - // Empty slots, ordered by class primary abilities first. + // Empty slots, ordered DESCENDING by their clade+species bonus — + // highest pool value goes to the ability with the highest bonus + // already coming in, maximising the final score on each. Ties + // broken by class.PrimaryAbility order. var cls = CodexContent.Class(_draft.ClassId); var primary = cls?.PrimaryAbility ?? System.Array.Empty(); var emptyAbilities = Abilities .Where(a => !_draft.StatAssign.ContainsKey(a)) - .OrderBy(a => + .OrderByDescending(a => AbilityCalc.TotalBonus(a, _draft)) + .ThenBy(a => { int idx = System.Array.IndexOf(primary, a); return idx < 0 ? 99 : idx; diff --git a/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs b/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs index 3326a09..b7175a8 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs @@ -50,7 +50,7 @@ public partial class StepSubclass : VBoxContainer, IStep AutowrapMode = TextServer.AutowrapMode.WordSmart, }); - _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; _grid.AddThemeConstantOverride("h_separation", 16); _grid.AddThemeConstantOverride("v_separation", 16); AddChild(_grid); diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs index e50ea2d..c18823f 100644 --- a/Theriapolis.Godot/Scenes/Wizard.cs +++ b/Theriapolis.Godot/Scenes/Wizard.cs @@ -50,7 +50,7 @@ public partial class Wizard : Control typeof(Steps.StepSubclass), // 3 Subclass typeof(Steps.StepBackground), // 4 History typeof(Steps.StepStats), // 5 Abilities - null, // 6 Skills — M6.5 + typeof(Steps.StepSkills), // 6 Skills null, // 7 Sign — M6.6 }; diff --git a/Theriapolis.Godot/UI/AbilityCalc.cs b/Theriapolis.Godot/UI/AbilityCalc.cs new file mode 100644 index 0000000..bf8b79b --- /dev/null +++ b/Theriapolis.Godot/UI/AbilityCalc.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Linq; +using Theriapolis.Core.Data; + +namespace Theriapolis.GodotHost.UI; + +/// +/// Final ability score math — base assignment + clade and species mods. +/// Used by StepStats and Aside to render the score breakdown with a +/// "+N from Canidae · +2 from Wolf" hover popover. +/// +/// For hybrids the lineage contribution comes from BOTH parent clades +/// (each tagged with its lineage in the source label) and the dominant +/// species. Core's CharacterBuilder.TryBuildHybrid is the authority on +/// the final mechanical rules at character-finalize time; this is a +/// preview-display helper. +/// +public static class AbilityCalc +{ + public readonly record struct ModSource(string Label, int Value); + + public static List Sources(string ability, CharacterDraft draft) + { + var list = new List(); + if (draft.IsHybrid) + { + AddCladeSource(list, ability, CodexContent.Clade(draft.SireCladeId), " (sire)"); + AddCladeSource(list, ability, CodexContent.Clade(draft.DamCladeId), " (dam)"); + } + else + { + AddCladeSource(list, ability, CodexContent.Clade(draft.CladeId), ""); + } + + var sp = CodexContent.SpeciesById(draft.EffectiveSpeciesId); + if (sp is not null) AddDictSource(list, ability, sp.Name, sp.AbilityMods); + + return list; + } + + public static int TotalBonus(string ability, CharacterDraft draft) + => Sources(ability, draft).Sum(s => s.Value); + + public static int BaseScore(string ability, CharacterDraft draft) + => draft.StatAssign.ContainsKey(ability) ? (int)draft.StatAssign[ability] : 0; + + public static int FinalScore(string ability, CharacterDraft draft) + => BaseScore(ability, draft) + TotalBonus(ability, draft); + + public static int D20Modifier(int score) => (int)System.Math.Floor((score - 10) / 2.0); + + public static string FormatSigned(int n) => n >= 0 ? $"+{n}" : n.ToString(); + + /// "+1 from Canidae · +2 from Wolf" — empty when no sources. + public static string FormatBreakdown(IEnumerable sources) => + string.Join(" · ", sources.Select(s => $"{FormatSigned(s.Value)} from {s.Label}")); + + private static void AddCladeSource(List list, string ability, CladeDef? clade, string suffix) + { + if (clade is null) return; + AddDictSource(list, ability, clade.Name + suffix, clade.AbilityMods); + } + + private static void AddDictSource(List list, string ability, string sourceName, Dictionary mods) + { + if (mods.TryGetValue(ability, out int v) && v != 0) + list.Add(new ModSource(sourceName, v)); + } +} diff --git a/Theriapolis.Godot/UI/BackgroundAvailability.cs b/Theriapolis.Godot/UI/BackgroundAvailability.cs new file mode 100644 index 0000000..3039c70 --- /dev/null +++ b/Theriapolis.Godot/UI/BackgroundAvailability.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace Theriapolis.GodotHost.UI; + +/// +/// Per-background availability rules — which backgrounds are visible / +/// pickable for a given . The +/// backgrounds.json schema doesn't carry restriction fields (gating +/// lives in flavor text), so they're hardcoded here. +/// +/// Used by StepBackground to filter the visible card list and by +/// StepClade to clear the currently-selected background when a clade +/// change makes it no longer valid (e.g. picking a non-canid lineage +/// while Pack-Raised is selected). +/// +public static class BackgroundAvailability +{ + private static readonly Dictionary> Rules = new() + { + // Hybrid-only backgrounds — flavor text explicitly hybrid. + { "passer", d => d.IsHybrid }, + { "hybrid_underground", d => d.IsHybrid }, + { "former_chattel", d => d.IsHybrid }, + + // Clade-restricted backgrounds. + { "warren_runner", d => d.HasClade("leporidae") }, + { "pack_raised", d => d.HasClade("canidae") }, + { "herd_city_born", d => d.HasAnyCladeOfKind("prey") }, + }; + + /// True if the background id can be picked under the given + /// draft state. Backgrounds not in the rules table are universally + /// available; the empty id is also "available" (means no + /// background selected). + public static bool IsAvailable(string backgroundId, CharacterDraft draft) + { + if (string.IsNullOrEmpty(backgroundId)) return true; + if (!Rules.TryGetValue(backgroundId, out var rule)) return true; + return rule(draft); + } +} diff --git a/Theriapolis.Godot/UI/SkillsCatalog.cs b/Theriapolis.Godot/UI/SkillsCatalog.cs new file mode 100644 index 0000000..c9e0e91 --- /dev/null +++ b/Theriapolis.Godot/UI/SkillsCatalog.cs @@ -0,0 +1,78 @@ +namespace Theriapolis.GodotHost.UI; + +/// +/// All 18 skills with display labels, governing ability, and codex +/// flavor descriptions. Entries are keyed by their snake_case JSON id — +/// the same string that appears in class.skill_options and +/// background.skill_proficiencies in Content/Data. +/// +/// Labels and ability mapping mirror Theriapolis.Core.Rules.Stats.SkillId; +/// descriptions are ported verbatim from src/data.jsx's +/// SKILL_DESC table in the React prototype. If the JSON schema +/// gains a description field later, swap to a data-driven lookup. +/// +public static class SkillsCatalog +{ + public record SkillEntry(string JsonId, string Label, string Ability, string Description); + + public static readonly SkillEntry[] All = + { + new("acrobatics", "Acrobatics", "DEX", + "Tumbling, balance, and the kind of footwork that keeps you upright on a coliseum sand-floor or a warren-rope. Body-cunning under pressure."), + new("animal_handling", "Animal Handling", "WIS", + "Reading and steering non-sentient beasts — feral hounds, draft-kine, the wild cousins of your own clade. Calming, herding, riding."), + new("arcana", "Arcana", "INT", + "Knowledge of the older magics: scent-sorcery, blood-sigil, the half-forgotten rites that pre-date the Covenant of Claws."), + new("athletics", "Athletics", "STR", + "Raw physical effort. Climbing scaffold, swimming the foul canal, hauling a packmate from the pit, breaking a hold."), + new("deception", "Deception", "CHA", + "Speaking convincingly past your scent. The art of the false posture, the planted rumor, the answer that is technically true."), + new("history", "History", "INT", + "The long memory of Theriapolis — the Imperium's fall, the Compact's ratification, which clade owes which other a centuries-old debt."), + new("insight", "Insight", "WIS", + "Reading another's true posture beneath their words. Catching the off-note in a snarl, the held breath, the lie in a friendly tail."), + new("intimidation", "Intimidation", "CHA", + "Bared-teeth diplomacy. The threat made plain enough that violence is not required to extract compliance."), + new("investigation", "Investigation", "INT", + "Methodical search and inference: scene-reading, document-sifting, the patient accumulation of small facts into a verdict."), + new("medicine", "Medicine", "WIS", + "Field surgery, poultice-craft, knowing which clade tolerates which tincture. Stabilizing the dying without finishing them."), + new("nature", "Nature", "INT", + "Knowledge of the wild outside the city wall — terrain, weather, plant-lore, and the unsigned beasts that observe no Covenant."), + new("perception", "Perception", "WIS", + "Awareness through every sense your clade gives you: ear-cock, scent-prickle, the half-glimpsed shape at the edge of vision."), + new("performance", "Performance", "CHA", + "Holding an audience — coliseum crowd, courtroom gallery, market square. Song, oratory, the body that compels watching."), + new("persuasion", "Persuasion", "CHA", + "Open-handed argument. The case made on its merits, the appeal to mutual benefit, the patient construction of agreement."), + new("religion", "Religion", "INT", + "The hymn-cycles of the Cervid liturgy, the Compact's sacred clauses, the small household rites your clade keeps without thinking."), + new("sleight_of_hand", "Sleight of Hand", "DEX", + "Quiet fingers — pickpocketing, palmed coins, the swap performed under another's nose. Useful in markets and courtrooms alike."), + new("stealth", "Stealth", "DEX", + "Movement unseen and unsmelled. Wind-checking, scent-suppression, the slow weight-shift on a creaking floor."), + new("survival", "Survival", "WIS", + "Field-craft beyond the wall: tracking, foraging, fire-making, knowing which run-off is safe to drink and which carries the upstream butcher's leavings."), + }; + + public static readonly string[] Abilities = { "STR", "DEX", "CON", "INT", "WIS", "CHA" }; + + public static readonly System.Collections.Generic.Dictionary AbilityFullName = new() + { + { "STR", "Strength" }, + { "DEX", "Dexterity" }, + { "CON", "Constitution" }, + { "INT", "Intellect" }, + { "WIS", "Wisdom" }, + { "CHA", "Charisma" }, + }; + + public static SkillEntry? Get(string jsonId) => + System.Array.Find(All, s => s.JsonId == jsonId); + + public static System.Collections.Generic.IEnumerable ByAbility(string ability) + { + foreach (var s in All) + if (s.Ability == ability) yield return s; + } +}