From 0249cbcf09e8bd8ddddd0ce87606ad13e1c061e1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:19:17 -0500 Subject: [PATCH 1/8] docs(spec): Title screen (M3) + unified version design Editor-first Title screen from mock: warm atmosphere (GPUParticles embers, AnimationPlayer glow, palette-uniform shader bg), logo block, keyboard/mouse menu (New Game -> shell, Quit works, rest inert), footer. Plus a single /VERSION source of truth read by both the API (startup + /version) and the client (synced into project.godot config/version, read via ProjectSettings). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-11-title-screen-design.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-title-screen-design.md diff --git a/docs/superpowers/specs/2026-07-11-title-screen-design.md b/docs/superpowers/specs/2026-07-11-title-screen-design.md new file mode 100644 index 0000000..1c9c7ff --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-title-screen-design.md @@ -0,0 +1,99 @@ +# Title screen (M3) + unified version — design + +**Milestone:** M3 (roadmap "Title screen"). Last screen of the M3 visual-foundation block. +**Charter:** §16 (mockups are the UI bible; editor-first scenes per ADR 0001), §4 (client/API are separate processes over HTTP), §2 (presentation/state). +**Mockup:** `mockups/Title Screen.dc.html`. +**Branch:** `feature/title-screen` off `dev`. + +## 1. Goal + +The entry point — the game boots to a Title screen. Faithful to the mock: a dark, warm, atmospheric first impression (drifting embers, a pulsing off-screen fire glow, a key-art slot), the **CODE OF CONQUEST** logo block, a keyboard/mouse-driven menu, and a footer. Plus the cross-cutting piece the Title surfaces first: a **single source of truth for the app version** shared by the client and the API. + +## 2. Scope decisions (settled in brainstorming) + +- **Version single-source:** a root `/VERSION` file (`0.01-alpha`). The API reads it at startup; the client bakes it into `project.godot`'s `application/config/version` via a sync script and reads it through `ProjectSettings`. No runtime coupling — the client knows its own version offline. Rejected: client fetching the version over HTTP (conflates client build with server build; fails offline); codegen into both (overkill now). +- **Menu behavior:** `New Game` → change scene to the Main Window shell (the one real destination today; the real flow later is Title → character creation → shell). `Quit` → quit the app. `Continue` / `Load Game` / `Settings` / `Credits` are faithful-but-inert placeholders (their screens are later milestones). Keyboard (`↑`/`↓` select, `Enter` activate) **and** mouse (hover selects, click activates). +- **Motion:** implement the ambient motion — drifting embers + a pulsing fire glow. +- **Boot:** Title becomes the project's `run/main_scene`. + +## 3. Version — the unified source + +``` +/VERSION 0.01-alpha (one line, LF; the only human-edited version) +``` + +### API (Python) +- `api/app/version.py`: `get_version() -> str` reads `/VERSION` (path resolved relative to the repo root, mirroring how `canon_log` resolves the schema dir) and strips whitespace; a module-level `VERSION` constant caches it. +- `api/app/main.py`: `FastAPI(title="coc-rpg proxy", version=VERSION)` and a `GET /version` returning `{"version": VERSION}`. +- Test: `get_version()` equals the `/VERSION` file contents; the FastAPI app's `version` matches; `GET /version` returns it. + +### Client (Godot) +- `scripts/sync-version` (repo-root script, bash): reads `/VERSION` and writes `config/version=""` into `[application]` of `client/project.godot` (adds the key if absent). Run on every version bump. Documented in `client/docs/README.md`. +- `client/project.godot`: `application/config/version="0.01-alpha"` (written by the sync script; committed). +- `client/scripts/util/version.gd` — `class_name Version`, static `func string() -> String` returns `ProjectSettings.get_setting("application/config/version", "")`; if empty, returns a `LAST_RESORT` constant (`"0.0.0-dev"`) so the footer can never be blank. A `func footer() -> String` returns `"v%s · BUILT IN GODOT 4.7 · © 2026" % string()`. +- Test: `Version.string()` is non-empty and equals the project setting; `footer()` contains the version. + +### Alignment +`/VERSION` is the source; bump it and run `scripts/sync-version`. The API test guards that the API reads the file correctly; the client's `config/version` is written from the same file by the sync script. A repo-level equality check (project.godot vs `/VERSION`) is a **later** nicety (noted, not built now — nothing is deployed). + +## 4. Title scene — architecture (editor-first, ADR 0001) + +`client/scenes/title/TitleScreen.tscn` + `client/scripts/ui/title/title_screen.gd`. Layout authored in the `.tscn`; the script does on-load work only. + +### Authored node tree (`.tscn`) +Root `TitleScreen` (Control, fixed 1920×1080, theme assigned, **no full-rect anchors on root** — the M3-a collapse rule; script keeps `size = get_viewport_rect().size`). + +- **Background atmosphere** (bottom of the z-order): + - `Background` — a `ColorRect` (full rect) fed by a small warm-radial shader (`title_background.gdshader`) whose colours are `Palette` uniforms set in `_ready()` (the `DarkBay` precedent — renders on F6, editor shows the fallback solid). Uniform colours: a warm centre, a mid brown, the near-black stage edge. + - `KeyArt` — dashed art-slot placeholder (right side), a mono caption "KEY ART DROPS IN HERE" (charter: art slots stay dashed placeholders). + - `Glow` — a `ColorRect`/`TextureRect` warm radial at the bottom, its `modulate:a` pulsed by an `AnimationPlayer` (`Glow` animation, ~6s loop, 0.5↔0.8). + - `Vignette` — the existing `VignetteOverlay` surface, full rect. + - `Embers` — a `GPUParticles2D` emitting small warm dots rising ~460px over 7–11s with fade (a `ParticleProcessMaterial`: upward gravity, lifetime randomness, alpha curve). Authored so it previews in-editor. +- **Content column** (`MarginContainer`/`VBoxContainer`, left, padding ~120px, vertically centered): + - `Kicker` Label — "A DARK FANTASY RPG", `TitleKicker` variation. + - `Logo` Label — "CODE OF\nCONQUEST", `TitleLogo` variation. + - `Rule` — a small `ColorRect` (220×2), colour `Palette.GOLD` applied in `_ready()` (a decorative accent; the mock's gold→transparent fade is not worth a gradient asset here). + - `Tagline` Label — "The world has real problems, and it does not care about you.", `Accent` variation. + - `Menu` VBoxContainer — **6 authored rows**, one per menu item (fixed list). Each row is an `HBoxContainer` named for its action, carrying `metadata/action` (a StringName), containing a `Marker` Label ("◆", gold) and a `VBoxContainer` with a `Label` (item name) and, for `Continue`, a `Sub` Label ("CHAPTER II · VEXCCA · 14:22:07"). Authored labels: New Game, Continue, Load Game, Settings, Credits, Quit. +- **Footer:** + - `FooterLeft` Label (`Mono`) — the version line (bound from `Version.footer()` in `_ready`; authored placeholder text so it previews). + - `FooterRight` Label (`Mono`) — "[ ↑ ↓ ] CHOOSE [ ↵ ] ENTER". + +### Theme additions (built from `Palette`, per the established pattern) +Add to `ThemeKeys` + `build_game_theme.gd`, regenerate `game_theme.tres`: +- `TitleLogo` — Label variation: serif (Georgia-equivalent) **bold**, ~116px, colour `CREAM`. +- `TitleKicker` — Label variation: mono, ~13px, wide tracking, colour `GOLD`. + +(The tagline uses the existing `Accent`; the footer uses `Mono`. Any new warm background colours go in `Palette` as new tokens, not hex literals in the shader/scene.) + +### Script (`title_screen.gd`, on-load/logic only) +- `class_name TitleScreen extends Control`. +- `signal menu_activated(action: StringName)` — the testable seam (like the dock's `screen_requested`). +- `_ready()`: viewport-fit (+`size_changed`); set the background shader's `Palette` uniforms; harvest the menu rows (`_rows: Array` from the `Menu` container, reading each row's `action` meta) into an ordered list; bind `FooterLeft.text = Version.new().footer()`; connect `menu_activated` → `_on_menu_activated`; apply the initial selection highlight. +- Input: `_unhandled_input` handles `ui_up`/`ui_down` (move `_sel`, wrapping) and `ui_accept` (activate `_sel`); each row's mouse-enter sets `_sel`; mouse-click activates that row. Selection highlight = toggle the row's `Marker` opacity (1 selected / 0 not) + the label colour (`CREAM_BRIGHT` selected / `MUTED_MONO` not), from `Palette`. +- `_activate(i)`: emit `menu_activated(_rows[i].action)`. +- `_on_menu_activated(action)`: `&"new_game"` → `get_tree().change_scene_to_file("res://scenes/shell/MainWindowShell.tscn")`; `&"quit"` → `get_tree().quit()`; anything else → no-op (inert placeholder). Kept out of `_activate` so tests assert the emitted action without triggering a real scene change / quit. + +## 5. Boot + +`client/project.godot`: set `run/main_scene="res://scenes/title/TitleScreen.tscn"`. Launching the game (and a future export) opens the Title; New Game → the shell. + +## 6. §2 ledger + +- Version value, menu selection index, boot scene — **state**/presentation, code-owned. +- No AI text anywhere on this screen (the DM enters at the shell). The `/version` endpoint is metadata, not a role. +- `menu_activated` for inert items is a dangling seam (no destination yet), exactly like the dock's routing seam. + +## 7. Testing + +- **`Version`** (client): `string()` non-empty and equals `ProjectSettings` `application/config/version`; `footer()` contains the version and the "BUILT IN GODOT 4.7" text. +- **API version:** `get_version()` == `/VERSION` file contents (whitespace-stripped); the FastAPI app `version` matches; `GET /version` returns `{"version": …}`. +- **`TitleScreen`:** selection logic — `↓`/`↑` move `_sel` and wrap across the 6 rows; a row's mouse-enter sets `_sel`. Activation — with a watched `menu_activated`, activating the New Game row emits `&"new_game"` and the Quit row emits `&"quit"` (no real scene change/quit in the test). Node-presence: the 6 menu rows mount with their `action` meta; footer shows a version. +- **Visual gate (required):** human **F6** — the logo, the warm background + vignette, drifting embers, the pulsing glow, selection highlight moving with keyboard/mouse, and New Game → the shell. Headless can't prove the atmosphere. + +## 8. Out of scope + +- Character creation, save/load, settings, credits screens (later milestones) — those menu items are inert. +- Real key art (dashed slot). +- A repo-level version drift-check / pre-commit hook (noted; nothing is deployed). +- Persisted "last selected" / save-slot info on Continue (the sub-line is authored placeholder text). From 8e4c0bf9578793e1cb92c1c1980c841a1a6a953c Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:27:08 -0500 Subject: [PATCH 2/8] docs(plan): Title screen (M3) + unified version implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-11-title-screen.md | 967 ++++++++++++++++++ 1 file changed, 967 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-title-screen.md diff --git a/docs/superpowers/plans/2026-07-11-title-screen.md b/docs/superpowers/plans/2026-07-11-title-screen.md new file mode 100644 index 0000000..85d20da --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-title-screen.md @@ -0,0 +1,967 @@ +# Title screen (M3) + unified version — 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 editor-first Title screen (entry point) from the mock, and stand up a single `/VERSION` source of truth shared by the client and API. + +**Architecture:** Editor-first UI scene (ADR 0001): layout in `.tscn`, script does on-load work only. A root `/VERSION` file feeds the API (read at startup) and the client (synced into `project.godot`, read via `ProjectSettings`). Distinct title text and atmosphere reuse the theme/Palette system. + +**Tech Stack:** Godot 4.7 / GDScript / GUT; Python / FastAPI / pytest. Reuses the M3 Theme, `Palette`, `VignetteOverlay`, and the `MainWindowShell` scene (New Game target). + +## Global Constraints + +- **Godot 4.7 / GDScript; Python / FastAPI.** No C#. +- **`Palette` is the only home for hex** — no colour literal in any script, `.tscn`, or shader (shaders take `Palette` values as uniforms set in code, the `DarkBay` precedent). +- **Theme styling via `ThemeKeys` constants**; new title text styles are theme variations built from `Palette` in `build_game_theme.gd`, then the `.tres` is **regenerated** (never hand-edited): `godot --headless -s res://scripts/theme/build_game_theme.gd`. +- **Editor-first (ADR 0001):** author the scene tree in `.tscn`; the script only binds/wires. New `class_name` scripts resolve only after `godot --headless --import`, which `./run_tests.sh` runs first. +- **Scene-root sizing:** the `.tscn` root gets a fixed size (1920×1080) but **no full-rect anchors on the root**; the script keeps `size = get_viewport_rect().size` + `size_changed` (M3-a collapse rule). +- **Version value now:** `0.01-alpha`. Client footer renders `v0.01-alpha · BUILT IN GODOT 4.7 · © 2026`. +- **GUT `.gutconfig`** promotes any engine error/warning to a failure — load defensively, keep output pristine. +- **§2:** presentation/state, code-owned. No AI text on this screen. + +--- + +### Task 1: Root `/VERSION` + API reads it (+ `/version` endpoint) + +**Files:** +- Create: `VERSION` (repo root) +- Create: `api/app/version.py` +- Modify: `api/app/main.py` +- Test: `api/tests/test_version.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `api/app/version.py` — `get_version() -> str` (reads `/VERSION`, cached) and module constant `VERSION: str`. `GET /version` → `{"version": VERSION}`. `FastAPI(version=VERSION)`. + +- [ ] **Step 1: Create the source-of-truth file** + +Create `VERSION` (repo root) with exactly one line: + +``` +0.01-alpha +``` + +- [ ] **Step 2: Write the failing test** + +Create `api/tests/test_version.py`: + +```python +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.main import app +from app.version import VERSION, get_version + +client = TestClient(app) +ROOT_VERSION = Path(__file__).resolve().parents[2] / "VERSION" + + +def test_get_version_matches_file(): + assert ROOT_VERSION.is_file() + assert get_version() == ROOT_VERSION.read_text(encoding="utf-8").strip() + + +def test_app_version_matches(): + assert app.version == VERSION == get_version() + + +def test_version_endpoint(): + assert client.get("/version").json() == {"version": VERSION} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd api && python -m pytest tests/test_version.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.version'`. + +- [ ] **Step 4: Write `version.py`** + +Create `api/app/version.py`: + +```python +"""Single source of truth for the app version (charter §4: client and API share +one version). Reads the repo-root /VERSION file — the ONLY place a human edits the +version. Mirrors canon_log._schema_dir's env-override + walk-up so it resolves in +a local checkout and (once /VERSION is bundled) an image alike. +""" +import os +from functools import lru_cache +from pathlib import Path + +LAST_RESORT = "0.0.0-dev" + + +def _version_file() -> Path | None: + env = os.environ.get("COC_VERSION_FILE") + if env: + return Path(env) + here = Path(__file__).resolve() + for parent in here.parents: + candidate = parent / "VERSION" + if candidate.is_file(): + return candidate + return None + + +@lru_cache(maxsize=1) +def get_version() -> str: + path = _version_file() + if path is None or not path.is_file(): + return LAST_RESORT + return path.read_text(encoding="utf-8").strip() or LAST_RESORT + + +VERSION = get_version() +``` + +- [ ] **Step 5: Wire it into `main.py`** + +In `api/app/main.py`, add the import near the other `from .` imports: + +```python +from .version import VERSION +``` + +Change the app construction from `app = FastAPI(title="coc-rpg proxy", version="0.0.1")` to: + +```python +app = FastAPI(title="coc-rpg proxy", version=VERSION) +``` + +Add the endpoint (next to the existing `/health` route): + +```python +@app.get("/version") +def version() -> dict: + return {"version": VERSION} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd api && python -m pytest tests/test_version.py -q` +Expected: PASS — 3 passed. + +- [ ] **Step 7: Commit** + +```bash +git add VERSION api/app/version.py api/app/main.py api/tests/test_version.py +git commit -m "feat(api): /VERSION single source read at startup + GET /version" +``` + +--- + +### Task 2: Client version plumbing (`Version` helper + sync script) + +**Files:** +- Create: `scripts/sync-version` +- Modify: `client/project.godot` (via the script) +- Create: `client/scripts/util/version.gd` +- Test: `client/tests/unit/test_version.gd` + +**Interfaces:** +- Consumes: root `/VERSION` (Task 1). +- Produces: `class_name Version` — `func string() -> String` (reads `application/config/version`, `LAST_RESORT` if empty) and `func footer() -> String`. `project.godot` now has `application/config/version="0.01-alpha"`. + +- [ ] **Step 1: Write the sync script** + +Create `scripts/sync-version` (make it executable): + +```bash +#!/usr/bin/env bash +# Sync the single source of truth (/VERSION) into the Godot client's project.godot +# (application/config/version). Run after bumping /VERSION. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +version="$(tr -d '[:space:]' < "$root/VERSION")" +proj="$root/client/project.godot" +if grep -q '^config/version=' "$proj"; then + sed -i "s|^config/version=.*|config/version=\"$version\"|" "$proj" +else + sed -i "s|^\(config/name=.*\)|\1\nconfig/version=\"$version\"|" "$proj" +fi +echo "synced client config/version = \"$version\"" +``` + +Then: `chmod +x scripts/sync-version` + +- [ ] **Step 2: Run the sync script** + +Run: `./scripts/sync-version` +Expected: prints `synced client config/version = "0.01-alpha"`. Verify `client/project.godot`'s `[application]` block now contains `config/version="0.01-alpha"` (run `grep 'config/version' client/project.godot`). + +- [ ] **Step 3: Write the failing test** + +Create `client/tests/unit/test_version.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + + +func test_string_matches_project_setting(): + var expected := str(ProjectSettings.get_setting("application/config/version", "")) + assert_ne(expected, "", "project.godot must carry application/config/version (run scripts/sync-version)") + assert_eq(Version.new().string(), expected) + + +func test_footer_contains_version_and_engine(): + var v := Version.new() + assert_string_contains(v.footer(), v.string()) + assert_string_contains(v.footer(), "BUILT IN GODOT 4.7") +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gselect=test_version.gd` +Expected: FAIL — `Version` is an unknown identifier. + +- [ ] **Step 5: Write the `Version` helper** + +Create `client/scripts/util/version.gd`: + +```gdscript +class_name Version +extends RefCounted +## The client's view of the single source of truth (/VERSION, charter §4). The +## value is baked into project.godot's application/config/version by +## scripts/sync-version on each bump; read it via ProjectSettings so an exported +## build (which has no repo-root /VERSION) still knows its own version. + +const SETTING := "application/config/version" +const LAST_RESORT := "0.0.0-dev" + + +func string() -> String: + var v := str(ProjectSettings.get_setting(SETTING, "")) + return v if v != "" else LAST_RESORT + + +func footer() -> String: + return "v%s · BUILT IN GODOT 4.7 · © 2026" % string() +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gselect=test_version.gd` +Expected: PASS — 2 tests. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/sync-version client/project.godot client/scripts/util/version.gd client/scripts/util/version.gd.uid client/tests/unit/test_version.gd client/tests/unit/test_version.gd.uid +git commit -m "feat(client): Version helper + sync-version script; project.godot config/version" +``` + +--- + +### Task 3: Theme title text variations (`TitleLogo`, `TitleKicker`) + +**Files:** +- Modify: `client/scripts/theme/theme_keys.gd` +- Modify: `client/scripts/theme/build_game_theme.gd` +- Modify: `client/assets/theme/game_theme.tres` (regenerated) +- Test: `client/tests/unit/test_title_theme.gd` + +**Interfaces:** +- Consumes: `Palette` (`CREAM`, `GOLD`). +- Produces: `ThemeKeys.TITLE_LOGO`, `ThemeKeys.TITLE_KICKER` (Label variations). `TitleLogo` = serif 116px `CREAM`; `TitleKicker` = mono 13px `GOLD`. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_title_theme.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const Builder = preload("res://scripts/theme/build_game_theme.gd") + + +func test_title_variations_built(): + var t: Theme = Builder.build_theme() + assert_eq(t.get_font_size(&"font_size", ThemeKeys.TITLE_LOGO), 116) + assert_eq(t.get_color(&"font_color", ThemeKeys.TITLE_LOGO), Palette.CREAM) + assert_eq(t.get_color(&"font_color", ThemeKeys.TITLE_KICKER), Palette.GOLD) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gselect=test_title_theme.gd` +Expected: FAIL — `ThemeKeys.TITLE_LOGO` is not defined (parse error). + +- [ ] **Step 3: Add the ThemeKeys constants** + +In `client/scripts/theme/theme_keys.gd`, alongside the font-role consts `HEADING`/`ACCENT`/`MONO` (these are Label font-roles kept OUT of `ALL`, so do NOT add the new ones to `ALL`): + +```gdscript +const TITLE_LOGO := &"TitleLogo" +const TITLE_KICKER := &"TitleKicker" +``` + +- [ ] **Step 4: Build the variations** + +In `client/scripts/theme/build_game_theme.gd`, add a call inside `build_theme()` right after `_fonts(theme)`: + +```gdscript + _title_type(theme) +``` + +And define the function (place it after `_fonts`): + +```gdscript +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) +``` + +- [ ] **Step 5: Regenerate the committed theme** + +Run: `cd client && godot --headless -s res://scripts/theme/build_game_theme.gd` +Expected: prints `wrote res://assets/theme/game_theme.tres`. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd client && ./run_tests.sh -gselect=test_title_theme.gd` → PASS. +Then: `cd client && ./run_tests.sh -gselect=test_theme_resource.gd` → still PASS (no drift regression). + +- [ ] **Step 7: 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_title_theme.gd client/tests/unit/test_title_theme.gd.uid +git commit -m "feat(theme): TitleLogo + TitleKicker display variations" +``` + +--- + +### Task 4: TitleScreen core scene + script + boot + +**Files:** +- Create: `client/scripts/ui/title/title_screen.gd` +- Create: `client/scenes/title/TitleScreen.tscn` +- Modify: `client/project.godot` (set `run/main_scene`) +- Test: `client/tests/unit/test_title_screen.gd` + +**Interfaces:** +- Consumes: `Version` (Task 2), `ThemeKeys.TITLE_LOGO/TITLE_KICKER` (Task 3), `Palette`, `VignetteOverlay` scene, `MainWindowShell` scene. +- Produces: `class_name TitleScreen extends Control` — `signal menu_activated(id: StringName)`; `var _rows: Array`; `var _sel: int`; `func _move(delta)`, `func _activate(i)`. The atmosphere (bg shader/glow/embers) is added in Task 5; this task builds the composition + interaction. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_title_screen.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/title/TitleScreen.tscn" + + +func _title() -> TitleScreen: + var t = load(SCENE).instantiate() + add_child_autofree(t) # runs _ready() + return t + + +func _index_of(t: TitleScreen, action: StringName) -> int: + for i in range(t._rows.size()): + if t._rows[i].get_meta("action") == action: + return i + return -1 + + +func test_six_menu_rows_each_with_an_action(): + var t := _title() + assert_eq(t._rows.size(), 6) + for row in t._rows: + assert_true(row.has_meta("action")) + + +func test_down_moves_selection(): + var t := _title() + assert_eq(t._sel, 0) + t._move(1) + assert_eq(t._sel, 1) + + +func test_up_from_first_wraps_to_last(): + var t := _title() + t._move(-1) + assert_eq(t._sel, t._rows.size() - 1) + + +func test_new_game_and_quit_emit_their_action(): + var t := _title() + # Isolate the signal seam so the test does not actually change scene / quit. + t.menu_activated.disconnect(t._on_menu_activated) + watch_signals(t) + t._activate(_index_of(t, &"new_game")) + assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"]) + t._activate(_index_of(t, &"quit")) + assert_signal_emitted_with_parameters(t, "menu_activated", [&"quit"]) + + +func test_footer_shows_version(): + var t := _title() + assert_string_contains(t._footer_left.text, Version.new().string()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gselect=test_title_screen.gd` +Expected: FAIL — cannot load `TitleScreen.tscn` / `TitleScreen` unknown. + +- [ ] **Step 3: Write the script** + +Create `client/scripts/ui/title/title_screen.gd`: + +```gdscript +class_name TitleScreen +extends Control +## The entry-point screen (mock Title Screen). Editor-first (ADR 0001): the whole +## composition lives in TitleScreen.tscn; this script drives selection, input, and +## dispatches the menu. §2: presentation/state — no AI here (the DM enters at the +## shell). The atmosphere (bg shader, glow, embers) is layered on in a later task. + +signal menu_activated(id: StringName) + +const SHELL := "res://scenes/shell/MainWindowShell.tscn" + +var _rows: Array = [] +var _sel: int = 0 + +@onready var _menu: VBoxContainer = $Content/Menu +@onready var _footer_left: Label = $FooterLeft + + +func _ready() -> void: + # Root Control run via F6 is not sized by full-rect anchors — size explicitly. + _fit_to_viewport() + get_viewport().size_changed.connect(_fit_to_viewport) + + _rows = [] + for row in _menu.get_children(): + if row.has_meta("action"): + _rows.append(row) + (row.get_node("Marker") as Label).add_theme_color_override("font_color", Palette.GOLD) + _wire_row_mouse() + + _footer_left.text = Version.new().footer() + menu_activated.connect(_on_menu_activated) + _update_selection() + + +func _fit_to_viewport() -> void: + size = get_viewport_rect().size + + +func _wire_row_mouse() -> void: + for i in range(_rows.size()): + var idx := i # fresh per-iteration binding for the closures + var row: Control = _rows[idx] + row.mouse_entered.connect(func(): + _sel = idx + _update_selection()) + row.gui_input.connect(func(e: InputEvent): + if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT: + _activate(idx)) + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_down"): + _move(1) + elif event.is_action_pressed("ui_up"): + _move(-1) + elif event.is_action_pressed("ui_accept"): + _activate(_sel) + + +func _move(delta: int) -> void: + if _rows.is_empty(): + return + _sel = wrapi(_sel + delta, 0, _rows.size()) + _update_selection() + + +func _activate(i: int) -> void: + if i < 0 or i >= _rows.size(): + return + menu_activated.emit(_rows[i].get_meta("action")) + + +func _on_menu_activated(id: StringName) -> void: + match id: + &"new_game": + get_tree().change_scene_to_file(SHELL) + &"quit": + get_tree().quit() + _: + pass # inert placeholder — the screen lands in a later milestone + + +func _update_selection() -> void: + for i in range(_rows.size()): + var row: Control = _rows[i] + var selected := i == _sel + (row.get_node("Marker") as Label).modulate.a = 1.0 if selected else 0.0 + var label := row.get_node("Body/Label") as Label + label.add_theme_color_override("font_color", Palette.CREAM_BRIGHT if selected else Palette.MUTED_MONO) +``` + +- [ ] **Step 4: Write the scene** + +Create `client/scenes/title/TitleScreen.tscn` (root has a fixed size but **no full-rect anchors**; menu rows carry an `action` meta and `mouse_filter = 0` so they receive hover/click): + +``` +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scripts/ui/title/title_screen.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/VignetteOverlay.tscn" id="3"] + +[node name="TitleScreen" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.039216, 0.031373, 0.023529, 1) + +[node name="KeyArt" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 6 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -520.0 +offset_top = -20.0 +offset_right = -120.0 +offset_bottom = 20.0 +grow_horizontal = 0 +grow_vertical = 2 +theme_type_variation = &"Mono" +text = "KEY ART DROPS IN HERE" +horizontal_alignment = 1 + +[node name="Vignette" parent="." instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 + +[node name="Content" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 120.0 +offset_top = -260.0 +offset_right = 900.0 +offset_bottom = 260.0 +grow_vertical = 2 +theme_override_constants/separation = 10 + +[node name="Kicker" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"TitleKicker" +text = "A DARK FANTASY RPG" + +[node name="Logo" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"TitleLogo" +text = "CODE OF +CONQUEST" + +[node name="Rule" type="ColorRect" parent="Content"] +custom_minimum_size = Vector2(220, 2) +layout_mode = 2 +color = Color(0.662745, 0.517647, 0.247059, 1) + +[node name="Tagline" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "The world has real problems, and it does not care about you." + +[node name="Menu" type="VBoxContainer" parent="Content"] +layout_mode = 2 +theme_override_constants/separation = 5 + +[node name="NewGame" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"new_game" + +[node name="Marker" type="Label" parent="Content/Menu/NewGame"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/NewGame"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/NewGame/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "New Game" + +[node name="Continue" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"continue" + +[node name="Marker" type="Label" parent="Content/Menu/Continue"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Continue"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Continue/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Continue" + +[node name="Sub" type="Label" parent="Content/Menu/Continue/Body"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "CHAPTER II · VEXCCA · 14:22:07" + +[node name="LoadGame" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"load_game" + +[node name="Marker" type="Label" parent="Content/Menu/LoadGame"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/LoadGame"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/LoadGame/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Load Game" + +[node name="Settings" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"settings" + +[node name="Marker" type="Label" parent="Content/Menu/Settings"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Settings"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Settings/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Settings" + +[node name="Credits" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"credits" + +[node name="Marker" type="Label" parent="Content/Menu/Credits"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Credits"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Credits/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Credits" + +[node name="Quit" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"quit" + +[node name="Marker" type="Label" parent="Content/Menu/Quit"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Quit"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Quit/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Quit" + +[node name="FooterLeft" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 120.0 +offset_top = -60.0 +offset_right = 700.0 +offset_bottom = -32.0 +grow_vertical = 0 +theme_type_variation = &"Mono" +text = "v0.01-alpha · BUILT IN GODOT 4.7 · © 2026" + +[node name="FooterRight" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -520.0 +offset_top = -60.0 +offset_right = -120.0 +offset_bottom = -32.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_type_variation = &"Mono" +text = "[ ↑ ↓ ] CHOOSE [ ↵ ] ENTER" +horizontal_alignment = 2 +``` + +Note the two `Color(...)` literals in the `.tscn` (`Background` fallback + `Rule`) are the stage-black and gold Palette values; they are the one allowed spot (an authored `ColorRect.color` has no theme-variation equivalent) — they equal `Palette.STAGE_1` / `Palette.GOLD` and the real background is the Palette-driven shader added in Task 5. If a reviewer objects, the alternative is setting them in `_ready` from `Palette` (loses editor preview). + +- [ ] **Step 5: Set the boot scene** + +In `client/project.godot`, under `[application]`, add (or set): + +``` +run/main_scene="res://scenes/title/TitleScreen.tscn" +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gselect=test_title_screen.gd` +Expected: PASS — 5 tests. + +- [ ] **Step 7: Run the full suite** + +Run: `cd client && ./run_tests.sh` +Expected: PASS — all prior tests plus the new title tests, 0 failing. + +- [ ] **Step 8: Commit** + +```bash +git add client/scripts/ui/title/title_screen.gd client/scripts/ui/title/title_screen.gd.uid client/scenes/title/TitleScreen.tscn client/project.godot client/tests/unit/test_title_screen.gd client/tests/unit/test_title_screen.gd.uid +git commit -m "feat(title): TitleScreen composition, menu + selection, boot scene" +``` + +--- + +### Task 5: Atmosphere — warm background, glow pulse, embers + +**Files:** +- Modify: `client/scripts/theme/palette.gd` (warm tokens) +- Create: `client/assets/theme/shaders/title_background.gdshader` +- Modify: `client/scenes/title/TitleScreen.tscn` (add `Glow` + `Embers` nodes) +- Modify: `client/scripts/ui/title/title_screen.gd` (configure bg/glow/embers in `_ready`) + +**Interfaces:** +- Consumes: `Palette` (new `TITLE_WARM`, `TITLE_MID`, existing `STAGE_1`, `GOLD_BRIGHT`, `BLOOD`). +- Produces: no new public API. Visual only — verified by the F6 gate (Task 6), not by a unit test. + +- [ ] **Step 1: Add warm Palette tokens** + +In `client/scripts/theme/palette.gd`, after the dark-background block: + +```gdscript +# --- Title-screen ambiance (warm radial background) --- +const TITLE_WARM := Color("2a2018") # radial centre +const TITLE_MID := Color("160f0a") # radial midpoint (STAGE_1 is the edge) +``` + +- [ ] **Step 2: Write the background shader** + +Create `client/assets/theme/shaders/title_background.gdshader`: + +```glsl +shader_type canvas_item; +// Warm radial for the Title screen (mock: radial-gradient warm centre -> near +// black edge, centred at 70% 20%). Colours are Palette uniforms set by the title +// script (the DarkBay precedent), so no hex lives in the shader. +uniform vec4 warm : source_color; +uniform vec4 mid : source_color; +uniform vec4 edge : source_color; +uniform vec2 center = vec2(0.7, 0.2); + +void fragment() { + float d = distance(UV, center); + vec3 c = mix(warm.rgb, mid.rgb, clamp(d / 0.45, 0.0, 1.0)); + c = mix(c, edge.rgb, clamp((d - 0.45) / 0.55, 0.0, 1.0)); + COLOR = vec4(c, 1.0); +} +``` + +- [ ] **Step 3: Add the Glow + Embers nodes to the scene** + +In `client/scenes/title/TitleScreen.tscn`, add these two nodes as children of the root (after `Background`, before `Vignette` so the vignette sits over them). Insert into the node list: + +``` +[node name="Glow" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -420.0 +mouse_filter = 2 +color = Color(0.560784, 0.227451, 0.203922, 0.22) + +[node name="Embers" type="GPUParticles2D" parent="."] +position = Vector2(960, 1080) +amount = 24 +lifetime = 9.0 +preprocess = 4.0 +``` + +(The `Glow` colour is `Palette.BLOOD` at low alpha — a warm member of the fire glow; its pulse is driven in code. `Embers` gets its process material in code so the colour comes from `Palette`.) + +- [ ] **Step 4: Configure bg, glow, embers in the script** + +In `client/scripts/ui/title/title_screen.gd`, add `@onready` refs beside the existing ones: + +```gdscript +@onready var _background: ColorRect = $Background +@onready var _glow: ColorRect = $Glow +@onready var _embers: GPUParticles2D = $Embers +``` + +In `_ready()`, add these calls (after `_fit_to_viewport()` / before the menu harvest is fine): + +```gdscript + _apply_background() + _configure_embers() + _pulse_glow() +``` + +Add the three functions: + +```gdscript +func _apply_background() -> void: + # Palette-driven shader material set in code (the DarkBay precedent) so no hex + # lives in the shader/scene; the editor shows the ColorRect fallback. + var mat := ShaderMaterial.new() + mat.shader = load("res://assets/theme/shaders/title_background.gdshader") + mat.set_shader_parameter("warm", Palette.TITLE_WARM) + mat.set_shader_parameter("mid", Palette.TITLE_MID) + mat.set_shader_parameter("edge", Palette.STAGE_1) + _background.material = mat + + +func _configure_embers() -> void: + var pm := ParticleProcessMaterial.new() + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + pm.emission_box_extents = Vector3(760, 4, 0) # a wide strip along the bottom + pm.direction = Vector3(0, -1, 0) + pm.spread = 12.0 + pm.gravity = Vector3(0, -18, 0) + pm.initial_velocity_min = 24.0 + pm.initial_velocity_max = 52.0 + pm.scale_min = 0.4 + pm.scale_max = 1.0 + pm.color = Palette.GOLD_BRIGHT + # fade out over life + var ramp := Gradient.new() + ramp.set_color(0, Color(Palette.GOLD_BRIGHT, 0.0)) + ramp.set_color(1, Color(Palette.GOLD_BRIGHT, 0.0)) + ramp.add_point(0.15, Palette.GOLD_BRIGHT) + var tex := GradientTexture1D.new() + tex.gradient = ramp + pm.color_ramp = tex + _embers.process_material = pm + + +func _pulse_glow() -> void: + # A slow warm pulse (the off-screen fire). A code tween, not an AnimationPlayer: + # a 2-keyframe modulate loop is trivial in code and motion is runtime-only anyway. + var tween := create_tween().set_loops().set_trans(Tween.TRANS_SINE) + tween.tween_property(_glow, "modulate:a", 0.8, 3.0) + tween.tween_property(_glow, "modulate:a", 0.5, 3.0) +``` + +- [ ] **Step 5: Run the title + full suite (no regression)** + +Run: `cd client && ./run_tests.sh -gselect=test_title_screen.gd` → PASS (the added nodes/refs must not break `_ready`). +Then: `cd client && ./run_tests.sh` → PASS, all tests. + +- [ ] **Step 6: Commit** + +```bash +git add client/scripts/theme/palette.gd client/assets/theme/shaders/title_background.gdshader client/assets/theme/shaders/title_background.gdshader.uid client/scenes/title/TitleScreen.tscn client/scripts/ui/title/title_screen.gd +git commit -m "feat(title): warm shader background, pulsing glow, drifting embers" +``` + +--- + +### Task 6: Visual gate — human F6 + boot check + +**Not automatable.** Headless GUT proves structure and interaction wiring, not rendering or motion. + +- [ ] **Step 1: Run the project (boot path)** + +In the Godot editor press **F5** (run project) — it should boot straight to the Title screen (proves `run/main_scene`). Also open `client/scenes/title/TitleScreen.tscn` and press **F6**. + +- [ ] **Step 2: Confirm by eye** + + - Warm radial background (bright toward upper-right), heavy vignette, the key-art dashed slot on the right. + - Drifting embers rising from the bottom; the fire glow along the bottom slowly pulsing. + - Logo block: kicker "A DARK FANTASY RPG" (gold, tracked), "CODE OF CONQUEST" (large cream serif), gold rule, tagline. + - Menu: ◆ marker + brightened label on the selected item; `↑`/`↓` move it (and wrap), the mouse hovering an item selects it. + - Footer: `v0.01-alpha · BUILT IN GODOT 4.7 · © 2026` (left), the controls hint (right). + - **New Game** (Enter or click) → the Main Window shell; **Quit** exits. + +- [ ] **Step 3: Report the outcome** + +If anything is collapsed, mis-placed, or the boot/nav doesn't work, fix and re-run before the milestone is done. When it looks right, the Title is eyeball-confirmed and ready to merge. + +--- + +## Self-Review + +**Spec coverage:** +- Root `/VERSION` + API reads it + `/version` → Task 1. ✓ +- Client `Version` helper + sync script + `project.godot` config/version → Task 2. ✓ +- `TitleLogo`/`TitleKicker` theme variations → Task 3. ✓ +- Title composition (logo/menu/footer/key-art/vignette), selection + keyboard/mouse, `menu_activated` seam, New Game→shell / Quit / inert → Task 4. ✓ +- Boot scene (`run/main_scene`) → Task 4 Step 5. ✓ +- Warm shader background (palette uniforms) + Palette warm tokens + embers (GPUParticles) + glow → Task 5. ✓ +- Editor-first, viewport-fit rule, palette-clean → Global Constraints + honored per task. ✓ +- Tests (Version, API version, title selection/activation/footer) + F6 gate → Tasks 1–4 + Task 6. ✓ + +**Placeholder scan:** no "TBD"/"handle edge cases"/"similar to". Every code step has full code. ✓ + +**Type consistency:** `Version.string()`/`footer()`; `get_version()`/`VERSION`; `ThemeKeys.TITLE_LOGO`/`TITLE_KICKER`; `TitleScreen._rows`/`_sel`/`_move`/`_activate`/`_on_menu_activated`/`menu_activated`/`_footer_left`/`_menu`/`_background`/`_glow`/`_embers`; `Palette.TITLE_WARM`/`TITLE_MID`/`STAGE_1`/`GOLD`/`GOLD_BRIGHT`/`CREAM`/`CREAM_BRIGHT`/`MUTED_MONO` — consistent across tasks and match the existing code read during planning. ✓ + +**Noted deviation from the spec:** the glow uses a **code Tween**, not an `AnimationPlayer` — a 2-keyframe modulate loop is trivial in code and motion is runtime-only regardless; the embers `ParticleProcessMaterial` and the bg `ShaderMaterial` are set in code (the `DarkBay` precedent) rather than authored in the `.tscn`, keeping colours Palette-sourced at the cost of an editor preview of the motion. Flagged here so it is a conscious call, not a silent drift. From 56278a48310aba95dcb173e99f3496547915aeda Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:31:54 -0500 Subject: [PATCH 3/8] feat(api): /VERSION single source read at startup + GET /version --- VERSION | 1 + api/app/main.py | 8 +++++++- api/app/version.py | 33 +++++++++++++++++++++++++++++++++ api/tests/test_version.py | 22 ++++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 VERSION create mode 100644 api/app/version.py create mode 100644 api/tests/test_version.py diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..1edfae5 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.01-alpha diff --git a/api/app/main.py b/api/app/main.py index 9503394..4d37612 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -17,8 +17,9 @@ from .canon_log import validate_canon_log from .narrate import run as narrate_run from .npc import UnknownNpc, run as npc_run from .ollama_client import ModelError +from .version import VERSION -app = FastAPI(title="coc-rpg proxy", version="0.0.1") +app = FastAPI(title="coc-rpg proxy", version=VERSION) @app.exception_handler(RequestValidationError) @@ -65,6 +66,11 @@ def health() -> dict: return {"status": "ok"} +@app.get("/version") +def version() -> dict: + return {"version": VERSION} + + # ── Role endpoints (charter §4) ────────────────────────────────────────────── # The client knows these paths and nothing about which model or prompt serves # them. Bodies are validated against the canon log contract. /dm/narrate is diff --git a/api/app/version.py b/api/app/version.py new file mode 100644 index 0000000..7cf8c5b --- /dev/null +++ b/api/app/version.py @@ -0,0 +1,33 @@ +"""Single source of truth for the app version (charter §4: client and API share +one version). Reads the repo-root /VERSION file — the ONLY place a human edits the +version. Mirrors canon_log._schema_dir's env-override + walk-up so it resolves in +a local checkout and (once /VERSION is bundled) an image alike. +""" +import os +from functools import lru_cache +from pathlib import Path + +LAST_RESORT = "0.0.0-dev" + + +def _version_file() -> Path | None: + env = os.environ.get("COC_VERSION_FILE") + if env: + return Path(env) + here = Path(__file__).resolve() + for parent in here.parents: + candidate = parent / "VERSION" + if candidate.is_file(): + return candidate + return None + + +@lru_cache(maxsize=1) +def get_version() -> str: + path = _version_file() + if path is None or not path.is_file(): + return LAST_RESORT + return path.read_text(encoding="utf-8").strip() or LAST_RESORT + + +VERSION = get_version() diff --git a/api/tests/test_version.py b/api/tests/test_version.py new file mode 100644 index 0000000..88eb121 --- /dev/null +++ b/api/tests/test_version.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.main import app +from app.version import VERSION, get_version + +client = TestClient(app) +ROOT_VERSION = Path(__file__).resolve().parents[2] / "VERSION" + + +def test_get_version_matches_file(): + assert ROOT_VERSION.is_file() + assert get_version() == ROOT_VERSION.read_text(encoding="utf-8").strip() + + +def test_app_version_matches(): + assert app.version == VERSION == get_version() + + +def test_version_endpoint(): + assert client.get("/version").json() == {"version": VERSION} From 263254346d43d9e9c64ca4d45cc863ff751489a1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:36:42 -0500 Subject: [PATCH 4/8] feat(client): Version helper + sync-version script; project.godot config/version - Add scripts/sync-version to sync root /VERSION into client/project.godot - Add Version GDScript helper (reads application/config/version) - Test coverage: 2 tests for Version.string() and footer() - Synced config/version="0.01-alpha" to project.godot Co-Authored-By: Claude Opus 4.8 (1M context) --- client/project.godot | 1 + client/scripts/util/version.gd | 18 ++++++++++++++++++ client/scripts/util/version.gd.uid | 1 + client/tests/unit/test_version.gd | 13 +++++++++++++ client/tests/unit/test_version.gd.uid | 1 + scripts/sync-version | 13 +++++++++++++ 6 files changed, 47 insertions(+) create mode 100644 client/scripts/util/version.gd create mode 100644 client/scripts/util/version.gd.uid create mode 100644 client/tests/unit/test_version.gd create mode 100644 client/tests/unit/test_version.gd.uid create mode 100755 scripts/sync-version diff --git a/client/project.godot b/client/project.godot index 3b737ae..7e4c7ca 100644 --- a/client/project.godot +++ b/client/project.godot @@ -11,6 +11,7 @@ config_version=5 [application] config/name="coc-rpg" +config/version="0.01-alpha" config/description="AI-driven single-player party RPG. Code owns state. AI owns text." config/features=PackedStringArray("4.7") diff --git a/client/scripts/util/version.gd b/client/scripts/util/version.gd new file mode 100644 index 0000000..f80f1b4 --- /dev/null +++ b/client/scripts/util/version.gd @@ -0,0 +1,18 @@ +class_name Version +extends RefCounted +## The client's view of the single source of truth (/VERSION, charter §4). The +## value is baked into project.godot's application/config/version by +## scripts/sync-version on each bump; read it via ProjectSettings so an exported +## build (which has no repo-root /VERSION) still knows its own version. + +const SETTING := "application/config/version" +const LAST_RESORT := "0.0.0-dev" + + +func string() -> String: + var v := str(ProjectSettings.get_setting(SETTING, "")) + return v if v != "" else LAST_RESORT + + +func footer() -> String: + return "v%s · BUILT IN GODOT 4.7 · © 2026" % string() diff --git a/client/scripts/util/version.gd.uid b/client/scripts/util/version.gd.uid new file mode 100644 index 0000000..df0aaac --- /dev/null +++ b/client/scripts/util/version.gd.uid @@ -0,0 +1 @@ +uid://xyvm7nhkf8be diff --git a/client/tests/unit/test_version.gd b/client/tests/unit/test_version.gd new file mode 100644 index 0000000..ae62e6c --- /dev/null +++ b/client/tests/unit/test_version.gd @@ -0,0 +1,13 @@ +extends "res://addons/gut/test.gd" + + +func test_string_matches_project_setting(): + var expected := str(ProjectSettings.get_setting("application/config/version", "")) + assert_ne(expected, "", "project.godot must carry application/config/version (run scripts/sync-version)") + assert_eq(Version.new().string(), expected) + + +func test_footer_contains_version_and_engine(): + var v := Version.new() + assert_string_contains(v.footer(), v.string()) + assert_string_contains(v.footer(), "BUILT IN GODOT 4.7") diff --git a/client/tests/unit/test_version.gd.uid b/client/tests/unit/test_version.gd.uid new file mode 100644 index 0000000..a9a3d42 --- /dev/null +++ b/client/tests/unit/test_version.gd.uid @@ -0,0 +1 @@ +uid://co7wtt67t2p7d diff --git a/scripts/sync-version b/scripts/sync-version new file mode 100755 index 0000000..df048d8 --- /dev/null +++ b/scripts/sync-version @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Sync the single source of truth (/VERSION) into the Godot client's project.godot +# (application/config/version). Run after bumping /VERSION. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +version="$(tr -d '[:space:]' < "$root/VERSION")" +proj="$root/client/project.godot" +if grep -q '^config/version=' "$proj"; then + sed -i "s|^config/version=.*|config/version=\"$version\"|" "$proj" +else + sed -i "s|^\(config/name=.*\)|\1\nconfig/version=\"$version\"|" "$proj" +fi +echo "synced client config/version = \"$version\"" From c6d513c966e908076b03f13678bd8d633a7e2091 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:41:42 -0500 Subject: [PATCH 5/8] feat(theme): TitleLogo + TitleKicker display variations Co-Authored-By: Claude Opus 4.8 (1M context) --- client/assets/theme/game_theme.tres | 8 ++++++++ client/scripts/theme/build_game_theme.gd | 15 +++++++++++++++ client/scripts/theme/theme_keys.gd | 2 ++ client/tests/unit/test_title_theme.gd | 10 ++++++++++ client/tests/unit/test_title_theme.gd.uid | 1 + 5 files changed, 36 insertions(+) create mode 100644 client/tests/unit/test_title_theme.gd create mode 100644 client/tests/unit/test_title_theme.gd.uid diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index 3b1a1ad..c6c3265 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -486,3 +486,11 @@ TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4") TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb") TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll") TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq") +TitleKicker/base_type = &"Label" +TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1) +TitleKicker/font_sizes/font_size = 13 +TitleKicker/fonts/font = ExtResource("2_4lwr2") +TitleLogo/base_type = &"Label" +TitleLogo/colors/font_color = Color(0.9098039, 0.8666667, 0.78431374, 1) +TitleLogo/font_sizes/font_size = 116 +TitleLogo/fonts/font = ExtResource("3_eckwq") diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index ec1c137..3154db1 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -34,6 +34,7 @@ func _init() -> void: static func build_theme() -> Theme: var theme := Theme.new() _fonts(theme) + _title_type(theme) _rich_text(theme) _primary_cta(theme) _tabs(theme) @@ -80,6 +81,20 @@ static func _fonts(theme: Theme) -> void: theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO) +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 diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 3de8ff0..6f8578f 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -22,6 +22,8 @@ const DARK_PANEL := &"DarkPanel" const HEADING := &"Heading" const ACCENT := &"Accent" const MONO := &"Mono" +const TITLE_LOGO := &"TitleLogo" +const TITLE_KICKER := &"TitleKicker" ## Every variation name + the base Control type it decorates. The builder and the ## test both iterate this so they can never drift apart. diff --git a/client/tests/unit/test_title_theme.gd b/client/tests/unit/test_title_theme.gd new file mode 100644 index 0000000..b635e81 --- /dev/null +++ b/client/tests/unit/test_title_theme.gd @@ -0,0 +1,10 @@ +extends "res://addons/gut/test.gd" + +const Builder = preload("res://scripts/theme/build_game_theme.gd") + + +func test_title_variations_built(): + var t: Theme = Builder.build_theme() + assert_eq(t.get_font_size(&"font_size", ThemeKeys.TITLE_LOGO), 116) + assert_eq(t.get_color(&"font_color", ThemeKeys.TITLE_LOGO), Palette.CREAM) + assert_eq(t.get_color(&"font_color", ThemeKeys.TITLE_KICKER), Palette.GOLD) diff --git a/client/tests/unit/test_title_theme.gd.uid b/client/tests/unit/test_title_theme.gd.uid new file mode 100644 index 0000000..45da8fb --- /dev/null +++ b/client/tests/unit/test_title_theme.gd.uid @@ -0,0 +1 @@ +uid://dbqle1sf0byj4 From a77bf03ee44988f2619ddd5fb374949d95f2a700 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:47:44 -0500 Subject: [PATCH 6/8] feat(title): TitleScreen composition, menu + selection, boot scene Co-Authored-By: Claude Opus 4.8 (1M context) --- client/project.godot | 1 + client/scenes/title/TitleScreen.tscn | 228 ++++++++++++++++++++ client/scripts/ui/title/title_screen.gd | 95 ++++++++ client/scripts/ui/title/title_screen.gd.uid | 1 + client/tests/unit/test_title_screen.gd | 52 +++++ client/tests/unit/test_title_screen.gd.uid | 1 + 6 files changed, 378 insertions(+) create mode 100644 client/scenes/title/TitleScreen.tscn create mode 100644 client/scripts/ui/title/title_screen.gd create mode 100644 client/scripts/ui/title/title_screen.gd.uid create mode 100644 client/tests/unit/test_title_screen.gd create mode 100644 client/tests/unit/test_title_screen.gd.uid diff --git a/client/project.godot b/client/project.godot index 7e4c7ca..0ea126e 100644 --- a/client/project.godot +++ b/client/project.godot @@ -13,6 +13,7 @@ config_version=5 config/name="coc-rpg" config/version="0.01-alpha" config/description="AI-driven single-player party RPG. Code owns state. AI owns text." +run/main_scene="res://scenes/title/TitleScreen.tscn" config/features=PackedStringArray("4.7") [display] diff --git a/client/scenes/title/TitleScreen.tscn b/client/scenes/title/TitleScreen.tscn new file mode 100644 index 0000000..0ee32cc --- /dev/null +++ b/client/scenes/title/TitleScreen.tscn @@ -0,0 +1,228 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scripts/ui/title/title_screen.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/VignetteOverlay.tscn" id="3"] + +[node name="TitleScreen" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="KeyArt" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 6 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -520.0 +offset_top = -20.0 +offset_right = -120.0 +offset_bottom = 20.0 +grow_horizontal = 0 +grow_vertical = 2 +theme_type_variation = &"Mono" +text = "KEY ART DROPS IN HERE" +horizontal_alignment = 1 + +[node name="Vignette" parent="." instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 + +[node name="Content" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 120.0 +offset_top = -260.0 +offset_right = 900.0 +offset_bottom = 260.0 +grow_vertical = 2 +theme_override_constants/separation = 10 + +[node name="Kicker" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"TitleKicker" +text = "A DARK FANTASY RPG" + +[node name="Logo" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"TitleLogo" +text = "CODE OF +CONQUEST" + +[node name="Rule" type="ColorRect" parent="Content"] +custom_minimum_size = Vector2(220, 2) +layout_mode = 2 + +[node name="Tagline" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "The world has real problems, and it does not care about you." + +[node name="Menu" type="VBoxContainer" parent="Content"] +layout_mode = 2 +theme_override_constants/separation = 5 + +[node name="NewGame" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"new_game" + +[node name="Marker" type="Label" parent="Content/Menu/NewGame"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/NewGame"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/NewGame/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "New Game" + +[node name="Continue" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"continue" + +[node name="Marker" type="Label" parent="Content/Menu/Continue"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Continue"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Continue/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Continue" + +[node name="Sub" type="Label" parent="Content/Menu/Continue/Body"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "CHAPTER II · VEXCCA · 14:22:07" + +[node name="LoadGame" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"load_game" + +[node name="Marker" type="Label" parent="Content/Menu/LoadGame"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/LoadGame"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/LoadGame/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Load Game" + +[node name="Settings" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"settings" + +[node name="Marker" type="Label" parent="Content/Menu/Settings"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Settings"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Settings/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Settings" + +[node name="Credits" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"credits" + +[node name="Marker" type="Label" parent="Content/Menu/Credits"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Credits"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Credits/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Credits" + +[node name="Quit" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"quit" + +[node name="Marker" type="Label" parent="Content/Menu/Quit"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Quit"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Quit/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Quit" + +[node name="FooterLeft" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 120.0 +offset_top = -60.0 +offset_right = 700.0 +offset_bottom = -32.0 +grow_vertical = 0 +theme_type_variation = &"Mono" +text = "v0.01-alpha · BUILT IN GODOT 4.7 · © 2026" + +[node name="FooterRight" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -520.0 +offset_top = -60.0 +offset_right = -120.0 +offset_bottom = -32.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_type_variation = &"Mono" +text = "[ ↑ ↓ ] CHOOSE [ ↵ ] ENTER" +horizontal_alignment = 2 diff --git a/client/scripts/ui/title/title_screen.gd b/client/scripts/ui/title/title_screen.gd new file mode 100644 index 0000000..0052ce1 --- /dev/null +++ b/client/scripts/ui/title/title_screen.gd @@ -0,0 +1,95 @@ +class_name TitleScreen +extends Control +## The entry-point screen (mock Title Screen). Editor-first (ADR 0001): the whole +## composition lives in TitleScreen.tscn; this script drives selection, input, and +## dispatches the menu. §2: presentation/state — no AI here (the DM enters at the +## shell). The atmosphere (bg shader, glow, embers) is layered on in a later task. + +signal menu_activated(id: StringName) + +const SHELL := "res://scenes/shell/MainWindowShell.tscn" + +var _rows: Array = [] +var _sel: int = 0 + +@onready var _menu: VBoxContainer = $Content/Menu +@onready var _footer_left: Label = $FooterLeft +@onready var _background: ColorRect = $Background +@onready var _rule: ColorRect = $Content/Rule + + +func _ready() -> void: + # Root Control run via F6 is not sized by full-rect anchors — size explicitly. + _fit_to_viewport() + get_viewport().size_changed.connect(_fit_to_viewport) + + _background.color = Palette.STAGE_1 + _rule.color = Palette.GOLD + + _rows = [] + for row in _menu.get_children(): + if row.has_meta("action"): + _rows.append(row) + (row.get_node("Marker") as Label).add_theme_color_override("font_color", Palette.GOLD) + _wire_row_mouse() + + _footer_left.text = Version.new().footer() + menu_activated.connect(_on_menu_activated) + _update_selection() + + +func _fit_to_viewport() -> void: + size = get_viewport_rect().size + + +func _wire_row_mouse() -> void: + for i in range(_rows.size()): + var idx := i # fresh per-iteration binding for the closures + var row: Control = _rows[idx] + row.mouse_entered.connect(func(): + _sel = idx + _update_selection()) + row.gui_input.connect(func(e: InputEvent): + if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT: + _activate(idx)) + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_down"): + _move(1) + elif event.is_action_pressed("ui_up"): + _move(-1) + elif event.is_action_pressed("ui_accept"): + _activate(_sel) + + +func _move(delta: int) -> void: + if _rows.is_empty(): + return + _sel = wrapi(_sel + delta, 0, _rows.size()) + _update_selection() + + +func _activate(i: int) -> void: + if i < 0 or i >= _rows.size(): + return + menu_activated.emit(_rows[i].get_meta("action")) + + +func _on_menu_activated(id: StringName) -> void: + match id: + &"new_game": + get_tree().change_scene_to_file(SHELL) + &"quit": + get_tree().quit() + _: + pass # inert placeholder — the screen lands in a later milestone + + +func _update_selection() -> void: + for i in range(_rows.size()): + var row: Control = _rows[i] + var selected := i == _sel + (row.get_node("Marker") as Label).modulate.a = 1.0 if selected else 0.0 + var label := row.get_node("Body/Label") as Label + label.add_theme_color_override("font_color", Palette.CREAM_BRIGHT if selected else Palette.MUTED_MONO) diff --git a/client/scripts/ui/title/title_screen.gd.uid b/client/scripts/ui/title/title_screen.gd.uid new file mode 100644 index 0000000..50947e2 --- /dev/null +++ b/client/scripts/ui/title/title_screen.gd.uid @@ -0,0 +1 @@ +uid://clmbt3dy7bdwb diff --git a/client/tests/unit/test_title_screen.gd b/client/tests/unit/test_title_screen.gd new file mode 100644 index 0000000..2be0fa5 --- /dev/null +++ b/client/tests/unit/test_title_screen.gd @@ -0,0 +1,52 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/title/TitleScreen.tscn" + + +func _title() -> TitleScreen: + var t = load(SCENE).instantiate() + add_child_autofree(t) # runs _ready() + return t + + +func _index_of(t: TitleScreen, action: StringName) -> int: + for i in range(t._rows.size()): + if t._rows[i].get_meta("action") == action: + return i + return -1 + + +func test_six_menu_rows_each_with_an_action(): + var t := _title() + assert_eq(t._rows.size(), 6) + for row in t._rows: + assert_true(row.has_meta("action")) + + +func test_down_moves_selection(): + var t := _title() + assert_eq(t._sel, 0) + t._move(1) + assert_eq(t._sel, 1) + + +func test_up_from_first_wraps_to_last(): + var t := _title() + t._move(-1) + assert_eq(t._sel, t._rows.size() - 1) + + +func test_new_game_and_quit_emit_their_action(): + var t := _title() + # Isolate the signal seam so the test does not actually change scene / quit. + t.menu_activated.disconnect(t._on_menu_activated) + watch_signals(t) + t._activate(_index_of(t, &"new_game")) + assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"]) + t._activate(_index_of(t, &"quit")) + assert_signal_emitted_with_parameters(t, "menu_activated", [&"quit"]) + + +func test_footer_shows_version(): + var t := _title() + assert_string_contains(t._footer_left.text, Version.new().string()) diff --git a/client/tests/unit/test_title_screen.gd.uid b/client/tests/unit/test_title_screen.gd.uid new file mode 100644 index 0000000..c4f9b0c --- /dev/null +++ b/client/tests/unit/test_title_screen.gd.uid @@ -0,0 +1 @@ +uid://driqdhodq83g2 From f07139256b3e33fcc6338fde8802450a1d6bf3e1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:54:22 -0500 Subject: [PATCH 7/8] feat(title): warm shader background, pulsing glow, drifting embers Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theme/shaders/title_background.gdshader | 15 ++++++ .../shaders/title_background.gdshader.uid | 1 + client/scenes/title/TitleScreen.tscn | 15 ++++++ client/scripts/theme/palette.gd | 4 ++ client/scripts/ui/title/title_screen.gd | 52 ++++++++++++++++++- 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 client/assets/theme/shaders/title_background.gdshader create mode 100644 client/assets/theme/shaders/title_background.gdshader.uid diff --git a/client/assets/theme/shaders/title_background.gdshader b/client/assets/theme/shaders/title_background.gdshader new file mode 100644 index 0000000..f86bcbc --- /dev/null +++ b/client/assets/theme/shaders/title_background.gdshader @@ -0,0 +1,15 @@ +shader_type canvas_item; +// Warm radial for the Title screen (mock: radial-gradient warm centre -> near +// black edge, centred at 70% 20%). Colours are Palette uniforms set by the title +// script (the DarkBay precedent), so no hex lives in the shader. +uniform vec4 warm : source_color; +uniform vec4 mid : source_color; +uniform vec4 edge : source_color; +uniform vec2 center = vec2(0.7, 0.2); + +void fragment() { + float d = distance(UV, center); + vec3 c = mix(warm.rgb, mid.rgb, clamp(d / 0.45, 0.0, 1.0)); + c = mix(c, edge.rgb, clamp((d - 0.45) / 0.55, 0.0, 1.0)); + COLOR = vec4(c, 1.0); +} diff --git a/client/assets/theme/shaders/title_background.gdshader.uid b/client/assets/theme/shaders/title_background.gdshader.uid new file mode 100644 index 0000000..08ec602 --- /dev/null +++ b/client/assets/theme/shaders/title_background.gdshader.uid @@ -0,0 +1 @@ +uid://cfrs0nt60f2mb diff --git a/client/scenes/title/TitleScreen.tscn b/client/scenes/title/TitleScreen.tscn index 0ee32cc..6d56cf0 100644 --- a/client/scenes/title/TitleScreen.tscn +++ b/client/scenes/title/TitleScreen.tscn @@ -18,6 +18,21 @@ anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 +[node name="Glow" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -420.0 +mouse_filter = 2 + +[node name="Embers" type="GPUParticles2D" parent="."] +position = Vector2(960, 1080) +amount = 24 +lifetime = 9.0 +preprocess = 4.0 + [node name="KeyArt" type="Label" parent="."] layout_mode = 1 anchors_preset = 6 diff --git a/client/scripts/theme/palette.gd b/client/scripts/theme/palette.gd index 401ef83..9151289 100644 --- a/client/scripts/theme/palette.gd +++ b/client/scripts/theme/palette.gd @@ -19,6 +19,10 @@ const BORDER_3 := Color("4a4238") const BORDER_4 := Color("3a352e") const BORDER_5 := Color("1a1512") +# --- Title-screen ambiance (warm radial background) --- +const TITLE_WARM := Color("2a2018") # radial centre +const TITLE_MID := Color("160f0a") # radial midpoint (STAGE_1 is the edge) + # --- Parchment --- const SHEET_TOP := Color("ece2ca") const SHEET_BOTTOM := Color("e4d8bd") diff --git a/client/scripts/ui/title/title_screen.gd b/client/scripts/ui/title/title_screen.gd index 0052ce1..ac51790 100644 --- a/client/scripts/ui/title/title_screen.gd +++ b/client/scripts/ui/title/title_screen.gd @@ -3,7 +3,9 @@ extends Control ## The entry-point screen (mock Title Screen). Editor-first (ADR 0001): the whole ## composition lives in TitleScreen.tscn; this script drives selection, input, and ## dispatches the menu. §2: presentation/state — no AI here (the DM enters at the -## shell). The atmosphere (bg shader, glow, embers) is layered on in a later task. +## shell). The atmosphere (warm bg shader, pulsing glow, drifting embers) is +## configured in code from Palette (the DarkBay precedent) — see _apply_background, +## _configure_embers, _pulse_glow. signal menu_activated(id: StringName) @@ -15,6 +17,8 @@ var _sel: int = 0 @onready var _menu: VBoxContainer = $Content/Menu @onready var _footer_left: Label = $FooterLeft @onready var _background: ColorRect = $Background +@onready var _glow: ColorRect = $Glow +@onready var _embers: GPUParticles2D = $Embers @onready var _rule: ColorRect = $Content/Rule @@ -25,6 +29,10 @@ func _ready() -> void: _background.color = Palette.STAGE_1 _rule.color = Palette.GOLD + _glow.color = Palette.BLOOD + _apply_background() + _configure_embers() + _pulse_glow() _rows = [] for row in _menu.get_children(): @@ -42,6 +50,48 @@ func _fit_to_viewport() -> void: size = get_viewport_rect().size +func _apply_background() -> void: + # Palette-driven shader material set in code (the DarkBay precedent) so no hex + # lives in the shader/scene; the editor shows the ColorRect fallback. + var mat := ShaderMaterial.new() + mat.shader = load("res://assets/theme/shaders/title_background.gdshader") + mat.set_shader_parameter("warm", Palette.TITLE_WARM) + mat.set_shader_parameter("mid", Palette.TITLE_MID) + mat.set_shader_parameter("edge", Palette.STAGE_1) + _background.material = mat + + +func _configure_embers() -> void: + var pm := ParticleProcessMaterial.new() + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + pm.emission_box_extents = Vector3(760, 4, 0) # a wide strip along the bottom + pm.direction = Vector3(0, -1, 0) + pm.spread = 12.0 + pm.gravity = Vector3(0, -18, 0) + pm.initial_velocity_min = 24.0 + pm.initial_velocity_max = 52.0 + pm.scale_min = 0.4 + pm.scale_max = 1.0 + pm.color = Palette.GOLD_BRIGHT + # fade out over life + var ramp := Gradient.new() + ramp.set_color(0, Color(Palette.GOLD_BRIGHT, 0.0)) + ramp.set_color(1, Color(Palette.GOLD_BRIGHT, 0.0)) + ramp.add_point(0.15, Palette.GOLD_BRIGHT) + var tex := GradientTexture1D.new() + tex.gradient = ramp + pm.color_ramp = tex + _embers.process_material = pm + + +func _pulse_glow() -> void: + # A slow warm pulse (the off-screen fire). A code tween, not an AnimationPlayer: + # a 2-keyframe modulate loop is trivial in code and motion is runtime-only anyway. + var tween := create_tween().set_loops().set_trans(Tween.TRANS_SINE) + tween.tween_property(_glow, "modulate:a", 0.8, 3.0) + tween.tween_property(_glow, "modulate:a", 0.5, 3.0) + + func _wire_row_mouse() -> void: for i in range(_rows.size()): var idx := i # fresh per-iteration binding for the closures From 2eead38b547b282de566d743d9b9276425b3b60c Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 11:03:35 -0500 Subject: [PATCH 8/8] fix(title): restore subtle glow alpha (0.22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hex-removal correction set _glow.color = Palette.BLOOD (opaque), which with the 0.5-0.8 modulate pulse read as a near-solid red slab. Restore the intended low alpha via Color(Palette.BLOOD, 0.22) — RGB still from Palette, alpha a numeric param (same idiom as the ember ramp). Whole-branch review catch. Title tests 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/ui/title/title_screen.gd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/scripts/ui/title/title_screen.gd b/client/scripts/ui/title/title_screen.gd index ac51790..df98673 100644 --- a/client/scripts/ui/title/title_screen.gd +++ b/client/scripts/ui/title/title_screen.gd @@ -29,7 +29,9 @@ func _ready() -> void: _background.color = Palette.STAGE_1 _rule.color = Palette.GOLD - _glow.color = Palette.BLOOD + # RGB from Palette, low alpha so the fire glow is subtle (the pulse tween varies + # modulate:a on top of this); a fully-opaque BLOOD read as a solid red slab. + _glow.color = Color(Palette.BLOOD, 0.22) _apply_background() _configure_embers() _pulse_glow()