Close the gap where the design system (.project/designs/design-tokens.json)
drove the React guide but not the Godot game.
- tools/build-ui-theme.py: compiles the W3C/style-dictionary token SoT into a
complete Godot Theme (7 StyleBoxFlat sub-resources, Button/Label/Panel/
PanelContainer/ItemList/RichTextLabel colors + font sizes + corner radii/
border widths per UI_DESIGN_SYSTEM.md §3/§4/§6). ui_theme.tres is now a
GENERATED artifact; tokens are the single source of truth. Deterministic
output (sorted keys, fixed float fmt, preserved uid://ui_theme_fantasy) with
a --check drift gate. Idempotent; --import does not rewrite it.
- project.godot [gui] theme/custom: applies ui_theme.tres at viewport level so
every non-overriding default Control renders the copper fantasy styling.
- ThemeAssets.color(name) -> Color: resolves dotted token names (accent.gold,
semantic.positive, text.primary, …) against the metadata/tokens JSON blob
baked into the .tres by the generator. Fully data-driven from the SoT, no
hardcoded color map. (Godot rejects dots in Theme color item names, so the
token table ships as resource metadata.) Unknown names return an explicit
fallback. This is the API p2-74 will de-hardcode 45 scripts onto.
- ui_theme_proof.{tscn,gd}: bare-widget + color()-swatch proof scene.
test_theme_assets_color.gd: GUT accessor coverage (5/5 headless).
Proof captured on apricot under weston, reviewed in conversation:
.project/screenshots/p2-73-ui-theme-proof.png. Workspace green — full unit
(16==16) and integration (18==18) suites show identical HEAD-baseline-vs-patch
failure counts, zero regressions; patch adds +5 passing tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
334 lines
12 KiB
Python
Executable file
334 lines
12 KiB
Python
Executable file
#!/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 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"
|
|
|
|
# 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 -> hex strings.
|
|
|
|
A token leaf is a dict carrying a `$value`. Intermediate groups recurse.
|
|
"""
|
|
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"]).lstrip("#").lower()
|
|
else:
|
|
_walk_colors(value, path, out)
|
|
|
|
|
|
def flatten_color_tokens(tokens: dict) -> dict[str, str]:
|
|
"""All `color.*` tokens as a flat {dotted_name: 'rrggbb[aa]'} dict."""
|
|
out: dict[str, str] = {}
|
|
_walk_colors(tokens.get("color", {}), "", out)
|
|
return dict(sorted(out.items()))
|
|
|
|
|
|
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)
|
|
|
|
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}",
|
|
]
|
|
|
|
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"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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)
|
|
|
|
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 ""
|
|
if current == text:
|
|
print(f"OK: {OUTPUT_PATH.relative_to(REPO_ROOT)} is up to date.")
|
|
return 0
|
|
print(
|
|
f"DRIFT: {OUTPUT_PATH.relative_to(REPO_ROOT)} is stale.\n"
|
|
"Run tools/build-ui-theme.py to regenerate it.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
OUTPUT_PATH.write_text(text, encoding="utf-8")
|
|
print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} ({len(text)} bytes).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|