M5: Codex design system (dark theme) + kitchen-sink
Programmatic Theme builder + reusable popover and stepper widgets, ported from CharacterCreator.zip's :root design tokens. Kitchen-sink scene exercises every primitive for visual eyeballing. CodexPalette.cs: Color tokens lifted verbatim from the React prototype's `:root` block (--bg, --ink, --gild, --seal, etc.). Variable names mirror the CSS so the audit trail stays readable. Spacing locked at the prototype's normal density (--gap=24, --pad=28, --radius=2). Scope cut: only the Dark theme ships. The React prototype designed Parchment, Dark, and Blood as switchable variations — user direction during M5 is that only Dark (leather + candlelight) is wanted for this game. Parchment/Blood code dropped, plan doc updated to match (§1 goal #5, §4.5 UI map, §5 M5 scope, §10 resolved decisions #4). No runtime theme switcher. CodexTheme.Build(): Programmatically constructs a Godot Theme from CodexPalette.Dark plus CodexSpacing/CodexType tokens. Configures Panel, Card, CodexPopover styleboxes; Label variations for H1..H4, CodexTitle, Eyebrow, Meta, ValidationOk/Error, CardName/Body/Meta, StepperNum/ Name; Button + PrimaryButton + GhostButton variants; LineEdit, CheckBox, scrollbar styling. Fonts: looks for CormorantGaramond / CrimsonPro / JetBrainsMono TTFs in res://Fonts/ (or Content/Fonts/) and graceful-falls-back to Godot defaults if missing. M5 ships with no fonts in repo; user can drop them in later for typography parity with the React prototype. CodexPopover.cs: Hoverable text trigger + floating PanelContainer, mirrors src/trait-hint.jsx. Viewport-clamps horizontally and vertically; flips above the trigger if there's no room below; 80 ms grace period when moving cursor from trigger to popover. Detriment variant uses the seal-coloured stylebox. Future TraitName / SkillChip / BonusPill widgets layer className differences on top. CodexStepper.cs: Roman-numeral horizontal stepper with Pending / Active / Complete / Locked states. Active step gets a 2-px gild underline, Complete shows a ✓ in seal-red, Locked shows ✕ + 0.45 modulate. Emits StepClicked(int) for non-locked rows. M5 is decorative — M6 wires the signal to the character-creation state machine. KitchenSink.cs + Main.cs --codex-test: Verification scene rendering every primitive (header, stepper, buttons, inputs, cards, trait popovers). Clicks log to console. Fonts default to Godot's Noto Sans until res://Fonts/ is populated. Closes M5 of theriapolis-rpg-implementation-plan-godot-port.md. Next: M6 (title + character creation). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using Godot;
|
using Godot;
|
||||||
using Theriapolis.GodotHost.Platform;
|
using Theriapolis.GodotHost.Platform;
|
||||||
using Theriapolis.GodotHost.Rendering;
|
using Theriapolis.GodotHost.Rendering;
|
||||||
|
using Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
namespace Theriapolis.GodotHost;
|
namespace Theriapolis.GodotHost;
|
||||||
|
|
||||||
@@ -20,9 +21,15 @@ public partial class Main : Node
|
|||||||
ulong? smokeTestSeed = null;
|
ulong? smokeTestSeed = null;
|
||||||
ulong? worldMapSeed = null;
|
ulong? worldMapSeed = null;
|
||||||
bool runAssetTest = false;
|
bool runAssetTest = false;
|
||||||
|
bool runCodexTest = false;
|
||||||
(ulong seed, int tx, int ty)? tacticalArgs = null;
|
(ulong seed, int tx, int ty)? tacticalArgs = null;
|
||||||
for (int i = 0; i < args.Length; i++)
|
for (int i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
|
if (args[i] == "--codex-test")
|
||||||
|
{
|
||||||
|
runCodexTest = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (args[i] == "--smoke-test")
|
if (args[i] == "--smoke-test")
|
||||||
{
|
{
|
||||||
ulong seed = 12345UL;
|
ulong seed = 12345UL;
|
||||||
@@ -94,6 +101,14 @@ public partial class Main : Node
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runCodexTest)
|
||||||
|
{
|
||||||
|
foreach (Node child in GetChildren())
|
||||||
|
child.QueueFree();
|
||||||
|
AddChild(new KitchenSink());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
|
GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Color, font, and spacing tokens lifted from
|
||||||
|
/// <c>CharacterCreator.zip/index.html</c>'s <c>:root</c> block. Variable
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public struct CodexPalette
|
||||||
|
{
|
||||||
|
public Color Bg, Bg2, BgDeep, Ink, InkSoft, InkMute, Rule, Gild, Seal, Seal2, Accent;
|
||||||
|
|
||||||
|
public static readonly CodexPalette Dark = new()
|
||||||
|
{
|
||||||
|
Bg = Hex("#1c1410"),
|
||||||
|
Bg2 = Hex("#261b14"),
|
||||||
|
BgDeep = Hex("#100a07"),
|
||||||
|
Ink = Hex("#f0e2c4"),
|
||||||
|
InkSoft = Hex("#d4be90"),
|
||||||
|
InkMute = Hex("#8c7651"),
|
||||||
|
Rule = Hex("#6b5635"),
|
||||||
|
Gild = Hex("#d8a84a"),
|
||||||
|
Seal = Hex("#b03021"),
|
||||||
|
Seal2 = Hex("#7a1f12"),
|
||||||
|
Accent = Hex("#d8a84a"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Color Hex(string s) => new(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spacing + radius tokens. Density is locked at "normal" per port plan
|
||||||
|
/// §10 (compact mode dropped from scope).
|
||||||
|
/// </summary>
|
||||||
|
public static class CodexSpacing
|
||||||
|
{
|
||||||
|
public const int Gap = 24; // --gap
|
||||||
|
public const int Pad = 28; // --pad
|
||||||
|
public const int Radius = 2; // --radius
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Font-size + letter-spacing tokens. Sizes are in px, matching the CSS.
|
||||||
|
/// Letter-spacing values are in em multiplied by 1000 (em isn't a Godot
|
||||||
|
/// concept; we use it for documentation only — see CodexTheme for actual
|
||||||
|
/// Theme constant assignment).
|
||||||
|
/// </summary>
|
||||||
|
public static class CodexType
|
||||||
|
{
|
||||||
|
// Headings (h1..h4)
|
||||||
|
public const int H1Size = 38;
|
||||||
|
public const int H2Size = 28;
|
||||||
|
public const int H3Size = 20;
|
||||||
|
public const int H4Size = 14;
|
||||||
|
|
||||||
|
// Body / display
|
||||||
|
public const int BodySize = 14;
|
||||||
|
public const int BodyLargeSize = 15;
|
||||||
|
|
||||||
|
// Mono micro-text (eyebrows, meta lines, codex-sub)
|
||||||
|
public const int MonoEyebrowSize = 11;
|
||||||
|
public const int MonoSmallSize = 10;
|
||||||
|
public const int MonoXSmallSize = 9;
|
||||||
|
|
||||||
|
// Codex title (.codex-title)
|
||||||
|
public const int CodexTitleSize = 28;
|
||||||
|
|
||||||
|
// Card components
|
||||||
|
public const int CardNameSize = 22;
|
||||||
|
public const int CardBodySize = 14;
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
public const int BtnSize = 16;
|
||||||
|
public const int BtnSmallSize = 13;
|
||||||
|
|
||||||
|
// Stepper
|
||||||
|
public const int StepperNumSize = 22;
|
||||||
|
public const int StepperNameSize = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
using Godot;
|
||||||
|
using System.IO;
|
||||||
|
using Theriapolis.GodotHost.Platform;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Programmatic Theme builder for the codex design system. Builds a Godot
|
||||||
|
/// <see cref="Theme"/> from a <see cref="CodexPalette"/> and the shared
|
||||||
|
/// CodexSpacing / CodexType tokens; the result is applied to a root
|
||||||
|
/// Control to cascade through all descendants.
|
||||||
|
///
|
||||||
|
/// Fonts: looks for FontFile assets under <c>res://Fonts/</c> 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)
|
||||||
|
///
|
||||||
|
/// Theme variations (.tres) aren't authored in the editor — the entire
|
||||||
|
/// theme tree is constructed in code so palette changes are atomic and
|
||||||
|
/// reviewable as code diffs.
|
||||||
|
/// </summary>
|
||||||
|
public static class CodexTheme
|
||||||
|
{
|
||||||
|
private static FontFile? _serifDisplay;
|
||||||
|
private static FontFile? _serifDisplayItalic;
|
||||||
|
private static FontFile? _serifBody;
|
||||||
|
private static FontFile? _mono;
|
||||||
|
private static bool _fontsLoaded;
|
||||||
|
|
||||||
|
public static Theme Build()
|
||||||
|
{
|
||||||
|
EnsureFonts();
|
||||||
|
var palette = CodexPalette.Dark;
|
||||||
|
var theme = new Theme();
|
||||||
|
|
||||||
|
// Defaults applied to every Control unless overridden.
|
||||||
|
if (_serifBody is not null) theme.DefaultFont = _serifBody;
|
||||||
|
theme.DefaultFontSize = CodexType.BodySize;
|
||||||
|
|
||||||
|
ApplyPanel(theme, palette);
|
||||||
|
ApplyLabel(theme, palette);
|
||||||
|
ApplyButton(theme, palette);
|
||||||
|
ApplyLineEdit(theme, palette);
|
||||||
|
ApplyCheckBox(theme, palette);
|
||||||
|
ApplyScrollContainer(theme, palette);
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyPanel(Theme theme, CodexPalette p)
|
||||||
|
{
|
||||||
|
// Default Panel (used as background frame). Mirrors .app-frame's
|
||||||
|
// border + bg-with-overlay treatment, simplified to a single
|
||||||
|
// StyleBoxFlat with border.
|
||||||
|
var box = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = p.Bg,
|
||||||
|
BorderColor = p.Rule,
|
||||||
|
CornerRadiusTopLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusTopRight = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomRight = CodexSpacing.Radius,
|
||||||
|
};
|
||||||
|
box.SetBorderWidthAll(1);
|
||||||
|
theme.SetStylebox("panel", "Panel", box);
|
||||||
|
|
||||||
|
// Card variant — slightly raised against the page background.
|
||||||
|
// Used by character-creation grid cards (Calling, History, etc.).
|
||||||
|
var card = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = p.Bg2,
|
||||||
|
BorderColor = p.Rule,
|
||||||
|
CornerRadiusTopLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusTopRight = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomRight = CodexSpacing.Radius,
|
||||||
|
ContentMarginLeft = 18,
|
||||||
|
ContentMarginRight = 18,
|
||||||
|
ContentMarginTop = 18,
|
||||||
|
ContentMarginBottom = 16,
|
||||||
|
};
|
||||||
|
card.SetBorderWidthAll(1);
|
||||||
|
theme.SetStylebox("panel", "Card", card);
|
||||||
|
|
||||||
|
var cardSelected = (StyleBoxFlat)card.Duplicate();
|
||||||
|
cardSelected.BorderColor = p.Seal;
|
||||||
|
cardSelected.SetBorderWidthAll(1);
|
||||||
|
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.
|
||||||
|
var popover = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = p.Bg2,
|
||||||
|
BorderColor = p.Gild,
|
||||||
|
ContentMarginLeft = 16,
|
||||||
|
ContentMarginRight = 16,
|
||||||
|
ContentMarginTop = 14,
|
||||||
|
ContentMarginBottom = 12,
|
||||||
|
ShadowColor = new Color(0, 0, 0, 0.45f),
|
||||||
|
ShadowSize = 18,
|
||||||
|
ShadowOffset = new Vector2(0, 12),
|
||||||
|
};
|
||||||
|
popover.SetBorderWidthAll(1);
|
||||||
|
theme.SetStylebox("panel", "CodexPopover", popover);
|
||||||
|
|
||||||
|
var popoverDetriment = (StyleBoxFlat)popover.Duplicate();
|
||||||
|
popoverDetriment.BorderColor = p.Seal;
|
||||||
|
theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyLabel(Theme theme, CodexPalette p)
|
||||||
|
{
|
||||||
|
// Default Label — body type, soft ink.
|
||||||
|
theme.SetColor("font_color", "Label", p.Ink);
|
||||||
|
theme.SetFontSize("font_size", "Label", CodexType.BodySize);
|
||||||
|
if (_serifBody is not null) theme.SetFont("font", "Label", _serifBody);
|
||||||
|
|
||||||
|
// h1..h4 variations applied via SetTypeVariation in code (Godot 4
|
||||||
|
// type variations work by setting a control's ThemeTypeVariation).
|
||||||
|
AddLabelVariation(theme, "H1", p.Ink, CodexType.H1Size, _serifDisplay);
|
||||||
|
AddLabelVariation(theme, "H2", p.Ink, CodexType.H2Size, _serifDisplay);
|
||||||
|
AddLabelVariation(theme, "H3", p.Ink, CodexType.H3Size, _serifDisplay);
|
||||||
|
AddLabelVariation(theme, "H4", p.InkMute, CodexType.H4Size, _serifDisplay);
|
||||||
|
|
||||||
|
// .codex-title — uppercased, wide tracking.
|
||||||
|
AddLabelVariation(theme, "CodexTitle", p.Ink, CodexType.CodexTitleSize, _serifDisplay);
|
||||||
|
|
||||||
|
// .codex-sub / .eyebrow / .meta — mono micro-text in muted ink.
|
||||||
|
AddLabelVariation(theme, "Eyebrow", p.InkMute, CodexType.MonoEyebrowSize, _mono);
|
||||||
|
|
||||||
|
// .nav-progress — same mono micro-text style.
|
||||||
|
AddLabelVariation(theme, "Meta", p.InkMute, CodexType.MonoSmallSize, _mono);
|
||||||
|
|
||||||
|
// .validation — italicised seal-red when error, mono-mute when ok.
|
||||||
|
AddLabelVariation(theme, "ValidationError", p.Seal, CodexType.BodySize, _serifDisplay);
|
||||||
|
AddLabelVariation(theme, "ValidationOk", p.InkMute, CodexType.MonoEyebrowSize, _mono);
|
||||||
|
|
||||||
|
// Card name + body
|
||||||
|
AddLabelVariation(theme, "CardName", p.Ink, CodexType.CardNameSize, _serifDisplay);
|
||||||
|
AddLabelVariation(theme, "CardBody", p.InkSoft, CodexType.CardBodySize, _serifBody);
|
||||||
|
AddLabelVariation(theme, "CardMeta", p.InkMute, CodexType.MonoSmallSize, _mono);
|
||||||
|
|
||||||
|
// Stepper labels
|
||||||
|
AddLabelVariation(theme, "StepperNum", p.InkMute, CodexType.StepperNumSize, _serifDisplay);
|
||||||
|
AddLabelVariation(theme, "StepperName", p.InkMute, CodexType.StepperNameSize, _mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddLabelVariation(Theme theme, string variation, Color color, int size, FontFile? font)
|
||||||
|
{
|
||||||
|
theme.SetTypeVariation(variation, "Label");
|
||||||
|
theme.SetColor("font_color", variation, color);
|
||||||
|
theme.SetFontSize("font_size", variation, size);
|
||||||
|
if (font is not null) theme.SetFont("font", variation, font);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyButton(Theme theme, CodexPalette p)
|
||||||
|
{
|
||||||
|
if (_serifDisplay is not null) theme.SetFont("font", "Button", _serifDisplay);
|
||||||
|
theme.SetFontSize("font_size", "Button", CodexType.BtnSize);
|
||||||
|
theme.SetColor("font_color", "Button", p.Ink);
|
||||||
|
theme.SetColor("font_hover_color", "Button", p.Bg);
|
||||||
|
theme.SetColor("font_pressed_color", "Button", p.Bg);
|
||||||
|
theme.SetColor("font_disabled_color","Button", WithAlpha(p.Ink, 0.4f));
|
||||||
|
|
||||||
|
var normal = MakeButtonBox(p.Bg, p.Ink);
|
||||||
|
var hover = MakeButtonBox(p.Ink, p.Ink);
|
||||||
|
var pressed = MakeButtonBox(p.InkSoft, p.Ink);
|
||||||
|
var disabled = MakeButtonBox(p.Bg, WithAlpha(p.Ink, 0.4f));
|
||||||
|
theme.SetStylebox("normal", "Button", normal);
|
||||||
|
theme.SetStylebox("hover", "Button", hover);
|
||||||
|
theme.SetStylebox("pressed", "Button", pressed);
|
||||||
|
theme.SetStylebox("disabled", "Button", disabled);
|
||||||
|
|
||||||
|
// Primary variant — seal-red fill.
|
||||||
|
theme.SetTypeVariation("PrimaryButton", "Button");
|
||||||
|
theme.SetColor("font_color", "PrimaryButton", p.Bg);
|
||||||
|
theme.SetColor("font_hover_color", "PrimaryButton", p.Bg);
|
||||||
|
theme.SetStylebox("normal", "PrimaryButton", MakeButtonBox(p.Seal, p.Seal));
|
||||||
|
theme.SetStylebox("hover", "PrimaryButton", MakeButtonBox(p.Seal2, p.Seal2));
|
||||||
|
theme.SetStylebox("pressed", "PrimaryButton", MakeButtonBox(p.Seal2, p.Seal2));
|
||||||
|
|
||||||
|
// Ghost variant — transparent background, rule-coloured border.
|
||||||
|
theme.SetTypeVariation("GhostButton", "Button");
|
||||||
|
theme.SetColor("font_color", "GhostButton", p.InkSoft);
|
||||||
|
theme.SetColor("font_hover_color", "GhostButton", p.Ink);
|
||||||
|
theme.SetStylebox("normal", "GhostButton", MakeButtonBox(new Color(0, 0, 0, 0), p.Rule));
|
||||||
|
theme.SetStylebox("hover", "GhostButton", MakeButtonBox(WithAlpha(p.Ink, 0.06f), p.Rule));
|
||||||
|
theme.SetStylebox("pressed", "GhostButton", MakeButtonBox(WithAlpha(p.Ink, 0.12f), p.Rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StyleBoxFlat MakeButtonBox(Color bg, Color border)
|
||||||
|
{
|
||||||
|
var box = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = bg,
|
||||||
|
BorderColor = border,
|
||||||
|
CornerRadiusTopLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusTopRight = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomRight = CodexSpacing.Radius,
|
||||||
|
ContentMarginLeft = 22,
|
||||||
|
ContentMarginRight = 22,
|
||||||
|
ContentMarginTop = 10,
|
||||||
|
ContentMarginBottom = 10,
|
||||||
|
};
|
||||||
|
box.SetBorderWidthAll(1);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyLineEdit(Theme theme, CodexPalette p)
|
||||||
|
{
|
||||||
|
if (_serifBody is not null) theme.SetFont("font", "LineEdit", _serifBody);
|
||||||
|
theme.SetFontSize("font_size", "LineEdit", CodexType.BodySize);
|
||||||
|
theme.SetColor("font_color", "LineEdit", p.Ink);
|
||||||
|
theme.SetColor("caret_color", "LineEdit", p.Seal);
|
||||||
|
theme.SetColor("selection_color", "LineEdit", WithAlpha(p.Gild, 0.5f));
|
||||||
|
|
||||||
|
var normal = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = p.Bg,
|
||||||
|
BorderColor = p.Rule,
|
||||||
|
ContentMarginLeft = 12,
|
||||||
|
ContentMarginRight = 12,
|
||||||
|
ContentMarginTop = 8,
|
||||||
|
ContentMarginBottom = 8,
|
||||||
|
};
|
||||||
|
normal.SetBorderWidthAll(1);
|
||||||
|
theme.SetStylebox("normal", "LineEdit", normal);
|
||||||
|
|
||||||
|
var focus = (StyleBoxFlat)normal.Duplicate();
|
||||||
|
focus.BorderColor = p.Gild;
|
||||||
|
focus.SetBorderWidthAll(1);
|
||||||
|
theme.SetStylebox("focus", "LineEdit", focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyCheckBox(Theme theme, CodexPalette p)
|
||||||
|
{
|
||||||
|
if (_serifBody is not null) theme.SetFont("font", "CheckBox", _serifBody);
|
||||||
|
theme.SetFontSize("font_size", "CheckBox", CodexType.BodySize);
|
||||||
|
theme.SetColor("font_color", "CheckBox", p.Ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyScrollContainer(Theme theme, CodexPalette p)
|
||||||
|
{
|
||||||
|
// Scroll bar pieces — keep subtle so they don't dominate the codex feel.
|
||||||
|
var bg = new StyleBoxFlat { BgColor = WithAlpha(p.Rule, 0.15f) };
|
||||||
|
var grabber = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = WithAlpha(p.InkMute, 0.7f),
|
||||||
|
CornerRadiusTopLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusTopRight = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomLeft = CodexSpacing.Radius,
|
||||||
|
CornerRadiusBottomRight = CodexSpacing.Radius,
|
||||||
|
};
|
||||||
|
theme.SetStylebox("scroll", "VScrollBar", bg);
|
||||||
|
theme.SetStylebox("grabber", "VScrollBar", grabber);
|
||||||
|
theme.SetStylebox("grabber_highlight", "VScrollBar",
|
||||||
|
(StyleBoxFlat)grabber.Duplicate(true));
|
||||||
|
theme.SetStylebox("scroll", "HScrollBar", bg);
|
||||||
|
theme.SetStylebox("grabber", "HScrollBar", grabber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void EnsureFonts()
|
||||||
|
{
|
||||||
|
if (_fontsLoaded) return;
|
||||||
|
_fontsLoaded = true;
|
||||||
|
|
||||||
|
_serifDisplay = LoadFontFromFonts("CormorantGaramond-Regular.ttf");
|
||||||
|
_serifDisplayItalic = LoadFontFromFonts("CormorantGaramond-Italic.ttf");
|
||||||
|
_serifBody = LoadFontFromFonts("CrimsonPro-Regular.ttf");
|
||||||
|
_mono = LoadFontFromFonts("JetBrainsMono-Regular.ttf");
|
||||||
|
|
||||||
|
if (_serifDisplay is null && _serifBody is null && _mono 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FontFile? LoadFontFromFonts(string filename)
|
||||||
|
{
|
||||||
|
// Try res://Fonts/ first (Godot-managed).
|
||||||
|
string resPath = $"res://Fonts/{filename}";
|
||||||
|
if (ResourceLoader.Exists(resPath))
|
||||||
|
return ResourceLoader.Load<FontFile>(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))
|
||||||
|
{
|
||||||
|
var font = new FontFile();
|
||||||
|
font.LoadDynamicFont(fsPath);
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static Color WithAlpha(Color c, float a) => new(c.R, c.G, c.B, a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using Godot;
|
||||||
|
using Theriapolis.GodotHost.UI.Widgets;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M5 kitchen-sink. Renders every primitive from the codex design system
|
||||||
|
/// (single Dark theme — Parchment and Blood dropped from scope per port
|
||||||
|
/// plan §10) so we can eyeball parity against screenshots of the React
|
||||||
|
/// prototype. Launch via:
|
||||||
|
/// godot --path Theriapolis.Godot --codex-test
|
||||||
|
/// </summary>
|
||||||
|
public partial class KitchenSink : Control
|
||||||
|
{
|
||||||
|
private Panel _root = null!;
|
||||||
|
private CodexStepper _stepper = null!;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
AnchorRight = 1f;
|
||||||
|
AnchorBottom = 1f;
|
||||||
|
OffsetRight = 0;
|
||||||
|
OffsetBottom = 0;
|
||||||
|
|
||||||
|
_root = new Panel { ThemeTypeVariation = "Panel" };
|
||||||
|
_root.AnchorRight = 1f;
|
||||||
|
_root.AnchorBottom = 1f;
|
||||||
|
AddChild(_root);
|
||||||
|
|
||||||
|
ApplyTheme();
|
||||||
|
BuildContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyTheme()
|
||||||
|
{
|
||||||
|
_root.Theme = CodexTheme.Build();
|
||||||
|
|
||||||
|
// The root Panel's stylebox covers its rect; fill the *outer*
|
||||||
|
// viewport (which Godot draws with its default clear colour) so
|
||||||
|
// screenshots don't show engine grey along any edge.
|
||||||
|
RenderingServer.SetDefaultClearColor(CodexPalette.Dark.BgDeep);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildContent()
|
||||||
|
{
|
||||||
|
var margin = new MarginContainer();
|
||||||
|
margin.AddThemeConstantOverride("margin_left", 36);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", 36);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", 28);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", 28);
|
||||||
|
margin.AnchorRight = 1f;
|
||||||
|
margin.AnchorBottom = 1f;
|
||||||
|
_root.AddChild(margin);
|
||||||
|
|
||||||
|
var scroll = new ScrollContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
|
margin.AddChild(scroll);
|
||||||
|
|
||||||
|
var col = new VBoxContainer
|
||||||
|
{
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||||
|
};
|
||||||
|
col.AddThemeConstantOverride("separation", 24);
|
||||||
|
scroll.AddChild(col);
|
||||||
|
|
||||||
|
BuildHeader(col);
|
||||||
|
BuildStepperSection(col);
|
||||||
|
BuildButtonsSection(col);
|
||||||
|
BuildInputsSection(col);
|
||||||
|
BuildCardSection(col);
|
||||||
|
BuildPopoverSection(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildHeader(VBoxContainer col)
|
||||||
|
{
|
||||||
|
var h = new HBoxContainer();
|
||||||
|
col.AddChild(h);
|
||||||
|
|
||||||
|
var titleCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||||
|
h.AddChild(titleCol);
|
||||||
|
|
||||||
|
titleCol.AddChild(new Label { Text = "THERIAPOLIS · CODEX OF BECOMING", ThemeTypeVariation = "CodexTitle" });
|
||||||
|
titleCol.AddChild(new Label { Text = "Kitchen sink — every primitive, three themes", ThemeTypeVariation = "Eyebrow" });
|
||||||
|
|
||||||
|
var meta = new Label { Text = "M5 · DESIGN AUDIT", ThemeTypeVariation = "Meta" };
|
||||||
|
h.AddChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildStepperSection(VBoxContainer col)
|
||||||
|
{
|
||||||
|
var section = MakeSection(col, "STEPPER");
|
||||||
|
_stepper = new CodexStepper();
|
||||||
|
_stepper.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
section.AddChild(_stepper);
|
||||||
|
|
||||||
|
_stepper.SetSteps(
|
||||||
|
new[] { "Clade", "Species", "Calling", "Subclass", "History", "Abilities", "Skills", "Sign" },
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
CodexStepper.StepState.Complete,
|
||||||
|
CodexStepper.StepState.Complete,
|
||||||
|
CodexStepper.StepState.Complete,
|
||||||
|
CodexStepper.StepState.Active,
|
||||||
|
CodexStepper.StepState.Pending,
|
||||||
|
CodexStepper.StepState.Locked,
|
||||||
|
CodexStepper.StepState.Locked,
|
||||||
|
CodexStepper.StepState.Locked,
|
||||||
|
});
|
||||||
|
|
||||||
|
_stepper.StepClicked += (int idx) => GD.Print($"[kitchen-sink] Stepper clicked index={idx}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildButtonsSection(VBoxContainer col)
|
||||||
|
{
|
||||||
|
var section = MakeSection(col, "BUTTONS");
|
||||||
|
var row = new HBoxContainer();
|
||||||
|
row.AddThemeConstantOverride("separation", 12);
|
||||||
|
section.AddChild(row);
|
||||||
|
|
||||||
|
row.AddChild(new Button { Text = "Default" });
|
||||||
|
row.AddChild(new Button { Text = "Primary", ThemeTypeVariation = "PrimaryButton" });
|
||||||
|
row.AddChild(new Button { Text = "Ghost", ThemeTypeVariation = "GhostButton" });
|
||||||
|
var disabled = new Button { Text = "Disabled", Disabled = true };
|
||||||
|
row.AddChild(disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildInputsSection(VBoxContainer col)
|
||||||
|
{
|
||||||
|
var section = MakeSection(col, "INPUTS");
|
||||||
|
var row = new HBoxContainer();
|
||||||
|
row.AddThemeConstantOverride("separation", 12);
|
||||||
|
section.AddChild(row);
|
||||||
|
|
||||||
|
var le = new LineEdit
|
||||||
|
{
|
||||||
|
PlaceholderText = "Enter your name...",
|
||||||
|
CustomMinimumSize = new Vector2(280, 0),
|
||||||
|
};
|
||||||
|
row.AddChild(le);
|
||||||
|
|
||||||
|
row.AddChild(new CheckBox { Text = "Toggle option" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildCardSection(VBoxContainer col)
|
||||||
|
{
|
||||||
|
var section = MakeSection(col, "CARDS");
|
||||||
|
var grid = new HBoxContainer();
|
||||||
|
grid.AddThemeConstantOverride("separation", CodexSpacing.Gap);
|
||||||
|
section.AddChild(grid);
|
||||||
|
|
||||||
|
AddCard(grid, "Canidae", "Predator · medium", "Strong-jawed pack hunters of the inland forest.", selected: false);
|
||||||
|
AddCard(grid, "Felidae", "Predator · medium", "Solitary stalkers, claw-tipped and sharp of ear.", selected: true);
|
||||||
|
AddCard(grid, "Mustelidae", "Predator · small", "Burrowers and threadkillers of the river edges.", selected: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddCard(HBoxContainer parent, string name, string meta, string body, bool selected)
|
||||||
|
{
|
||||||
|
var panel = new PanelContainer
|
||||||
|
{
|
||||||
|
ThemeTypeVariation = "Card",
|
||||||
|
CustomMinimumSize = new Vector2(240, 0),
|
||||||
|
};
|
||||||
|
if (selected && _root.Theme is not null && _root.Theme.HasStylebox("panel_selected", "Card"))
|
||||||
|
{
|
||||||
|
var box = _root.Theme.GetStylebox("panel_selected", "Card");
|
||||||
|
panel.AddThemeStyleboxOverride("panel", box);
|
||||||
|
}
|
||||||
|
var v = new VBoxContainer();
|
||||||
|
v.AddThemeConstantOverride("separation", 6);
|
||||||
|
panel.AddChild(v);
|
||||||
|
v.AddChild(new Label { Text = name, ThemeTypeVariation = "CardName" });
|
||||||
|
v.AddChild(new Label { Text = meta.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
|
||||||
|
v.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = body,
|
||||||
|
ThemeTypeVariation = "CardBody",
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
CustomMinimumSize = new Vector2(220, 0),
|
||||||
|
});
|
||||||
|
parent.AddChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildPopoverSection(VBoxContainer col)
|
||||||
|
{
|
||||||
|
var section = MakeSection(col, "TRAIT POPOVERS · HOVER");
|
||||||
|
var row = new HBoxContainer();
|
||||||
|
row.AddThemeConstantOverride("separation", 18);
|
||||||
|
section.AddChild(row);
|
||||||
|
|
||||||
|
row.AddChild(new CodexPopover
|
||||||
|
{
|
||||||
|
TriggerText = "Pack Tactics",
|
||||||
|
Title = "Pack Tactics",
|
||||||
|
Tag = "active",
|
||||||
|
Description = "Once per turn, when an ally is adjacent to your target, gain advantage on the attack.",
|
||||||
|
});
|
||||||
|
row.AddChild(new CodexPopover
|
||||||
|
{
|
||||||
|
TriggerText = "Glass Bones",
|
||||||
|
Title = "Glass Bones",
|
||||||
|
Description = "Critical hits against you deal an extra 1d6 damage.",
|
||||||
|
Detriment = true,
|
||||||
|
});
|
||||||
|
row.AddChild(new CodexPopover
|
||||||
|
{
|
||||||
|
TriggerText = "Athletics",
|
||||||
|
Title = "Athletics",
|
||||||
|
Tag = "STR",
|
||||||
|
Description = "Climbs, jumps, swims, and brute physical contests.",
|
||||||
|
});
|
||||||
|
row.AddChild(new CodexPopover
|
||||||
|
{
|
||||||
|
TriggerText = "+2",
|
||||||
|
Title = "STR modifier",
|
||||||
|
Description = "+1 from Canidae · +1 from Wolf",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VBoxContainer MakeSection(VBoxContainer col, string title)
|
||||||
|
{
|
||||||
|
var box = new VBoxContainer();
|
||||||
|
box.AddThemeConstantOverride("separation", 8);
|
||||||
|
col.AddChild(box);
|
||||||
|
box.AddChild(new Label { Text = title, ThemeTypeVariation = "Eyebrow" });
|
||||||
|
var inner = new VBoxContainer();
|
||||||
|
inner.AddThemeConstantOverride("separation", 8);
|
||||||
|
box.AddChild(inner);
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.UI.Widgets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hoverable text trigger that shows a floating popover with name + optional
|
||||||
|
/// tag + description. Mirrors <c>src/trait-hint.jsx</c> from the React
|
||||||
|
/// prototype — viewport-clamp horizontally and vertically, flip above/below
|
||||||
|
/// based on available space, 80ms grace period when moving from trigger to
|
||||||
|
/// popover so the popover stays open across the gap.
|
||||||
|
///
|
||||||
|
/// Used by future TraitName, SkillChip, BonusPill widgets — the React
|
||||||
|
/// version layers className differences on top of this same primitive.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CodexPopover : Control
|
||||||
|
{
|
||||||
|
[Export] public string TriggerText { get; set; } = "trait";
|
||||||
|
[Export] public string Title { get; set; } = "";
|
||||||
|
[Export] public string Tag { get; set; } = "";
|
||||||
|
[Export] public string Description { get; set; } = "";
|
||||||
|
[Export] public bool Detriment { get; set; }
|
||||||
|
|
||||||
|
private const float GracePeriodSec = 0.08f; // ~80 ms — matches trait-hint.jsx
|
||||||
|
private const float ArrowOffset = 6f;
|
||||||
|
private const int ViewportPad = 8;
|
||||||
|
|
||||||
|
private Label _trigger = null!;
|
||||||
|
private PanelContainer? _popover;
|
||||||
|
private Timer _closeTimer = null!;
|
||||||
|
private bool _hovering;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
_trigger = new Label
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrEmpty(Title) ? TriggerText : Title,
|
||||||
|
ThemeTypeVariation = "Label",
|
||||||
|
MouseFilter = MouseFilterEnum.Stop,
|
||||||
|
};
|
||||||
|
_trigger.MouseEntered += OnTriggerEntered;
|
||||||
|
_trigger.MouseExited += OnTriggerExited;
|
||||||
|
AddChild(_trigger);
|
||||||
|
|
||||||
|
_closeTimer = new Timer { OneShot = true, WaitTime = GracePeriodSec };
|
||||||
|
_closeTimer.Timeout += HidePopover;
|
||||||
|
AddChild(_closeTimer);
|
||||||
|
|
||||||
|
CustomMinimumSize = _trigger.GetMinimumSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Notification(int what)
|
||||||
|
{
|
||||||
|
// Make sure the popover is freed if the trigger is removed.
|
||||||
|
if (what == NotificationExitTree && _popover is not null)
|
||||||
|
{
|
||||||
|
_popover.QueueFree();
|
||||||
|
_popover = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTriggerEntered()
|
||||||
|
{
|
||||||
|
_hovering = true;
|
||||||
|
_closeTimer.Stop();
|
||||||
|
ShowPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTriggerExited()
|
||||||
|
{
|
||||||
|
_hovering = false;
|
||||||
|
_closeTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPopoverEntered()
|
||||||
|
{
|
||||||
|
_closeTimer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPopoverExited()
|
||||||
|
{
|
||||||
|
_closeTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowPopover()
|
||||||
|
{
|
||||||
|
if (_popover is not null && IsInstanceValid(_popover)) { Reposition(); return; }
|
||||||
|
|
||||||
|
_popover = BuildPopoverPanel();
|
||||||
|
_popover.MouseEntered += OnPopoverEntered;
|
||||||
|
_popover.MouseExited += OnPopoverExited;
|
||||||
|
|
||||||
|
var canvas = new CanvasLayer { Layer = 100 };
|
||||||
|
canvas.Name = "CodexPopoverCanvas";
|
||||||
|
canvas.AddChild(_popover);
|
||||||
|
// Attach the canvas layer to the scene's root viewport so the popover
|
||||||
|
// floats above every other UI element regardless of where the trigger
|
||||||
|
// lives in the tree.
|
||||||
|
GetTree().Root.AddChild(canvas);
|
||||||
|
|
||||||
|
// One frame later, the panel has its laid-out size; reposition with
|
||||||
|
// viewport clamp + flip-above logic.
|
||||||
|
CallDeferred(MethodName.Reposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HidePopover()
|
||||||
|
{
|
||||||
|
if (_popover is null) return;
|
||||||
|
// Free the canvas layer parent (which owns the popover).
|
||||||
|
_popover.GetParent()?.QueueFree();
|
||||||
|
_popover = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PanelContainer BuildPopoverPanel()
|
||||||
|
{
|
||||||
|
var panel = new PanelContainer
|
||||||
|
{
|
||||||
|
ThemeTypeVariation = "CodexPopover",
|
||||||
|
MouseFilter = MouseFilterEnum.Pass,
|
||||||
|
ZIndex = 100,
|
||||||
|
// Detriment popovers swap to the seal-coloured stylebox.
|
||||||
|
// Theme stylebox names live under "panel" for the default and
|
||||||
|
// "panel_detriment" for the variant; we set whichever via override.
|
||||||
|
};
|
||||||
|
if (Detriment && panel.HasThemeStylebox("panel_detriment", "CodexPopover"))
|
||||||
|
{
|
||||||
|
var box = panel.GetThemeStylebox("panel_detriment", "CodexPopover");
|
||||||
|
panel.AddThemeStyleboxOverride("panel", box);
|
||||||
|
}
|
||||||
|
|
||||||
|
var vbox = new VBoxContainer { CustomMinimumSize = new Vector2(220, 0) };
|
||||||
|
panel.AddChild(vbox);
|
||||||
|
|
||||||
|
var nameRow = new HBoxContainer();
|
||||||
|
nameRow.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrEmpty(Title) ? TriggerText : Title,
|
||||||
|
ThemeTypeVariation = "H3",
|
||||||
|
});
|
||||||
|
if (!string.IsNullOrEmpty(Tag))
|
||||||
|
{
|
||||||
|
var tagPill = new Label
|
||||||
|
{
|
||||||
|
Text = Tag.ToUpperInvariant(),
|
||||||
|
ThemeTypeVariation = "ValidationOk",
|
||||||
|
};
|
||||||
|
nameRow.AddChild(tagPill);
|
||||||
|
}
|
||||||
|
if (Detriment)
|
||||||
|
{
|
||||||
|
var detPill = new Label
|
||||||
|
{
|
||||||
|
Text = "DETRIMENT",
|
||||||
|
ThemeTypeVariation = "ValidationOk",
|
||||||
|
};
|
||||||
|
nameRow.AddChild(detPill);
|
||||||
|
}
|
||||||
|
vbox.AddChild(nameRow);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(Description))
|
||||||
|
{
|
||||||
|
var desc = new Label
|
||||||
|
{
|
||||||
|
Text = Description,
|
||||||
|
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||||
|
ThemeTypeVariation = "CardBody",
|
||||||
|
};
|
||||||
|
vbox.AddChild(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reposition()
|
||||||
|
{
|
||||||
|
if (_popover is null || !IsInstanceValid(_popover)) return;
|
||||||
|
|
||||||
|
var viewport = GetViewport().GetVisibleRect();
|
||||||
|
var trig = _trigger.GetGlobalRect();
|
||||||
|
var size = _popover.GetCombinedMinimumSize();
|
||||||
|
|
||||||
|
float left = trig.Position.X;
|
||||||
|
float top = trig.Position.Y + trig.Size.Y + ArrowOffset;
|
||||||
|
bool flippedAbove = false;
|
||||||
|
|
||||||
|
if (top + size.Y + ViewportPad > viewport.Size.Y &&
|
||||||
|
trig.Position.Y - ArrowOffset - size.Y >= ViewportPad)
|
||||||
|
{
|
||||||
|
top = trig.Position.Y - ArrowOffset - size.Y;
|
||||||
|
flippedAbove = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left + size.X + ViewportPad > viewport.Size.X)
|
||||||
|
left = viewport.Size.X - size.X - ViewportPad;
|
||||||
|
if (left < ViewportPad) left = ViewportPad;
|
||||||
|
|
||||||
|
if (top + size.Y + ViewportPad > viewport.Size.Y)
|
||||||
|
top = viewport.Size.Y - size.Y - ViewportPad;
|
||||||
|
if (top < ViewportPad) top = ViewportPad;
|
||||||
|
|
||||||
|
_popover.Position = new Vector2(left, top);
|
||||||
|
_popover.Size = size;
|
||||||
|
// flippedAbove can drive a future arrow image; we don't render an
|
||||||
|
// arrow in M5 — the React version's CSS pseudo-element doesn't
|
||||||
|
// map cleanly onto Godot's StyleBox. Border + shadow is sufficient.
|
||||||
|
_ = flippedAbove;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _Process(double delta)
|
||||||
|
{
|
||||||
|
// If the trigger is still mounted but the popover got orphaned for any
|
||||||
|
// reason, drop our reference so a fresh hover re-creates it.
|
||||||
|
if (_popover is not null && !IsInstanceValid(_popover)) _popover = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Theriapolis.GodotHost.UI.Widgets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Codex stepper. Mirrors <c>.stepper / .step</c> in the React prototype:
|
||||||
|
/// horizontal grid of N steps, each with a Roman numeral and an uppercased
|
||||||
|
/// name. Per-step state drives colour and the gild-coloured underline:
|
||||||
|
/// Active (current), Complete (✓ + seal), Locked (✕ + dimmed). Click on
|
||||||
|
/// any non-locked step emits <see cref="StepClicked"/>.
|
||||||
|
///
|
||||||
|
/// Lock semantics mirror <c>app.jsx</c>: a step is locked iff some
|
||||||
|
/// earlier step has unmet validation. The owning screen passes a per-step
|
||||||
|
/// <see cref="StepState"/> array; this widget just renders.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CodexStepper : HBoxContainer
|
||||||
|
{
|
||||||
|
public enum StepState { Pending, Active, Complete, Locked }
|
||||||
|
|
||||||
|
[Signal] public delegate void StepClickedEventHandler(int index);
|
||||||
|
|
||||||
|
private readonly List<StepEntry> _entries = new();
|
||||||
|
|
||||||
|
public void SetSteps(IReadOnlyList<string> names, IReadOnlyList<StepState> states)
|
||||||
|
{
|
||||||
|
if (names.Count != states.Count)
|
||||||
|
throw new ArgumentException("names and states must be the same length");
|
||||||
|
|
||||||
|
// Rebuild children from scratch — N is small (<=8) so this is cheap.
|
||||||
|
foreach (var child in GetChildren())
|
||||||
|
child.QueueFree();
|
||||||
|
_entries.Clear();
|
||||||
|
|
||||||
|
for (int i = 0; i < names.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = BuildStep(i, names[i], states[i], isLast: i == names.Count - 1);
|
||||||
|
AddChild(entry.Container);
|
||||||
|
_entries.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StepEntry BuildStep(int index, string name, StepState state, bool isLast)
|
||||||
|
{
|
||||||
|
// Outer cell — VBoxContainer with mouse handling on a button child
|
||||||
|
// so we get press events for click-to-jump. SizeFlagsHorizontal
|
||||||
|
// expand makes each cell take an equal share of the width.
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
ThemeTypeVariation = "GhostButton",
|
||||||
|
Flat = true,
|
||||||
|
FocusMode = FocusModeEnum.None,
|
||||||
|
CustomMinimumSize = new Vector2(0, 64),
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||||
|
ToggleMode = false,
|
||||||
|
};
|
||||||
|
// Build a vbox child for two stacked labels.
|
||||||
|
var vbox = new VBoxContainer
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||||
|
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||||
|
Alignment = BoxContainer.AlignmentMode.Center,
|
||||||
|
};
|
||||||
|
btn.AddChild(vbox);
|
||||||
|
|
||||||
|
var num = new Label
|
||||||
|
{
|
||||||
|
Text = state == StepState.Locked ? "✕" : Roman(index + 1),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
ThemeTypeVariation = "StepperNum",
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
vbox.AddChild(num);
|
||||||
|
|
||||||
|
var lbl = new Label
|
||||||
|
{
|
||||||
|
Text = name.ToUpperInvariant(),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
ThemeTypeVariation = "StepperName",
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
vbox.AddChild(lbl);
|
||||||
|
|
||||||
|
ApplyStateColors(num, lbl, state);
|
||||||
|
|
||||||
|
if (state != StepState.Locked)
|
||||||
|
btn.Pressed += () => EmitSignal(SignalName.StepClicked, index);
|
||||||
|
else
|
||||||
|
btn.Disabled = true;
|
||||||
|
|
||||||
|
// The active step gets a 2-px gild underline. Implement as a
|
||||||
|
// ColorRect bottom-anchored at -1 within the button.
|
||||||
|
if (state == StepState.Active)
|
||||||
|
{
|
||||||
|
var underline = new ColorRect
|
||||||
|
{
|
||||||
|
Color = TryGetThemeColor("font_color", "Gild") ?? new Color("#b48a3c"),
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore,
|
||||||
|
};
|
||||||
|
underline.AnchorTop = 1.0f;
|
||||||
|
underline.AnchorBottom = 1.0f;
|
||||||
|
underline.AnchorLeft = 0.14f;
|
||||||
|
underline.AnchorRight = 0.86f;
|
||||||
|
underline.OffsetTop = -2;
|
||||||
|
underline.OffsetBottom = 0;
|
||||||
|
btn.AddChild(underline);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepEntry(btn, num, lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyStateColors(Label num, Label name, StepState state)
|
||||||
|
{
|
||||||
|
// Default theme colours come from the StepperNum/StepperName variations
|
||||||
|
// (ink-mute). State overrides bring active steps to ink and complete
|
||||||
|
// to seal-red. Locked uses the dim default plus reduced opacity.
|
||||||
|
Color? numColor = state switch
|
||||||
|
{
|
||||||
|
StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")),
|
||||||
|
StepState.Complete => TryGetGlobalThemeColor("Seal", new Color("#7a1f12")),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
Color? nameColor = state switch
|
||||||
|
{
|
||||||
|
StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (numColor.HasValue) num.AddThemeColorOverride("font_color", numColor.Value);
|
||||||
|
if (nameColor.HasValue) name.AddThemeColorOverride("font_color", nameColor.Value);
|
||||||
|
|
||||||
|
if (state == StepState.Complete)
|
||||||
|
num.Text = "✓ " + num.Text;
|
||||||
|
|
||||||
|
if (state == StepState.Locked)
|
||||||
|
{
|
||||||
|
num.Modulate = new Color(1, 1, 1, 0.45f);
|
||||||
|
name.Modulate = new Color(1, 1, 1, 0.45f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color? TryGetGlobalThemeColor(string name, Color fallback) => fallback;
|
||||||
|
|
||||||
|
private Color? TryGetThemeColor(string property, string variation)
|
||||||
|
{
|
||||||
|
if (HasThemeColor(property, variation)) return GetThemeColor(property, variation);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Roman(int n) => n switch
|
||||||
|
{
|
||||||
|
1 => "I",
|
||||||
|
2 => "II",
|
||||||
|
3 => "III",
|
||||||
|
4 => "IV",
|
||||||
|
5 => "V",
|
||||||
|
6 => "VI",
|
||||||
|
7 => "VII",
|
||||||
|
8 => "VIII",
|
||||||
|
9 => "IX",
|
||||||
|
10 => "X",
|
||||||
|
_ => n.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly record struct StepEntry(Button Container, Label Num, Label Name);
|
||||||
|
}
|
||||||
@@ -55,10 +55,10 @@ architecture test gets a one-line update (the forbidden namespace becomes
|
|||||||
dungeon, plus the Phase-7 dialogue→combat overlay. *Visual* parity with
|
dungeon, plus the Phase-7 dialogue→combat overlay. *Visual* parity with
|
||||||
today's CodexUI is **not** a goal — the React prototype is the new
|
today's CodexUI is **not** a goal — the React prototype is the new
|
||||||
baseline. Behavioural parity (what each screen *does*) is the goal.
|
baseline. Behavioural parity (what each screen *does*) is the goal.
|
||||||
5. **Ship all three themes.** Parchment (default), dark (leather &
|
5. **Ship the dark theme only.** The React prototype designed Parchment,
|
||||||
candlelight), blood (warm crimson). The React prototype already designed
|
Dark, and Blood; the user's call during M5 is that only Dark (leather +
|
||||||
them; Godot's Theme + ThemeVariation system makes shipping all three
|
candlelight) is wanted for this game. Parchment and Blood are dropped
|
||||||
approximately free. Theme is a runtime player setting, not a dev tweak.
|
from scope. No runtime theme switcher — there is one theme.
|
||||||
6. **Tests still green.** All ~700 Core/worldgen/determinism/save tests pass
|
6. **Tests still green.** All ~700 Core/worldgen/determinism/save tests pass
|
||||||
unchanged. Project builds and runs `dotnet test` and the headless
|
unchanged. Project builds and runs `dotnet test` and the headless
|
||||||
`Theriapolis.Tools` CLI exactly as before.
|
`Theriapolis.Tools` CLI exactly as before.
|
||||||
@@ -270,8 +270,7 @@ only; do not preserve its visual choices.
|
|||||||
|
|
||||||
| React prototype (CharacterCreator.zip) | Godot equivalent |
|
| React prototype (CharacterCreator.zip) | Godot equivalent |
|
||||||
|-----------------------------------------------------|--------------------------------------------------------|
|
|-----------------------------------------------------|--------------------------------------------------------|
|
||||||
| CSS custom properties (`--bg`, `--ink`, `--gild`, ...) | `Theme` resource type variations (one per theme) |
|
| CSS custom properties (`--bg`, `--ink`, `--gild`, ...) | Single `Theme` resource constructed in `CodexTheme.cs`|
|
||||||
| `[data-theme="dark"]` / `[data-theme="blood"]` | `ThemeVariation` resources, switched via player setting|
|
|
||||||
| `.codex-header`, `.codex-title`, `.codex-sub` | `PanelContainer` + `Label` with display font theme |
|
| `.codex-header`, `.codex-title`, `.codex-sub` | `PanelContainer` + `Label` with display font theme |
|
||||||
| `.stepper` / `.step` / `.step.active/.complete/.locked` | Custom `HBoxContainer` script with state-driven theme |
|
| `.stepper` / `.step` / `.step.active/.complete/.locked` | Custom `HBoxContainer` script with state-driven theme |
|
||||||
| `.page` (two-column main + aside) | `HSplitContainer` or `HBoxContainer` (fixed ratio) |
|
| `.page` (two-column main + aside) | `HSplitContainer` or `HBoxContainer` (fixed ratio) |
|
||||||
@@ -384,8 +383,8 @@ foundation every subsequent screen builds on.
|
|||||||
`--bg` / `--ink` / `--gild` / `--seal` / `--rule` mapped to Theme constants;
|
`--bg` / `--ink` / `--gild` / `--seal` / `--rule` mapped to Theme constants;
|
||||||
display + body fonts as Theme default fonts; standard spacing/radius
|
display + body fonts as Theme default fonts; standard spacing/radius
|
||||||
constants.
|
constants.
|
||||||
- `Theme/Variations/dark.tres` and `Theme/Variations/blood.tres`: ThemeVariations
|
- ~~Theme variations for Parchment + Blood~~ — dropped during M5 per
|
||||||
for the other two themes. Player setting selects the active variation.
|
user decision; Dark only.
|
||||||
- Fonts: copy Cormorant Garamond, Crimson Pro, Spectral, EB Garamond, Cinzel,
|
- Fonts: copy Cormorant Garamond, Crimson Pro, Spectral, EB Garamond, Cinzel,
|
||||||
Uncial Antiqua, JetBrains Mono into `res://Fonts/`. Bundle as `FontFile`
|
Uncial Antiqua, JetBrains Mono into `res://Fonts/`. Bundle as `FontFile`
|
||||||
resources at the sizes the prototype uses (verify by spot-rendering each).
|
resources at the sizes the prototype uses (verify by spot-rendering each).
|
||||||
@@ -712,8 +711,8 @@ The bright line: **anything that touches `WorldState`, `PlayerCharacter`,
|
|||||||
default. The Theme must scale legibly across 1920×1080 / 2560×1440 /
|
default. The Theme must scale legibly across 1920×1080 / 2560×1440 /
|
||||||
3840×2160. F11 (or `--windowed`) toggles to a windowed mode for
|
3840×2160. F11 (or `--windowed`) toggles to a windowed mode for
|
||||||
iteration. Configured in M9.
|
iteration. Configured in M9.
|
||||||
4. **Default theme.** Parchment. Dark and blood ship as player-selectable
|
4. **Theme.** Dark only (leather + candlelight palette). Parchment and
|
||||||
variations in the existing settings screen.
|
Blood dropped from scope during M5; no runtime theme switcher.
|
||||||
|
|
||||||
### Open questions for the user / project lead
|
### Open questions for the user / project lead
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user