Files
code_of_conquest_dnd/client/scripts/theme/build_game_theme.gd
Phillip Tarrant 63cbc8c16e fix(creation-screen): the white text, the badge on top of the roll, and a window too big for the laptop
The human's first F6 run found four defects headless GUT could not see, all
now fixed and break-proven.

1. Fifteen Labels shipped invisible. _fonts() sets a default font and size but
   never a default font_color for the base Label type, so a Label with no
   type-variation inherited Godot's built-in default — WHITE — and rendered
   white-on-parchment: the four race names, four race blurbs, and seven calling
   names, every unreadable string on the sheet. New CardTitle / CardBody roles
   (mock #3a2f1c / #6a5a3a) fix it. NOT a global default Label colour: the Title
   screen has 12 bare Labels on a dark background that rely on white.

2. The PRIMARY badge covered the rolled value. The ability card is a
   PanelContainer, and a Container force-fits its children, so the badge's
   authored anchors were dead letters — it stretched to the card's full width
   and centred over the value. Nesting it under a plain Control (not a
   Container) restores absolute positioning. The badges also carried _flat()'s
   12/6 card padding; a new tight pill stylebox matches the mock's 2px 9px.

3. Race cards overflowed their own border (108px card, 114px of content). Bumped
   to 140. The CHOSEN badge on the calling card moved from centre to the mock's
   top-right, and the ability card got its own AbilityCard variation with 16px
   top headroom for the badge (ParchmentCard is shared with the shell).

4. The default window (1600x900) was larger than a 1600x900 laptop's usable
   area, so the WM clamped it to 1589x752 and the run came up pillarboxed.
   Default is now 1280x720, resizable; the 1920x1080 design canvas is unchanged
   (canvas_items + keep scales it, so no layout number moved).

Also closes M4-b's two open copy items: the cutpurse origin fragment (it
contradicted its own blurb) and the Hint label (it omitted the roll's floor of
8, now pinned to NewGame.roll_attributes).

Guards for 1-4 are new and each was re-broken and watched go red. The overflow
guard in 3 was FIRST WRITTEN AS A TAUTOLOGY — Godot clamps Control.size up to
its combined minimum, so `min <= size` is `x <= x` — and passed against the bug,
29/29, until it was rewritten to measure against the card that clips. That is
traps.md #17 and the thirteenth cannot-fail assertion this branch has caught.

319 client tests green, content build green, theme drift guard satisfied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:56:09 -05:00

316 lines
15 KiB
GDScript

# client/scripts/theme/build_game_theme.gd
@tool
extends SceneTree
## One-shot Theme builder. Run headless:
## godot --headless -s res://scripts/theme/build_game_theme.gd
## Constructs game_theme.tres from Palette + the bundled fonts and saves it.
## The builder is the source of truth; the .tres is a committed artifact.
##
## build_theme() is a static func so it is reachable without saving/quitting —
## tests preload this script and call Builder.build_theme() directly to compare
## a fresh in-memory Theme against the committed .tres (drift guard). Preloading
## a script and calling a static func does not run _init() — only `.new()` does
## — so this is safe to call from a test without side effects.
## §2: presentation.
const OUT := "res://assets/theme/game_theme.tres"
const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
func _init() -> void:
var t := build_theme()
var err := ResourceSaver.save(t, OUT)
if err != OK:
push_error("theme save failed: %d" % err)
else:
print("wrote ", OUT)
quit()
static func build_theme() -> Theme:
var theme := Theme.new()
_fonts(theme)
_title_type(theme)
_rich_text(theme)
_primary_cta(theme)
_tabs(theme)
_dock_button(theme)
_parchment_button(theme)
_chip(theme)
_section_label(theme)
_select_card(theme)
_skill_chip(theme)
_tags_and_primary(theme)
_tiles(theme)
_cards_and_panels(theme)
return theme
static func _pill(bg: Color, radius: int, margin_x: int, margin_y: int) -> StyleBoxFlat:
# A BADGE, not a card. _flat() bakes in the 12/6 content margin every panel and
# button on the sheet wants — on a 9px "PRIMARY" flag that padding is most of the
# control, which is what made the badge swallow the ability card's rolled value.
# The mock's flags are padding:2px 9px (PRIMARY) and 3px 8px (CHOSEN).
var s := StyleBoxFlat.new()
s.bg_color = bg
s.border_color = bg
s.set_border_width_all(0)
s.set_corner_radius_all(radius)
s.content_margin_left = margin_x
s.content_margin_right = margin_x
s.content_margin_top = margin_y
s.content_margin_bottom = margin_y
return s
static func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = bg
s.border_color = border
s.set_border_width_all(width)
s.set_corner_radius_all(radius)
s.content_margin_left = 12
s.content_margin_right = 12
s.content_margin_top = 6
s.content_margin_bottom = 6
return s
static func _fonts(theme: Theme) -> void:
var serif: FontFile = load(SERIF)
# Global defaults: prose serif at body size.
theme.default_font = serif
theme.default_font_size = 18
# Heading role via the Label "Heading" variation.
theme.set_type_variation(ThemeKeys.HEADING, "Label")
theme.set_font(&"font", ThemeKeys.HEADING, serif)
theme.set_font_size(&"font_size", ThemeKeys.HEADING, 34)
theme.set_color(&"font_color", ThemeKeys.HEADING, Palette.INK_HEADING)
# Accent (hand-inked) role.
theme.set_type_variation(ThemeKeys.ACCENT, "Label")
theme.set_font(&"font", ThemeKeys.ACCENT, load(ACCENT))
theme.set_font_size(&"font_size", ThemeKeys.ACCENT, 24)
theme.set_color(&"font_color", ThemeKeys.ACCENT, Palette.INK_GOLD)
# Mono chrome role.
theme.set_type_variation(ThemeKeys.MONO, "Label")
theme.set_font(&"font", ThemeKeys.MONO, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.MONO, 13)
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
# Card type — the name + blurb inside a select-card. These sit ON PARCHMENT, so
# they take INK, not CREAM. Godot's built-in default Label colour is WHITE, and a
# Label left bare inherits it: the race and calling names shipped invisible on the
# parchment sheet for exactly that reason. There is no such thing as an unstyled
# Label on this sheet — every one of them names a role. (mock: 19px/17px #3a2f1c
# title, 13px italic-serif #6a5a3a blurb.)
theme.set_type_variation(ThemeKeys.CARD_TITLE, "Label")
theme.set_font(&"font", ThemeKeys.CARD_TITLE, serif)
theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 19)
theme.set_color(&"font_color", ThemeKeys.CARD_TITLE, Palette.INK_HEADING)
theme.set_type_variation(ThemeKeys.CARD_BODY, "Label")
theme.set_font(&"font", ThemeKeys.CARD_BODY, serif)
theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 13)
theme.set_color(&"font_color", ThemeKeys.CARD_BODY, Palette.INK_MUTED)
static func _title_type(theme: Theme) -> void:
# Title-screen display type (mock Title Screen): the big serif logo + the mono
# kicker above it. Label font-role variations (font/size/colour), the same
# shape as Heading/Accent/Mono.
theme.set_type_variation(ThemeKeys.TITLE_LOGO, "Label")
theme.set_font(&"font", ThemeKeys.TITLE_LOGO, load(SERIF))
theme.set_font_size(&"font_size", ThemeKeys.TITLE_LOGO, 116)
theme.set_color(&"font_color", ThemeKeys.TITLE_LOGO, Palette.CREAM)
theme.set_type_variation(ThemeKeys.TITLE_KICKER, "Label")
theme.set_font(&"font", ThemeKeys.TITLE_KICKER, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.TITLE_KICKER, 13)
theme.set_color(&"font_color", ThemeKeys.TITLE_KICKER, Palette.GOLD)
static func _rich_text(theme: Theme) -> void:
# Base RichTextLabel styling — the narrative "prose" voice (mock README §Typography:
# Georgia/serif for DM/flavor body, italic for emphasis). RichTextLabel does NOT
# inherit default_font/default_color, so without this its text renders in the engine
# default (near-white) and is unreadable on parchment. Every parchment prose surface
# (the narration book, later quest briefs/dialogue) reads dark serif ink from here.
theme.set_font(&"normal_font", "RichTextLabel", load(SERIF))
theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC))
theme.set_font_size(&"normal_font_size", "RichTextLabel", 20)
theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY)
static func _primary_cta(theme: Theme) -> void:
var k := ThemeKeys.PRIMARY_CTA
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 4))
theme.set_stylebox(&"hover", k, _flat(Palette.BLOOD_BRIGHT, Palette.BLOOD, 1, 4))
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD_DARK, Palette.BLOOD, 1, 4))
# Unaffordable/disabled CTA state (2a shell needs this) — desaturated/greyed.
theme.set_stylebox(&"disabled", k, _flat(Palette.MUTED_MONO_DIM, Palette.MUTED_MONO_DIM, 1, 4))
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_disabled_color", k, Palette.MUTED_MONO)
static func _tabs(theme: Theme) -> void:
var tab := ThemeKeys.TAB
theme.set_type_variation(tab, "Button")
theme.set_stylebox(&"normal", tab, _flat(Palette.TAB_INACTIVE_BG, Palette.PARCHMENT_BORDER_2, 1, 14))
theme.set_stylebox(&"hover", tab, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_2, 1, 14))
theme.set_stylebox(&"pressed", tab, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 1, 14))
theme.set_stylebox(&"focus", tab, StyleBoxEmpty.new())
theme.set_color(&"font_color", tab, Palette.INK_MUTED)
var tab_active := ThemeKeys.TAB_ACTIVE
theme.set_type_variation(tab_active, "Button")
theme.set_stylebox(&"normal", tab_active, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
theme.set_stylebox(&"hover", tab_active, _flat(Palette.BLOOD_BRIGHT, Palette.BLOOD_BRIGHT, 1, 14))
theme.set_stylebox(&"pressed", tab_active, _flat(Palette.BLOOD_DARK, Palette.BLOOD, 1, 14))
theme.set_stylebox(&"focus", tab_active, StyleBoxEmpty.new())
theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT)
static func _dock_button(theme: Theme) -> void:
# Dark nav button for the slide-in system dock (mock 2a). Button-base so
# normal/hover/pressed styleboxes actually apply (a PanelContainer variation's
# lone `panel` stylebox does not skin a Button). Colours are dock-dark.
var k := ThemeKeys.DOCK_BUTTON
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.DARK_PANEL_70, Palette.BORDER_1, 1, 8))
theme.set_stylebox(&"hover", k, _flat(Palette.DARK_PANEL_85, Palette.GOLD, 1, 8))
theme.set_stylebox(&"pressed", k, _flat(Palette.DARK_PANEL_90, Palette.GOLD, 1, 8))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_color(&"font_color", k, Palette.CREAM_SECONDARY)
static func _parchment_button(theme: Theme) -> void:
# Clickable parchment row/card for the narration book (mock 2a response
# choices + the refire affordance). Button-base so normal/hover/pressed/
# disabled styleboxes actually apply (a PanelContainer variation's lone
# `panel` stylebox does not skin a Button). Colours are the mock's choice-card.
var k := ThemeKeys.PARCHMENT_BUTTON
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 8))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 8))
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_INSET_2, Palette.INK_LABEL, 1, 8))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 8))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_color(&"font_color", k, Palette.INK_BODY)
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
static func _chip(theme: Theme) -> void:
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
var k := ThemeKeys.CHIP
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Color(Palette.MUTED_MONO, 0.12), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"hover", k, _flat(Color(Palette.MUTED_MONO, 0.22), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"pressed", k, _flat(Color(Palette.MUTED_MONO, 0.32), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 11)
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
static func _section_label(theme: Theme) -> void:
# The mono section headers on the parchment sheet (RACE / CALLING / NAME …).
# Mono's own colour is for dark surfaces; on parchment it needs the muted ink.
theme.set_type_variation(ThemeKeys.SECTION_LABEL, "Label")
theme.set_font(&"font", ThemeKeys.SECTION_LABEL, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 13)
theme.set_color(&"font_color", ThemeKeys.SECTION_LABEL, Palette.INK_LABEL_MUTED)
static func _select_card(theme: Theme) -> void:
# Race + calling cards. Button-base (a PanelContainer variation's lone `panel`
# stylebox does not skin a Button — ADR 0001). `pressed` IS the CHOSEN state:
# the card is a toggle in a ButtonGroup, so `pressed` persists after the click.
var k := ThemeKeys.SELECT_CARD
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 12))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 12))
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_CARD, Palette.BLOOD, 3, 12))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 12))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_color(&"font_color", k, Palette.INK_HEADING)
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
static func _skill_chip(theme: Theme) -> void:
# A proficiency chip is a TOGGLE with three states the player must be able to
# read at a glance: unpicked (parchment), picked (crimson), and granted-by-race
# (dim + inert — the elf already has perception, so the chip cannot be pressed).
var k := ThemeKeys.SKILL_CHIP
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 14))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 14))
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 14))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 12)
theme.set_color(&"font_color", k, Palette.INK_LABEL)
theme.set_color(&"font_pressed_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_hover_color", k, Palette.INK_LABEL)
theme.set_color(&"font_disabled_color", k, Palette.PARCHMENT_BORDER_3)
static func _tags_and_primary(theme: Theme) -> void:
# The two little flags that ride on a card's top edge, and the gold ring the
# calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4.
var chosen := ThemeKeys.CHOSEN_TAG
theme.set_type_variation(chosen, "Label")
theme.set_stylebox(&"normal", chosen, _pill(Palette.BLOOD, 5, 8, 3))
theme.set_font(&"font", chosen, load(MONO))
theme.set_font_size(&"font_size", chosen, 10)
theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT)
var primary_tag := ThemeKeys.PRIMARY_TAG
theme.set_type_variation(primary_tag, "Label")
theme.set_stylebox(&"normal", primary_tag, _pill(Palette.INK_GOLD, 5, 9, 2))
theme.set_font(&"font", primary_tag, load(MONO))
theme.set_font_size(&"font_size", primary_tag, 9)
theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT)
# The ability card in both states. It is NOT a plain ParchmentCard: it wears the PRIMARY
# flag on its top edge, and _flat()'s 6px top margin puts the "STR" key label directly
# under the badge — they collided. The mock's card is padding:16px 10px 14px, and that
# 16px IS the headroom the badge needs. ParchmentCard cannot simply be retuned: the
# shell shares it, and the shell has no badge.
theme.set_stylebox(&"panel", ThemeKeys.ABILITY_CARD,
_ability_card(Palette.PARCHMENT_BORDER, 1))
theme.set_type_variation(ThemeKeys.ABILITY_CARD, "PanelContainer")
theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD,
_ability_card(Palette.INK_GOLD, 2))
static func _ability_card(border: Color, width: int) -> StyleBoxFlat:
var s := _flat(Palette.PARCHMENT_CARD, border, width, 12)
s.content_margin_top = 16
s.content_margin_bottom = 14
s.content_margin_left = 10
s.content_margin_right = 10
return s
static func _tiles(theme: Theme) -> void:
var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6)
theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE, filled)
var empty := _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 6)
empty.draw_center = false
theme.set_type_variation(ThemeKeys.ITEM_TILE_EMPTY, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE_EMPTY, empty)
static func _cards_and_panels(theme: Theme) -> void:
theme.set_type_variation(ThemeKeys.PARCHMENT_CARD, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_CARD, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 6))
theme.set_type_variation(ThemeKeys.PARCHMENT_INSET, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_INSET, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 4))
theme.set_type_variation(ThemeKeys.DARK_PANEL, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.DARK_PANEL, _flat(Palette.DARK_PANEL_70, Palette.BORDER_4, 1, 4))