magicciv/tools/build-ui-theme.py

335 lines
12 KiB
Python
Raw Permalink Normal View History

#!/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())