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) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 10:19:17 -05:00
parent d9caf1dff6
commit 0249cbcf09

View File

@@ -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="<v>"` 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 711s 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).