Files
code_of_conquest_dnd/client/scripts/theme/build_game_theme.gd
Phillip Tarrant 1ae62fe0db feat(creation): distinguish granted skills as owned, and enlarge the small text
Two F6-review follow-ups from the human's read of the running screen.

1. GRANTED skill chips now read as 'you already have this', not 'unavailable'.
   The elf's granted `perception` shared SkillChip's dim `disabled` look with the
   'picks are spent' chips, so it read as blocked. Split out a fourth state,
   SkillChipGranted — a solid gold fill, cream text, and a ✓ prefix (owned) — while
   the pool-is-spent chips keep the plain dim disabled. Applied per-chip in _bind_skills.

2. Enlarged the small mono/body/prose roles for readability on sub-1920 laptops
   (the 1920 design canvas scales DOWN on a smaller screen, so 11-13px text got
   tiny): DM prose 20->22, MONO 13->14, section labels 13->14, card title 19->20,
   card body 13->14, chips 11->12, skill chips 12->14, the two flag pills +1. Big
   serif headings unchanged.

   Two layout consequences the guards caught and I fixed, not silenced:
   - Race cards overflowed the taller blurb -> card min-height 140->180 (the overflow
     guard, which measures against the card the engine does NOT derive from the text,
     went red first; traps.md #17).
   - The 9-chip Human bonus row, wider at the bigger font, blew the whole sheet past
     the viewport -> BonusRow HBoxContainer -> HFlowContainer so it wraps instead of
     forcing the sheet wide. Nothing depends on the row's type (tests check child
     count + visibility only).

319 client tests green (theme drift guard + overflow guard included); screenshot-
verified at 1280x720: granted chip owned, no clipping, bonus row wraps cleanly.

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

337 lines
16 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)
_skill_chip_granted(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, 14)
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, 20)
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, 14)
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", 22)
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, 12)
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, 14)
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, 14)
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 _skill_chip_granted(theme: Theme) -> void:
# The FOURTH skill-chip state, split out of SkillChip's dim `disabled` look. A
# race-GRANTED skill (the elf already has perception) must read "you already have
# this" — OWNED — not "unavailable". So: a solid gold fill with cream text (the
# binding prefixes a ✓), where the pool-is-spent chips keep the plain dim disabled.
# It is inert like a disabled chip, so every interactive state paints the owned box.
var k := ThemeKeys.SKILL_CHIP_GRANTED
theme.set_type_variation(k, "Button")
var owned := _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 14)
theme.set_stylebox(&"normal", k, owned)
theme.set_stylebox(&"hover", k, owned)
theme.set_stylebox(&"pressed", k, owned)
theme.set_stylebox(&"disabled", k, owned)
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 14)
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_disabled_color", k, Palette.CREAM_BRIGHT)
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, 11)
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, 10)
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))