docs(plans): add M3-a shared Theme implementation plan
Five TDD tasks: palette tokens, bundled OFL fonts, script-built game_theme.tres, three shader surfaces, showcase eyeball proof. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
835
docs/superpowers/plans/2026-07-10-shared-theme.md
Normal file
835
docs/superpowers/plans/2026-07-10-shared-theme.md
Normal file
@@ -0,0 +1,835 @@
|
||||
# Shared Theme Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the one shared visual system (palette, fonts, styleboxes, signature surfaces) every later Code of Conquest screen inherits — as reusable Godot resources with a human-eyeballed showcase proof.
|
||||
|
||||
**Architecture:** Three consumers of one palette. `palette.gd` holds every colour as `const Color` (the single source of truth). `game_theme.tres` — a `Theme` resource built by a committed GDScript builder — carries fonts + StyleBoxFlat type-variations for crisp bordered UI. Three `.gdshader`-backed `Control` subclasses render the parchment / dark-stripe-bay / vignette surfaces (a StyleBox can't run a shader). No game state, no AI, no network.
|
||||
|
||||
**Tech Stack:** Godot 4.7, GDScript, GUT (headless), OFL fonts, Godot shading language.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Engine:** Godot **4.7**, **GDScript** only (charter §16).
|
||||
- **Tests:** GUT headless via `./run_tests.sh`. `.gutconfig.json` sets `failure_error_types: ["engine","gut"]` — **any `push_error`, failed `load()`, or engine warning fails the suite.** Load resources defensively; never let a missing file emit an engine error in a passing path.
|
||||
- **Test idiom:** every test file `extends "res://addons/gut/test.gd"`, `preload`s its subject, uses `assert_eq`/`assert_ne`/`assert_true`.
|
||||
- **Script idiom:** `class_name X extends RefCounted` (or the needed Control type), `##` doc comment at top stating purpose + §2 side.
|
||||
- **§2 side:** presentation — **owns no state, makes no call.** (charter §2)
|
||||
- **Palette is the single source of truth:** no raw hex literal may appear in any script, `.tres`, or shader *except* inside `palette.gd`. Everything else references it.
|
||||
- **Fonts:** OFL-licensed only. Georgia is proprietary and must never ship (spec §3).
|
||||
- **No real art:** portraits / key-art / iso views stay dashed art slots.
|
||||
- **Canvas:** 1920×1080 reference (charter §16); do not tune stretch here.
|
||||
- **Spec:** `docs/superpowers/specs/2026-07-10-shared-theme-design.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Palette tokens (`palette.gd`)
|
||||
|
||||
The single source of truth for every colour. Pure `const Color`s grouped by role, plus the two helpers the code-driven cases need.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/theme/palette.gd`
|
||||
- Test: `client/tests/unit/test_palette.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `class_name Palette` (a `RefCounted` used only as a namespace for `const`s + `static func`s). Key consts used by later tasks: `BLOOD`, `GOLD`, `STEEL`, `GREEN`, `PARCHMENT_CARD`, `PARCHMENT_BORDER`, `DARK_PANEL_70`, `CREAM`, `INK_BODY`, `INK_HEADING`, `STAGE_1`, `SHEET_TOP`, `SHEET_BOTTOM`, `BAY_STRIPE_A`, `BAY_STRIPE_B`, `HP_A`/`HP_B`, `MP_A`/`MP_B`, `STAMINA`, `RARITY` (Array). Statics: `rarity_color(rarity: int) -> Color`, `stat_bar(kind: StringName) -> Array` (returns `[from, to]`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```gdscript
|
||||
# client/tests/unit/test_palette.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const Palette = preload("res://scripts/theme/palette.gd")
|
||||
|
||||
|
||||
func test_core_tokens_are_colors():
|
||||
assert_true(Palette.BLOOD is Color)
|
||||
assert_true(Palette.GOLD is Color)
|
||||
assert_true(Palette.PARCHMENT_CARD is Color)
|
||||
assert_true(Palette.CREAM is Color)
|
||||
|
||||
|
||||
func test_blood_core_matches_readme_hex():
|
||||
# #8f3a34 — the primary CTA / danger / selection red.
|
||||
assert_eq(Palette.BLOOD, Color("8f3a34"))
|
||||
|
||||
|
||||
func test_dark_panel_is_translucent():
|
||||
assert_almost_eq(Palette.DARK_PANEL_70.a, 0.7, 0.01)
|
||||
|
||||
|
||||
func test_rarity_ladder_has_five_ordered_colors():
|
||||
assert_eq(Palette.RARITY.size(), 5)
|
||||
assert_eq(Palette.rarity_color(0), Color("8a8378")) # common
|
||||
assert_eq(Palette.rarity_color(4), Color("c8963f")) # legendary
|
||||
|
||||
|
||||
func test_rarity_color_clamps_out_of_range():
|
||||
# Out-of-range must NOT index-error (would emit an engine error -> fail).
|
||||
assert_eq(Palette.rarity_color(-3), Palette.RARITY[0])
|
||||
assert_eq(Palette.rarity_color(99), Palette.RARITY[4])
|
||||
|
||||
|
||||
func test_stat_bar_returns_from_to_pair():
|
||||
var hp = Palette.stat_bar(&"hp")
|
||||
assert_eq(hp.size(), 2)
|
||||
assert_eq(hp[0], Color("b8524a"))
|
||||
assert_eq(hp[1], Color("8f3a34"))
|
||||
|
||||
|
||||
func test_stat_bar_unknown_kind_is_safe():
|
||||
# Unknown kind returns a grey pair, never null / error.
|
||||
var pair = Palette.stat_bar(&"nonsense")
|
||||
assert_eq(pair.size(), 2)
|
||||
assert_true(pair[0] is Color)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_palette.gd`
|
||||
Expected: FAIL — `Palette` script not found / consts undefined.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```gdscript
|
||||
# client/scripts/theme/palette.gd
|
||||
class_name Palette
|
||||
extends RefCounted
|
||||
## The single source of truth for every colour token (mockups README, "Color
|
||||
## tokens"). §2: presentation — owns no state. No hex literal may live anywhere
|
||||
## else in the client; every script, shader, and the Theme builder reads here.
|
||||
|
||||
# --- Dark backgrounds ---
|
||||
const STAGE_1 := Color("0a0806")
|
||||
const STAGE_2 := Color("0f0d0b")
|
||||
const BAY_STRIPE_A := Color("26221c")
|
||||
const BAY_STRIPE_B := Color("221e18")
|
||||
const DARK_PANEL_70 := Color("120f0b", 0.7)
|
||||
const DARK_PANEL_85 := Color("0e0b09", 0.85)
|
||||
const DARK_PANEL_90 := Color("0a0806", 0.9)
|
||||
# border ladder, light -> dark
|
||||
const BORDER_1 := Color("6b6152")
|
||||
const BORDER_2 := Color("5a5348")
|
||||
const BORDER_3 := Color("4a4238")
|
||||
const BORDER_4 := Color("3a352e")
|
||||
const BORDER_5 := Color("1a1512")
|
||||
|
||||
# --- Parchment ---
|
||||
const SHEET_TOP := Color("ece2ca")
|
||||
const SHEET_BOTTOM := Color("e4d8bd")
|
||||
const LOG_TOP := Color("e7dcc4")
|
||||
const LOG_BOTTOM := Color("ddd0b2")
|
||||
const PARCHMENT_CARD := Color("f1e8d2")
|
||||
const PARCHMENT_INSET := Color("e6d9b8")
|
||||
const PARCHMENT_INSET_2 := Color("d9c9a2")
|
||||
const PARCHMENT_BORDER := Color("bfa878")
|
||||
const PARCHMENT_BORDER_2 := Color("cbb684")
|
||||
const PARCHMENT_BORDER_3 := Color("a99464")
|
||||
const TAB_INACTIVE_BG := Color("efe6cf")
|
||||
|
||||
# --- Ink (text on parchment) ---
|
||||
const INK_HEADING := Color("3a2f1c")
|
||||
const INK_HEADING_2 := Color("4a3520")
|
||||
const INK_BODY := Color("4a3f2c")
|
||||
const INK_MUTED := Color("6a5a3a")
|
||||
const INK_LABEL := Color("5a4326")
|
||||
const INK_GOLD := Color("8f6a2a")
|
||||
|
||||
# --- Text on dark ---
|
||||
const CREAM := Color("e8ddc8")
|
||||
const CREAM_BRIGHT := Color("f0e6d0")
|
||||
const CREAM_SECONDARY := Color("c9bda3")
|
||||
const MUTED_MONO := Color("9a8f78")
|
||||
const MUTED_MONO_DIM := Color("7a7060")
|
||||
|
||||
# --- Accents (muted for grit) ---
|
||||
const BLOOD := Color("8f3a34") # core CTA / danger / selection / enemies
|
||||
const BLOOD_BRIGHT := Color("b8524a") # hover
|
||||
const BLOOD_DARK := Color("7a2f29") # CTA gradient bottom
|
||||
const GOLD := Color("a9843f") # highlight / currency / current-turn
|
||||
const GOLD_BRIGHT := Color("c8963f")
|
||||
const STEEL := Color("5f8a9a") # allies / mana
|
||||
const STEEL_BLUE := Color("4f7ab8")
|
||||
const STEEL_DEEP := Color("243f6f")
|
||||
const GREEN := Color("5f8a4f") # stamina / success / cleared
|
||||
|
||||
# --- Stat bars ---
|
||||
const HP_A := Color("b8524a")
|
||||
const HP_B := Color("8f3a34")
|
||||
const MP_A := Color("4f7ab8")
|
||||
const MP_B := Color("243f6f")
|
||||
const STAMINA := Color("8f9a4f")
|
||||
const BAR_TRACK := Color("2a251f")
|
||||
const BAR_BORDER := Color("4a4238")
|
||||
|
||||
# --- Rarity (ordered common..legendary) ---
|
||||
const RARITY: Array[Color] = [
|
||||
Color("8a8378"), # 0 common
|
||||
Color("5f8a4f"), # 1 uncommon
|
||||
Color("4f7ab8"), # 2 rare
|
||||
Color("8a5fb8"), # 3 epic
|
||||
Color("c8963f"), # 4 legendary
|
||||
]
|
||||
|
||||
|
||||
static func rarity_color(rarity: int) -> Color:
|
||||
return RARITY[clampi(rarity, 0, RARITY.size() - 1)]
|
||||
|
||||
|
||||
static func stat_bar(kind: StringName) -> Array:
|
||||
match kind:
|
||||
&"hp": return [HP_A, HP_B]
|
||||
&"mp": return [MP_A, MP_B]
|
||||
&"stamina": return [STAMINA, STAMINA]
|
||||
_: return [MUTED_MONO, MUTED_MONO_DIM]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_palette.gd`
|
||||
Expected: PASS — all 7 tests green, 0 engine errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/theme/palette.gd client/tests/unit/test_palette.gd
|
||||
git commit -m "feat(theme): palette tokens as the single colour source"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Bundle the fonts
|
||||
|
||||
Acquire the three OFL font families and verify Godot imports them. Deliverable: font files committed + a test proving each loads as a `FontFile`.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/assets/theme/fonts/EBGaramond-VariableFont_wght.ttf`
|
||||
- Create: `client/assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf`
|
||||
- Create: `client/assets/theme/fonts/ArchitectsDaughter-Regular.ttf`
|
||||
- Create: `client/assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf`
|
||||
- Test: `client/tests/unit/test_fonts.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: four font resource paths under `res://assets/theme/fonts/`, consumed by Task 3's Theme builder.
|
||||
|
||||
- [ ] **Step 1: Download the fonts (all OFL, from the official google/fonts repo)**
|
||||
|
||||
```bash
|
||||
cd client/assets/theme/fonts
|
||||
BASE="https://github.com/google/fonts/raw/main/ofl"
|
||||
curl -fL -o EBGaramond-VariableFont_wght.ttf "$BASE/ebgaramond/EBGaramond%5Bwght%5D.ttf"
|
||||
curl -fL -o EBGaramond-Italic-VariableFont_wght.ttf "$BASE/ebgaramond/EBGaramond-Italic%5Bwght%5D.ttf"
|
||||
curl -fL -o ArchitectsDaughter-Regular.ttf "$BASE/architectsdaughter/ArchitectsDaughter-Regular.ttf"
|
||||
curl -fL -o JetBrainsMono-VariableFont_wght.ttf "$BASE/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf"
|
||||
ls -la
|
||||
```
|
||||
|
||||
Expected: four `.ttf` files, each non-empty (tens–hundreds of KB). If a URL 404s (repo layout changed), find the file under https://github.com/google/fonts/tree/main/ofl and adjust the path — the licences are OFL either way.
|
||||
|
||||
- [ ] **Step 2: Import them into Godot**
|
||||
|
||||
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true`
|
||||
This generates the `.import` sidecars so `load()` resolves the fonts.
|
||||
|
||||
- [ ] **Step 3: Write the failing test**
|
||||
|
||||
```gdscript
|
||||
# client/tests/unit/test_fonts.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const FONTS := {
|
||||
"serif": "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf",
|
||||
"serif_italic": "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf",
|
||||
"accent": "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf",
|
||||
"mono": "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf",
|
||||
}
|
||||
|
||||
|
||||
func test_all_fonts_exist_and_load_as_fontfile():
|
||||
for key in FONTS:
|
||||
var path: String = FONTS[key]
|
||||
assert_true(FileAccess.file_exists(path), "missing font: %s" % path)
|
||||
var f = load(path)
|
||||
assert_true(f is FontFile, "%s did not load as FontFile" % key)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_fonts.gd`
|
||||
Expected: PASS. (This test only fails before the files are present; after download+import it is green. If it fails on `load()`, re-run Step 2.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/assets/theme/fonts/ client/tests/unit/test_fonts.gd
|
||||
git commit -m "feat(theme): bundle EB Garamond, Architects Daughter, JetBrains Mono (OFL)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Theme keys + `game_theme.tres` (built by a committed script)
|
||||
|
||||
`theme_keys.gd` names every type-variation. `build_game_theme.gd` constructs the `Theme` programmatically (deterministic + reviewable — a `.tres` can't be hand-authored reliably headless) and saves it. The committed `.tres` is the artifact the game loads; the builder is its source of truth.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/theme/theme_keys.gd`
|
||||
- Create: `client/scripts/theme/build_game_theme.gd`
|
||||
- Create (generated by the builder, then committed): `client/assets/theme/game_theme.tres`
|
||||
- Test: `client/tests/unit/test_theme_resource.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Palette` (Task 1); the four fonts (Task 2).
|
||||
- Produces: `class_name ThemeKeys` with `const StringName` variation names: `PRIMARY_CTA`, `TAB`, `TAB_ACTIVE`, `CHIP`, `ITEM_TILE`, `ITEM_TILE_EMPTY`, `PARCHMENT_CARD`, `PARCHMENT_INSET`, `DARK_PANEL`. Produces `res://assets/theme/game_theme.tres` — a `Theme` whose base type for each variation is `PanelContainer` (cards/panels/tiles) or `Button` (CTA/tab/chip).
|
||||
|
||||
- [ ] **Step 1: Write `theme_keys.gd`**
|
||||
|
||||
```gdscript
|
||||
# client/scripts/theme/theme_keys.gd
|
||||
class_name ThemeKeys
|
||||
extends RefCounted
|
||||
## Type-variation names for game_theme.tres. Scripts set `theme_type_variation`
|
||||
## to one of these so a typo is a compile-time symbol error, not a silent miss.
|
||||
## §2: presentation.
|
||||
|
||||
const PRIMARY_CTA := &"PrimaryCTA"
|
||||
const TAB := &"Tab"
|
||||
const TAB_ACTIVE := &"TabActive"
|
||||
const CHIP := &"Chip"
|
||||
const ITEM_TILE := &"ItemTile"
|
||||
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
|
||||
const PARCHMENT_CARD := &"ParchmentCard"
|
||||
const PARCHMENT_INSET := &"ParchmentInset"
|
||||
const DARK_PANEL := &"DarkPanel"
|
||||
|
||||
## Every variation name + the base Control type it decorates. The builder and the
|
||||
## test both iterate this so they can never drift apart.
|
||||
const ALL := {
|
||||
PRIMARY_CTA: "Button",
|
||||
TAB: "Button",
|
||||
TAB_ACTIVE: "Button",
|
||||
CHIP: "Button",
|
||||
ITEM_TILE: "PanelContainer",
|
||||
ITEM_TILE_EMPTY: "PanelContainer",
|
||||
PARCHMENT_CARD: "PanelContainer",
|
||||
PARCHMENT_INSET: "PanelContainer",
|
||||
DARK_PANEL: "PanelContainer",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the builder**
|
||||
|
||||
```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.
|
||||
## §2: presentation.
|
||||
|
||||
const Palette = preload("res://scripts/theme/palette.gd")
|
||||
const ThemeKeys = preload("res://scripts/theme/theme_keys.gd")
|
||||
|
||||
const OUT := "res://assets/theme/game_theme.tres"
|
||||
const SERIF := "res://assets/theme/fonts/EBGaramond-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 theme := Theme.new()
|
||||
_fonts(theme)
|
||||
_primary_cta(theme)
|
||||
_tabs(theme)
|
||||
_chip(theme)
|
||||
_tiles(theme)
|
||||
_cards_and_panels(theme)
|
||||
var err := ResourceSaver.save(theme, OUT)
|
||||
if err != OK:
|
||||
push_error("theme save failed: %d" % err)
|
||||
else:
|
||||
print("wrote ", OUT)
|
||||
quit()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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(&"Heading", "Label")
|
||||
theme.set_font(&"font", &"Heading", serif)
|
||||
theme.set_font_size(&"font_size", &"Heading", 34)
|
||||
theme.set_color(&"font_color", &"Heading", Palette.INK_HEADING)
|
||||
# Accent (hand-inked) role.
|
||||
theme.set_type_variation(&"Accent", "Label")
|
||||
theme.set_font(&"font", &"Accent", load(ACCENT))
|
||||
theme.set_font_size(&"font_size", &"Accent", 24)
|
||||
theme.set_color(&"font_color", &"Accent", Palette.INK_GOLD)
|
||||
# Mono chrome role.
|
||||
theme.set_type_variation(&"Mono", "Label")
|
||||
theme.set_font(&"font", &"Mono", load(MONO))
|
||||
theme.set_font_size(&"font_size", &"Mono", 13)
|
||||
theme.set_color(&"font_color", &"Mono", Palette.MUTED_MONO)
|
||||
|
||||
|
||||
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))
|
||||
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
|
||||
|
||||
|
||||
func _tabs(theme: Theme) -> void:
|
||||
theme.set_type_variation(ThemeKeys.TAB, "Button")
|
||||
theme.set_stylebox(&"normal", ThemeKeys.TAB, _flat(Palette.TAB_INACTIVE_BG, Palette.PARCHMENT_BORDER_2, 1, 14))
|
||||
theme.set_color(&"font_color", ThemeKeys.TAB, Palette.INK_MUTED)
|
||||
theme.set_type_variation(ThemeKeys.TAB_ACTIVE, "Button")
|
||||
theme.set_stylebox(&"normal", ThemeKeys.TAB_ACTIVE, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
|
||||
theme.set_color(&"font_color", ThemeKeys.TAB_ACTIVE, Palette.CREAM_BRIGHT)
|
||||
|
||||
|
||||
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")
|
||||
var s := _flat(Color(Palette.MUTED_MONO, 0.12), Palette.MUTED_MONO, 1, 3)
|
||||
theme.set_stylebox(&"normal", k, s)
|
||||
theme.set_font(&"font", k, load(MONO))
|
||||
theme.set_font_size(&"font_size", k, 11)
|
||||
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the builder to generate the `.tres`**
|
||||
|
||||
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true && godot --headless -s res://scripts/theme/build_game_theme.gd`
|
||||
Expected: prints `wrote res://assets/theme/game_theme.tres`; the file now exists. No `theme save failed`.
|
||||
|
||||
- [ ] **Step 4: Write the failing test**
|
||||
|
||||
```gdscript
|
||||
# client/tests/unit/test_theme_resource.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const ThemeKeys = preload("res://scripts/theme/theme_keys.gd")
|
||||
const THEME_PATH := "res://assets/theme/game_theme.tres"
|
||||
|
||||
|
||||
func test_theme_loads():
|
||||
assert_true(FileAccess.file_exists(THEME_PATH), "theme not built — run build_game_theme.gd")
|
||||
var t = load(THEME_PATH)
|
||||
assert_true(t is Theme)
|
||||
|
||||
|
||||
func test_every_variation_resolves():
|
||||
var t: Theme = load(THEME_PATH)
|
||||
for key in ThemeKeys.ALL:
|
||||
var base: String = ThemeKeys.ALL[key]
|
||||
assert_eq(t.get_type_variation_base(key), StringName(base),
|
||||
"variation %s missing or wrong base" % key)
|
||||
|
||||
|
||||
func test_cta_has_normal_stylebox():
|
||||
var t: Theme = load(THEME_PATH)
|
||||
assert_true(t.has_stylebox(&"normal", ThemeKeys.PRIMARY_CTA))
|
||||
|
||||
|
||||
func test_default_font_is_set():
|
||||
var t: Theme = load(THEME_PATH)
|
||||
assert_true(t.default_font is FontFile)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `cd client && ./run_tests.sh -gtest=res://tests/unit/test_theme_resource.gd`
|
||||
Expected: PASS — 4 tests green.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/theme/theme_keys.gd client/scripts/theme/build_game_theme.gd \
|
||||
client/assets/theme/game_theme.tres client/tests/unit/test_theme_resource.gd
|
||||
git commit -m "feat(theme): game_theme.tres (fonts + stylebox variations) via builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: The three signature surfaces (shaders + drop-in scripts)
|
||||
|
||||
Each surface = one `.gdshader` + one `class_name` `Control` subclass that assigns a `ShaderMaterial` with palette-derived uniforms. A trivial `.tscn` per surface makes it an instanceable drop-in. (A `StyleBox` can't run a shader — spec §2.)
|
||||
|
||||
**Files:**
|
||||
- Create: `client/assets/theme/shaders/parchment.gdshader`
|
||||
- Create: `client/assets/theme/shaders/dark_bay.gdshader`
|
||||
- Create: `client/assets/theme/shaders/vignette.gdshader`
|
||||
- Create: `client/scripts/theme/surfaces/parchment_panel.gd`
|
||||
- Create: `client/scripts/theme/surfaces/dark_bay.gd`
|
||||
- Create: `client/scripts/theme/surfaces/vignette_overlay.gd`
|
||||
- Create: `client/scenes/theme/surfaces/ParchmentPanel.tscn`
|
||||
- Create: `client/scenes/theme/surfaces/DarkBay.tscn`
|
||||
- Create: `client/scenes/theme/surfaces/VignetteOverlay.tscn`
|
||||
- Test: `client/tests/unit/test_surfaces.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Palette` (Task 1).
|
||||
- Produces: `class_name ParchmentPanel extends Panel`, `class_name DarkBay extends Panel`, `class_name VignetteOverlay extends ColorRect` — each sets `self.material` to a `ShaderMaterial` in `_ready()`, uniforms fed from `Palette`. Instanceable via their `.tscn`.
|
||||
|
||||
- [ ] **Step 1: Write the three shaders**
|
||||
|
||||
```glsl
|
||||
// client/assets/theme/shaders/parchment.gdshader
|
||||
shader_type canvas_item;
|
||||
uniform vec4 top : source_color;
|
||||
uniform vec4 bottom : source_color;
|
||||
void fragment() {
|
||||
COLOR = mix(top, bottom, UV.y);
|
||||
}
|
||||
```
|
||||
|
||||
```glsl
|
||||
// client/assets/theme/shaders/dark_bay.gdshader
|
||||
shader_type canvas_item;
|
||||
uniform vec4 stripe_a : source_color;
|
||||
uniform vec4 stripe_b : source_color;
|
||||
uniform float band_px = 16.0; // 16px bands per README
|
||||
uniform float vignette = 0.55; // edge darkening strength
|
||||
void fragment() {
|
||||
// 135deg diagonal stripe: sum of pixel coords, banded.
|
||||
vec2 px = FRAGCOORD.xy;
|
||||
float diag = px.x + px.y;
|
||||
float band = mod(floor(diag / band_px), 2.0);
|
||||
vec4 base = mix(stripe_a, stripe_b, band);
|
||||
// radial vignette centred slightly high (README: 50% 38%).
|
||||
float d = distance(UV, vec2(0.5, 0.38));
|
||||
base.rgb *= 1.0 - clamp(d * vignette, 0.0, 1.0);
|
||||
COLOR = base;
|
||||
}
|
||||
```
|
||||
|
||||
```glsl
|
||||
// client/assets/theme/shaders/vignette.gdshader
|
||||
shader_type canvas_item;
|
||||
uniform float strength = 0.62;
|
||||
void fragment() {
|
||||
float d = distance(UV, vec2(0.5));
|
||||
COLOR = vec4(0.0, 0.0, 0.0, clamp(d * strength, 0.0, 1.0));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the three surface scripts**
|
||||
|
||||
```gdscript
|
||||
# client/scripts/theme/surfaces/parchment_panel.gd
|
||||
class_name ParchmentPanel
|
||||
extends Panel
|
||||
## Drop-in parchment backdrop (mockups "Dark Bay + Parchment" motif). §2: presentation.
|
||||
const Palette = preload("res://scripts/theme/palette.gd")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = load("res://assets/theme/shaders/parchment.gdshader")
|
||||
m.set_shader_parameter("top", Palette.SHEET_TOP)
|
||||
m.set_shader_parameter("bottom", Palette.SHEET_BOTTOM)
|
||||
material = m
|
||||
```
|
||||
|
||||
```gdscript
|
||||
# client/scripts/theme/surfaces/dark_bay.gd
|
||||
class_name DarkBay
|
||||
extends Panel
|
||||
## Drop-in dark stripe+vignette bay backdrop. §2: presentation.
|
||||
const Palette = preload("res://scripts/theme/palette.gd")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = load("res://assets/theme/shaders/dark_bay.gdshader")
|
||||
m.set_shader_parameter("stripe_a", Palette.BAY_STRIPE_A)
|
||||
m.set_shader_parameter("stripe_b", Palette.BAY_STRIPE_B)
|
||||
material = m
|
||||
```
|
||||
|
||||
```gdscript
|
||||
# client/scripts/theme/surfaces/vignette_overlay.gd
|
||||
class_name VignetteOverlay
|
||||
extends ColorRect
|
||||
## Drop-in edge-dim overlay for pause/dialogue dim. §2: presentation.
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
color = Color(0, 0, 0, 0) # base transparent; shader draws the dim
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = load("res://assets/theme/shaders/vignette.gdshader")
|
||||
material = m
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Author the three trivial scenes**
|
||||
|
||||
Each `.tscn` is one root node with the script attached, anchored full-rect. Create `ParchmentPanel.tscn`:
|
||||
|
||||
```
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://scripts/theme/surfaces/parchment_panel.gd" id="1"]
|
||||
[node name="ParchmentPanel" type="Panel"]
|
||||
anchors_preset = 15
|
||||
script = ExtResource("1")
|
||||
```
|
||||
|
||||
Create `DarkBay.tscn` (same shape, `type="Panel"`, script `dark_bay.gd`, node name `DarkBay`). Create `VignetteOverlay.tscn` (`type="ColorRect"`, script `vignette_overlay.gd`, node name `VignetteOverlay`).
|
||||
|
||||
- [ ] **Step 4: Write the failing test**
|
||||
|
||||
```gdscript
|
||||
# client/tests/unit/test_surfaces.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const SCENES := [
|
||||
"res://scenes/theme/surfaces/ParchmentPanel.tscn",
|
||||
"res://scenes/theme/surfaces/DarkBay.tscn",
|
||||
"res://scenes/theme/surfaces/VignetteOverlay.tscn",
|
||||
]
|
||||
|
||||
|
||||
func test_surfaces_instance_with_shader_material():
|
||||
for path in SCENES:
|
||||
var packed = load(path)
|
||||
assert_true(packed is PackedScene, "not a scene: %s" % path)
|
||||
var node = packed.instantiate()
|
||||
add_child_autofree(node) # triggers _ready()
|
||||
assert_true(node.material is ShaderMaterial, "%s has no ShaderMaterial" % path)
|
||||
assert_true(node.material.shader is Shader, "%s shader missing" % path)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Import, then run the test**
|
||||
|
||||
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true && ./run_tests.sh -gtest=res://tests/unit/test_surfaces.gd`
|
||||
Expected: PASS — all three instance and carry a compiled `ShaderMaterial`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/assets/theme/shaders/ client/scripts/theme/surfaces/ \
|
||||
client/scenes/theme/surfaces/ client/tests/unit/test_surfaces.gd
|
||||
git commit -m "feat(theme): parchment/dark-bay/vignette shader surfaces as drop-in scenes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: The showcase proof (`theme_showcase.tscn`) + human confirm
|
||||
|
||||
The one screen a human opens to confirm "the look is one system." Renders the palette, every stylebox variation, all three fonts, the rarity ladder, sample stat bars, and the three surfaces. GUT can only assert it instances cleanly; the real proof is the human eyeball (spec §7).
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/theme/theme_showcase.gd`
|
||||
- Create: `client/scenes/theme/theme_showcase.tscn`
|
||||
- Test: `client/tests/unit/test_theme_showcase.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Palette`, `ThemeKeys`, `game_theme.tres`, the three surface scenes.
|
||||
- Produces: a runnable scene; no API for later tasks.
|
||||
|
||||
- [ ] **Step 1: Write the showcase script**
|
||||
|
||||
```gdscript
|
||||
# client/scripts/theme/theme_showcase.gd
|
||||
extends Control
|
||||
## The eyeball proof for the shared Theme (spec §7). Builds every token, stylebox,
|
||||
## font, surface, and bar on one scrollable screen. No AI, no state. §2: presentation.
|
||||
const Palette = preload("res://scripts/theme/palette.gd")
|
||||
const ThemeKeys = preload("res://scripts/theme/theme_keys.gd")
|
||||
const GAME_THEME := "res://assets/theme/game_theme.tres"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
theme = load(GAME_THEME)
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(scroll)
|
||||
var col := VBoxContainer.new()
|
||||
col.custom_minimum_size = Vector2(1900, 0)
|
||||
col.add_theme_constant_override("separation", 16)
|
||||
scroll.add_child(col)
|
||||
|
||||
col.add_child(_heading("SHARED THEME — SHOWCASE"))
|
||||
col.add_child(_swatches())
|
||||
col.add_child(_variations())
|
||||
col.add_child(_rarity())
|
||||
col.add_child(_bars())
|
||||
col.add_child(_surfaces())
|
||||
|
||||
|
||||
func _heading(text: String) -> Label:
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.theme_type_variation = &"Heading"
|
||||
return l
|
||||
|
||||
|
||||
func _swatches() -> Control:
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 8
|
||||
for c in [Palette.STAGE_1, Palette.BAY_STRIPE_A, Palette.DARK_PANEL_70,
|
||||
Palette.SHEET_TOP, Palette.PARCHMENT_CARD, Palette.BLOOD, Palette.GOLD,
|
||||
Palette.STEEL, Palette.GREEN, Palette.INK_BODY, Palette.CREAM]:
|
||||
var r := ColorRect.new()
|
||||
r.color = c
|
||||
r.custom_minimum_size = Vector2(120, 60)
|
||||
grid.add_child(r)
|
||||
return grid
|
||||
|
||||
|
||||
func _variations() -> Control:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
var cta := Button.new(); cta.text = "ENTER THE WORLD"; cta.theme_type_variation = ThemeKeys.PRIMARY_CTA
|
||||
var tab := Button.new(); tab.text = "INACTIVE"; tab.theme_type_variation = ThemeKeys.TAB
|
||||
var tab_a := Button.new(); tab_a.text = "ACTIVE"; tab_a.theme_type_variation = ThemeKeys.TAB_ACTIVE
|
||||
var chip := Button.new(); chip.text = "INTIMIDATE · STR"; chip.theme_type_variation = ThemeKeys.CHIP
|
||||
for b in [cta, tab, tab_a, chip]:
|
||||
row.add_child(b)
|
||||
var card := PanelContainer.new(); card.theme_type_variation = ThemeKeys.PARCHMENT_CARD
|
||||
card.custom_minimum_size = Vector2(160, 80)
|
||||
row.add_child(card)
|
||||
return row
|
||||
|
||||
|
||||
func _rarity() -> Control:
|
||||
var row := HBoxContainer.new()
|
||||
for i in range(5):
|
||||
var tile := PanelContainer.new()
|
||||
tile.theme_type_variation = ThemeKeys.ITEM_TILE
|
||||
tile.custom_minimum_size = Vector2(72, 72)
|
||||
var bar := ColorRect.new()
|
||||
bar.color = Palette.rarity_color(i)
|
||||
bar.custom_minimum_size = Vector2(60, 4)
|
||||
tile.add_child(bar)
|
||||
row.add_child(tile)
|
||||
return row
|
||||
|
||||
|
||||
func _bars() -> Control:
|
||||
var row := HBoxContainer.new()
|
||||
for kind in [&"hp", &"mp", &"stamina"]:
|
||||
var pair = Palette.stat_bar(kind)
|
||||
var bar := ColorRect.new()
|
||||
bar.color = pair[0]
|
||||
bar.custom_minimum_size = Vector2(200, 18)
|
||||
row.add_child(bar)
|
||||
return row
|
||||
|
||||
|
||||
func _surfaces() -> Control:
|
||||
var row := HBoxContainer.new()
|
||||
row.custom_minimum_size = Vector2(0, 240)
|
||||
for path in ["res://scenes/theme/surfaces/ParchmentPanel.tscn",
|
||||
"res://scenes/theme/surfaces/DarkBay.tscn"]:
|
||||
var s = load(path).instantiate()
|
||||
s.custom_minimum_size = Vector2(600, 220)
|
||||
row.add_child(s)
|
||||
return row
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Author the scene**
|
||||
|
||||
Create `client/scenes/theme/theme_showcase.tscn`:
|
||||
|
||||
```
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://scripts/theme/theme_showcase.gd" id="1"]
|
||||
[node name="ThemeShowcase" type="Control"]
|
||||
anchors_preset = 15
|
||||
script = ExtResource("1")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the smoke test**
|
||||
|
||||
```gdscript
|
||||
# client/tests/unit/test_theme_showcase.gd
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
|
||||
func test_showcase_instances_without_error():
|
||||
var packed = load("res://scenes/theme/theme_showcase.tscn")
|
||||
assert_true(packed is PackedScene)
|
||||
var node = packed.instantiate()
|
||||
add_child_autofree(node) # runs _ready(); any engine error here fails the suite
|
||||
assert_true(node is Control)
|
||||
assert_true(node.theme is Theme, "showcase did not apply game_theme")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Import, then run the test**
|
||||
|
||||
Run: `cd client && godot --headless --import >/dev/null 2>&1 || true && ./run_tests.sh -gtest=res://tests/unit/test_theme_showcase.gd`
|
||||
Expected: PASS — instances cleanly, theme applied, 0 engine errors.
|
||||
|
||||
- [ ] **Step 5: Run the FULL suite (nothing regressed)**
|
||||
|
||||
Run: `cd client && ./run_tests.sh`
|
||||
Expected: all prior tests (110+) plus the new theme tests PASS, 0 failures, 0 engine errors.
|
||||
|
||||
- [ ] **Step 6: HUMAN CONFIRM — the eyeball proof (spec §7, cannot be skipped)**
|
||||
|
||||
Ask the human to open the showcase in the editor and confirm it reads as the mockups' system:
|
||||
|
||||
```
|
||||
godot res://scenes/theme/theme_showcase.tscn
|
||||
```
|
||||
|
||||
Human checks: parchment reads as warm aged paper; the dark bay shows the diagonal stripe + vignette; EB Garamond looks like a bookish serif; the CTA is blood-red with a cream label; tabs, chip, card, rarity tiles, and stat bars match the mock palette. **Do not mark the milestone done until the human signs off.** Record tweaks (uniform values, sizes) as follow-up edits to `palette.gd` / the builder, then re-run the builder + full suite.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/theme/theme_showcase.gd client/scenes/theme/theme_showcase.tscn \
|
||||
client/tests/unit/test_theme_showcase.gd
|
||||
git commit -m "feat(theme): showcase harness — the human-eyeball proof"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Branch:** this is non-doc code — work on a `feature/shared-theme` branch off `dev`, never commit to `dev`/`master` directly (charter §18). The human does the `--no-ff` merge after the eyeball sign-off.
|
||||
- **`.uid` files:** Godot 4.7 writes a `.gd.uid` beside each new script on import. Add them with their script (the repo already tracks `.uid`s).
|
||||
- **If a font URL 404s:** the google/fonts repo occasionally re-lays-out; find the OFL dir in-browser and adjust. Any of the three families can be swapped for another OFL face of the same character without a design change (spec §3) — but update `palette.gd`'s comment and the builder paths together.
|
||||
- **Builder re-runs:** any palette/stylebox change means re-running `build_game_theme.gd` to regenerate `game_theme.tres`, then re-running the suite. The builder is the source of truth; never hand-edit the `.tres`.
|
||||
Reference in New Issue
Block a user