#!/usr/bin/env python3 """Generate the Godot in-game Theme (`ui_theme.tres`) from the design-token SoT. The single source of truth for the Age of Dwarves visual language is `.project/designs/design-tokens.json` (W3C / style-dictionary format). This script compiles those tokens into a complete Godot `Theme` resource: * StyleBoxFlat sub-resources for panels, buttons (5 states), and list rows, with token-driven background / border colors, corner radii (§6) and border widths. * Default theme colors + font sizes for Button / Label / Panel / PanelContainer / ItemList / RichTextLabel (§3 / §4). * A `metadata/tokens` JSON blob carrying the entire `color.*` token tree as flat dotted keys (e.g. `accent.gold`, `text.primary`, `semantic.positive`). `ThemeAssets.color(name)` resolves names against this blob at runtime — the accessor is therefore data-driven from the SoT with no hardcoded color map. `ui_theme.tres` is a GENERATED artifact. Never hand-edit it; edit the tokens and re-run. The output is deterministic (sorted keys, fixed float formatting, stable sub-resource ids, preserved uid) so `--check` is a meaningful drift gate for CI. Usage: tools/build-ui-theme.py # regenerate ui_theme.tres in place tools/build-ui-theme.py --check # exit 1 if the .tres is stale (no write) tools/build-ui-theme.py --print # write nothing, dump to stdout """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # Repo root = two levels up from tools/build-ui-theme.py REPO_ROOT = Path(__file__).resolve().parent.parent TOKENS_PATH = REPO_ROOT / ".project" / "designs" / "design-tokens.json" OUTPUT_PATH = REPO_ROOT / "public" / "games" / "age-of-dwarves" / "ui_theme.tres" GUIDE_COLORS_TS_PATH = REPO_ROOT / "public" / "games" / "age-of-dwarves" / "guide" / "src" / "theme" / "generated-guide-colors.ts" # Player colours have a SINGLE source: palettes.json (which also owns the # colourblind variants + drives runtime player rendering via ThemeAssets). # The `player.*` UI tokens are GENERATED from its default variant here so the # 12 colours are authored once and can never drift. Order ↔ default array index. PALETTES_PATH = REPO_ROOT / "public" / "games" / "age-of-dwarves" / "data" / "palettes.json" PLAYER_COLOR_NAMES = [ "blue", "red", "green", "yellow", "purple", "orange", "cyan", "magenta", "brown", "gray", "sage", "navy", ] # Preserve the existing resource uid so path/uid references stay stable. THEME_UID = "uid://ui_theme_fantasy" # --------------------------------------------------------------------------- # # Token access # --------------------------------------------------------------------------- # def load_tokens() -> dict: with TOKENS_PATH.open(encoding="utf-8") as fh: return json.load(fh) def _walk_colors(node: dict, prefix: str, out: dict[str, str]) -> None: """Recursively flatten the `color.*` subtree into dotted -> raw-value strings. A token leaf is a dict carrying a `$value`. Intermediate groups recurse. The raw `$value` is preserved verbatim (literal hex OR a `{color.x.y}` alias reference) — alias resolution happens in `_resolve_aliases`. """ for key, value in node.items(): if key.startswith("$"): continue if not isinstance(value, dict): continue path = f"{prefix}.{key}" if prefix else key if "$value" in value: out[path] = str(value["$value"]) else: _walk_colors(value, path, out) _ALIAS_RE = re.compile(r"^\{(.+)\}$") def _resolve_aliases(raw: dict[str, str]) -> dict[str, str]: """Resolve W3C-style `{color.x.y}` alias references to literal `rrggbb[aa]`. Tiered tokens (component -> semantic -> primitive) reference each other so a colour lives in exactly one place. Literal hexes pass through unchanged. Detects cycles and dangling targets so a typo fails the build loudly. """ resolved: dict[str, str] = {} def resolve(name: str, seen: frozenset[str]) -> str: if name in resolved: return resolved[name] if name not in raw: raise ValueError(f"alias target not found: '{name}'") if name in seen: raise ValueError(f"alias cycle through '{name}'") match = _ALIAS_RE.match(raw[name].strip()) if match: target = match.group(1).strip() if target.startswith("color."): target = target[len("color."):] value = resolve(target, seen | {name}) else: value = raw[name].lstrip("#").lower() resolved[name] = value return value for token_name in raw: resolve(token_name, frozenset()) return resolved def flatten_color_tokens(tokens: dict) -> dict[str, str]: """All `color.*` tokens as flat {dotted_name: 'rrggbb[aa]'}, aliases resolved.""" raw: dict[str, str] = {} _walk_colors(tokens.get("color", {}), "", raw) return dict(sorted(_resolve_aliases(raw).items())) def generate_player_tokens() -> dict[str, str]: """Derive `player.` colour tokens from palettes.json's default variant. Single source of truth: palettes.json owns the player palette (+ colourblind variants); these UI tokens are generated, never hand-authored, so they cannot drift from what `ThemeAssets.get_player_color()` renders. """ with PALETTES_PATH.open(encoding="utf-8") as fh: palettes = json.load(fh) colours = palettes["default"]["player_colors"] if len(colours) < len(PLAYER_COLOR_NAMES): raise ValueError( f"palettes.json default has {len(colours)} player colours; " f"expected >= {len(PLAYER_COLOR_NAMES)}" ) return { f"player.{name}": str(colours[i]).lstrip("#").lower() for i, name in enumerate(PLAYER_COLOR_NAMES) } def _px(value) -> float: """'14px' / 14 / '14' -> 14.0.""" s = str(value).strip().lower().removesuffix("px") return float(s) def font_size(tokens: dict, name: str) -> int: return int(_px(tokens["typography"]["fontSize"][name]["$value"])) def radius(tokens: dict, name: str) -> int: return int(_px(tokens["borderRadius"][name]["$value"])) def border_w(tokens: dict, name: str) -> int: return int(_px(tokens["borderWidth"][name]["$value"])) def spacing(tokens: dict, name: str) -> float: return _px(tokens["spacing"][name]["$value"]) # --------------------------------------------------------------------------- # # Hex -> Godot Color # --------------------------------------------------------------------------- # def hex_to_color(hex_str: str) -> str: """Convert an SoT hex token to a Godot `Color(r, g, b, a)` literal string. Godot's `Color.html()` treats 8-digit hex as RRGGBBAA (verified on the target runtime), so this generator uses the identical byte order to keep the .tres and the runtime `color()` accessor in agreement. """ h = hex_str.lstrip("#").lower() if len(h) == 6: h += "ff" if len(h) != 8: raise ValueError(f"unsupported hex token: {hex_str!r}") r = int(h[0:2], 16) / 255.0 g = int(h[2:4], 16) / 255.0 b = int(h[4:6], 16) / 255.0 a = int(h[6:8], 16) / 255.0 return "Color(%s, %s, %s, %s)" % tuple(_fmt_float(v) for v in (r, g, b, a)) def _fmt_float(v: float) -> str: """Deterministic float formatting: trim trailing zeros, keep at least 0/1.""" s = f"{v:.6f}".rstrip("0").rstrip(".") return s if s else "0" def color_of(colors: dict[str, str], dotted: str) -> str: return hex_to_color(colors[dotted]) # --------------------------------------------------------------------------- # # StyleBoxFlat emission # --------------------------------------------------------------------------- # def stylebox( sub_id: str, *, bg: str, border: str, border_width: int, corner: int, margins: tuple[float, float, float, float] | None = None, ) -> str: """Render one [sub_resource type="StyleBoxFlat"] block.""" lines = [ f'[sub_resource type="StyleBoxFlat" id="{sub_id}"]', f"bg_color = {bg}", f"border_width_left = {border_width}", f"border_width_top = {border_width}", f"border_width_right = {border_width}", f"border_width_bottom = {border_width}", f"border_color = {border}", f"corner_radius_top_left = {corner}", f"corner_radius_top_right = {corner}", f"corner_radius_bottom_left = {corner}", f"corner_radius_bottom_right = {corner}", ] if margins is not None: ml, mt, mr, mb = (_fmt_float(m) for m in margins) lines += [ f"content_margin_left = {ml}", f"content_margin_top = {mt}", f"content_margin_right = {mr}", f"content_margin_bottom = {mb}", ] return "\n".join(lines) # --------------------------------------------------------------------------- # # Theme assembly # --------------------------------------------------------------------------- # def build_theme_text(tokens: dict) -> str: colors = flatten_color_tokens(tokens) # Merge in player.* tokens generated from palettes.json (single source). colors.update(generate_player_tokens()) colors = dict(sorted(colors.items())) r_panel = radius(tokens, "panel") r_button = radius(tokens, "button") r_list = radius(tokens, "list") bw_default = border_w(tokens, "default") bw_emphasis = border_w(tokens, "emphasis") # Margins from the spacing scale (§5): panels 12/6, buttons 12/6. pad_h = spacing(tokens, "3") # 12px horizontal pad_v = spacing(tokens, "2") # 8px vertical btn_h = spacing(tokens, "3") # 12px btn_v = spacing(tokens, "2") # 8px fs_base = font_size(tokens, "base") fs_sm = font_size(tokens, "sm") c = lambda name: color_of(colors, name) # noqa: E731 subs = [ stylebox( "StyleBoxFlat_panel", bg=c("background.panel"), border=c("border.panel"), border_width=bw_default, corner=r_panel, margins=(pad_h, pad_v, pad_h, pad_v), ), stylebox( "StyleBoxFlat_button_normal", bg=c("button.bgNormal"), border=c("border.panel"), border_width=bw_default, corner=r_button, margins=(btn_h, btn_v, btn_h, btn_v), ), stylebox( "StyleBoxFlat_button_hover", bg=c("button.bgHover"), border=c("accent.goldBright"), border_width=bw_default, corner=r_button, margins=(btn_h, btn_v, btn_h, btn_v), ), stylebox( "StyleBoxFlat_button_pressed", bg=c("button.bgPressed"), border=c("accent.goldPress"), border_width=bw_emphasis, corner=r_button, margins=(btn_h, btn_v, btn_h, btn_v), ), stylebox( "StyleBoxFlat_button_focus", bg="Color(0, 0, 0, 0)", border=c("border.focus"), border_width=bw_emphasis, corner=r_button, ), stylebox( "StyleBoxFlat_item_list_bg", bg=c("background.list"), border=c("border.list"), border_width=bw_default, corner=r_list, ), stylebox( "StyleBoxFlat_item_list_selected", bg=c("background.listSelected"), border=c("border.listSelected"), border_width=bw_default, corner=r_list, ), ] # metadata/tokens — entire color tree, flat dotted keys, sorted, compact. tokens_blob = json.dumps(colors, separators=(",", ":"), sort_keys=True) tokens_blob_escaped = tokens_blob.replace("\\", "\\\\").replace('"', '\\"') resource_lines = [ "[resource]", f'metadata/tokens = "{tokens_blob_escaped}"', f"Button/colors/font_color = {c('text.button')}", f"Button/colors/font_hover_color = {c('text.buttonHover')}", f"Button/colors/font_pressed_color = {c('text.buttonPressed')}", f"Button/colors/font_focus_color = {c('text.buttonHover')}", f"Button/colors/font_disabled_color = {c('text.disabled')}", f"Button/font_sizes/font_size = {fs_base}", 'Button/styles/normal = SubResource("StyleBoxFlat_button_normal")', 'Button/styles/hover = SubResource("StyleBoxFlat_button_hover")', 'Button/styles/pressed = SubResource("StyleBoxFlat_button_pressed")', 'Button/styles/focus = SubResource("StyleBoxFlat_button_focus")', 'Button/styles/disabled = SubResource("StyleBoxFlat_button_normal")', f"Label/colors/font_color = {c('text.primary')}", f"Label/font_sizes/font_size = {fs_base}", 'PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel")', 'Panel/styles/panel = SubResource("StyleBoxFlat_panel")', f"ItemList/colors/font_color = {c('text.primary')}", f"ItemList/colors/font_selected_color = {c('text.buttonHover')}", f"ItemList/font_sizes/font_size = {fs_sm}", 'ItemList/styles/panel = SubResource("StyleBoxFlat_item_list_bg")', 'ItemList/styles/selected = SubResource("StyleBoxFlat_item_list_selected")', 'ItemList/styles/selected_focus = SubResource("StyleBoxFlat_item_list_selected")', f"RichTextLabel/colors/default_color = {c('text.primary')}", f"RichTextLabel/font_sizes/normal_font_size = {fs_sm}", ] # Type variations (p2-87): inheritance targets so widgets can set # theme_type_variation instead of add_theme_color_override. Skips _doc. variations = tokens.get("typeVariations", {}) for vname in sorted(variations): if vname.startswith("_"): continue spec: dict = variations[vname] resource_lines.append('%s/base_type = &"%s"' % (vname, spec["base"])) if "fontColor" in spec: resource_lines.append("%s/colors/font_color = %s" % (vname, c(spec["fontColor"]))) if "panel" in spec: pan: dict = spec["panel"] sub_id: str = "StyleBoxFlat_%s" % vname margins = tuple(pan["margins"]) if "margins" in pan else None subs.append( stylebox( sub_id, bg=c(pan["bg"]), border=c(pan["border"]), border_width=int(pan["borderWidth"]), corner=int(pan["corner"]), margins=margins, ) ) resource_lines.append('%s/styles/panel = SubResource("%s")' % (vname, sub_id)) parts = [ f'[gd_resource type="Theme" format=3 uid="{THEME_UID}"]', "", ] for sub in subs: parts.append("") parts.append(sub) parts.append("") parts.append("\n".join(resource_lines)) return "\n".join(parts).lstrip("\n") + "\n" def generate_guide_colors_ts(tokens: dict) -> str: """Emit TS module with resolved guide + accent + semantic colours (p2-87 cluster-4). All values from the token table (aliases resolved). No raw hex in consumer (guide de-hex). """ resolved = flatten_color_tokens(tokens) # resolves {aliases} to hex lines: list[str] = [ "// GENERATED from .project/designs/design-tokens.json by build-ui-theme.py -- p2-87", "// Single source of truth. Edit tokens, re-run. Do not hand-edit.", "export const GUIDE_COLORS = {", ] # All guide.* (resolved hex) for key in sorted(k for k in resolved if k.startswith("guide.")): short = key[len("guide."):] val = resolved[key] lines.append(f' "{short}": "#' + val + '",') # Supporting for guide pages (accent etc) for group in ("accent", "semantic", "text"): for key in sorted(k for k in resolved if k.startswith(group + ".")): val = resolved[key] lines.append(f' "{key}": "#' + val + '",') lines.append("} as const;") lines.append("") lines.append("export type GuideColorKey = keyof typeof GUIDE_COLORS;") return "\n".join(lines) + "\n" # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--check", action="store_true", help="exit 1 if ui_theme.tres differs from the regenerated output (no write)", ) parser.add_argument( "--print", dest="print_only", action="store_true", help="print the generated .tres to stdout, write nothing", ) args = parser.parse_args() tokens = load_tokens() text = build_theme_text(tokens) guide_ts = generate_guide_colors_ts(tokens) if args.print_only: sys.stdout.write(text) return 0 if args.check: current = OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else "" guide_current = GUIDE_COLORS_TS_PATH.read_text(encoding="utf-8") if GUIDE_COLORS_TS_PATH.exists() else "" if current == text and guide_current == guide_ts: print(f"OK: {OUTPUT_PATH.relative_to(REPO_ROOT)} and guide colors up to date.") return 0 print( "DRIFT: ui_theme.tres or generated-guide-colors.ts stale.\n" "Run tools/build-ui-theme.py to regenerate.", file=sys.stderr, ) return 1 OUTPUT_PATH.write_text(text, encoding="utf-8") GUIDE_COLORS_TS_PATH.write_text(guide_ts, encoding="utf-8") print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} ({len(text)} bytes).") print(f"Wrote {GUIDE_COLORS_TS_PATH.relative_to(REPO_ROOT)} ({len(guide_ts)} bytes).") return 0 if __name__ == "__main__": raise SystemExit(main())