From e3f0296e6faae01ac575ccac3a96f2ff872c5804 Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 3 May 2026 22:04:24 -0700 Subject: [PATCH] M6.7: Parchment theme pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights up the M5 codex design system across the wizard. Default palette swaps from dark leather to aged-parchment cream with sealing-wax red selection emphasis, matching the React prototype's default theme variant. CodexTheme.Build() is applied at the wizard root so every step + Aside + popover cascades through it. Theme additions: - Parchment palette in CodexPalette (Dark retained as alt) - Type variations registered for Card, CodexPopover, Pill, PillDetriment, AbilityToken, AbilitySlot, SkillRow — without SetTypeVariation, panel-stylebox lookup falls through to Godot's default dark slate, which is what was happening to every bare PanelContainer before this pass. - panel_hover stylebox on Card (gild border) wired via CodexCard's MouseEntered/Exited helper; panel_selected bumped to 3px seal-red border + soft shadow so selection reads at a glance. Card selection refactor: - Replaced the warm-cream Modulate hint on cards with stylebox swaps via the new CodexCard.SetSelected helper. The Modulate approach was a no-op on cream-on-cream parchment; the stylebox swap looks the same on either palette. - Step intros + Aside section headers now use the existing Eyebrow / H2 / H3 / CardName / CardMeta / CardBody label variations. - Confirm button on Step VIII uses the PrimaryButton variation. Popover + chip behaviour: - PopoverLayer is now MouseFilter=Ignore so clicks/scroll/hover all pass through. Adjacent chips fire reliably even when the previous popover overlaps them spatially. - Dropped the 80ms grace timer; chip MouseExited closes immediately. - TraitChip MouseFilter Stop → Pass so clicks bubble up to the parent card's GuiInput (selecting the card). Misc: - Wizard._Ready inserts a backing Panel so the parchment Bg fills the canvas — Wizard root is a plain Control, which paints nothing. - CodexTheme font lookup tries Cormorant-Medium before -Regular and globalizes res://Fonts/ for runtime FontFile load (the previous fallback used ContentPaths which points at a sibling data tree). - StepStats final-score Label rendered at font_size 22 to match the AbilityToken die. Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/Scenes/Aside.cs | 9 +- .../Scenes/Steps/StepBackground.cs | 18 +-- Theriapolis.Godot/Scenes/Steps/StepClade.cs | 37 +++-- Theriapolis.Godot/Scenes/Steps/StepClass.cs | 20 ++- Theriapolis.Godot/Scenes/Steps/StepReview.cs | 7 +- Theriapolis.Godot/Scenes/Steps/StepSkills.cs | 17 +- Theriapolis.Godot/Scenes/Steps/StepSpecies.cs | 25 +-- Theriapolis.Godot/Scenes/Steps/StepStats.cs | 11 +- .../Scenes/Steps/StepSubclass.cs | 16 +- .../Scenes/Widgets/AbilitySlot.cs | 1 + .../Scenes/Widgets/AbilityToken.cs | 1 + Theriapolis.Godot/Scenes/Widgets/CodexCard.cs | 67 ++++++++ .../Scenes/Widgets/PopoverLayer.cs | 34 ++-- Theriapolis.Godot/Scenes/Widgets/TraitChip.cs | 13 +- Theriapolis.Godot/Scenes/Wizard.cs | 22 ++- Theriapolis.Godot/UI/CodexPalette.cs | 29 +++- Theriapolis.Godot/UI/CodexTheme.cs | 150 ++++++++++++++---- 17 files changed, 348 insertions(+), 129 deletions(-) create mode 100644 Theriapolis.Godot/Scenes/Widgets/CodexCard.cs diff --git a/Theriapolis.Godot/Scenes/Aside.cs b/Theriapolis.Godot/Scenes/Aside.cs index 97e8859..352b0f2 100644 --- a/Theriapolis.Godot/Scenes/Aside.cs +++ b/Theriapolis.Godot/Scenes/Aside.cs @@ -75,6 +75,7 @@ public partial class Aside : MarginContainer { Text = name, HorizontalAlignment = HorizontalAlignment.Center, + ThemeTypeVariation = "H3", }); _content.AddChild(new HSeparator()); } @@ -143,6 +144,7 @@ public partial class Aside : MarginContainer { Text = label, HorizontalAlignment = HorizontalAlignment.Center, + ThemeTypeVariation = "Eyebrow", }); col.AddChild(new HSeparator()); return col; @@ -155,8 +157,7 @@ public partial class Aside : MarginContainer // 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); + var lbl = new Label { Text = label.ToUpperInvariant(), ThemeTypeVariation = "Eyebrow" }; v.AddChild(lbl); // Autowrap on the value so long names ("Hybrid Underground") @@ -176,7 +177,7 @@ public partial class Aside : MarginContainer private void BuildAttributes() { - _content.AddChild(new Label { Text = "ATTRIBUTES" }); + _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. @@ -241,7 +242,7 @@ public partial class Aside : MarginContainer private void BuildPills() { - _content.AddChild(new Label { Text = "TRAITS · FEATS · SKILLS" }); + _content.AddChild(new Label { Text = "TRAITS · FEATS · SKILLS", ThemeTypeVariation = "Eyebrow" }); var flow = new HFlowContainer(); flow.AddThemeConstantOverride("h_separation", 6); diff --git a/Theriapolis.Godot/Scenes/Steps/StepBackground.cs b/Theriapolis.Godot/Scenes/Steps/StepBackground.cs index e8be81a..569ef04 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepBackground.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepBackground.cs @@ -33,8 +33,8 @@ public partial class StepBackground : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO V · HISTORY" }); - intro.AddChild(new Label { Text = "Choose a History" }); + intro.AddChild(new Label { Text = "FOLIO V · HISTORY", ThemeTypeVariation = "Eyebrow" }); + intro.AddChild(new Label { Text = "Choose a History", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Where your character came from before the wandering began. " @@ -66,12 +66,9 @@ public partial class StepBackground : VBoxContainer, IStep { bool selected = _draft.BackgroundId == bg.Id; - var card = new PanelContainer - { - CustomMinimumSize = new Vector2(200, 0), - MouseFilter = MouseFilterEnum.Stop, - }; - if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + var card = CodexCard.Make(); + card.CustomMinimumSize = new Vector2(200, 0); + CodexCard.SetSelected(card, selected); card.GuiInput += (InputEvent e) => { @@ -83,13 +80,14 @@ public partial class StepBackground : VBoxContainer, IStep box.AddThemeConstantOverride("separation", 6); card.AddChild(box); - box.AddChild(new Label { Text = bg.Name }); + box.AddChild(new Label { Text = bg.Name, ThemeTypeVariation = "CardName" }); if (!string.IsNullOrEmpty(bg.Flavor)) { box.AddChild(new Label { Text = bg.Flavor, AutowrapMode = TextServer.AutowrapMode.WordSmart, + ThemeTypeVariation = "CardBody", }); } @@ -113,7 +111,7 @@ public partial class StepBackground : VBoxContainer, IStep var featRow = new HBoxContainer(); featRow.AddThemeConstantOverride("separation", 6); box.AddChild(featRow); - featRow.AddChild(new Label { Text = "FEATURE" }); + featRow.AddChild(new Label { Text = "FEATURE", ThemeTypeVariation = "Eyebrow" }); featRow.AddChild(new TraitChip { TraitName = bg.FeatureName, diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index e0c5458..02857b8 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -21,7 +21,7 @@ namespace Theriapolis.GodotHost.Scenes.Steps; public partial class StepClade : VBoxContainer, IStep { private CharacterDraft _draft = null!; - private CheckBox _hybridToggle = null!; + private Button _hybridToggle = null!; private VBoxContainer _purebredSection = null!; private VBoxContainer _hybridSection = null!; private OptionButton _dominantToggle = null!; @@ -61,8 +61,8 @@ public partial class StepClade : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO I · CLADE" }); - intro.AddChild(new Label { Text = "Choose a Clade" }); + intro.AddChild(new Label { Text = "FOLIO I · CLADE", ThemeTypeVariation = "Eyebrow" }); + intro.AddChild(new Label { Text = "Choose a Clade", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "The broad mammalian family of your line. Clade defines the largest " @@ -71,10 +71,18 @@ public partial class StepClade : VBoxContainer, IStep AutowrapMode = TextServer.AutowrapMode.WordSmart, }); + // Toggle Button (not CheckBox) so the inverted-on-press button style + // from the codex theme handles selection visually — no checkbox glyph + // needed, the bg colour shift is the affordance. var toggleRow = new HBoxContainer(); toggleRow.AddThemeConstantOverride("separation", 12); AddChild(toggleRow); - _hybridToggle = new CheckBox { Text = "Hybrid Origin (two parent lineages)" }; + _hybridToggle = new Button + { + Text = "Hybrid Origin (two parent lineages)", + ToggleMode = true, + FocusMode = Control.FocusModeEnum.None, + }; _hybridToggle.Toggled += OnHybridToggled; toggleRow.AddChild(_hybridToggle); @@ -91,12 +99,12 @@ public partial class StepClade : VBoxContainer, IStep _hybridSection.AddThemeConstantOverride("separation", 16); AddChild(_hybridSection); - _hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage" }); + _hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" }); var sireGrid = MakeGrid(); _hybridSection.AddChild(sireGrid); PopulateGrid(sireGrid, _sireCards, id => OnLineageCladePicked("sire", id)); - _hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage" }); + _hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" }); var damGrid = MakeGrid(); _hybridSection.AddChild(damGrid); PopulateGrid(damGrid, _damCards, id => OnLineageCladePicked("dam", id)); @@ -106,7 +114,7 @@ public partial class StepClade : VBoxContainer, IStep _bonusSection = new VBoxContainer(); _bonusSection.AddThemeConstantOverride("separation", 8); _hybridSection.AddChild(_bonusSection); - _bonusSection.AddChild(new Label { Text = "LINEAGE BONUSES" }); + _bonusSection.AddChild(new Label { Text = "LINEAGE BONUSES", ThemeTypeVariation = "Eyebrow" }); _sireBonusRow = new HBoxContainer(); _sireBonusRow.AddThemeConstantOverride("separation", 8); @@ -119,7 +127,7 @@ public partial class StepClade : VBoxContainer, IStep var dominantRow = new HBoxContainer(); dominantRow.AddThemeConstantOverride("separation", 8); _hybridSection.AddChild(dominantRow); - dominantRow.AddChild(new Label { Text = "DOMINANT LINEAGE" }); + dominantRow.AddChild(new Label { Text = "DOMINANT LINEAGE", ThemeTypeVariation = "Eyebrow" }); _dominantToggle = new OptionButton(); _dominantToggle.AddItem("Sire", 0); _dominantToggle.AddItem("Dam", 1); @@ -253,7 +261,7 @@ public partial class StepClade : VBoxContainer, IStep private static void UpdateSelection(Dictionary cards, string selectedId) { foreach (var (id, card) in cards) - card.Modulate = id == selectedId ? new Color(1f, 0.95f, 0.85f) : Colors.White; + CodexCard.SetSelected(card, id == selectedId); } private void OnPurebredCladePicked(string cladeId) @@ -318,11 +326,8 @@ public partial class StepClade : VBoxContainer, IStep private PanelContainer BuildCard(CladeDef clade, System.Action onClick) { - var card = new PanelContainer - { - CustomMinimumSize = new Vector2(200, 0), - MouseFilter = MouseFilterEnum.Stop, - }; + var card = CodexCard.Make(); + card.CustomMinimumSize = new Vector2(200, 0); card.GuiInput += (InputEvent e) => { if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) @@ -333,8 +338,8 @@ public partial class StepClade : VBoxContainer, IStep box.AddThemeConstantOverride("separation", 6); card.AddChild(box); - box.AddChild(new Label { Text = clade.Name }); - box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant() }); + box.AddChild(new Label { Text = clade.Name, ThemeTypeVariation = "CardName" }); + box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" }); if (clade.AbilityMods.Count > 0) { diff --git a/Theriapolis.Godot/Scenes/Steps/StepClass.cs b/Theriapolis.Godot/Scenes/Steps/StepClass.cs index ee49233..cc52c27 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClass.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClass.cs @@ -37,8 +37,8 @@ public partial class StepClass : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO III · CALLING" }); - intro.AddChild(new Label { Text = "Choose a Calling" }); + intro.AddChild(new Label { Text = "FOLIO III · CALLING", ThemeTypeVariation = "Eyebrow" }); + intro.AddChild(new Label { Text = "Choose a Calling", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Your character's path — fighter, hunter, scholar, or something stranger. " @@ -67,12 +67,9 @@ public partial class StepClass : VBoxContainer, IStep { bool selected = _draft.ClassId == cls.Id; - var card = new PanelContainer - { - CustomMinimumSize = new Vector2(200, 0), - MouseFilter = MouseFilterEnum.Stop, - }; - if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + var card = CodexCard.Make(); + card.CustomMinimumSize = new Vector2(200, 0); + CodexCard.SetSelected(card, selected); card.GuiInput += (InputEvent e) => { @@ -93,10 +90,11 @@ public partial class StepClass : VBoxContainer, IStep box.AddThemeConstantOverride("separation", 6); card.AddChild(box); - box.AddChild(new Label { Text = cls.Name }); + box.AddChild(new Label { Text = cls.Name, ThemeTypeVariation = "CardName" }); box.AddChild(new Label { Text = $"d{cls.HitDie} · {string.Join("/", cls.PrimaryAbility)}", + ThemeTypeVariation = "CardMeta", }); if (cls.Saves.Length > 0) @@ -104,9 +102,9 @@ public partial class StepClass : VBoxContainer, IStep var savesRow = new HBoxContainer(); savesRow.AddThemeConstantOverride("separation", 6); box.AddChild(savesRow); - savesRow.AddChild(new Label { Text = "SAVES" }); + savesRow.AddChild(new Label { Text = "SAVES", ThemeTypeVariation = "Eyebrow" }); foreach (var s in cls.Saves) - savesRow.AddChild(new Label { Text = s }); + savesRow.AddChild(new Label { Text = s, ThemeTypeVariation = "CardMeta" }); } // Level-1 features. Filter out stubs and subclass-selection markers diff --git a/Theriapolis.Godot/Scenes/Steps/StepReview.cs b/Theriapolis.Godot/Scenes/Steps/StepReview.cs index 83f5077..00f48c4 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepReview.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepReview.cs @@ -43,8 +43,8 @@ public partial class StepReview : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO VIII · SIGN" }); - intro.AddChild(new Label { Text = "Sign the Codex" }); + intro.AddChild(new Label { Text = "FOLIO VIII · SIGN", ThemeTypeVariation = "Eyebrow" }); + intro.AddChild(new Label { Text = "Sign the Codex", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Review the right-rail summary, then sign your name. " @@ -56,7 +56,7 @@ public partial class StepReview : VBoxContainer, IStep var nameBlock = new VBoxContainer(); nameBlock.AddThemeConstantOverride("separation", 6); AddChild(nameBlock); - nameBlock.AddChild(new Label { Text = "NAME" }); + nameBlock.AddChild(new Label { Text = "NAME", ThemeTypeVariation = "Eyebrow" }); _nameField = new LineEdit { PlaceholderText = "Enter your character's name...", @@ -82,6 +82,7 @@ public partial class StepReview : VBoxContainer, IStep { Text = "Confirm & Begin", CustomMinimumSize = new Vector2(220, 0), + ThemeTypeVariation = "PrimaryButton", }; _confirmBtn.Pressed += OnConfirmPressed; actionBlock.AddChild(_confirmBtn); diff --git a/Theriapolis.Godot/Scenes/Steps/StepSkills.cs b/Theriapolis.Godot/Scenes/Steps/StepSkills.cs index a6e607d..25958fe 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSkills.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSkills.cs @@ -41,8 +41,8 @@ public partial class StepSkills : VBoxContainer, IStep 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 = "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 " @@ -51,7 +51,7 @@ public partial class StepSkills : VBoxContainer, IStep AutowrapMode = TextServer.AutowrapMode.WordSmart, }); - _countLabel = new Label { Text = "0 / 0 chosen" }; + _countLabel = new Label { Text = "0 / 0 chosen", ThemeTypeVariation = "Meta" }; AddChild(_countLabel); _groupsGrid = new GridContainer @@ -96,7 +96,11 @@ public partial class StepSkills : VBoxContainer, IStep HashSet chosen, int required) { - var panel = new PanelContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + var panel = new PanelContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + ThemeTypeVariation = "Card", + }; var col = new VBoxContainer(); col.AddThemeConstantOverride("separation", 4); panel.AddChild(col); @@ -105,10 +109,10 @@ public partial class StepSkills : VBoxContainer, IStep var header = new HBoxContainer(); header.AddThemeConstantOverride("separation", 8); col.AddChild(header); - header.AddChild(new Label { Text = SkillsCatalog.AbilityFullName[ability] }); + 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 }); + header.AddChild(new Label { Text = ability, ThemeTypeVariation = "Eyebrow" }); foreach (var s in SkillsCatalog.ByAbility(ability)) col.AddChild(BuildSkillRow(s, lockedFromBg, classOptions, chosen, required)); @@ -130,6 +134,7 @@ public partial class StepSkills : VBoxContainer, IStep var row = new PanelContainer { MouseFilter = MouseFilterEnum.Stop, + ThemeTypeVariation = "SkillRow", }; // Visual state: dim unavailable rows, gild-tint background-locked, diff --git a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs index 14bf1a8..06122d0 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs @@ -39,8 +39,8 @@ public partial class StepSpecies : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO II · SPECIES" }); - intro.AddChild(new Label { Text = "Choose a Species" }); + intro.AddChild(new Label { Text = "FOLIO II · SPECIES", ThemeTypeVariation = "Eyebrow" }); + intro.AddChild(new Label { Text = "Choose a Species", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Refine your line. Species inherits the clade's traits and adds its " @@ -59,11 +59,11 @@ public partial class StepSpecies : VBoxContainer, IStep _hybridSection.AddThemeConstantOverride("separation", 16); AddChild(_hybridSection); - _hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage" }); + _hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" }); _sireGrid = MakeGrid(); _hybridSection.AddChild(_sireGrid); - _hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage" }); + _hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" }); _damGrid = MakeGrid(); _hybridSection.AddChild(_damGrid); @@ -109,12 +109,9 @@ public partial class StepSpecies : VBoxContainer, IStep private static Control BuildCard(SpeciesDef sp, bool selected, System.Action onClick) { - var card = new PanelContainer - { - CustomMinimumSize = new Vector2(200, 0), - MouseFilter = MouseFilterEnum.Stop, - }; - if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + var card = CodexCard.Make(); + card.CustomMinimumSize = new Vector2(200, 0); + CodexCard.SetSelected(card, selected); card.GuiInput += (InputEvent e) => { @@ -126,8 +123,12 @@ public partial class StepSpecies : VBoxContainer, IStep box.AddThemeConstantOverride("separation", 6); card.AddChild(box); - box.AddChild(new Label { Text = sp.Name }); - box.AddChild(new Label { Text = $"{sp.Size.ToUpperInvariant()} · {sp.BaseSpeedFt} FT/TURN" }); + box.AddChild(new Label { Text = sp.Name, ThemeTypeVariation = "CardName" }); + box.AddChild(new Label + { + Text = $"{sp.Size.ToUpperInvariant()} · {sp.BaseSpeedFt} FT/TURN", + ThemeTypeVariation = "CardMeta", + }); if (sp.AbilityMods.Count > 0) { diff --git a/Theriapolis.Godot/Scenes/Steps/StepStats.cs b/Theriapolis.Godot/Scenes/Steps/StepStats.cs index 6f6da53..738afd4 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepStats.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepStats.cs @@ -61,8 +61,8 @@ public partial class StepStats : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO VI · ABILITIES" }); - intro.AddChild(new Label { Text = "Assign your Ability Scores" }); + intro.AddChild(new Label { Text = "FOLIO VI · ABILITIES", ThemeTypeVariation = "Eyebrow" }); + intro.AddChild(new Label { Text = "Assign your Ability Scores", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Pick a method, then drag a value from the pool into one of the six " @@ -135,14 +135,17 @@ public partial class StepStats : VBoxContainer, IStep _bonusChips[captured] = bonus; row.AddChild(bonus); - // Final score (= base + total bonus). + // Final score (= base + total bonus). Sized to match the + // AbilityToken numeric label so the 'before / after' values + // read at the same visual weight. var finalLbl = new Label { Text = "—", - CustomMinimumSize = new Vector2(48, 0), + CustomMinimumSize = new Vector2(56, 0), HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, }; + finalLbl.AddThemeFontSizeOverride("font_size", 22); _finalLabels[captured] = finalLbl; row.AddChild(finalLbl); diff --git a/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs b/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs index b7175a8..b24a945 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSubclass.cs @@ -40,8 +40,8 @@ public partial class StepSubclass : VBoxContainer, IStep var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); - intro.AddChild(new Label { Text = "FOLIO IV · SUBCLASS" }); - intro.AddChild(new Label { Text = "Choose a Subclass" }); + intro.AddChild(new Label { Text = "FOLIO IV · SUBCLASS", ThemeTypeVariation = "Eyebrow" }); + intro.AddChild(new Label { Text = "Choose a Subclass", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Specialization within your calling. Subclass features unlock at " @@ -77,12 +77,9 @@ public partial class StepSubclass : VBoxContainer, IStep { bool selected = _draft.SubclassId == sub.Id; - var card = new PanelContainer - { - CustomMinimumSize = new Vector2(200, 0), - MouseFilter = MouseFilterEnum.Stop, - }; - if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); + var card = CodexCard.Make(); + card.CustomMinimumSize = new Vector2(200, 0); + CodexCard.SetSelected(card, selected); card.GuiInput += (InputEvent e) => { @@ -94,13 +91,14 @@ public partial class StepSubclass : VBoxContainer, IStep box.AddThemeConstantOverride("separation", 6); card.AddChild(box); - box.AddChild(new Label { Text = sub.Name }); + box.AddChild(new Label { Text = sub.Name, ThemeTypeVariation = "CardName" }); if (!string.IsNullOrEmpty(sub.Flavor)) { box.AddChild(new Label { Text = sub.Flavor, AutowrapMode = TextServer.AutowrapMode.WordSmart, + ThemeTypeVariation = "CardBody", }); } diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs b/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs index b5e3892..a10c034 100644 --- a/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs +++ b/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs @@ -23,6 +23,7 @@ public partial class AbilitySlot : PanelContainer public override void _Ready() { CustomMinimumSize = new Vector2(56, 56); + ThemeTypeVariation = "AbilitySlot"; MouseFilter = MouseFilterEnum.Stop; } diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs b/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs index e3bcd63..9aad72a 100644 --- a/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs +++ b/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs @@ -25,6 +25,7 @@ public partial class AbilityToken : PanelContainer public override void _Ready() { CustomMinimumSize = new Vector2(56, 56); + ThemeTypeVariation = "AbilityToken"; // PASS so clicks propagate up to the parent AbilitySlot's GuiInput // handler (click-to-return). Drag detection still triggers on the // deepest non-IGNORE Control under the cursor, so PASS works for diff --git a/Theriapolis.Godot/Scenes/Widgets/CodexCard.cs b/Theriapolis.Godot/Scenes/Widgets/CodexCard.cs new file mode 100644 index 0000000..af615a7 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Widgets/CodexCard.cs @@ -0,0 +1,67 @@ +using Godot; + +namespace Theriapolis.GodotHost.Scenes.Widgets; + +/// +/// Card-style PanelContainer helpers. The codex Theme defines three +/// styleboxes for type-variation "Card": +/// - "panel" → unselected look (Bg2 fill, Rule border) +/// - "panel_hover" → gild border, slightly heavier weight +/// - "panel_selected" → seal-red border + soft red shadow +/// +/// State is held in Godot meta on the card so hover and selected can be +/// driven independently by different call sites (Make wires the hover +/// signals; SetSelected is called by step Refresh handlers). The active +/// stylebox is picked by Apply: selected beats hover beats default. +/// +public static class CodexCard +{ + private const string SelectedMeta = "codex_card_selected"; + private const string HoverMeta = "codex_card_hover"; + + /// + /// Creates a PanelContainer with ThemeTypeVariation = "Card" plus + /// hover signal wiring. The MouseEntered/MouseExited handlers update + /// the hover meta and re-apply the right stylebox. + /// + public static PanelContainer Make() + { + var card = new PanelContainer + { + ThemeTypeVariation = "Card", + MouseFilter = Control.MouseFilterEnum.Stop, + }; + card.MouseEntered += () => SetHover(card, true); + card.MouseExited += () => SetHover(card, false); + return card; + } + + public static void SetSelected(PanelContainer card, bool selected) + { + card.SetMeta(SelectedMeta, selected); + Apply(card); + } + + private static void SetHover(PanelContainer card, bool hover) + { + card.SetMeta(HoverMeta, hover); + Apply(card); + } + + private static void Apply(PanelContainer card) + { + bool selected = card.HasMeta(SelectedMeta) && (bool)card.GetMeta(SelectedMeta); + bool hover = card.HasMeta(HoverMeta) && (bool)card.GetMeta(HoverMeta); + + // Priority: selected > hover > default. The default branch removes + // the override so the type variation's "panel" stylebox applies. + StringName picked = selected ? "panel_selected" + : hover ? "panel_hover" + : "panel"; + + if (picked == "panel" || !card.HasThemeStylebox(picked, "Card")) + card.RemoveThemeStyleboxOverride("panel"); + else + card.AddThemeStyleboxOverride("panel", card.GetThemeStylebox(picked, "Card")); + } +} diff --git a/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs b/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs index 288b5f5..8fc0f21 100644 --- a/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs +++ b/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs @@ -6,8 +6,15 @@ 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). +/// popover hides as soon as the trigger fires MouseExited. +/// +/// The popover itself is MouseFilter=Ignore so it never intercepts +/// input — clicks pass through to the chip's parent (card selection), +/// scroll wheel events go to the underlying ScrollContainer, and the +/// chip's hover state stays accurate when the cursor moves onto the +/// popover area (the cursor is registered as "outside the chip", so +/// MouseExited fires and we hide). This lets adjacent chips fire +/// reliably even when the previous popover overlaps them spatially. /// /// One PopoverLayer per scene; lives as a CanvasLayer child of /// Wizard.tscn so popovers float above every step's content. Mirrors @@ -17,7 +24,6 @@ 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; @@ -25,7 +31,6 @@ public partial class PopoverLayer : CanvasLayer private Label _titleLabel = null!; private Label _tagLabel = null!; private Label _descLabel = null!; - private Timer _closeTimer = null!; public override void _EnterTree() { @@ -45,14 +50,15 @@ public partial class PopoverLayer : CanvasLayer private void BuildPopover() { + // Ignore so clicks/scroll/hover all pass through to whatever's + // beneath. The popover is purely a visual readout; the chip + // owns the lifecycle entirely. _popup = new PanelContainer { Visible = false, - MouseFilter = Control.MouseFilterEnum.Pass, + MouseFilter = Control.MouseFilterEnum.Ignore, ZIndex = 100, }; - _popup.MouseEntered += CancelClose; - _popup.MouseExited += ScheduleClose; AddChild(_popup); var v = new VBoxContainer { CustomMinimumSize = new Vector2(240, 0) }; @@ -75,15 +81,10 @@ public partial class PopoverLayer : CanvasLayer 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; @@ -100,10 +101,11 @@ public partial class PopoverLayer : CanvasLayer Reposition(trigger); } - public void ScheduleClose() => _closeTimer.Start(); - public void CancelClose() => _closeTimer.Stop(); - - private void HidePopover() => _popup.Visible = false; + /// Hide the popover. Was previously a 80ms-grace timer when + /// the popover stayed alive across chip→popover hover transitions, but + /// the popover is now non-interactive so there's no transition to + /// cover for — close immediately. + public void ScheduleClose() => _popup.Visible = false; private void Reposition(Control trigger) { diff --git a/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs b/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs index 44ccf0e..a219e2a 100644 --- a/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs +++ b/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs @@ -24,7 +24,12 @@ public partial class TraitChip : PanelContainer public override void _Ready() { - MouseFilter = MouseFilterEnum.Stop; + // Pass so click events bubble up to the parent card's GuiInput + // (selecting the card). Hover signals fire regardless of filter + // mode — they're driven by cursor-rect intersection, not input + // event routing. + MouseFilter = MouseFilterEnum.Pass; + ApplyVariation(); _label = new Label { Text = TraitName, @@ -42,6 +47,12 @@ public partial class TraitChip : PanelContainer Tag = tag; Detriment = detriment; if (_label is not null) _label.Text = name; + ApplyVariation(); + } + + private void ApplyVariation() + { + ThemeTypeVariation = Detriment ? "PillDetriment" : "Pill"; } private void OnHoverEntered() diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs index e818803..f54a132 100644 --- a/Theriapolis.Godot/Scenes/Wizard.cs +++ b/Theriapolis.Godot/Scenes/Wizard.cs @@ -8,10 +8,8 @@ namespace Theriapolis.GodotHost.Scenes; /// nav bar. Owns the resource and dispatches /// each step's content into the StepHost. /// -/// Default theme only at this layer — per guide §12 (build order), -/// the parchment Theme lands as a final pass once structural correctness -/// is verified. Until then, font/colour issues are clearly font/colour -/// issues, not layout issues. +/// The codex Theme is applied at this root in and +/// cascades through every descendant — steps, Aside, popover layer. /// public partial class Wizard : Control { @@ -56,6 +54,22 @@ public partial class Wizard : Control public override void _Ready() { + Theme = UI.CodexTheme.Build(); + + // The wizard root is a Control, which paints nothing — without a + // backing Panel the viewport's default grey clear color shows + // through. Insert a Panel sized to the full rect so the theme's + // parchment Bg fills the canvas, then move it behind the existing + // children so it doesn't intercept mouse events. + var bg = new Panel + { + AnchorRight = 1, + AnchorBottom = 1, + MouseFilter = MouseFilterEnum.Ignore, + }; + AddChild(bg); + MoveChild(bg, 0); + Character = new UI.CharacterDraft(); _stepper = GetNode("%Stepper"); diff --git a/Theriapolis.Godot/UI/CodexPalette.cs b/Theriapolis.Godot/UI/CodexPalette.cs index 6664325..e135e88 100644 --- a/Theriapolis.Godot/UI/CodexPalette.cs +++ b/Theriapolis.Godot/UI/CodexPalette.cs @@ -8,16 +8,35 @@ namespace Theriapolis.GodotHost.UI; /// names mirror the React prototype's CSS custom properties so the audit /// trail is readable: --bg, --ink, --gild, --seal, etc. /// -/// Single theme ships: Dark (leather + candlelight). The React prototype -/// shipped Parchment and Blood as alternates and Compact density as a dev -/// toggle; per user direction during M5, only Dark is needed for this -/// game and the rest are dropped from scope (port plan §10 resolved -/// decisions). +/// Two palettes ship today: (default — aged paper +/// + sealing-wax red) and (leather + candlelight, +/// retained from the M5 design audit). The React prototype's "blood" +/// variant is dropped from scope. /// public struct CodexPalette { public Color Bg, Bg2, BgDeep, Ink, InkSoft, InkMute, Rule, Gild, Seal, Seal2, Accent; + /// + /// Aged-paper palette. Cream backgrounds with deep brown-black ink, + /// sealing-wax red for primary action / selection emphasis, and a + /// muted gold gild for accents. This is the default codex feel. + /// + public static readonly CodexPalette Parchment = new() + { + Bg = Hex("#e8dcc0"), + Bg2 = Hex("#d9c9a6"), + BgDeep = Hex("#c7b48b"), + Ink = Hex("#2b1d10"), + InkSoft = Hex("#5a4527"), + InkMute = Hex("#8a6f48"), + Rule = Hex("#8a6f48"), + Gild = Hex("#b48a3c"), + Seal = Hex("#7a1f12"), + Seal2 = Hex("#5a160c"), + Accent = Hex("#6b4a1e"), + }; + public static readonly CodexPalette Dark = new() { Bg = Hex("#1c1410"), diff --git a/Theriapolis.Godot/UI/CodexTheme.cs b/Theriapolis.Godot/UI/CodexTheme.cs index 85317f7..382bc90 100644 --- a/Theriapolis.Godot/UI/CodexTheme.cs +++ b/Theriapolis.Godot/UI/CodexTheme.cs @@ -1,6 +1,5 @@ using Godot; using System.IO; -using Theriapolis.GodotHost.Platform; namespace Theriapolis.GodotHost.UI; @@ -11,12 +10,12 @@ namespace Theriapolis.GodotHost.UI; /// Control to cascade through all descendants. /// /// Fonts: looks for FontFile assets under res://Fonts/ and falls -/// back to Godot's default sans if missing. Drop these in to fully match -/// the React prototype's typography: -/// res://Fonts/CormorantGaramond-Regular.ttf (serif-display) -/// res://Fonts/CormorantGaramond-Italic.ttf -/// res://Fonts/CrimsonPro-Regular.ttf (serif-body) -/// res://Fonts/JetBrainsMono-Regular.ttf (mono) +/// back to Godot's default sans if missing. Each role tries a list of +/// candidate filenames in priority order so the project can ship +/// Cormorant-Medium or Cormorant-Regular interchangeably. +/// Display serif: CormorantGaramond-{Medium,Regular}.ttf +/// Body serif: CrimsonPro-Regular.ttf +/// Mono: JetBrainsMono-Regular.ttf (optional; falls back to body) /// /// Theme variations (.tres) aren't authored in the editor — the entire /// theme tree is constructed in code so palette changes are atomic and @@ -30,10 +29,11 @@ public static class CodexTheme private static FontFile? _mono; private static bool _fontsLoaded; - public static Theme Build() + public static Theme Build() => Build(CodexPalette.Parchment); + + public static Theme Build(CodexPalette palette) { EnsureFonts(); - var palette = CodexPalette.Dark; var theme = new Theme(); // Defaults applied to every Control unless overridden. @@ -69,6 +69,11 @@ public static class CodexTheme // Card variant — slightly raised against the page background. // Used by character-creation grid cards (Calling, History, etc.). + // SetTypeVariation registers the inheritance so a PanelContainer + // with ThemeTypeVariation="Card" actually resolves "panel" to the + // Card stylebox; without it, Godot's default PanelContainer panel + // (dark slate) wins and the parchment colours never land. + theme.SetTypeVariation("Card", "PanelContainer"); var card = new StyleBoxFlat { BgColor = p.Bg2, @@ -85,15 +90,24 @@ public static class CodexTheme card.SetBorderWidthAll(1); theme.SetStylebox("panel", "Card", card); + // Hover — gild border so the affordance pops without committing + // the seal-red selection signal yet. CodexCard wires this in via + // MouseEntered/MouseExited. + var cardHover = (StyleBoxFlat)card.Duplicate(); + cardHover.BorderColor = p.Gild; + cardHover.SetBorderWidthAll(2); + theme.SetStylebox("panel_hover", "Card", cardHover); + var cardSelected = (StyleBoxFlat)card.Duplicate(); cardSelected.BorderColor = p.Seal; - cardSelected.SetBorderWidthAll(1); + cardSelected.SetBorderWidthAll(3); cardSelected.ShadowColor = WithAlpha(p.Seal, 0.6f); cardSelected.ShadowSize = 14; cardSelected.ShadowOffset = new Vector2(0, 14); theme.SetStylebox("panel_selected", "Card", cardSelected); // Popover frame — gild border + soft shadow. Matches .trait-hint. + theme.SetTypeVariation("CodexPopover", "PanelContainer"); var popover = new StyleBoxFlat { BgColor = p.Bg2, @@ -112,6 +126,75 @@ public static class CodexTheme var popoverDetriment = (StyleBoxFlat)popover.Duplicate(); popoverDetriment.BorderColor = p.Seal; theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment); + + // Pill — small trait/skill chip. Mirrors .trait-chips .t-name from + // the React prototype: gild-tinted bg over the page bg with a + // translucent gild border. Detriment variant swaps to seal red. + // The page bg (p.Bg) is cream on parchment so the pill reads as + // a slightly warmer carved-into shape inside a card. + theme.SetTypeVariation("Pill", "PanelContainer"); + var pill = new StyleBoxFlat + { + BgColor = p.Bg.Lerp(p.Gild, 0.07f), + BorderColor = WithAlpha(p.Gild, 0.55f), + CornerRadiusTopLeft = CodexSpacing.Radius, + CornerRadiusTopRight = CodexSpacing.Radius, + CornerRadiusBottomLeft = CodexSpacing.Radius, + CornerRadiusBottomRight = CodexSpacing.Radius, + ContentMarginLeft = 9, + ContentMarginRight = 9, + ContentMarginTop = 3, + ContentMarginBottom = 3, + }; + pill.SetBorderWidthAll(1); + theme.SetStylebox("panel", "Pill", pill); + + theme.SetTypeVariation("PillDetriment", "PanelContainer"); + var pillDetriment = (StyleBoxFlat)pill.Duplicate(); + pillDetriment.BgColor = p.Bg.Lerp(p.Seal, 0.08f); + pillDetriment.BorderColor = WithAlpha(p.Seal, 0.55f); + theme.SetStylebox("panel", "PillDetriment", pillDetriment); + + // Ability tokens + slots — fixed-size 56×56 panels used by Step V. + // Token = the draggable die; slot = the drop target. Both use the + // page bg with a Rule border so they read as carved-into the page + // rather than floating cards. Mirrors .die / .slot in the React + // prototype's CSS (parchment block). + var dieBox = new StyleBoxFlat + { + BgColor = p.Bg, + BorderColor = p.Rule, + CornerRadiusTopLeft = CodexSpacing.Radius, + CornerRadiusTopRight = CodexSpacing.Radius, + CornerRadiusBottomLeft = CodexSpacing.Radius, + CornerRadiusBottomRight = CodexSpacing.Radius, + }; + dieBox.SetBorderWidthAll(1); + + theme.SetTypeVariation("AbilityToken", "PanelContainer"); + theme.SetStylebox("panel", "AbilityToken", dieBox); + + theme.SetTypeVariation("AbilitySlot", "PanelContainer"); + theme.SetStylebox("panel", "AbilitySlot", dieBox); + + // Skill row — sits inside an ability-group Card. Bg2 fill (matching + // the card so rows blend into the card bg by default); state tints + // applied per row via Modulate in StepSkills (background-granted → + // warm gild, chosen → pale green, unavailable → reduced alpha). + theme.SetTypeVariation("SkillRow", "PanelContainer"); + var skillRow = new StyleBoxFlat + { + BgColor = p.Bg2, + CornerRadiusTopLeft = CodexSpacing.Radius, + CornerRadiusTopRight = CodexSpacing.Radius, + CornerRadiusBottomLeft = CodexSpacing.Radius, + CornerRadiusBottomRight = CodexSpacing.Radius, + ContentMarginLeft = 8, + ContentMarginRight = 8, + ContentMarginTop = 4, + ContentMarginBottom = 4, + }; + theme.SetStylebox("panel", "SkillRow", skillRow); } private static void ApplyLabel(Theme theme, CodexPalette p) @@ -273,34 +356,45 @@ public static class CodexTheme if (_fontsLoaded) return; _fontsLoaded = true; - _serifDisplay = LoadFontFromFonts("CormorantGaramond-Regular.ttf"); - _serifDisplayItalic = LoadFontFromFonts("CormorantGaramond-Italic.ttf"); + _serifDisplay = LoadFontFromFonts("CormorantGaramond-Medium.ttf", + "CormorantGaramond-Regular.ttf"); + _serifDisplayItalic = LoadFontFromFonts("CormorantGaramond-MediumItalic.ttf", + "CormorantGaramond-Italic.ttf"); _serifBody = LoadFontFromFonts("CrimsonPro-Regular.ttf"); _mono = LoadFontFromFonts("JetBrainsMono-Regular.ttf"); - if (_serifDisplay is null && _serifBody is null && _mono is null) + // Mono falls back to body so eyebrow/meta labels still render in a + // serif rather than collapsing to Godot's default sans. + if (_mono is null) _mono = _serifBody; + + if (_serifDisplay is null && _serifBody is null) { GD.Print("[codex-theme] No fonts in res://Fonts/. Using Godot defaults. " + - "Drop CormorantGaramond, CrimsonPro, JetBrainsMono TTFs into " + - "res://Fonts/ for full design parity."); + "Drop CormorantGaramond + CrimsonPro TTFs into res://Fonts/ " + + "for full design parity."); } } - private static FontFile? LoadFontFromFonts(string filename) + private static FontFile? LoadFontFromFonts(params string[] candidateFilenames) { - // Try res://Fonts/ first (Godot-managed). - string resPath = $"res://Fonts/{filename}"; - if (ResourceLoader.Exists(resPath)) - return ResourceLoader.Load(resPath); - - // Fall back to Content/Fonts/ via filesystem load (sibling of Gfx, - // mirrors how MonoGame's CodexFonts loader walks). - string fsPath = Path.Combine(ContentPaths.ContentRoot, "Fonts", filename); - if (File.Exists(fsPath)) + foreach (var filename in candidateFilenames) { - var font = new FontFile(); - font.LoadDynamicFont(fsPath); - return font; + // Try res://Fonts/ first (Godot-managed import). + string resPath = $"res://Fonts/{filename}"; + if (ResourceLoader.Exists(resPath)) + return ResourceLoader.Load(resPath); + + // Fall back to globalized res://Fonts/ via runtime FontFile load. + // ContentPaths is for game data (Content/Data, Content/Gfx) which + // sits next to Theriapolis.Godot, not inside it — so it can't be + // used for fonts shipped under res://Fonts/. + string globalRes = ProjectSettings.GlobalizePath(resPath); + if (File.Exists(globalRes)) + { + var font = new FontFile(); + font.LoadDynamicFont(globalRes); + return font; + } } return null; }