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. 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 { private CharacterDraft? _draft; private VBoxContainer _content = null!; public override void _Ready() { AddThemeConstantOverride("margin_left", 18); AddThemeConstantOverride("margin_right", 18); AddThemeConstantOverride("margin_top", 18); AddThemeConstantOverride("margin_bottom", 18); // 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); scroll.AddChild(_content); } public void SetDraft(CharacterDraft draft) { _draft = draft; _draft.Changed += Refresh; Refresh(); } private void Refresh() { if (_draft is null || _content is null) return; foreach (var c in _content.GetChildren()) c.QueueFree(); BuildName(); BuildDetailsGrid(); BuildAttributes(); BuildPills(); } // ────────────────────────────────────────────────────────────────────── // Section 1 — Name private void BuildName() { var name = string.IsNullOrEmpty(_draft!.CharacterName) ? "Unnamed" : _draft.CharacterName; _content.AddChild(new Label { Text = name, HorizontalAlignment = HorizontalAlignment.Center, ThemeTypeVariation = "H3", }); _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 { var lineageGrid = MakeFullWidthGrid(); _content.AddChild(lineageGrid); lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.CladeId)?.Name)); lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name)); } // 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 GridContainer MakeFullWidthGrid() { var grid = new GridContainer { Columns = 2, SizeFlagsHorizontal = SizeFlags.ExpandFill }; grid.AddThemeConstantOverride("h_separation", 12); grid.AddThemeConstantOverride("v_separation", 8); return grid; } private static Control MakeColumnHeader(string label) { // 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, ThemeTypeVariation = "Eyebrow", }); 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(), ThemeTypeVariation = "Eyebrow" }; 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", ThemeTypeVariation = "Eyebrow" }); // 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", ThemeTypeVariation = "Eyebrow" }); var flow = new HFlowContainer(); flow.AddThemeConstantOverride("h_separation", 6); flow.AddThemeConstantOverride("v_separation", 6); _content.AddChild(flow); // Clade — purebred: full trait+detriment list of the one clade. // Hybrid: only the player-picked clade traits per parent (2/1 split), // but ALL clade detriments from BOTH parents per doc rule. if (_draft!.IsHybrid) { AddPickedCladeTraits(flow, CodexContent.Clade(_draft.SireCladeId), _draft.SireChosenCladeTraits); AddPickedCladeTraits(flow, CodexContent.Clade(_draft.DamCladeId), _draft.DamChosenCladeTraits); AddCladeDetriments(flow, CodexContent.Clade(_draft.SireCladeId)); AddCladeDetriments(flow, CodexContent.Clade(_draft.DamCladeId)); } else { AddCladeTraits(flow, CodexContent.Clade(_draft.CladeId)); } // Species — purebred shows everything from the single species. // Hybrid shows only the picked trait + picked detriment per parent // (single-pick each, per doc) plus the four universal detriments. if (_draft.IsHybrid) { var sireSp = CodexContent.SpeciesById(_draft.SireSpeciesId); var damSp = CodexContent.SpeciesById(_draft.DamSpeciesId); AddPickedSpeciesPick(flow, sireSp, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment); AddPickedSpeciesPick(flow, damSp, _draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment); AddVariantContent(flow, sireSp, _draft.ResolveVariantId(sireSp, "sire")); AddVariantContent(flow, damSp, _draft.ResolveVariantId(damSp, "dam")); // Universal hybrid detriments — every hybrid has all four. foreach (var (name, desc) in UniversalHybridDetriments) flow.AddChild(new TraitChip { TraitName = name, Description = desc, Detriment = true }); } else { var sp = CodexContent.SpeciesById(_draft.SpeciesId); AddSpeciesTraits(flow, sp); AddVariantContent(flow, sp, _draft.ResolveVariantId(sp, "")); } // 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 AddSpeciesTraits(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species) { if (species is null) return; foreach (var t in species.Traits) flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); foreach (var d in species.Detriments) flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } /// Hybrid: only the chosen subset of clade traits. private static void AddPickedCladeTraits(HFlowContainer flow, Theriapolis.Core.Data.CladeDef? clade, Godot.Collections.Array chosen) { if (clade is null) return; foreach (var t in clade.Traits) if (chosen.Contains(t.Id)) flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); } /// Hybrid: all clade detriments from this parent (both inherited). private static void AddCladeDetriments(HFlowContainer flow, Theriapolis.Core.Data.CladeDef? clade) { if (clade is null) return; foreach (var d in clade.Detriments) flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } /// Render the resolved variant's extra traits/detriments, /// if any. is the variant key (e.g. "male" /// or "sheep"); empty when no resolution applies. private static void AddVariantContent(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species, string variantId) { if (species is null || string.IsNullOrEmpty(variantId)) return; var variant = System.Array.Find(species.Variants, v => v.Id == variantId); if (variant is null) return; foreach (var t in variant.Traits) flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); foreach (var d in variant.Detriments) flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } /// Hybrid: one chosen species trait + one chosen species detriment. private static void AddPickedSpeciesPick(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species, string chosenTraitId, string chosenDetrimentId) { if (species is null) return; foreach (var t in species.Traits) if (t.Id == chosenTraitId) flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); foreach (var d in species.Detriments) if (d.Id == chosenDetrimentId) flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } /// /// Hardcoded from theriapolis-rpg-clades.md "Universal Hybrid Detriments" /// (lines 749-753). Every hybrid character has all four. The schema /// doesn't carry these yet — they live in the clades doc as canonical /// rules text. /// private static readonly (string Name, string Description)[] UniversalHybridDetriments = { ("Scent Dysphoria", "Your scent broadcasts conflicting Clade signals. Creatures with scent ability " + "who detect you must make a WIS save (DC 10) or experience instinctive unease, " + "imposing disadvantage on their first CHA check with you. Scent-masking products " + "can suppress this for 1d4 hours but fail under stress."), ("Illegible Body Language", "Your tail and ear movements blend two Clade grammars. Disadvantage on nonverbal " + "communication checks (CHA checks relying on body language) with purebred creatures. " + "Other hybrids read you normally."), ("Social Stigma", "In non-progressive settlements, disadvantage on CHA checks with strangers and on " + "checks to secure housing, employment, or official services. Even in progressive " + "areas, the first CHA check with any new NPC is made at -2 until you've established " + "rapport."), ("Medical Incompatibility", "Healing potions and magical healing function at 75% effectiveness (round down). " + "Medical treatment calibrated for purebred physiologies works imperfectly on your " + "blended body. Hybrid-specific medicine exists but is expensive and rare."), }; 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, }); } }