# 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))