Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-11-title-screen.md
2026-07-11 10:27:08 -05:00

968 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 14 + 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.