From cc365c8b73989ec91f62c4f8274165a450d38597 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 08:35:02 -0500 Subject: [PATCH 01/13] docs(spec): M3-b Main Window shell (2a) design The exploration HUD frame: two-panel tabletop split, DM narration book wired to the proven /dm/narrate loop (prose-only + refire), thin tested ShellState for HUD readouts, inert system dock + command bar as faithful placeholder. Pure-core/thin-shim per the house pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-11-main-window-shell-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-main-window-shell-design.md diff --git a/docs/superpowers/specs/2026-07-11-main-window-shell-design.md b/docs/superpowers/specs/2026-07-11-main-window-shell-design.md new file mode 100644 index 0000000..67195f1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-main-window-shell-design.md @@ -0,0 +1,96 @@ +# Main Window shell (2a) — design + +**Milestone:** M3-b (roadmap "Main Window shell (2a)"). +**Charter:** §2 (code owns state, AI owns text), §16 (mockups are the UI bible), §13 (degraded DM, never an error), §11 (canon log). +**Mockup:** `mockups/Main Window + Wireframes.dc.html`, option **2a** (the four low-fi wireframes below it are ignored). +**Branch:** `feature/main-window-shell` off `dev`. + +## 1. Goal + +The exploration HUD — the frame every other screen lives in. A two-panel "tabletop split": an isometric world view (art slot) on the left, a permanent DM "narration book" on the right wired to the **proven** `/dm/narrate` loop. Around the world view: a turn-order rail, a minimap slot, a slide-in system dock, and a bottom command bar (vitals/gold, quick consumables, End Turn). + +This milestone builds the **frame** and its **one live seam** (the DM narration panel). Everything else is faithful placeholder until the milestones that give it meaning (combat, inventory, the individual screens). + +## 2. Scope decisions + +Settled during brainstorming: + +- **DM wire = prose only, with a refire control.** Narrate fires once on scene load to fill the book (considering-state + authored fallback + fact-harvest). One in-world refire affordance (`▸ The Master continues…`) refires it. The numbered response choices and the "describe your own action" free-text line are rendered faithfully but **inert** — there is no action system yet (combat/dialogue are later). +- **HUD readouts come from a thin, tested `ShellState` model**, not hardcoded scene values. Seed values now; later systems (combat, inventory) become its writers. This is the §2 state seam and the testable core. +- **System dock buttons are inert.** They emit `screen_requested(id)` (the routing seam) which is a no-op this milestone; the individual screens land later. +- **Dock slide animation is live** — it is the dock's visual identity and cheap. +- **Keybind navigation (I/C/J/K/M/P) is deferred** — a shortcut belongs with the screen it opens. +- **Dock has 6 items** (Inventory / Character / Quest Log / Spellbook / World Map / Party) — faithful to mock 2a. The brief listed 5 (no Spellbook); kept all 6 since the buttons are inert and it costs nothing. Flagged as a reconcile point — Spellbook is not one of the 11 mock screens. + +## 3. Architecture + +House pattern: **pure, tested cores behind thin scene shims** — the same shape as `DmService`, `ConsideringPhrases`, `MoveValidator` (pure logic unit-tested; the scene/tree wiring is the untested shim, eyeball-gated). Units, each with one purpose: + +| Unit | Kind | §2 | Purpose | +|---|---|---|---| +| `ShellState` | `RefCounted`, **tested** | state | The HUD data the shell owns: `vitals {hp, hp_max, mp, mp_max, gold}`, `turn_order[]`, `consumables[]`, `round_label`, `location_label`, `dock_open`. Getters + `toggle_dock()`. Seeded now; later systems write it. | +| `TurnEntry` | `RefCounted` (or a typed dict inside `ShellState`), **tested** | state | One turn-order combatant: `initials`, `initiative`, `is_active`, `is_downed`, `side` (`you`/`ally`/`enemy`). | +| `SystemDock` | Control scene + script, thin shim | state | The 6 toggle buttons + gold handle. Slide+opacity `Tween`, arrow flip. Emits `screen_requested(id: StringName)` (inert). Toggle drives `ShellState.dock_open`. | +| `NarrationBook` | Control scene + script, thin shim | text | Right parchment panel. DM header (round · location), prose `RichTextLabel`, placeholder response-choices, inert free-text line, one refire affordance. Consumes a `NarrateResult`; injectable service seam so `FakeTransport` drives it in tests. | +| `MainWindowShell` | Control scene root, thin shim | — | The two-panel split (1180 world / 740 book). Builds the world side inline (DarkBay iso slot + caption, turn rail from state, minimap slot, mounts `SystemDock`, command bar). Constructs `DmService` + `ConsideringIndicator` and owns the narrate loop. Handles viewport-fit. | + +### Reused, not rebuilt + +- **Theme:** `client/assets/theme/game_theme.tres`, type-variation names from `ThemeKeys`, colours from `Palette`. Surfaces `ParchmentPanel` / `DarkBay` / `VignetteOverlay` (`client/scenes/theme/surfaces/`). +- **DM loop:** `DmService.narrate(canon_log) -> NarrateResult{display_text, facts, degraded}`, via `HttpTransport(HTTPRequest)` + `FallbackLibrary`. `DmTransport` is the injectable seam; `FakeTransport` (test double) already exists. +- **Considering-state:** `ConsideringIndicator.start(label)` / `.stop()` rotates authored phrases at 3.0s. +- **Seed canon log:** hand-built like `narrate_harness.gd`'s `_build_scene_log()` (`CanonLog` + `LogPlayer` + `PartyMember` + `Quest` + facts/humiliations), until character-creation (M4) and save/load (M9) construct the real one. + +### The viewport-fit gotcha (from M3-a) + +A root `Control` run directly (F6) is **not** reliably sized by full-rect anchors — it collapses to `(0,0)` and clips everything. `MainWindowShell._ready()` must set `size = get_viewport_rect().size` explicitly (no full-rect anchors on the root) and reconnect on `get_viewport().size_changed`, exactly as `theme_showcase.gd` does. The scene is authored at 1920×1080; display is already `canvas_items` stretch @ 1920×1080, window override 1600×900. + +## 4. Data flow + +**Load (`MainWindowShell._ready()`):** +1. `theme = load(game_theme.tres)`; fit to viewport; connect `size_changed`. +2. Build seed `ShellState` and seed `CanonLog`. +3. Build the world side (from `ShellState`) and mount `NarrationBook` + `SystemDock`. +4. Construct `DmService(HttpTransport(HTTPRequest), FallbackLibrary)` and `ConsideringIndicator`. +5. Fire the initial narrate (below). + +**Narrate / refire:** +1. Disable the refire affordance. +2. `indicator.start(status_label)`. +3. `var r := await service.narrate(seed_log)`. +4. `indicator.stop()`. +5. `book.show_prose(r)` — set the `RichTextLabel`, surface `r.degraded` subtly. +6. `for f in r.facts: seed_log.add_fact(f)` (§11 — the caller applies the state write, explicit). +7. Re-enable the affordance. + +Any non-200 → `DmService` already returns the authored fallback (§13); the 35s `HttpTransport` timeout bounds a hang. **No new failure surface.** + +**Dock toggle:** handle pressed → `ShellState.toggle_dock()` → `SystemDock` Tweens slide/opacity and flips the handle arrow. + +## 5. §2 ledger + +- `ShellState`, `dock_open`, HUD numbers, turn order, consumable slots — **state**, client-owned. +- Narration prose — **text**, consumed from `/dm/narrate`, never a source of truth; only harvested `[FACT]` tags become state (§11). +- `screen_requested` routing, response choices, free-text line — inert seams; no state written, no text consumed, this milestone. + +Nothing in the shell lets an AI response set persistent state directly. + +## 6. Testing + +TDD via GUT (`cd client && ./run_tests.sh`; `.gutconfig` promotes engine errors to failures — load defensively). New `class_name`s need `godot --headless --import` before GUT sees them. + +- **`ShellState`** — seed shape; `toggle_dock()` flips `dock_open`; turn-order and consumable entries have the expected fields/values. +- **`TurnEntry`** — field round-trip; `side`/`is_active`/`is_downed` flags. +- **`SystemDock`** — emits `screen_requested` with the correct id per button; toggling updates state (signal + state assertions, no render needed). +- **`NarrationBook`** — given a `NarrateResult`, sets the prose text and surfaces `degraded`; the refire affordance invokes the injected service (drive with `FakeTransport`, assert prose updates + facts flow out). +- **`MainWindowShell`** — node-presence smoke (both panels + dock mount); viewport-fit yields non-zero `size`. + +**Visual gate (required):** a human F6 run of the shell scene. Headless tests cannot prove rendering — this catches layout/collapse regressions the unit tests can't (the M3-a lesson). + +## 7. Out of scope (this milestone) + +- Any of the individual screens the dock opens (Inventory, Character, Quest, Map, Party, Spellbook) — inert buttons only. +- A real action system behind the response choices / free-text line — combat (M5) and dialogue (M6). +- Real HUD writers (combat vitals, inventory consumables) — seed values only. +- Keybind navigation, real isometric render, real minimap, real portraits — art slots / later. +- Save/load of shell state — M9. From 400445f312275d5f355ab12ba3a146a327db6bf4 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 08:43:29 -0500 Subject: [PATCH 02/13] docs(plan): M3-b Main Window shell implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-11-main-window-shell.md | 888 ++++++++++++++++++ 1 file changed, 888 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-main-window-shell.md diff --git a/docs/superpowers/plans/2026-07-11-main-window-shell.md b/docs/superpowers/plans/2026-07-11-main-window-shell.md new file mode 100644 index 0000000..231bbe7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-main-window-shell.md @@ -0,0 +1,888 @@ +# Main Window shell (2a) 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 exploration HUD — a two-panel "tabletop split" frame with an isometric world-view slot, a permanent DM narration book wired to the proven `/dm/narrate` loop (prose-only + refire), a slide-in system dock, and a command bar — over a thin, tested `ShellState`. + +**Architecture:** House pattern — pure, tested cores behind thin scene shims (like `DmService`/`ConsideringPhrases`). `ShellState` is a tested value model; `SystemDock`/`NarrationBook`/`MainWindowShell` are Control scenes whose logic seams (signal emission, prose consume, dep injection) are unit-tested and whose layout is eyeball-gated by an F6 run. The narration book is the one live seam; everything else is faithful placeholder. + +**Tech Stack:** Godot 4.7, GDScript, GUT (headless). Reuses the M3-a Theme (`game_theme.tres`, `ThemeKeys`, `Palette`, surface scenes) and the M2 DM loop (`DmService`, `HttpTransport`, `FallbackLibrary`, `ConsideringIndicator`, `CanonLog`). + +## Global Constraints + +- **Godot 4.7 / GDScript.** No C#. Match surrounding style. +- **`Palette` is the only home for hex colours.** No colour literal may live in any new file — read `Palette.*`. +- **Theme variations via `ThemeKeys`** — set `theme_type_variation` to a `ThemeKeys` constant, never a raw string. +- **`§2`: code owns state, AI owns text.** `ShellState` and HUD numbers are state (client-owned); narration prose is text (consumed from `/dm/narrate`, never a source of truth — only harvested `[FACT]` tags become state). +- **Degrade, never error (§13).** Any non-200 → `DmService` already returns the authored fallback. Add no new failure surface. +- **Never surface LCK / any Luck number (§7).** Not in the HUD, not in the seed log's rendered fields. +- **New `class_name` scripts need an import pass before GUT resolves them.** `./run_tests.sh` runs `godot --headless --import` first, so always test through it. +- **Canvas is 1920×1080.** World panel 1180 wide, narration book 740 wide. +- **Root-Control viewport-fit gotcha:** a root `Control` run via F6 is not sized by full-rect anchors — it collapses to `(0,0)`. `MainWindowShell` must set `size = get_viewport_rect().size` in `_ready()` and reconnect on `size_changed`. Its `.tscn` root must NOT set `anchors_preset`. + +--- + +### Task 1: `ShellState` + `TurnEntry` (the HUD state model) + +**Files:** +- Create: `client/scripts/ui/shell/turn_entry.gd` +- Create: `client/scripts/ui/shell/shell_state.gd` +- Test: `client/tests/unit/test_shell_state.gd` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `class_name TurnEntry extends RefCounted` — `_init(initials: String, initiative: int, side: StringName, is_active := false, is_downed := false)`; fields `initials: String`, `initiative: int`, `side: StringName` (`&"you"`/`&"ally"`/`&"enemy"`), `is_active: bool`, `is_downed: bool`. + - `class_name ShellState extends RefCounted` — fields `vitals: Dictionary` (keys `hp`,`hp_max`,`mp`,`mp_max`,`gold`), `turn_order: Array[TurnEntry]`, `consumables: Array` (8 dicts `{hotkey:int, label:String}`; `label==""` is an empty slot), `round_label: String`, `location_label: String`, `dock_open: bool`. Methods `func toggle_dock() -> bool` (flips `dock_open`, returns new value); `static func seed() -> ShellState` (the mock-2a values). + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_shell_state.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + + +func test_turn_entry_holds_fields(): + var e := TurnEntry.new("DW", 12, &"ally") + assert_eq(e.initials, "DW") + assert_eq(e.initiative, 12) + assert_eq(e.side, &"ally") + assert_false(e.is_active) + assert_false(e.is_downed) + + +func test_seed_vitals(): + var s := ShellState.seed() + assert_eq(s.vitals["hp"], 42) + assert_eq(s.vitals["hp_max"], 60) + assert_eq(s.vitals["mp"], 18) + assert_eq(s.vitals["mp_max"], 30) + assert_eq(s.vitals["gold"], 1240) + + +func test_seed_turn_order(): + var s := ShellState.seed() + assert_eq(s.turn_order.size(), 4) + assert_eq(s.turn_order[0].initials, "EL") + assert_true(s.turn_order[0].is_active, "first combatant is the active turn") + assert_true(s.turn_order[3].is_downed, "last combatant is downed") + + +func test_seed_consumables(): + var s := ShellState.seed() + assert_eq(s.consumables.size(), 8) + assert_eq(s.consumables[0]["label"], "Salve") + assert_eq(s.consumables[0]["hotkey"], 1) + assert_eq(s.consumables[6]["label"], "", "slot 7 is empty") + + +func test_toggle_dock_flips(): + var s := ShellState.seed() + assert_true(s.dock_open, "seed opens with the dock out") + assert_false(s.toggle_dock()) + assert_false(s.dock_open) + assert_true(s.toggle_dock()) + assert_true(s.dock_open) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gselect=test_shell_state.gd` +Expected: FAIL — `TurnEntry`/`ShellState` are unknown identifiers (parse/compile error reported by GUT). + +- [ ] **Step 3: Write minimal implementation** + +Create `client/scripts/ui/shell/turn_entry.gd`: + +```gdscript +class_name TurnEntry +extends RefCounted +## One combatant in the turn-order rail (§2: state). Seed/placeholder now; the +## combat turn manager (M5) becomes its writer. `side` colours the rail: +## &"you" gold, &"ally" teal-muted, &"enemy" blood. + +var initials: String +var initiative: int +var side: StringName +var is_active: bool +var is_downed: bool + + +func _init(p_initials: String, p_initiative: int, p_side: StringName, + p_is_active := false, p_is_downed := false) -> void: + initials = p_initials + initiative = p_initiative + side = p_side + is_active = p_is_active + is_downed = p_is_downed +``` + +Create `client/scripts/ui/shell/shell_state.gd`: + +```gdscript +class_name ShellState +extends RefCounted +## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock +## 2a; later systems (combat vitals, inventory consumables) become the writers. +## No Luck/LCK value lives here — it is never surfaced (§7). + +var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0} +var turn_order: Array[TurnEntry] = [] +var consumables: Array = [] +var round_label: String = "" +var location_label: String = "" +var dock_open: bool = true + + +func toggle_dock() -> bool: + dock_open = not dock_open + return dock_open + + +static func seed() -> ShellState: + var s := ShellState.new() + s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240} + s.turn_order = [ + TurnEntry.new("EL", 17, &"you", true, false), + TurnEntry.new("DW", 12, &"ally"), + TurnEntry.new("HU", 9, &"ally"), + TurnEntry.new("GK", 0, &"enemy", false, true), + ] + var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""] + s.consumables = [] + for i in range(labels.size()): + s.consumables.append({"hotkey": i + 1, "label": labels[i]}) + s.round_label = "ROUND 4" + s.location_label = "THE LOWER WARD" + s.dock_open = true + return s +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gselect=test_shell_state.gd` +Expected: PASS — 5 passing tests, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add client/scripts/ui/shell/turn_entry.gd client/scripts/ui/shell/shell_state.gd client/tests/unit/test_shell_state.gd client/scripts/ui/shell/*.uid client/tests/unit/test_shell_state.gd.uid +git commit -m "feat(shell): ShellState + TurnEntry HUD state model" +``` + +--- + +### Task 2: `SystemDock` (slide-in dock, inert routing seam) + +**Files:** +- Create: `client/scripts/ui/shell/system_dock.gd` +- Create: `client/scenes/shell/SystemDock.tscn` +- Test: `client/tests/unit/test_system_dock.gd` + +**Interfaces:** +- Consumes: `ShellState` (Task 1) — reads/flips `dock_open`. +- Produces: + - `class_name SystemDock extends Control` — scene `res://scenes/shell/SystemDock.tscn`. + - `signal screen_requested(id: StringName)` — emitted when a dock button is pressed (inert this milestone; the routing seam). + - `const SCREENS := [[&"inventory","Inventory","I"], [&"character","Character","C"], [&"quest_log","Quest Log","J"], [&"spellbook","Spellbook","K"], [&"world_map","World Map","M"], [&"party","Party","P"]]` + - `var _screen_buttons: Dictionary` — `id: StringName -> Button`, populated in `_ready()`. + - `func setup(state: ShellState) -> void` — binds state, applies initial open. + - `func _on_handle_pressed() -> void` — flips `state.dock_open`, animates. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_system_dock.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/SystemDock.tscn" + + +func _dock() -> SystemDock: + var d = load(SCENE).instantiate() + add_child_autofree(d) # _ready() builds the buttons + return d + + +func test_builds_all_six_screen_buttons(): + var d := _dock() + assert_eq(d._screen_buttons.size(), 6) + assert_true(d._screen_buttons.has(&"spellbook"), "spellbook kept for mock fidelity") + assert_true(d._screen_buttons.has(&"party")) + + +func test_pressing_a_button_emits_its_id(): + var d := _dock() + watch_signals(d) + d._screen_buttons[&"inventory"].emit_signal("pressed") + assert_signal_emitted_with_parameters(d, "screen_requested", [&"inventory"]) + + +func test_handle_toggles_state(): + var d := _dock() + var state := ShellState.seed() + d.setup(state) + assert_true(state.dock_open) + d._on_handle_pressed() + assert_false(state.dock_open, "handle flips the shared state") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gselect=test_system_dock.gd` +Expected: FAIL — cannot load `SystemDock.tscn` / `SystemDock` unknown. + +- [ ] **Step 3: Write minimal implementation** + +Create `client/scripts/ui/shell/system_dock.gd`: + +```gdscript +class_name SystemDock +extends Control +## The slide-in system dock (mock 2a): a gold handle toggles a column of screen +## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons +## are the routing seam — they emit screen_requested; the screens they open land +## in later milestones, so the signal is currently a no-op for the shell. + +signal screen_requested(id: StringName) + +const SCREENS := [ + [&"inventory", "Inventory", "I"], + [&"character", "Character", "C"], + [&"quest_log", "Quest Log", "J"], + [&"spellbook", "Spellbook", "K"], + [&"world_map", "World Map", "M"], + [&"party", "Party", "P"], +] +const SLIDE_SECONDS := 0.32 +const HIDDEN_SHIFT := 230.0 + +var _screen_buttons: Dictionary = {} +var _state: ShellState +var _menu: VBoxContainer +var _handle: Button + + +func _ready() -> void: + _menu = VBoxContainer.new() + _menu.add_theme_constant_override("separation", 10) + add_child(_menu) + for entry in SCREENS: + var id: StringName = entry[0] + var button := Button.new() + button.text = "%s %s" % [entry[2], entry[1]] + button.theme_type_variation = ThemeKeys.DARK_PANEL + button.pressed.connect(func(): screen_requested.emit(id)) + _menu.add_child(button) + _screen_buttons[id] = button + + _handle = Button.new() + _handle.text = "›" + _handle.theme_type_variation = ThemeKeys.TAB_ACTIVE + _handle.pressed.connect(_on_handle_pressed) + add_child(_handle) + + +func setup(state: ShellState) -> void: + _state = state + _apply_open(_state.dock_open) + + +func _on_handle_pressed() -> void: + if _state == null: + return + _apply_open(_state.toggle_dock()) + + +func _apply_open(open: bool) -> void: + _handle.text = "›" if open else "‹" + var target := Vector2.ZERO if open else Vector2(HIDDEN_SHIFT, 0.0) + var alpha := 1.0 if open else 0.0 + if not is_inside_tree(): + _menu.position = target + _menu.modulate.a = alpha + return + var tween := create_tween().set_parallel(true) + tween.tween_property(_menu, "position", target, SLIDE_SECONDS) + tween.tween_property(_menu, "modulate:a", alpha, SLIDE_SECONDS) +``` + +Create `client/scenes/shell/SystemDock.tscn`: + +``` +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"] + +[node name="SystemDock" type="Control"] +script = ExtResource("1") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gselect=test_system_dock.gd` +Expected: PASS — 3 passing tests, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add client/scripts/ui/shell/system_dock.gd client/scenes/shell/SystemDock.tscn client/tests/unit/test_system_dock.gd client/scripts/ui/shell/system_dock.gd.uid client/scenes/shell/SystemDock.tscn.uid client/tests/unit/test_system_dock.gd.uid +git commit -m "feat(shell): SystemDock slide-in with inert screen-routing seam" +``` + +--- + +### Task 3: `NarrationBook` (the live DM panel + refire) + +**Files:** +- Create: `client/scripts/ui/shell/narration_book.gd` +- Create: `client/scenes/shell/NarrationBook.tscn` +- Test: `client/tests/unit/test_narration_book.gd` + +**Interfaces:** +- Consumes: `DmService` (`narrate(canon_log) -> NarrateResult`), `CanonLog`, `ShellState`, `ConsideringIndicator`, `NarrateResult` (`display_text`, `facts`, `degraded`). +- Produces: + - `class_name NarrationBook extends Control` — scene `res://scenes/shell/NarrationBook.tscn`. + - `func setup(service: DmService, log: CanonLog, state: ShellState) -> void` — injects deps, fills the header. + - `func narrate() -> void` — **async**; runs the refire flow (considering-state → await → prose → fact-harvest). + - `func show_prose(result: NarrateResult) -> void` — sets the prose text, records `last_degraded`. + - `var last_degraded: bool` — set by `show_prose` (test seam + degraded UI). + - `var _prose: RichTextLabel` — the narration surface. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_narration_book.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/NarrationBook.tscn" +const FakeTransport = preload("res://tests/doubles/fake_transport.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + + +func _book_with(response) -> NarrationBook: + var transport = FakeTransport.new() + transport.set_response(response) + var service := DmService.new(transport, FallbackLibrary.new()) + var book = load(SCENE).instantiate() + add_child_autofree(book) # _ready() builds nodes + the considering indicator + book.setup(service, CanonLog.new(), ShellState.seed()) + return book + + +func test_narrate_shows_prose_and_harvests_facts(): + var book := _book_with(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + var log := CanonLog.new() + var transport = FakeTransport.new() + transport.set_response(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed()) + await book.narrate() + assert_string_contains(book._prose.text, "The rain runs black into the gutter.") + assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose") + assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log") + assert_false(book.last_degraded) + + +func test_degraded_response_shows_fallback_and_sets_flag(): + var book := _book_with(DmResponse.failed("proxy down")) + await book.narrate() + assert_true(book.last_degraded, "a failed call degrades") + assert_ne(book._prose.text, "", "the fallback line is still shown (§13)") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gselect=test_narration_book.gd` +Expected: FAIL — cannot load `NarrationBook.tscn` / `NarrationBook` unknown. + +- [ ] **Step 3: Write minimal implementation** + +Create `client/scripts/ui/shell/narration_book.gd`: + +```gdscript +class_name NarrationBook +extends Control +## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes +## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are +## written back to the canon log (§11). Prose-only this milestone: the numbered +## response choices and the "describe your own action" line are faithful but inert +## (no action system yet); one in-world refire affordance re-runs narration. + +var last_degraded: bool = false + +var _service: DmService +var _log: CanonLog +var _state: ShellState +var _header: Label +var _prose: RichTextLabel +var _status: Label +var _refire: Button +var _degraded_tag: Label +var _indicator: ConsideringIndicator + + +func _ready() -> void: + var col := VBoxContainer.new() + col.set_anchors_preset(Control.PRESET_FULL_RECT) + col.add_theme_constant_override("separation", 18) + add_child(col) + + _header = Label.new() + _header.theme_type_variation = ThemeKeys.ACCENT + col.add_child(_header) + + _prose = RichTextLabel.new() + _prose.bbcode_enabled = true + _prose.fit_content = true + _prose.size_flags_vertical = Control.SIZE_EXPAND_FILL + _prose.custom_minimum_size = Vector2(660, 320) + col.add_child(_prose) + + _degraded_tag = Label.new() + _degraded_tag.theme_type_variation = ThemeKeys.MONO + _degraded_tag.text = "THE MASTER'S VOICE COMES THIN" # shown only when degraded + _degraded_tag.visible = false + col.add_child(_degraded_tag) + + # Faithful-but-inert placeholders (charter: prose-only this milestone). + var prompt := Label.new() + prompt.theme_type_variation = ThemeKeys.ACCENT + prompt.text = "▸ Your turn. What do you do?" + col.add_child(prompt) + for line in [ + "1 · Pay the fence double and keep your hand where he can see it", + "2 · Draw your blade — he's closer than his guards", + "3 · Lie — tell him the rest is coming with your dwarf"]: + var choice := Button.new() + choice.theme_type_variation = ThemeKeys.PARCHMENT_CARD + choice.text = line + choice.disabled = true # inert until combat/dialogue give choices meaning + col.add_child(choice) + var free_text := Label.new() + free_text.theme_type_variation = ThemeKeys.MONO + free_text.text = "⌨ …or describe your own action" + col.add_child(free_text) + + # The one live control: refire the narration. + _refire = Button.new() + _refire.theme_type_variation = ThemeKeys.PARCHMENT_CARD + _refire.text = "▸ The Master continues…" + _refire.pressed.connect(_on_refire_pressed) + col.add_child(_refire) + + _status = Label.new() + _status.theme_type_variation = ThemeKeys.MONO + col.add_child(_status) + + _indicator = ConsideringIndicator.new() + add_child(_indicator) + + +func setup(service: DmService, log: CanonLog, state: ShellState) -> void: + _service = service + _log = log + _state = state + _header.text = "%s · %s" % [state.round_label, state.location_label] + + +func narrate() -> void: + if _service == null or _log == null: + return + _refire.disabled = true + _indicator.start(_status) + var result: NarrateResult = await _service.narrate(_log) + _indicator.stop() + show_prose(result) + for f in result.facts: + _log.add_fact(f) # §11: code applies the state write, explicitly + _refire.disabled = false + + +func show_prose(result: NarrateResult) -> void: + _prose.text = result.display_text + last_degraded = result.degraded + _degraded_tag.visible = result.degraded + + +func _on_refire_pressed() -> void: + narrate() +``` + +Create `client/scenes/shell/NarrationBook.tscn`: + +``` +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"] + +[node name="NarrationBook" type="Control"] +script = ExtResource("1") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gselect=test_narration_book.gd` +Expected: PASS — 2 passing tests, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add client/scripts/ui/shell/narration_book.gd client/scenes/shell/NarrationBook.tscn client/tests/unit/test_narration_book.gd client/scripts/ui/shell/narration_book.gd.uid client/scenes/shell/NarrationBook.tscn.uid client/tests/unit/test_narration_book.gd.uid +git commit -m "feat(shell): NarrationBook wired to /dm/narrate with refire + fact-harvest" +``` + +--- + +### Task 4: `MainWindowShell` (assemble the frame + wire the DM loop) + +**Files:** +- Create: `client/scripts/ui/shell/main_window_shell.gd` +- Create: `client/scenes/shell/MainWindowShell.tscn` +- Test: `client/tests/unit/test_main_window_shell.gd` + +**Interfaces:** +- Consumes: `ShellState` (Task 1), `SystemDock` (Task 2), `NarrationBook` (Task 3), `DmService`/`HttpTransport`/`FallbackLibrary`, `CanonLog`/`LogPlayer`/`PartyMember`/`Quest`, the `DarkBay` surface scene, `Palette`, `ThemeKeys`. +- Produces: + - `class_name MainWindowShell extends Control` — scene `res://scenes/shell/MainWindowShell.tscn`; the F6 target. + - `var service: DmService = null` — **injection seam**: settable between `instantiate()` and `add_child()`; when null, `_ready()` builds the real `HttpTransport`-backed service. Tests inject a `FakeTransport`-backed service so the initial narrate is hermetic. + - `var book: NarrationBook`, `var dock: SystemDock`, `var _state: ShellState`, `var _log: CanonLog`. + +- [ ] **Step 1: Write the failing test** + +Create `client/tests/unit/test_main_window_shell.gd`: + +```gdscript +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/MainWindowShell.tscn" +const FakeTransport = preload("res://tests/doubles/fake_transport.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + + +func _shell() -> MainWindowShell: + # Inject a fake service BEFORE _ready (add_child) so the initial narrate does + # not touch the network — instantiate() does not run _ready(); add_child does. + var node = load(SCENE).instantiate() + var transport = FakeTransport.new() + transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."})) + node.service = DmService.new(transport, FallbackLibrary.new()) + add_child_autofree(node) + return node + + +func test_shell_applies_theme_and_fits_viewport(): + var packed = load(SCENE) + assert_true(packed is PackedScene) + var node := _shell() + assert_true(node is Control) + assert_true(node.theme is Theme, "shell applies game_theme") + assert_gt(node.size.x, 0.0, "viewport-fit sized the root (M3-a collapse guard)") + + +func test_shell_mounts_both_panels(): + var node := _shell() + assert_not_null(node.book, "narration book mounted") + assert_not_null(node.dock, "system dock mounted") + + +func test_shell_builds_a_code_owned_seed_log(): + var node := _shell() + assert_not_null(node._log, "a canon log exists to post (§11)") + assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && ./run_tests.sh -gselect=test_main_window_shell.gd` +Expected: FAIL — cannot load `MainWindowShell.tscn` / `MainWindowShell` unknown. + +- [ ] **Step 3: Write minimal implementation** + +Create `client/scripts/ui/shell/main_window_shell.gd`: + +```gdscript +class_name MainWindowShell +extends Control +## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel +## tabletop split: a dark isometric world slot (turn rail, minimap, system dock, +## command bar) + the permanent DM narration book. §2: the shell owns HUD state +## (ShellState) and consumes DM text (never owns narrative truth). It reuses the +## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented. + +const GAME_THEME := "res://assets/theme/game_theme.tres" +const DARK_BAY := "res://scenes/theme/surfaces/DarkBay.tscn" +const SYSTEM_DOCK := "res://scenes/shell/SystemDock.tscn" +const NARRATION_BOOK := "res://scenes/shell/NarrationBook.tscn" +const WORLD_WIDTH := 1180.0 +const BOOK_WIDTH := 740.0 + +var service: DmService = null # injection seam; real HttpTransport built if null + +var book: NarrationBook +var dock: SystemDock +var _state: ShellState +var _log: CanonLog +var _http: HTTPRequest + + +func _ready() -> void: + theme = load(GAME_THEME) + # Root Control is not sized by full-rect anchors under F6 — size explicitly, + # and no anchors_preset on the .tscn root (M3-a collapse bug). + _fit_to_viewport() + get_viewport().size_changed.connect(_fit_to_viewport) + + _state = ShellState.seed() + _log = _build_seed_log() + _build_layout() + + if service == null: + _http = HTTPRequest.new() + add_child(_http) + service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new()) + + book.setup(service, _log, _state) + dock.setup(_state) + dock.screen_requested.connect(_on_screen_requested) + book.narrate() # initial narration; async, fire-and-forget + + +func _fit_to_viewport() -> void: + size = get_viewport_rect().size + + +func _build_layout() -> void: + var split := HBoxContainer.new() + split.set_anchors_preset(Control.PRESET_FULL_RECT) + split.add_theme_constant_override("separation", 0) + add_child(split) + + var world := _build_world() + world.size_flags_horizontal = Control.SIZE_EXPAND_FILL + split.add_child(world) + + book = load(NARRATION_BOOK).instantiate() + book.custom_minimum_size = Vector2(BOOK_WIDTH, 0) + var book_panel := PanelContainer.new() + book_panel.theme_type_variation = ThemeKeys.PARCHMENT_CARD + book_panel.custom_minimum_size = Vector2(BOOK_WIDTH, 0) + book_panel.add_child(book) + split.add_child(book_panel) + + +func _build_world() -> Control: + var world := Control.new() + world.custom_minimum_size = Vector2(WORLD_WIDTH, 0) + world.clip_contents = true + + var bay = load(DARK_BAY).instantiate() + bay.set_anchors_preset(Control.PRESET_FULL_RECT) + world.add_child(bay) + + var caption := Label.new() + caption.theme_type_variation = ThemeKeys.MONO + caption.text = "ISOMETRIC WORLD VIEW" + caption.set_anchors_preset(Control.PRESET_CENTER) + world.add_child(caption) + + var rail := _build_turn_rail() + rail.position = Vector2(28, 28) + world.add_child(rail) + + var minimap := PanelContainer.new() + minimap.theme_type_variation = ThemeKeys.DARK_PANEL + minimap.custom_minimum_size = Vector2(200, 200) + minimap.set_anchors_preset(Control.PRESET_TOP_RIGHT) + minimap.position = Vector2(-228, 28) + var minimap_label := Label.new() + minimap_label.theme_type_variation = ThemeKeys.MONO + minimap_label.text = "MINIMAP" + minimap.add_child(minimap_label) + world.add_child(minimap) + + dock = load(SYSTEM_DOCK).instantiate() + dock.set_anchors_preset(Control.PRESET_CENTER_RIGHT) + dock.position = Vector2(-260, 0) + world.add_child(dock) + + var command_bar := _build_command_bar() + command_bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) + command_bar.position = Vector2(0, -150) + world.add_child(command_bar) + + return world + + +func _build_turn_rail() -> Control: + var panel := PanelContainer.new() + panel.theme_type_variation = ThemeKeys.DARK_PANEL + var col := VBoxContainer.new() + col.add_theme_constant_override("separation", 8) + panel.add_child(col) + var title := Label.new() + title.theme_type_variation = ThemeKeys.MONO + title.text = "TURN ORDER" + col.add_child(title) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 12) + col.add_child(row) + for e in _state.turn_order: + var token := Label.new() + token.theme_type_variation = ThemeKeys.ACCENT + token.text = "✕" if e.is_downed else e.initials + token.add_theme_color_override("font_color", _side_color(e)) + row.add_child(token) + return panel + + +func _side_color(e: TurnEntry) -> Color: + if e.is_downed: + return Palette.BLOOD + if e.is_active: + return Palette.GOLD + match e.side: + &"enemy": return Palette.BLOOD_BRIGHT + &"ally": return Palette.STEEL + _: return Palette.CREAM + + +func _build_command_bar() -> Control: + var bar := HBoxContainer.new() + bar.add_theme_constant_override("separation", 24) + bar.custom_minimum_size = Vector2(0, 150) + + var vitals := VBoxContainer.new() + var hp := Label.new() + hp.theme_type_variation = ThemeKeys.MONO + hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]] + var mp := Label.new() + mp.theme_type_variation = ThemeKeys.MONO + mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]] + var gold := Label.new() + gold.theme_type_variation = ThemeKeys.MONO + gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT) + gold.text = "◈ %d gp" % _state.vitals["gold"] + for l in [hp, mp, gold]: + vitals.add_child(l) + bar.add_child(vitals) + + var slots := HBoxContainer.new() + slots.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slots.add_theme_constant_override("separation", 9) + for c in _state.consumables: + var slot := PanelContainer.new() + slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY + slot.custom_minimum_size = Vector2(62, 62) + var name_label := Label.new() + name_label.theme_type_variation = ThemeKeys.MONO + name_label.text = c["label"] + slot.add_child(name_label) + slots.add_child(slot) + bar.add_child(slots) + + var end_turn := Button.new() + end_turn.theme_type_variation = ThemeKeys.PRIMARY_CTA + end_turn.text = "END TURN" + bar.add_child(end_turn) + + return bar + + +func _on_screen_requested(_id: StringName) -> void: + pass # routing seam — the dock's screens land in later milestones (§17) + + +func _build_seed_log() -> CanonLog: + # Code owns the canon log (§11). Hand-built until character creation (M4) and + # save/load (M9) construct the real one — mirrors narrate_harness. No Luck + # number is rendered anywhere (§7); the descriptor is prose-only. + var log := CanonLog.new() + log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you") + log.set_location("lower_ward", "the Lower Ward") + log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40)) + log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15)) + log.push_event("Followed the beastfolk fence into the Lower Ward after dark") + log.push_event("The sewer bite on Vexcca's left arm has begun to turn") + log.add_fact("the fence deals out of the Lower Ward") + log.add_fact("Vexcca carries an infected bite on the left arm") + log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence")) + log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4) + return log +``` + +Create `client/scenes/shell/MainWindowShell.tscn` (note: **no `anchors_preset` on the root** — the viewport-fit gotcha): + +``` +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"] + +[node name="MainWindowShell" type="Control"] +script = ExtResource("1") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd client && ./run_tests.sh -gselect=test_main_window_shell.gd` +Expected: PASS — 3 passing tests, 0 failing. + +- [ ] **Step 5: Run the full suite (no regressions)** + +Run: `cd client && ./run_tests.sh` +Expected: PASS — all prior tests (110+) plus the new shell tests, 0 failing. + +- [ ] **Step 6: Commit** + +```bash +git add client/scripts/ui/shell/main_window_shell.gd client/scenes/shell/MainWindowShell.tscn client/tests/unit/test_main_window_shell.gd client/scripts/ui/shell/main_window_shell.gd.uid client/scenes/shell/MainWindowShell.tscn.uid client/tests/unit/test_main_window_shell.gd.uid +git commit -m "feat(shell): MainWindowShell assembles the frame and wires the DM loop" +``` + +--- + +### Task 5: Visual gate — human F6 run + +**This task is not automatable.** Headless GUT cannot prove rendering (the M3-a lesson: a root Control collapsed to 0×0 and every test still passed). A human must eyeball the scene. + +- [ ] **Step 1: Open the scene in the Godot editor and run it** + +Open `client/scenes/shell/MainWindowShell.tscn`, press **F6**. For the live DM prose, have the proxy + Ollama up (see `client/docs/README.md` live-smoke steps); without them the book must degrade to the authored fallback line (§13), not error. + +- [ ] **Step 2: Confirm the checklist by eye** + + - The two-panel split fills 1920×1080 — world on the left (dark stripe + vignette), parchment book on the right. **Not** collapsed / blank. + - Turn-order rail (top-left), minimap slot (top-right), command bar (bottom: HP/MP/gold, 8 consumable slots, END TURN). + - The gold dock handle slides the dock in/out when clicked; the arrow flips ‹ / ›. + - The book shows the header (`ROUND 4 · THE LOWER WARD`), then either live DM prose or the fallback line; the considering line rotates while a call is in flight. + - Pressing **▸ The Master continues…** refires narration (or degrades cleanly if the proxy is down). + +- [ ] **Step 3: Report the outcome** + +If anything is collapsed, mis-coloured (hardcoded hex would show here), or errors, fix and re-run before the branch is considered done. When it looks right, the milestone is eyeball-confirmed and ready to merge to `dev`. + +--- + +## Self-Review + +**Spec coverage:** +- Iso world slot (art slot) → Task 4 `_build_world` (DarkBay + caption). ✓ +- DM narration book wired to `/dm/narrate` + considering-state → Task 3. ✓ +- Turn-order rail → Task 4 `_build_turn_rail` from `ShellState.turn_order`. ✓ +- Minimap slot → Task 4 (placeholder Panel). ✓ +- Slide-in system dock (6 toggles incl. Spellbook, inert) → Task 2. ✓ +- Command bar (HP/MP/gold, consumables, End Turn) → Task 4 `_build_command_bar`. ✓ +- Thin tested `ShellState` → Task 1. ✓ +- Refire control, prose-only, inert choices/free-text → Task 3. ✓ +- Degrade on non-200 (§13) → reused `DmService`, asserted in Task 3. ✓ +- Fact-harvest (§11) → Task 3 `narrate()`, asserted. ✓ +- Viewport-fit gotcha → Task 4, asserted in test + Task 5 eyeball. ✓ +- Visual gate → Task 5. ✓ + +**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to". Every code step shows full code. ✓ + +**Type consistency:** `ShellState.seed()`, `TurnEntry.new(...)`, `SystemDock.setup`/`_on_handle_pressed`/`_screen_buttons`/`screen_requested`, `NarrationBook.setup`/`narrate`/`show_prose`/`last_degraded`/`_prose`, `MainWindowShell.service`/`book`/`dock`/`_log` — names match across the tasks that reference them. `NarrateResult.display_text`/`facts`/`degraded`, `DmResponse.ok`/`failed`, `FakeTransport.set_response`, `CanonLog.add_fact`/`established_facts`/`set_location`/`push_event`/`active_quests`/`add_humiliation`, `LogPlayer.new`/`PartyMember.new`/`Quest.new` — all match the existing code read during planning. ✓ From c4aa74da5ab04002eac8eeca13016321d202fd20 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 08:46:56 -0500 Subject: [PATCH 03/13] feat(shell): ShellState + TurnEntry HUD state model --- client/scripts/ui/shell/shell_state.gd | 36 ++++++++++++++++++ client/scripts/ui/shell/shell_state.gd.uid | 1 + client/scripts/ui/shell/turn_entry.gd | 20 ++++++++++ client/scripts/ui/shell/turn_entry.gd.uid | 1 + client/tests/unit/test_shell_state.gd | 44 ++++++++++++++++++++++ client/tests/unit/test_shell_state.gd.uid | 1 + 6 files changed, 103 insertions(+) create mode 100644 client/scripts/ui/shell/shell_state.gd create mode 100644 client/scripts/ui/shell/shell_state.gd.uid create mode 100644 client/scripts/ui/shell/turn_entry.gd create mode 100644 client/scripts/ui/shell/turn_entry.gd.uid create mode 100644 client/tests/unit/test_shell_state.gd create mode 100644 client/tests/unit/test_shell_state.gd.uid diff --git a/client/scripts/ui/shell/shell_state.gd b/client/scripts/ui/shell/shell_state.gd new file mode 100644 index 0000000..27caa65 --- /dev/null +++ b/client/scripts/ui/shell/shell_state.gd @@ -0,0 +1,36 @@ +class_name ShellState +extends RefCounted +## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock +## 2a; later systems (combat vitals, inventory consumables) become the writers. +## No Luck/LCK value lives here — it is never surfaced (§7). + +var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0} +var turn_order: Array[TurnEntry] = [] +var consumables: Array = [] +var round_label: String = "" +var location_label: String = "" +var dock_open: bool = true + + +func toggle_dock() -> bool: + dock_open = not dock_open + return dock_open + + +static func seed() -> ShellState: + var s := ShellState.new() + s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240} + s.turn_order = [ + TurnEntry.new("EL", 17, &"you", true, false), + TurnEntry.new("DW", 12, &"ally"), + TurnEntry.new("HU", 9, &"ally"), + TurnEntry.new("GK", 0, &"enemy", false, true), + ] + var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""] + s.consumables = [] + for i in range(labels.size()): + s.consumables.append({"hotkey": i + 1, "label": labels[i]}) + s.round_label = "ROUND 4" + s.location_label = "THE LOWER WARD" + s.dock_open = true + return s diff --git a/client/scripts/ui/shell/shell_state.gd.uid b/client/scripts/ui/shell/shell_state.gd.uid new file mode 100644 index 0000000..e57742c --- /dev/null +++ b/client/scripts/ui/shell/shell_state.gd.uid @@ -0,0 +1 @@ +uid://f8vix48wmisv diff --git a/client/scripts/ui/shell/turn_entry.gd b/client/scripts/ui/shell/turn_entry.gd new file mode 100644 index 0000000..df28f45 --- /dev/null +++ b/client/scripts/ui/shell/turn_entry.gd @@ -0,0 +1,20 @@ +class_name TurnEntry +extends RefCounted +## One combatant in the turn-order rail (§2: state). Seed/placeholder now; the +## combat turn manager (M5) becomes its writer. `side` colours the rail: +## &"you" gold, &"ally" teal-muted, &"enemy" blood. + +var initials: String +var initiative: int +var side: StringName +var is_active: bool +var is_downed: bool + + +func _init(p_initials: String, p_initiative: int, p_side: StringName, + p_is_active := false, p_is_downed := false) -> void: + initials = p_initials + initiative = p_initiative + side = p_side + is_active = p_is_active + is_downed = p_is_downed diff --git a/client/scripts/ui/shell/turn_entry.gd.uid b/client/scripts/ui/shell/turn_entry.gd.uid new file mode 100644 index 0000000..311cb23 --- /dev/null +++ b/client/scripts/ui/shell/turn_entry.gd.uid @@ -0,0 +1 @@ +uid://toetic2iu7b8 diff --git a/client/tests/unit/test_shell_state.gd b/client/tests/unit/test_shell_state.gd new file mode 100644 index 0000000..cfea412 --- /dev/null +++ b/client/tests/unit/test_shell_state.gd @@ -0,0 +1,44 @@ +extends "res://addons/gut/test.gd" + + +func test_turn_entry_holds_fields(): + var e := TurnEntry.new("DW", 12, &"ally") + assert_eq(e.initials, "DW") + assert_eq(e.initiative, 12) + assert_eq(e.side, &"ally") + assert_false(e.is_active) + assert_false(e.is_downed) + + +func test_seed_vitals(): + var s := ShellState.seed() + assert_eq(s.vitals["hp"], 42) + assert_eq(s.vitals["hp_max"], 60) + assert_eq(s.vitals["mp"], 18) + assert_eq(s.vitals["mp_max"], 30) + assert_eq(s.vitals["gold"], 1240) + + +func test_seed_turn_order(): + var s := ShellState.seed() + assert_eq(s.turn_order.size(), 4) + assert_eq(s.turn_order[0].initials, "EL") + assert_true(s.turn_order[0].is_active, "first combatant is the active turn") + assert_true(s.turn_order[3].is_downed, "last combatant is downed") + + +func test_seed_consumables(): + var s := ShellState.seed() + assert_eq(s.consumables.size(), 8) + assert_eq(s.consumables[0]["label"], "Salve") + assert_eq(s.consumables[0]["hotkey"], 1) + assert_eq(s.consumables[6]["label"], "", "slot 7 is empty") + + +func test_toggle_dock_flips(): + var s := ShellState.seed() + assert_true(s.dock_open, "seed opens with the dock out") + assert_false(s.toggle_dock()) + assert_false(s.dock_open) + assert_true(s.toggle_dock()) + assert_true(s.dock_open) diff --git a/client/tests/unit/test_shell_state.gd.uid b/client/tests/unit/test_shell_state.gd.uid new file mode 100644 index 0000000..ae91db3 --- /dev/null +++ b/client/tests/unit/test_shell_state.gd.uid @@ -0,0 +1 @@ +uid://wkbpiulrv8rx From 9a02c9b038110ee1510a72052d6da6fcb4c1c2fa Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 08:51:14 -0500 Subject: [PATCH 04/13] feat(shell): SystemDock slide-in with inert screen-routing seam --- client/scenes/shell/SystemDock.tscn | 6 ++ client/scripts/ui/shell/system_dock.gd | 68 ++++++++++++++++++++++ client/scripts/ui/shell/system_dock.gd.uid | 1 + client/tests/unit/test_system_dock.gd | 32 ++++++++++ client/tests/unit/test_system_dock.gd.uid | 1 + 5 files changed, 108 insertions(+) create mode 100644 client/scenes/shell/SystemDock.tscn create mode 100644 client/scripts/ui/shell/system_dock.gd create mode 100644 client/scripts/ui/shell/system_dock.gd.uid create mode 100644 client/tests/unit/test_system_dock.gd create mode 100644 client/tests/unit/test_system_dock.gd.uid diff --git a/client/scenes/shell/SystemDock.tscn b/client/scenes/shell/SystemDock.tscn new file mode 100644 index 0000000..f984bdc --- /dev/null +++ b/client/scenes/shell/SystemDock.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"] + +[node name="SystemDock" type="Control"] +script = ExtResource("1") diff --git a/client/scripts/ui/shell/system_dock.gd b/client/scripts/ui/shell/system_dock.gd new file mode 100644 index 0000000..16df17a --- /dev/null +++ b/client/scripts/ui/shell/system_dock.gd @@ -0,0 +1,68 @@ +class_name SystemDock +extends Control +## The slide-in system dock (mock 2a): a gold handle toggles a column of screen +## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons +## are the routing seam — they emit screen_requested; the screens they open land +## in later milestones, so the signal is currently a no-op for the shell. + +signal screen_requested(id: StringName) + +const SCREENS := [ + [&"inventory", "Inventory", "I"], + [&"character", "Character", "C"], + [&"quest_log", "Quest Log", "J"], + [&"spellbook", "Spellbook", "K"], + [&"world_map", "World Map", "M"], + [&"party", "Party", "P"], +] +const SLIDE_SECONDS := 0.32 +const HIDDEN_SHIFT := 230.0 + +var _screen_buttons: Dictionary = {} +var _state: ShellState +var _menu: VBoxContainer +var _handle: Button + + +func _ready() -> void: + _menu = VBoxContainer.new() + _menu.add_theme_constant_override("separation", 10) + add_child(_menu) + for entry in SCREENS: + var id: StringName = entry[0] + var button := Button.new() + button.text = "%s %s" % [entry[2], entry[1]] + button.theme_type_variation = ThemeKeys.DARK_PANEL + button.pressed.connect(func(): screen_requested.emit(id)) + _menu.add_child(button) + _screen_buttons[id] = button + + _handle = Button.new() + _handle.text = "›" + _handle.theme_type_variation = ThemeKeys.TAB_ACTIVE + _handle.pressed.connect(_on_handle_pressed) + add_child(_handle) + + +func setup(state: ShellState) -> void: + _state = state + _apply_open(_state.dock_open) + + +func _on_handle_pressed() -> void: + if _state == null: + return + _apply_open(_state.toggle_dock()) + + +func _apply_open(open: bool) -> void: + _handle.text = "›" if open else "‹" + var target := Vector2.ZERO if open else Vector2(HIDDEN_SHIFT, 0.0) + var alpha := 1.0 if open else 0.0 + if not is_inside_tree(): + _menu.position = target + _menu.modulate.a = alpha + return + var tween := create_tween().set_parallel(true) + tween.tween_property(_menu, "position", target, SLIDE_SECONDS) + tween.tween_property(_menu, "modulate:a", alpha, SLIDE_SECONDS) diff --git a/client/scripts/ui/shell/system_dock.gd.uid b/client/scripts/ui/shell/system_dock.gd.uid new file mode 100644 index 0000000..a2b81dc --- /dev/null +++ b/client/scripts/ui/shell/system_dock.gd.uid @@ -0,0 +1 @@ +uid://dvbg24rvye3qw diff --git a/client/tests/unit/test_system_dock.gd b/client/tests/unit/test_system_dock.gd new file mode 100644 index 0000000..233a9c6 --- /dev/null +++ b/client/tests/unit/test_system_dock.gd @@ -0,0 +1,32 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/SystemDock.tscn" + + +func _dock() -> SystemDock: + var d = load(SCENE).instantiate() + add_child_autofree(d) # _ready() builds the buttons + return d + + +func test_builds_all_six_screen_buttons(): + var d := _dock() + assert_eq(d._screen_buttons.size(), 6) + assert_true(d._screen_buttons.has(&"spellbook"), "spellbook kept for mock fidelity") + assert_true(d._screen_buttons.has(&"party")) + + +func test_pressing_a_button_emits_its_id(): + var d := _dock() + watch_signals(d) + d._screen_buttons[&"inventory"].emit_signal("pressed") + assert_signal_emitted_with_parameters(d, "screen_requested", [&"inventory"]) + + +func test_handle_toggles_state(): + var d := _dock() + var state := ShellState.seed() + d.setup(state) + assert_true(state.dock_open) + d._on_handle_pressed() + assert_false(state.dock_open, "handle flips the shared state") diff --git a/client/tests/unit/test_system_dock.gd.uid b/client/tests/unit/test_system_dock.gd.uid new file mode 100644 index 0000000..5629317 --- /dev/null +++ b/client/tests/unit/test_system_dock.gd.uid @@ -0,0 +1 @@ +uid://dllvm2kmia00h From 35c458679247c4e166816d13deafb293166f61ac Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:00:54 -0500 Subject: [PATCH 05/13] fix(shell): dark DockButton theme variation for the system dock DarkPanel is a PanelContainer-base variation (only a `panel` stylebox); a Button looks up normal/hover/pressed, which don't exist on it, so SystemDock's menu buttons rendered as default gray Godot buttons instead of mock-faithful dark nav buttons. Add a dedicated Button-base DockButton variation (Palette-only colours, dock-dark normal/hover/pressed styleboxes), point system_dock.gd's menu buttons at it, and regenerate the committed game_theme.tres artifact from the builder. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/assets/theme/game_theme.tres | 120 +++++++++++++++++------ client/scripts/theme/build_game_theme.gd | 14 +++ client/scripts/theme/theme_keys.gd | 2 + client/scripts/ui/shell/system_dock.gd | 2 +- 4 files changed, 105 insertions(+), 33 deletions(-) diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index 8eef967..f311cff 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -70,7 +70,57 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_73lme"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.05490196, 0.043137256, 0.03529412, 0.85) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5176471, 0.24705882, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.41960785, 0.38039216, 0.32156864, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.039215688, 0.03137255, 0.023529412, 0.9) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5176471, 0.24705882, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -86,7 +136,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -103,7 +153,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -119,7 +169,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -135,7 +185,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -151,7 +201,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -167,7 +217,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -183,7 +233,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -199,9 +249,9 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_jly1r"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nmowx"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -217,7 +267,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -233,7 +283,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -249,9 +299,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nmowx"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_5u8fh"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -267,7 +317,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -283,7 +333,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -316,38 +366,44 @@ Chip/styles/normal = SubResource("StyleBoxFlat_cqbvn") Chip/styles/pressed = SubResource("StyleBoxFlat_q5gpk") DarkPanel/base_type = &"PanelContainer" DarkPanel/styles/panel = SubResource("StyleBoxFlat_bbpmc") +DockButton/base_type = &"Button" +DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1) +DockButton/styles/focus = SubResource("StyleBoxEmpty_73lme") +DockButton/styles/hover = SubResource("StyleBoxFlat_js3tr") +DockButton/styles/normal = SubResource("StyleBoxFlat_ube3r") +DockButton/styles/pressed = SubResource("StyleBoxFlat_2tooq") Heading/base_type = &"Label" Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) Heading/font_sizes/font_size = 34 Heading/fonts/font = ExtResource("3_4lwr2") ItemTile/base_type = &"PanelContainer" -ItemTile/styles/panel = SubResource("StyleBoxFlat_73lme") +ItemTile/styles/panel = SubResource("StyleBoxFlat_4kten") ItemTileEmpty/base_type = &"PanelContainer" -ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_js3tr") +ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_p6wtm") Mono/base_type = &"Label" Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Mono/font_sizes/font_size = 13 Mono/fonts/font = ExtResource("2_605w4") ParchmentCard/base_type = &"PanelContainer" -ParchmentCard/styles/panel = SubResource("StyleBoxFlat_ube3r") +ParchmentCard/styles/panel = SubResource("StyleBoxFlat_0fjdt") ParchmentInset/base_type = &"PanelContainer" -ParchmentInset/styles/panel = SubResource("StyleBoxFlat_2tooq") +ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ns3kg") PrimaryCTA/base_type = &"Button" PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_4kten") -PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p6wtm") -PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_0fjdt") -PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_ns3kg") +PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_jly1r") +PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_lw8lk") +PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_nh0e7") +PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_dtr07") Tab/base_type = &"Button" Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -Tab/styles/focus = SubResource("StyleBoxEmpty_jly1r") -Tab/styles/hover = SubResource("StyleBoxFlat_lw8lk") -Tab/styles/normal = SubResource("StyleBoxFlat_nh0e7") -Tab/styles/pressed = SubResource("StyleBoxFlat_dtr07") +Tab/styles/focus = SubResource("StyleBoxEmpty_nmowx") +Tab/styles/hover = SubResource("StyleBoxFlat_oi067") +Tab/styles/normal = SubResource("StyleBoxFlat_ukblj") +Tab/styles/pressed = SubResource("StyleBoxFlat_utt73") TabActive/base_type = &"Button" TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -TabActive/styles/focus = SubResource("StyleBoxEmpty_nmowx") -TabActive/styles/hover = SubResource("StyleBoxFlat_oi067") -TabActive/styles/normal = SubResource("StyleBoxFlat_ukblj") -TabActive/styles/pressed = SubResource("StyleBoxFlat_utt73") +TabActive/styles/focus = SubResource("StyleBoxEmpty_5u8fh") +TabActive/styles/hover = SubResource("StyleBoxFlat_p0qbh") +TabActive/styles/normal = SubResource("StyleBoxFlat_6y6ak") +TabActive/styles/pressed = SubResource("StyleBoxFlat_txxuc") diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index 96b08f6..df3b006 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -35,6 +35,7 @@ static func build_theme() -> Theme: _fonts(theme) _primary_cta(theme) _tabs(theme) + _dock_button(theme) _chip(theme) _tiles(theme) _cards_and_panels(theme) @@ -106,6 +107,19 @@ static func _tabs(theme: Theme) -> void: theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT) +static func _dock_button(theme: Theme) -> void: + # Dark nav button for the slide-in system dock (mock 2a). Button-base so + # normal/hover/pressed styleboxes actually apply (a PanelContainer variation's + # lone `panel` stylebox does not skin a Button). Colours are dock-dark. + var k := ThemeKeys.DOCK_BUTTON + theme.set_type_variation(k, "Button") + theme.set_stylebox(&"normal", k, _flat(Palette.DARK_PANEL_70, Palette.BORDER_1, 1, 8)) + theme.set_stylebox(&"hover", k, _flat(Palette.DARK_PANEL_85, Palette.GOLD, 1, 8)) + theme.set_stylebox(&"pressed", k, _flat(Palette.DARK_PANEL_90, Palette.GOLD, 1, 8)) + theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) + theme.set_color(&"font_color", k, Palette.CREAM_SECONDARY) + + static func _chip(theme: Theme) -> void: # Base chip; the semantic colour (red/gold/grey) is applied per-use by script. var k := ThemeKeys.CHIP diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 0a11113..8acf858 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -9,6 +9,7 @@ const PRIMARY_CTA := &"PrimaryCTA" const TAB := &"Tab" const TAB_ACTIVE := &"TabActive" const CHIP := &"Chip" +const DOCK_BUTTON := &"DockButton" const ITEM_TILE := &"ItemTile" const ITEM_TILE_EMPTY := &"ItemTileEmpty" const PARCHMENT_CARD := &"ParchmentCard" @@ -28,6 +29,7 @@ const ALL := { TAB: "Button", TAB_ACTIVE: "Button", CHIP: "Button", + DOCK_BUTTON: "Button", ITEM_TILE: "PanelContainer", ITEM_TILE_EMPTY: "PanelContainer", PARCHMENT_CARD: "PanelContainer", diff --git a/client/scripts/ui/shell/system_dock.gd b/client/scripts/ui/shell/system_dock.gd index 16df17a..282bd88 100644 --- a/client/scripts/ui/shell/system_dock.gd +++ b/client/scripts/ui/shell/system_dock.gd @@ -32,7 +32,7 @@ func _ready() -> void: var id: StringName = entry[0] var button := Button.new() button.text = "%s %s" % [entry[2], entry[1]] - button.theme_type_variation = ThemeKeys.DARK_PANEL + button.theme_type_variation = ThemeKeys.DOCK_BUTTON button.pressed.connect(func(): screen_requested.emit(id)) _menu.add_child(button) _screen_buttons[id] = button From bca04e3a2e1e5403452640a9f15a5ef3fb07b87f Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:07:16 -0500 Subject: [PATCH 06/13] feat(theme): parchment Button variation for clickable parchment rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeKeys.PARCHMENT_CARD is a PanelContainer-base variation (only a `panel` stylebox) — a Button looks up normal/hover/pressed/disabled, which don't exist there, so a Button styled with it renders unskinned. Add PARCHMENT_BUTTON, a Button-base variation with those four styleboxes plus an empty focus box, built from Palette colours only. Same fix pattern as DOCK_BUTTON for the system dock. Regenerated game_theme.tres from the builder. --- client/assets/theme/game_theme.tres | 128 ++++++++++++++++++----- client/scripts/theme/build_game_theme.gd | 17 +++ client/scripts/theme/theme_keys.gd | 2 + 3 files changed, 120 insertions(+), 27 deletions(-) diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index f311cff..7c05cc2 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -158,6 +158,72 @@ content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 +bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ns3kg"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) border_width_left = 1 border_width_top = 1 @@ -169,7 +235,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -185,7 +251,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -201,7 +267,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -217,7 +283,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -233,7 +299,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -249,9 +315,9 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nmowx"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_p0qbh"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -267,7 +333,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -283,7 +349,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -299,9 +365,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_5u8fh"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_najyk"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -317,7 +383,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wmbj4"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -333,7 +399,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -384,26 +450,34 @@ Mono/base_type = &"Label" Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Mono/font_sizes/font_size = 13 Mono/fonts/font = ExtResource("2_605w4") +ParchmentButton/base_type = &"Button" +ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1) +ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1) +ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_0fjdt") +ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_ns3kg") +ParchmentButton/styles/hover = SubResource("StyleBoxFlat_jly1r") +ParchmentButton/styles/normal = SubResource("StyleBoxFlat_lw8lk") +ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nh0e7") ParchmentCard/base_type = &"PanelContainer" -ParchmentCard/styles/panel = SubResource("StyleBoxFlat_0fjdt") +ParchmentCard/styles/panel = SubResource("StyleBoxFlat_dtr07") ParchmentInset/base_type = &"PanelContainer" -ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ns3kg") +ParchmentInset/styles/panel = SubResource("StyleBoxFlat_nmowx") PrimaryCTA/base_type = &"Button" PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_jly1r") -PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_lw8lk") -PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_nh0e7") -PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_dtr07") +PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_oi067") +PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_ukblj") +PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_utt73") +PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_5u8fh") Tab/base_type = &"Button" Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -Tab/styles/focus = SubResource("StyleBoxEmpty_nmowx") -Tab/styles/hover = SubResource("StyleBoxFlat_oi067") -Tab/styles/normal = SubResource("StyleBoxFlat_ukblj") -Tab/styles/pressed = SubResource("StyleBoxFlat_utt73") +Tab/styles/focus = SubResource("StyleBoxEmpty_p0qbh") +Tab/styles/hover = SubResource("StyleBoxFlat_6y6ak") +Tab/styles/normal = SubResource("StyleBoxFlat_txxuc") +Tab/styles/pressed = SubResource("StyleBoxFlat_7d6ug") TabActive/base_type = &"Button" TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -TabActive/styles/focus = SubResource("StyleBoxEmpty_5u8fh") -TabActive/styles/hover = SubResource("StyleBoxFlat_p0qbh") -TabActive/styles/normal = SubResource("StyleBoxFlat_6y6ak") -TabActive/styles/pressed = SubResource("StyleBoxFlat_txxuc") +TabActive/styles/focus = SubResource("StyleBoxEmpty_najyk") +TabActive/styles/hover = SubResource("StyleBoxFlat_jtb1k") +TabActive/styles/normal = SubResource("StyleBoxFlat_wmbj4") +TabActive/styles/pressed = SubResource("StyleBoxFlat_xdylb") diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index df3b006..0c4d644 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -36,6 +36,7 @@ static func build_theme() -> Theme: _primary_cta(theme) _tabs(theme) _dock_button(theme) + _parchment_button(theme) _chip(theme) _tiles(theme) _cards_and_panels(theme) @@ -120,6 +121,22 @@ static func _dock_button(theme: Theme) -> void: theme.set_color(&"font_color", k, Palette.CREAM_SECONDARY) +static func _parchment_button(theme: Theme) -> void: + # Clickable parchment row/card for the narration book (mock 2a response + # choices + the refire affordance). Button-base so normal/hover/pressed/ + # disabled styleboxes actually apply (a PanelContainer variation's lone + # `panel` stylebox does not skin a Button). Colours are the mock's choice-card. + var k := ThemeKeys.PARCHMENT_BUTTON + theme.set_type_variation(k, "Button") + theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 8)) + theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 8)) + theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_INSET_2, Palette.INK_LABEL, 1, 8)) + theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 8)) + theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) + theme.set_color(&"font_color", k, Palette.INK_BODY) + theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED) + + static func _chip(theme: Theme) -> void: # Base chip; the semantic colour (red/gold/grey) is applied per-use by script. var k := ThemeKeys.CHIP diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 8acf858..3de8ff0 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -10,6 +10,7 @@ const TAB := &"Tab" const TAB_ACTIVE := &"TabActive" const CHIP := &"Chip" const DOCK_BUTTON := &"DockButton" +const PARCHMENT_BUTTON := &"ParchmentButton" const ITEM_TILE := &"ItemTile" const ITEM_TILE_EMPTY := &"ItemTileEmpty" const PARCHMENT_CARD := &"ParchmentCard" @@ -30,6 +31,7 @@ const ALL := { TAB_ACTIVE: "Button", CHIP: "Button", DOCK_BUTTON: "Button", + PARCHMENT_BUTTON: "Button", ITEM_TILE: "PanelContainer", ITEM_TILE_EMPTY: "PanelContainer", PARCHMENT_CARD: "PanelContainer", From 5f1ebdf84004a426116c01aa4d82b49fcd7b768e Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:07:24 -0500 Subject: [PATCH 07/13] feat(shell): NarrationBook wired to /dm/narrate with refire + fact-harvest --- client/scenes/shell/NarrationBook.tscn | 6 + client/scripts/ui/shell/narration_book.gd | 106 ++++++++++++++++++ client/scripts/ui/shell/narration_book.gd.uid | 1 + client/tests/unit/test_narration_book.gd | 37 ++++++ client/tests/unit/test_narration_book.gd.uid | 1 + 5 files changed, 151 insertions(+) create mode 100644 client/scenes/shell/NarrationBook.tscn create mode 100644 client/scripts/ui/shell/narration_book.gd create mode 100644 client/scripts/ui/shell/narration_book.gd.uid create mode 100644 client/tests/unit/test_narration_book.gd create mode 100644 client/tests/unit/test_narration_book.gd.uid diff --git a/client/scenes/shell/NarrationBook.tscn b/client/scenes/shell/NarrationBook.tscn new file mode 100644 index 0000000..465a65b --- /dev/null +++ b/client/scenes/shell/NarrationBook.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"] + +[node name="NarrationBook" type="Control"] +script = ExtResource("1") diff --git a/client/scripts/ui/shell/narration_book.gd b/client/scripts/ui/shell/narration_book.gd new file mode 100644 index 0000000..d1a4626 --- /dev/null +++ b/client/scripts/ui/shell/narration_book.gd @@ -0,0 +1,106 @@ +class_name NarrationBook +extends Control +## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes +## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are +## written back to the canon log (§11). Prose-only this milestone: the numbered +## response choices and the "describe your own action" line are faithful but inert +## (no action system yet); one in-world refire affordance re-runs narration. + +var last_degraded: bool = false + +var _service: DmService +var _log: CanonLog +var _state: ShellState +var _header: Label +var _prose: RichTextLabel +var _status: Label +var _refire: Button +var _degraded_tag: Label +var _indicator: ConsideringIndicator + + +func _ready() -> void: + var col := VBoxContainer.new() + col.set_anchors_preset(Control.PRESET_FULL_RECT) + col.add_theme_constant_override("separation", 18) + add_child(col) + + _header = Label.new() + _header.theme_type_variation = ThemeKeys.ACCENT + col.add_child(_header) + + _prose = RichTextLabel.new() + _prose.bbcode_enabled = true + _prose.fit_content = true + _prose.size_flags_vertical = Control.SIZE_EXPAND_FILL + _prose.custom_minimum_size = Vector2(660, 320) + col.add_child(_prose) + + _degraded_tag = Label.new() + _degraded_tag.theme_type_variation = ThemeKeys.MONO + _degraded_tag.text = "THE MASTER'S VOICE COMES THIN" # shown only when degraded + _degraded_tag.visible = false + col.add_child(_degraded_tag) + + # Faithful-but-inert placeholders (charter: prose-only this milestone). + var prompt := Label.new() + prompt.theme_type_variation = ThemeKeys.ACCENT + prompt.text = "▸ Your turn. What do you do?" + col.add_child(prompt) + for line in [ + "1 · Pay the fence double and keep your hand where he can see it", + "2 · Draw your blade — he's closer than his guards", + "3 · Lie — tell him the rest is coming with your dwarf"]: + var choice := Button.new() + choice.theme_type_variation = ThemeKeys.PARCHMENT_BUTTON + choice.text = line + choice.disabled = true # inert until combat/dialogue give choices meaning + col.add_child(choice) + var free_text := Label.new() + free_text.theme_type_variation = ThemeKeys.MONO + free_text.text = "⌨ …or describe your own action" + col.add_child(free_text) + + # The one live control: refire the narration. + _refire = Button.new() + _refire.theme_type_variation = ThemeKeys.PARCHMENT_BUTTON + _refire.text = "▸ The Master continues…" + _refire.pressed.connect(_on_refire_pressed) + col.add_child(_refire) + + _status = Label.new() + _status.theme_type_variation = ThemeKeys.MONO + col.add_child(_status) + + _indicator = ConsideringIndicator.new() + add_child(_indicator) + + +func setup(service: DmService, log: CanonLog, state: ShellState) -> void: + _service = service + _log = log + _state = state + _header.text = "%s · %s" % [state.round_label, state.location_label] + + +func narrate() -> void: + if _service == null or _log == null: + return + _refire.disabled = true + _indicator.start(_status) + var result: NarrateResult = await _service.narrate(_log) + _indicator.stop() + show_prose(result) + for f in result.facts: + _log.add_fact(f) # §11: code applies the state write, explicitly + _refire.disabled = false + + +func show_prose(result: NarrateResult) -> void: + _prose.text = result.display_text + last_degraded = result.degraded + _degraded_tag.visible = result.degraded + + +func _on_refire_pressed() -> void: + narrate() diff --git a/client/scripts/ui/shell/narration_book.gd.uid b/client/scripts/ui/shell/narration_book.gd.uid new file mode 100644 index 0000000..5d65a8f --- /dev/null +++ b/client/scripts/ui/shell/narration_book.gd.uid @@ -0,0 +1 @@ +uid://blmbc06xpea3x diff --git a/client/tests/unit/test_narration_book.gd b/client/tests/unit/test_narration_book.gd new file mode 100644 index 0000000..5914a1f --- /dev/null +++ b/client/tests/unit/test_narration_book.gd @@ -0,0 +1,37 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/NarrationBook.tscn" +const FakeTransport = preload("res://tests/doubles/fake_transport.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + + +func _book_with(response) -> NarrationBook: + var transport = FakeTransport.new() + transport.set_response(response) + var service := DmService.new(transport, FallbackLibrary.new()) + var book = load(SCENE).instantiate() + add_child_autofree(book) # _ready() builds nodes + the considering indicator + book.setup(service, CanonLog.new(), ShellState.seed()) + return book + + +func test_narrate_shows_prose_and_harvests_facts(): + var book := _book_with(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + var log := CanonLog.new() + var transport = FakeTransport.new() + transport.set_response(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed()) + await book.narrate() + assert_string_contains(book._prose.text, "The rain runs black into the gutter.") + assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose") + assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log") + assert_false(book.last_degraded) + + +func test_degraded_response_shows_fallback_and_sets_flag(): + var book := _book_with(DmResponse.failed("proxy down")) + await book.narrate() + assert_true(book.last_degraded, "a failed call degrades") + assert_ne(book._prose.text, "", "the fallback line is still shown (§13)") diff --git a/client/tests/unit/test_narration_book.gd.uid b/client/tests/unit/test_narration_book.gd.uid new file mode 100644 index 0000000..bf30a1e --- /dev/null +++ b/client/tests/unit/test_narration_book.gd.uid @@ -0,0 +1 @@ +uid://dj6ly2ljf3ai4 From 578b3e86c9b1592df18ec1e110e2bd9a46f8dbfc Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:16:14 -0500 Subject: [PATCH 08/13] feat(shell): MainWindowShell assembles the frame and wires the DM loop Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scenes/shell/MainWindowShell.tscn | 6 + client/scripts/ui/shell/main_window_shell.gd | 212 ++++++++++++++++++ .../scripts/ui/shell/main_window_shell.gd.uid | 1 + client/tests/unit/test_main_window_shell.gd | 37 +++ .../tests/unit/test_main_window_shell.gd.uid | 1 + 5 files changed, 257 insertions(+) create mode 100644 client/scenes/shell/MainWindowShell.tscn create mode 100644 client/scripts/ui/shell/main_window_shell.gd create mode 100644 client/scripts/ui/shell/main_window_shell.gd.uid create mode 100644 client/tests/unit/test_main_window_shell.gd create mode 100644 client/tests/unit/test_main_window_shell.gd.uid diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn new file mode 100644 index 0000000..5ff2349 --- /dev/null +++ b/client/scenes/shell/MainWindowShell.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"] + +[node name="MainWindowShell" type="Control"] +script = ExtResource("1") diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd new file mode 100644 index 0000000..ebacf2c --- /dev/null +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -0,0 +1,212 @@ +class_name MainWindowShell +extends Control +## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel +## tabletop split: a dark isometric world slot (turn rail, minimap, system dock, +## command bar) + the permanent DM narration book. §2: the shell owns HUD state +## (ShellState) and consumes DM text (never owns narrative truth). It reuses the +## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented. + +const GAME_THEME := "res://assets/theme/game_theme.tres" +const DARK_BAY := "res://scenes/theme/surfaces/DarkBay.tscn" +const SYSTEM_DOCK := "res://scenes/shell/SystemDock.tscn" +const NARRATION_BOOK := "res://scenes/shell/NarrationBook.tscn" +const WORLD_WIDTH := 1180.0 +const BOOK_WIDTH := 740.0 + +# HttpTransport carries no class_name (unlike DmService/FallbackLibrary) — every +# other call site (narrate_harness.gd, test_net_primitives.gd) reaches it via +# preload, so this mirrors the existing pattern rather than inventing a new one. +const HttpTransport = preload("res://scripts/net/http_transport.gd") + +var service: DmService = null # injection seam; real HttpTransport built if null + +var book: NarrationBook +var dock: SystemDock +var _state: ShellState +var _log: CanonLog +var _http: HTTPRequest + + +func _ready() -> void: + theme = load(GAME_THEME) + # Root Control is not sized by full-rect anchors under F6 — size explicitly, + # and no anchors_preset on the .tscn root (M3-a collapse bug). + _fit_to_viewport() + get_viewport().size_changed.connect(_fit_to_viewport) + + _state = ShellState.seed() + _log = _build_seed_log() + _build_layout() + + if service == null: + _http = HTTPRequest.new() + add_child(_http) + service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new()) + + book.setup(service, _log, _state) + dock.setup(_state) + dock.screen_requested.connect(_on_screen_requested) + book.narrate() # initial narration; async, fire-and-forget + + +func _fit_to_viewport() -> void: + size = get_viewport_rect().size + + +func _build_layout() -> void: + var split := HBoxContainer.new() + split.set_anchors_preset(Control.PRESET_FULL_RECT) + split.add_theme_constant_override("separation", 0) + add_child(split) + + var world := _build_world() + world.size_flags_horizontal = Control.SIZE_EXPAND_FILL + split.add_child(world) + + book = load(NARRATION_BOOK).instantiate() + book.custom_minimum_size = Vector2(BOOK_WIDTH, 0) + var book_panel := PanelContainer.new() + book_panel.theme_type_variation = ThemeKeys.PARCHMENT_CARD + book_panel.custom_minimum_size = Vector2(BOOK_WIDTH, 0) + book_panel.add_child(book) + split.add_child(book_panel) + + +func _build_world() -> Control: + var world := Control.new() + world.custom_minimum_size = Vector2(WORLD_WIDTH, 0) + world.clip_contents = true + + var bay = load(DARK_BAY).instantiate() + bay.set_anchors_preset(Control.PRESET_FULL_RECT) + world.add_child(bay) + + var caption := Label.new() + caption.theme_type_variation = ThemeKeys.MONO + caption.text = "ISOMETRIC WORLD VIEW" + caption.set_anchors_preset(Control.PRESET_CENTER) + world.add_child(caption) + + var rail := _build_turn_rail() + rail.position = Vector2(28, 28) + world.add_child(rail) + + var minimap := PanelContainer.new() + minimap.theme_type_variation = ThemeKeys.DARK_PANEL + minimap.custom_minimum_size = Vector2(200, 200) + minimap.set_anchors_preset(Control.PRESET_TOP_RIGHT) + minimap.position = Vector2(-228, 28) + var minimap_label := Label.new() + minimap_label.theme_type_variation = ThemeKeys.MONO + minimap_label.text = "MINIMAP" + minimap.add_child(minimap_label) + world.add_child(minimap) + + dock = load(SYSTEM_DOCK).instantiate() + dock.set_anchors_preset(Control.PRESET_CENTER_RIGHT) + dock.position = Vector2(-260, 0) + world.add_child(dock) + + var command_bar := _build_command_bar() + command_bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) + command_bar.position = Vector2(0, -150) + world.add_child(command_bar) + + return world + + +func _build_turn_rail() -> Control: + var panel := PanelContainer.new() + panel.theme_type_variation = ThemeKeys.DARK_PANEL + var col := VBoxContainer.new() + col.add_theme_constant_override("separation", 8) + panel.add_child(col) + var title := Label.new() + title.theme_type_variation = ThemeKeys.MONO + title.text = "TURN ORDER" + col.add_child(title) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 12) + col.add_child(row) + for e in _state.turn_order: + var token := Label.new() + token.theme_type_variation = ThemeKeys.ACCENT + token.text = "✕" if e.is_downed else e.initials + token.add_theme_color_override("font_color", _side_color(e)) + row.add_child(token) + return panel + + +func _side_color(e: TurnEntry) -> Color: + if e.is_downed: + return Palette.BLOOD + if e.is_active: + return Palette.GOLD + match e.side: + &"enemy": return Palette.BLOOD_BRIGHT + &"ally": return Palette.STEEL + _: return Palette.CREAM + + +func _build_command_bar() -> Control: + var bar := HBoxContainer.new() + bar.add_theme_constant_override("separation", 24) + bar.custom_minimum_size = Vector2(0, 150) + + var vitals := VBoxContainer.new() + var hp := Label.new() + hp.theme_type_variation = ThemeKeys.MONO + hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]] + var mp := Label.new() + mp.theme_type_variation = ThemeKeys.MONO + mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]] + var gold := Label.new() + gold.theme_type_variation = ThemeKeys.MONO + gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT) + gold.text = "◈ %d gp" % _state.vitals["gold"] + for l in [hp, mp, gold]: + vitals.add_child(l) + bar.add_child(vitals) + + var slots := HBoxContainer.new() + slots.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slots.add_theme_constant_override("separation", 9) + for c in _state.consumables: + var slot := PanelContainer.new() + slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY + slot.custom_minimum_size = Vector2(62, 62) + var name_label := Label.new() + name_label.theme_type_variation = ThemeKeys.MONO + name_label.text = c["label"] + slot.add_child(name_label) + slots.add_child(slot) + bar.add_child(slots) + + var end_turn := Button.new() + end_turn.theme_type_variation = ThemeKeys.PRIMARY_CTA + end_turn.text = "END TURN" + bar.add_child(end_turn) + + return bar + + +func _on_screen_requested(_id: StringName) -> void: + pass # routing seam — the dock's screens land in later milestones (§17) + + +func _build_seed_log() -> CanonLog: + # Code owns the canon log (§11). Hand-built until character creation (M4) and + # save/load (M9) construct the real one — mirrors narrate_harness. No Luck + # number is rendered anywhere (§7); the descriptor is prose-only. + var log := CanonLog.new() + log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you") + log.set_location("lower_ward", "the Lower Ward") + log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40)) + log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15)) + log.push_event("Followed the beastfolk fence into the Lower Ward after dark") + log.push_event("The sewer bite on Vexcca's left arm has begun to turn") + log.add_fact("the fence deals out of the Lower Ward") + log.add_fact("Vexcca carries an infected bite on the left arm") + log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence")) + log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4) + return log diff --git a/client/scripts/ui/shell/main_window_shell.gd.uid b/client/scripts/ui/shell/main_window_shell.gd.uid new file mode 100644 index 0000000..32a41b2 --- /dev/null +++ b/client/scripts/ui/shell/main_window_shell.gd.uid @@ -0,0 +1 @@ +uid://bobihkpvsax2u diff --git a/client/tests/unit/test_main_window_shell.gd b/client/tests/unit/test_main_window_shell.gd new file mode 100644 index 0000000..44413ab --- /dev/null +++ b/client/tests/unit/test_main_window_shell.gd @@ -0,0 +1,37 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/MainWindowShell.tscn" +const FakeTransport = preload("res://tests/doubles/fake_transport.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + + +func _shell() -> MainWindowShell: + # Inject a fake service BEFORE _ready (add_child) so the initial narrate does + # not touch the network — instantiate() does not run _ready(); add_child does. + var node = load(SCENE).instantiate() + var transport = FakeTransport.new() + transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."})) + node.service = DmService.new(transport, FallbackLibrary.new()) + add_child_autofree(node) + return node + + +func test_shell_applies_theme_and_fits_viewport(): + var packed = load(SCENE) + assert_true(packed is PackedScene) + var node := _shell() + assert_true(node is Control) + assert_true(node.theme is Theme, "shell applies game_theme") + assert_gt(node.size.x, 0.0, "viewport-fit sized the root (M3-a collapse guard)") + + +func test_shell_mounts_both_panels(): + var node := _shell() + assert_not_null(node.book, "narration book mounted") + assert_not_null(node.dock, "system dock mounted") + + +func test_shell_builds_a_code_owned_seed_log(): + var node := _shell() + assert_not_null(node._log, "a canon log exists to post (§11)") + assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned") diff --git a/client/tests/unit/test_main_window_shell.gd.uid b/client/tests/unit/test_main_window_shell.gd.uid new file mode 100644 index 0000000..9547284 --- /dev/null +++ b/client/tests/unit/test_main_window_shell.gd.uid @@ -0,0 +1 @@ +uid://dbd4cq1awo4su From 4698732285916078cd451d39aac41f7fca8eec1a Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:38:22 -0500 Subject: [PATCH 09/13] refactor(shell): author shell scenes as editor-previewable node trees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the three shell scenes (SystemDock, NarrationBook, MainWindowShell) from build-everything-in-_ready() to authored .tscn node trees with the theme + type-variations set in the editor, so the layout previews and can be arranged in the Godot editor. Scripts keep only on-load work: @onready node refs, DM-loop wiring, the dock slide, and binding seed ShellState into the fixed authored turn-order tokens + consumable slots. Fixes the dock offset + over-long buttons (now fixed-width DockButtons in a right-anchored menu). Headless probe: root 1920x1080, world/book split 1180/740, dock at the world's right edge — no collapse. Suite 138/138. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scenes/shell/MainWindowShell.tscn | 280 ++++++++++++++++++- client/scenes/shell/NarrationBook.tscn | 81 +++++- client/scenes/shell/SystemDock.tscn | 82 +++++- client/scripts/ui/shell/main_window_shell.gd | 184 ++++-------- client/scripts/ui/shell/narration_book.gd | 69 +---- client/scripts/ui/shell/system_dock.gd | 47 ++-- 6 files changed, 525 insertions(+), 218 deletions(-) diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn index 5ff2349..389b409 100644 --- a/client/scenes/shell/MainWindowShell.tscn +++ b/client/scenes/shell/MainWindowShell.tscn @@ -1,6 +1,284 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=6 format=3] [ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.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/DarkBay.tscn" id="3"] +[ext_resource type="PackedScene" path="res://scenes/shell/SystemDock.tscn" id="4"] +[ext_resource type="PackedScene" path="res://scenes/shell/NarrationBook.tscn" id="5"] [node name="MainWindowShell" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") script = ExtResource("1") + +[node name="Split" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/separation = 0 + +[node name="World" type="Control" parent="Split"] +layout_mode = 2 +size_flags_horizontal = 3 +clip_contents = true + +[node name="Bay" parent="Split/World" instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Caption" type="Label" parent="Split/World"] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +grow_horizontal = 2 +grow_vertical = 2 +theme_type_variation = &"Mono" +text = "ISOMETRIC WORLD VIEW" + +[node name="TurnRail" type="PanelContainer" parent="Split/World"] +layout_mode = 1 +anchors_preset = 0 +offset_left = 28.0 +offset_top = 28.0 +offset_right = 300.0 +offset_bottom = 128.0 +theme_type_variation = &"DarkPanel" + +[node name="Col" type="VBoxContainer" parent="Split/World/TurnRail"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Title" type="Label" parent="Split/World/TurnRail/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "TURN ORDER" + +[node name="Tokens" type="HBoxContainer" parent="Split/World/TurnRail/Col"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="T0" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "EL" + +[node name="T1" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "DW" + +[node name="T2" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "HU" + +[node name="T3" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "✕" + +[node name="T4" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "—" + +[node name="Minimap" type="PanelContainer" parent="Split/World"] +custom_minimum_size = Vector2(200, 200) +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -228.0 +offset_top = 28.0 +offset_right = -28.0 +offset_bottom = 228.0 +grow_horizontal = 0 +theme_type_variation = &"DarkPanel" + +[node name="Label" type="Label" parent="Split/World/Minimap"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"Mono" +text = "MINIMAP" + +[node name="SystemDock" parent="Split/World" instance=ExtResource("4")] +layout_mode = 1 +anchors_preset = 6 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -268.0 +offset_top = -210.0 +offset_right = -28.0 +offset_bottom = 210.0 +grow_horizontal = 0 +grow_vertical = 2 + +[node name="CommandBar" type="HBoxContainer" parent="Split/World"] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 28.0 +offset_top = -150.0 +offset_right = -28.0 +grow_vertical = 0 +theme_override_constants/separation = 24 +alignment = 1 + +[node name="Vitals" type="VBoxContainer" parent="Split/World/CommandBar"] +custom_minimum_size = Vector2(230, 0) +layout_mode = 2 +size_flags_vertical = 8 +theme_override_constants/separation = 8 + +[node name="HP" type="Label" parent="Split/World/CommandBar/Vitals"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "HP 42 / 60" + +[node name="MP" type="Label" parent="Split/World/CommandBar/Vitals"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "MP 18 / 30" + +[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "◈ 1240 gp" + +[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 8 +theme_override_constants/separation = 9 +alignment = 1 + +[node name="Slot1" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot1"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" +text = "Salve" + +[node name="Slot2" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot2"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" +text = "Draught" + +[node name="Slot3" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot3"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" +text = "Bomb" + +[node name="Slot4" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot4"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" +text = "Antidote" + +[node name="Slot5" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot5"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" +text = "Ration" + +[node name="Slot6" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot6"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" +text = "Torch" + +[node name="Slot7" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTileEmpty" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot7"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" + +[node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(62, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTileEmpty" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot8"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +theme_type_variation = &"Mono" + +[node name="EndTurn" type="Button" parent="Split/World/CommandBar"] +custom_minimum_size = Vector2(150, 60) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"PrimaryCTA" +text = "END TURN" + +[node name="BookPanel" type="PanelContainer" parent="Split"] +custom_minimum_size = Vector2(740, 0) +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Book" parent="Split/BookPanel" instance=ExtResource("5")] +layout_mode = 2 diff --git a/client/scenes/shell/NarrationBook.tscn b/client/scenes/shell/NarrationBook.tscn index 465a65b..d46908d 100644 --- a/client/scenes/shell/NarrationBook.tscn +++ b/client/scenes/shell/NarrationBook.tscn @@ -1,6 +1,85 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=3 format=3] [ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] [node name="NarrationBook" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 740.0 +offset_bottom = 1080.0 +theme = ExtResource("2") script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/margin_left = 40 +theme_override_constants/margin_top = 34 +theme_override_constants/margin_right = 40 +theme_override_constants/margin_bottom = 34 + +[node name="Col" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 18 + +[node name="Header" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "ROUND 4 · THE LOWER WARD" + +[node name="Prose" type="RichTextLabel" parent="Margin/Col"] +custom_minimum_size = Vector2(660, 320) +layout_mode = 2 +size_flags_vertical = 3 +bbcode_enabled = true +text = "The alley stinks of piss and rot. Rain beads on the cobbles and runs black into the gutter." +fit_content = true + +[node name="DegradedTag" type="Label" parent="Margin/Col"] +visible = false +layout_mode = 2 +theme_type_variation = &"Mono" +text = "THE MASTER'S VOICE COMES THIN" + +[node name="Prompt" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "▸ Your turn. What do you do?" + +[node name="Choice1" type="Button" parent="Margin/Col"] +layout_mode = 2 +disabled = true +theme_type_variation = &"ParchmentButton" +text = "1 · Pay the fence double and keep your hand where he can see it" +alignment = 0 + +[node name="Choice2" type="Button" parent="Margin/Col"] +layout_mode = 2 +disabled = true +theme_type_variation = &"ParchmentButton" +text = "2 · Draw your blade — he's closer than his guards" +alignment = 0 + +[node name="Choice3" type="Button" parent="Margin/Col"] +layout_mode = 2 +disabled = true +theme_type_variation = &"ParchmentButton" +text = "3 · Lie — tell him the rest is coming with your dwarf" +alignment = 0 + +[node name="FreeText" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "⌨ …or describe your own action" + +[node name="Refire" type="Button" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "▸ The Master continues…" + +[node name="Status" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" diff --git a/client/scenes/shell/SystemDock.tscn b/client/scenes/shell/SystemDock.tscn index f984bdc..a281f7d 100644 --- a/client/scenes/shell/SystemDock.tscn +++ b/client/scenes/shell/SystemDock.tscn @@ -1,6 +1,86 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=3 format=3] [ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] [node name="SystemDock" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 240.0 +offset_bottom = 420.0 +theme = ExtResource("2") script = ExtResource("1") + +[node name="Menu" type="VBoxContainer" parent="."] +layout_mode = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -190.0 +offset_right = -10.0 +offset_bottom = 316.0 +grow_horizontal = 0 +theme_override_constants/separation = 10 + +[node name="Inventory" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "I Inventory" +alignment = 0 +metadata/screen_id = &"inventory" + +[node name="Character" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "C Character" +alignment = 0 +metadata/screen_id = &"character" + +[node name="QuestLog" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "J Quest Log" +alignment = 0 +metadata/screen_id = &"quest_log" + +[node name="Spellbook" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "K Spellbook" +alignment = 0 +metadata/screen_id = &"spellbook" + +[node name="WorldMap" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "M World Map" +alignment = 0 +metadata/screen_id = &"world_map" + +[node name="Party" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "P Party" +alignment = 0 +metadata/screen_id = &"party" + +[node name="Handle" type="Button" parent="."] +custom_minimum_size = Vector2(26, 104) +layout_mode = 1 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -222.0 +offset_top = -52.0 +offset_right = -196.0 +offset_bottom = 52.0 +grow_horizontal = 0 +grow_vertical = 2 +theme_type_variation = &"TabActive" +text = "›" diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd index ebacf2c..fcdce62 100644 --- a/client/scripts/ui/shell/main_window_shell.gd +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -5,13 +5,13 @@ extends Control ## command bar) + the permanent DM narration book. §2: the shell owns HUD state ## (ShellState) and consumes DM text (never owns narrative truth). It reuses the ## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented. - -const GAME_THEME := "res://assets/theme/game_theme.tres" -const DARK_BAY := "res://scenes/theme/surfaces/DarkBay.tscn" -const SYSTEM_DOCK := "res://scenes/shell/SystemDock.tscn" -const NARRATION_BOOK := "res://scenes/shell/NarrationBook.tscn" -const WORLD_WIDTH := 1180.0 -const BOOK_WIDTH := 740.0 +## +## Layout is authored in MainWindowShell.tscn (and its SystemDock/NarrationBook +## sub-scenes) — open it in the editor to arrange it. This script binds the seed +## ShellState into the authored nodes, wires the DM loop, and keeps the runtime +## viewport-fit. The turn-order tokens and consumable slots are a FIXED set of +## authored nodes bound from state (extra slots hide); combat/inventory become the +## writers later. # HttpTransport carries no class_name (unlike DmService/FallbackLibrary) — every # other call site (narrate_harness.gd, test_net_primitives.gd) reaches it via @@ -20,23 +20,29 @@ const HttpTransport = preload("res://scripts/net/http_transport.gd") var service: DmService = null # injection seam; real HttpTransport built if null -var book: NarrationBook -var dock: SystemDock var _state: ShellState var _log: CanonLog var _http: HTTPRequest +@onready var book: NarrationBook = $Split/BookPanel/Book +@onready var dock: SystemDock = $Split/World/SystemDock +@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens +@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP +@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP +@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold +@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables + func _ready() -> void: - theme = load(GAME_THEME) - # Root Control is not sized by full-rect anchors under F6 — size explicitly, - # and no anchors_preset on the .tscn root (M3-a collapse bug). + # The theme is assigned on the scene root in the editor (so it previews). Keep + # the runtime viewport-fit: a root Control run via F6 is not sized by full-rect + # anchors — the .tscn root uses a fixed size + no full-rect preset (M3-a bug). _fit_to_viewport() get_viewport().size_changed.connect(_fit_to_viewport) _state = ShellState.seed() _log = _build_seed_log() - _build_layout() + _bind_state() if service == null: _http = HTTPRequest.new() @@ -53,88 +59,24 @@ func _fit_to_viewport() -> void: size = get_viewport_rect().size -func _build_layout() -> void: - var split := HBoxContainer.new() - split.set_anchors_preset(Control.PRESET_FULL_RECT) - split.add_theme_constant_override("separation", 0) - add_child(split) - - var world := _build_world() - world.size_flags_horizontal = Control.SIZE_EXPAND_FILL - split.add_child(world) - - book = load(NARRATION_BOOK).instantiate() - book.custom_minimum_size = Vector2(BOOK_WIDTH, 0) - var book_panel := PanelContainer.new() - book_panel.theme_type_variation = ThemeKeys.PARCHMENT_CARD - book_panel.custom_minimum_size = Vector2(BOOK_WIDTH, 0) - book_panel.add_child(book) - split.add_child(book_panel) +func _bind_state() -> void: + _bind_turn_rail() + _bind_vitals() + _bind_consumables() -func _build_world() -> Control: - var world := Control.new() - world.custom_minimum_size = Vector2(WORLD_WIDTH, 0) - world.clip_contents = true - - var bay = load(DARK_BAY).instantiate() - bay.set_anchors_preset(Control.PRESET_FULL_RECT) - world.add_child(bay) - - var caption := Label.new() - caption.theme_type_variation = ThemeKeys.MONO - caption.text = "ISOMETRIC WORLD VIEW" - caption.set_anchors_preset(Control.PRESET_CENTER) - world.add_child(caption) - - var rail := _build_turn_rail() - rail.position = Vector2(28, 28) - world.add_child(rail) - - var minimap := PanelContainer.new() - minimap.theme_type_variation = ThemeKeys.DARK_PANEL - minimap.custom_minimum_size = Vector2(200, 200) - minimap.set_anchors_preset(Control.PRESET_TOP_RIGHT) - minimap.position = Vector2(-228, 28) - var minimap_label := Label.new() - minimap_label.theme_type_variation = ThemeKeys.MONO - minimap_label.text = "MINIMAP" - minimap.add_child(minimap_label) - world.add_child(minimap) - - dock = load(SYSTEM_DOCK).instantiate() - dock.set_anchors_preset(Control.PRESET_CENTER_RIGHT) - dock.position = Vector2(-260, 0) - world.add_child(dock) - - var command_bar := _build_command_bar() - command_bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - command_bar.position = Vector2(0, -150) - world.add_child(command_bar) - - return world - - -func _build_turn_rail() -> Control: - var panel := PanelContainer.new() - panel.theme_type_variation = ThemeKeys.DARK_PANEL - var col := VBoxContainer.new() - col.add_theme_constant_override("separation", 8) - panel.add_child(col) - var title := Label.new() - title.theme_type_variation = ThemeKeys.MONO - title.text = "TURN ORDER" - col.add_child(title) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 12) - col.add_child(row) - for e in _state.turn_order: - var token := Label.new() - token.theme_type_variation = ThemeKeys.ACCENT - token.text = "✕" if e.is_downed else e.initials - token.add_theme_color_override("font_color", _side_color(e)) - row.add_child(token) - return panel +func _bind_turn_rail() -> void: + # Fill the authored token labels from the turn order; hide any spare slots. + var tokens := _tokens.get_children() + for i in range(tokens.size()): + var label: Label = tokens[i] + if i < _state.turn_order.size(): + var e: TurnEntry = _state.turn_order[i] + label.text = "✕" if e.is_downed else e.initials + label.add_theme_color_override("font_color", _side_color(e)) + label.visible = true + else: + label.visible = false func _side_color(e: TurnEntry) -> Color: @@ -148,46 +90,26 @@ func _side_color(e: TurnEntry) -> Color: _: return Palette.CREAM -func _build_command_bar() -> Control: - var bar := HBoxContainer.new() - bar.add_theme_constant_override("separation", 24) - bar.custom_minimum_size = Vector2(0, 150) +func _bind_vitals() -> void: + _hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]] + _mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]] + _gold.text = "◈ %d gp" % _state.vitals["gold"] + _gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT) - var vitals := VBoxContainer.new() - var hp := Label.new() - hp.theme_type_variation = ThemeKeys.MONO - hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]] - var mp := Label.new() - mp.theme_type_variation = ThemeKeys.MONO - mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]] - var gold := Label.new() - gold.theme_type_variation = ThemeKeys.MONO - gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT) - gold.text = "◈ %d gp" % _state.vitals["gold"] - for l in [hp, mp, gold]: - vitals.add_child(l) - bar.add_child(vitals) - var slots := HBoxContainer.new() - slots.size_flags_horizontal = Control.SIZE_EXPAND_FILL - slots.add_theme_constant_override("separation", 9) - for c in _state.consumables: - var slot := PanelContainer.new() - slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY - slot.custom_minimum_size = Vector2(62, 62) - var name_label := Label.new() - name_label.theme_type_variation = ThemeKeys.MONO - name_label.text = c["label"] - slot.add_child(name_label) - slots.add_child(slot) - bar.add_child(slots) - - var end_turn := Button.new() - end_turn.theme_type_variation = ThemeKeys.PRIMARY_CTA - end_turn.text = "END TURN" - bar.add_child(end_turn) - - return bar +func _bind_consumables() -> void: + # The 8 slots are authored; bind labels + fill/empty variation, hide spares. + var slots := _consumables.get_children() + for i in range(slots.size()): + var slot: PanelContainer = slots[i] + var label: Label = slot.get_node("Label") + if i < _state.consumables.size(): + var c: Dictionary = _state.consumables[i] + label.text = c["label"] + slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY + slot.visible = true + else: + slot.visible = false func _on_screen_requested(_id: StringName) -> void: diff --git a/client/scripts/ui/shell/narration_book.gd b/client/scripts/ui/shell/narration_book.gd index d1a4626..0dbec7e 100644 --- a/client/scripts/ui/shell/narration_book.gd +++ b/client/scripts/ui/shell/narration_book.gd @@ -5,75 +5,30 @@ extends Control ## written back to the canon log (§11). Prose-only this milestone: the numbered ## response choices and the "describe your own action" line are faithful but inert ## (no action system yet); one in-world refire affordance re-runs narration. +## +## Layout is authored in NarrationBook.tscn (editor-previewable). This script only +## binds the header, drives the narrate loop, and swaps prose text. var last_degraded: bool = false var _service: DmService var _log: CanonLog var _state: ShellState -var _header: Label -var _prose: RichTextLabel -var _status: Label -var _refire: Button -var _degraded_tag: Label var _indicator: ConsideringIndicator +@onready var _header: Label = $Margin/Col/Header +@onready var _prose: RichTextLabel = $Margin/Col/Prose +@onready var _degraded_tag: Label = $Margin/Col/DegradedTag +@onready var _refire: Button = $Margin/Col/Refire +@onready var _status: Label = $Margin/Col/Status + func _ready() -> void: - var col := VBoxContainer.new() - col.set_anchors_preset(Control.PRESET_FULL_RECT) - col.add_theme_constant_override("separation", 18) - add_child(col) - - _header = Label.new() - _header.theme_type_variation = ThemeKeys.ACCENT - col.add_child(_header) - - _prose = RichTextLabel.new() - _prose.bbcode_enabled = true - _prose.fit_content = true - _prose.size_flags_vertical = Control.SIZE_EXPAND_FILL - _prose.custom_minimum_size = Vector2(660, 320) - col.add_child(_prose) - - _degraded_tag = Label.new() - _degraded_tag.theme_type_variation = ThemeKeys.MONO - _degraded_tag.text = "THE MASTER'S VOICE COMES THIN" # shown only when degraded - _degraded_tag.visible = false - col.add_child(_degraded_tag) - - # Faithful-but-inert placeholders (charter: prose-only this milestone). - var prompt := Label.new() - prompt.theme_type_variation = ThemeKeys.ACCENT - prompt.text = "▸ Your turn. What do you do?" - col.add_child(prompt) - for line in [ - "1 · Pay the fence double and keep your hand where he can see it", - "2 · Draw your blade — he's closer than his guards", - "3 · Lie — tell him the rest is coming with your dwarf"]: - var choice := Button.new() - choice.theme_type_variation = ThemeKeys.PARCHMENT_BUTTON - choice.text = line - choice.disabled = true # inert until combat/dialogue give choices meaning - col.add_child(choice) - var free_text := Label.new() - free_text.theme_type_variation = ThemeKeys.MONO - free_text.text = "⌨ …or describe your own action" - col.add_child(free_text) - - # The one live control: refire the narration. - _refire = Button.new() - _refire.theme_type_variation = ThemeKeys.PARCHMENT_BUTTON - _refire.text = "▸ The Master continues…" - _refire.pressed.connect(_on_refire_pressed) - col.add_child(_refire) - - _status = Label.new() - _status.theme_type_variation = ThemeKeys.MONO - col.add_child(_status) - + # The considering indicator is a non-visual Node (a Timer + label driver), so + # it stays code-created rather than authored in the scene. _indicator = ConsideringIndicator.new() add_child(_indicator) + _refire.pressed.connect(_on_refire_pressed) func setup(service: DmService, log: CanonLog, state: ShellState) -> void: diff --git a/client/scripts/ui/shell/system_dock.gd b/client/scripts/ui/shell/system_dock.gd index 282bd88..b42d05e 100644 --- a/client/scripts/ui/shell/system_dock.gd +++ b/client/scripts/ui/shell/system_dock.gd @@ -4,44 +4,37 @@ extends Control ## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons ## are the routing seam — they emit screen_requested; the screens they open land ## in later milestones, so the signal is currently a no-op for the shell. +## +## Layout is authored in SystemDock.tscn (editor-previewable). This script only +## wires the buttons' signals, drives the slide animation, and toggles state. Each +## dock button carries a `screen_id` StringName in its scene metadata; add/reorder +## buttons in the editor and the wiring follows. signal screen_requested(id: StringName) -const SCREENS := [ - [&"inventory", "Inventory", "I"], - [&"character", "Character", "C"], - [&"quest_log", "Quest Log", "J"], - [&"spellbook", "Spellbook", "K"], - [&"world_map", "World Map", "M"], - [&"party", "Party", "P"], -] const SLIDE_SECONDS := 0.32 const HIDDEN_SHIFT := 230.0 var _screen_buttons: Dictionary = {} var _state: ShellState -var _menu: VBoxContainer -var _handle: Button +var _menu_open_pos: Vector2 + +@onready var _menu: VBoxContainer = $Menu +@onready var _handle: Button = $Handle func _ready() -> void: - _menu = VBoxContainer.new() - _menu.add_theme_constant_override("separation", 10) - add_child(_menu) - for entry in SCREENS: - var id: StringName = entry[0] - var button := Button.new() - button.text = "%s %s" % [entry[2], entry[1]] - button.theme_type_variation = ThemeKeys.DOCK_BUTTON - button.pressed.connect(func(): screen_requested.emit(id)) - _menu.add_child(button) - _screen_buttons[id] = button - - _handle = Button.new() - _handle.text = "›" - _handle.theme_type_variation = ThemeKeys.TAB_ACTIVE + # The buttons live in the scene; harvest each one's authored screen_id so the + # routing seam follows editor edits (add/remove/reorder needs no code change). + for child in _menu.get_children(): + if child is Button and child.has_meta("screen_id"): + var id: StringName = child.get_meta("screen_id") + child.pressed.connect(func(): screen_requested.emit(id)) + _screen_buttons[id] = child _handle.pressed.connect(_on_handle_pressed) - add_child(_handle) + # Capture the authored open position so the slide is relative to wherever the + # menu is placed in the editor — moving it in the editor just works. + _menu_open_pos = _menu.position func setup(state: ShellState) -> void: @@ -57,7 +50,7 @@ func _on_handle_pressed() -> void: func _apply_open(open: bool) -> void: _handle.text = "›" if open else "‹" - var target := Vector2.ZERO if open else Vector2(HIDDEN_SHIFT, 0.0) + var target := _menu_open_pos if open else _menu_open_pos + Vector2(HIDDEN_SHIFT, 0.0) var alpha := 1.0 if open else 0.0 if not is_inside_tree(): _menu.position = target From 6ef71aaa310c0228b35970d3d93dbba19d87699f Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:47:21 -0500 Subject: [PATCH 10/13] fix(shell): readable ink prose + uniform consumable tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narration RichTextLabel had no theme styling, so it rendered near-white and was unreadable on parchment. Add a base RichTextLabel style to the theme (dark INK_BODY serif at 20px, italic face wired) — systemic and previews in the editor; every parchment prose surface now reads as dark ink. Also clip the consumable slot labels so a long name ("Antidote") can't stretch its tile wider than the empty ones — all 8 stay a uniform 62px. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/assets/theme/game_theme.tres | 199 ++++++++++++----------- client/scenes/shell/MainWindowShell.tscn | 16 ++ client/scripts/theme/build_game_theme.gd | 14 ++ 3 files changed, 132 insertions(+), 97 deletions(-) diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index 7c05cc2..3b1a1ad 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -1,12 +1,13 @@ [gd_resource type="Theme" format=3] -[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_cu4xe"] -[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_605w4"] -[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_4lwr2"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"] -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_eckwq"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i0b23"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -22,7 +23,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -38,7 +39,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -54,7 +55,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -70,9 +71,9 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_73lme"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -88,7 +89,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -104,7 +105,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -120,7 +121,7 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -136,7 +137,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -153,7 +154,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -169,46 +170,14 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ns3kg"] - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 -bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.3529412, 0.2627451, 0.14901961, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 8 -corner_radius_bottom_right = 8 -corner_radius_bottom_left = 8 - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 -bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 8 -corner_radius_bottom_right = 8 -corner_radius_bottom_left = 8 +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 content_margin_bottom = 6.0 -bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 @@ -229,13 +198,45 @@ border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 border_color = Color(0.7490196, 0.65882355, 0.47058824, 1) corner_radius_top_left = 6 corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -251,7 +252,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -267,7 +268,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -283,7 +284,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -299,7 +300,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -315,9 +316,9 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_p0qbh"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -333,7 +334,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -349,7 +350,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -365,9 +366,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_najyk"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -383,7 +384,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wmbj4"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -399,7 +400,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -416,68 +417,72 @@ corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 [resource] -default_font = ExtResource("3_4lwr2") +default_font = ExtResource("3_eckwq") default_font_size = 18 Accent/base_type = &"Label" Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1) Accent/font_sizes/font_size = 24 -Accent/fonts/font = ExtResource("1_cu4xe") +Accent/fonts/font = ExtResource("1_605w4") Chip/base_type = &"Button" Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Chip/font_sizes/font_size = 11 -Chip/fonts/font = ExtResource("2_605w4") -Chip/styles/focus = SubResource("StyleBoxEmpty_eckwq") -Chip/styles/hover = SubResource("StyleBoxFlat_i0b23") -Chip/styles/normal = SubResource("StyleBoxFlat_cqbvn") -Chip/styles/pressed = SubResource("StyleBoxFlat_q5gpk") +Chip/fonts/font = ExtResource("2_4lwr2") +Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn") +Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk") +Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc") +Chip/styles/pressed = SubResource("StyleBoxFlat_73lme") DarkPanel/base_type = &"PanelContainer" -DarkPanel/styles/panel = SubResource("StyleBoxFlat_bbpmc") +DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr") DockButton/base_type = &"Button" DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1) -DockButton/styles/focus = SubResource("StyleBoxEmpty_73lme") -DockButton/styles/hover = SubResource("StyleBoxFlat_js3tr") -DockButton/styles/normal = SubResource("StyleBoxFlat_ube3r") -DockButton/styles/pressed = SubResource("StyleBoxFlat_2tooq") +DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r") +DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq") +DockButton/styles/normal = SubResource("StyleBoxFlat_4kten") +DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm") Heading/base_type = &"Label" Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) Heading/font_sizes/font_size = 34 -Heading/fonts/font = ExtResource("3_4lwr2") +Heading/fonts/font = ExtResource("3_eckwq") ItemTile/base_type = &"PanelContainer" -ItemTile/styles/panel = SubResource("StyleBoxFlat_4kten") +ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt") ItemTileEmpty/base_type = &"PanelContainer" -ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_p6wtm") +ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg") Mono/base_type = &"Label" Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Mono/font_sizes/font_size = 13 -Mono/fonts/font = ExtResource("2_605w4") +Mono/fonts/font = ExtResource("2_4lwr2") ParchmentButton/base_type = &"Button" ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1) ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_0fjdt") -ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_ns3kg") -ParchmentButton/styles/hover = SubResource("StyleBoxFlat_jly1r") -ParchmentButton/styles/normal = SubResource("StyleBoxFlat_lw8lk") -ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nh0e7") +ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r") +ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk") +ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7") +ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07") +ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx") ParchmentCard/base_type = &"PanelContainer" -ParchmentCard/styles/panel = SubResource("StyleBoxFlat_dtr07") +ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067") ParchmentInset/base_type = &"PanelContainer" -ParchmentInset/styles/panel = SubResource("StyleBoxFlat_nmowx") +ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj") PrimaryCTA/base_type = &"Button" PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_oi067") -PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_ukblj") -PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_utt73") -PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_5u8fh") +PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73") +PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh") +PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh") +PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak") +RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1) +RichTextLabel/font_sizes/normal_font_size = 20 +RichTextLabel/fonts/italics_font = ExtResource("4_i0b23") +RichTextLabel/fonts/normal_font = ExtResource("3_eckwq") Tab/base_type = &"Button" Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -Tab/styles/focus = SubResource("StyleBoxEmpty_p0qbh") -Tab/styles/hover = SubResource("StyleBoxFlat_6y6ak") -Tab/styles/normal = SubResource("StyleBoxFlat_txxuc") -Tab/styles/pressed = SubResource("StyleBoxFlat_7d6ug") +Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc") +Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug") +Tab/styles/normal = SubResource("StyleBoxFlat_najyk") +Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k") TabActive/base_type = &"Button" TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -TabActive/styles/focus = SubResource("StyleBoxEmpty_najyk") -TabActive/styles/hover = SubResource("StyleBoxFlat_jtb1k") -TabActive/styles/normal = SubResource("StyleBoxFlat_wmbj4") -TabActive/styles/pressed = SubResource("StyleBoxFlat_xdylb") +TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4") +TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb") +TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll") +TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq") diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn index 389b409..d72c553 100644 --- a/client/scenes/shell/MainWindowShell.tscn +++ b/client/scenes/shell/MainWindowShell.tscn @@ -176,6 +176,8 @@ theme_type_variation = &"ItemTile" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" text = "Salve" @@ -189,6 +191,8 @@ theme_type_variation = &"ItemTile" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" text = "Draught" @@ -202,6 +206,8 @@ theme_type_variation = &"ItemTile" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" text = "Bomb" @@ -215,6 +221,8 @@ theme_type_variation = &"ItemTile" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" text = "Antidote" @@ -228,6 +236,8 @@ theme_type_variation = &"ItemTile" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" text = "Ration" @@ -241,6 +251,8 @@ theme_type_variation = &"ItemTile" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" text = "Torch" @@ -254,6 +266,8 @@ theme_type_variation = &"ItemTileEmpty" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" [node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] @@ -266,6 +280,8 @@ theme_type_variation = &"ItemTileEmpty" layout_mode = 2 horizontal_alignment = 1 vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" [node name="EndTurn" type="Button" parent="Split/World/CommandBar"] diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index 0c4d644..ec1c137 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -16,6 +16,7 @@ extends SceneTree const OUT := "res://assets/theme/game_theme.tres" const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" +const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" @@ -33,6 +34,7 @@ func _init() -> void: static func build_theme() -> Theme: var theme := Theme.new() _fonts(theme) + _rich_text(theme) _primary_cta(theme) _tabs(theme) _dock_button(theme) @@ -78,6 +80,18 @@ static func _fonts(theme: Theme) -> void: theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO) +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 + # inherit default_font/default_color, so without this its text renders in the engine + # default (near-white) and is unreadable on parchment. Every parchment prose surface + # (the narration book, later quest briefs/dialogue) reads dark serif ink from here. + theme.set_font(&"normal_font", "RichTextLabel", load(SERIF)) + theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC)) + theme.set_font_size(&"normal_font_size", "RichTextLabel", 20) + theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY) + + static func _primary_cta(theme: Theme) -> void: var k := ThemeKeys.PRIMARY_CTA theme.set_type_variation(k, "Button") From fb541d05cb19efe8e855ff25341c7e6b17d297b5 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:50:25 -0500 Subject: [PATCH 11/13] fix(shell): widen consumable tiles so labels fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 62px tiles clipped longer names (Draught, Antidote). Widen to 93px (~1.5x) — there is room in the command bar — so labels fit while tiles stay uniform. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scenes/shell/MainWindowShell.tscn | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn index d72c553..4977dcb 100644 --- a/client/scenes/shell/MainWindowShell.tscn +++ b/client/scenes/shell/MainWindowShell.tscn @@ -167,7 +167,7 @@ theme_override_constants/separation = 9 alignment = 1 [node name="Slot1" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTile" @@ -182,7 +182,7 @@ theme_type_variation = &"Mono" text = "Salve" [node name="Slot2" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTile" @@ -197,7 +197,7 @@ theme_type_variation = &"Mono" text = "Draught" [node name="Slot3" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTile" @@ -212,7 +212,7 @@ theme_type_variation = &"Mono" text = "Bomb" [node name="Slot4" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTile" @@ -227,7 +227,7 @@ theme_type_variation = &"Mono" text = "Antidote" [node name="Slot5" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTile" @@ -242,7 +242,7 @@ theme_type_variation = &"Mono" text = "Ration" [node name="Slot6" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTile" @@ -257,7 +257,7 @@ theme_type_variation = &"Mono" text = "Torch" [node name="Slot7" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTileEmpty" @@ -271,7 +271,7 @@ theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" [node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] -custom_minimum_size = Vector2(62, 62) +custom_minimum_size = Vector2(93, 62) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"ItemTileEmpty" From 58880554c2faeb0ed0797192daa8d2db69fa90a1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 09:59:23 -0500 Subject: [PATCH 12/13] fix(shell): dock handle to the right edge; command bar fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wider consumable tiles overflowed the command bar, shoving END TURN under the dock column. Trim the vitals min-width (230->120) and END TURN (150->140) and separation so the row fits — END TURN lands cleanly at the bottom-right, below the dock. Move the dock handle to the outer (right) side per mock 2a: the menu now sits left of the handle and slides right off-screen to hide, the handle staying at the world's right edge. Dock sits flush to that edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scenes/shell/MainWindowShell.tscn | 10 +++++----- client/scenes/shell/SystemDock.tscn | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn index 4977dcb..f38f727 100644 --- a/client/scenes/shell/MainWindowShell.tscn +++ b/client/scenes/shell/MainWindowShell.tscn @@ -118,9 +118,9 @@ anchor_left = 1.0 anchor_top = 0.5 anchor_right = 1.0 anchor_bottom = 0.5 -offset_left = -268.0 +offset_left = -228.0 offset_top = -210.0 -offset_right = -28.0 +offset_right = -8.0 offset_bottom = 210.0 grow_horizontal = 0 grow_vertical = 2 @@ -135,11 +135,11 @@ offset_left = 28.0 offset_top = -150.0 offset_right = -28.0 grow_vertical = 0 -theme_override_constants/separation = 24 +theme_override_constants/separation = 20 alignment = 1 [node name="Vitals" type="VBoxContainer" parent="Split/World/CommandBar"] -custom_minimum_size = Vector2(230, 0) +custom_minimum_size = Vector2(120, 0) layout_mode = 2 size_flags_vertical = 8 theme_override_constants/separation = 8 @@ -285,7 +285,7 @@ theme_override_font_sizes/font_size = 11 theme_type_variation = &"Mono" [node name="EndTurn" type="Button" parent="Split/World/CommandBar"] -custom_minimum_size = Vector2(150, 60) +custom_minimum_size = Vector2(140, 60) layout_mode = 2 size_flags_vertical = 4 theme_type_variation = &"PrimaryCTA" diff --git a/client/scenes/shell/SystemDock.tscn b/client/scenes/shell/SystemDock.tscn index a281f7d..7f03f26 100644 --- a/client/scenes/shell/SystemDock.tscn +++ b/client/scenes/shell/SystemDock.tscn @@ -6,7 +6,7 @@ [node name="SystemDock" type="Control"] layout_mode = 3 anchors_preset = 0 -offset_right = 240.0 +offset_right = 220.0 offset_bottom = 420.0 theme = ExtResource("2") script = ExtResource("1") @@ -15,8 +15,8 @@ script = ExtResource("1") layout_mode = 1 anchor_left = 1.0 anchor_right = 1.0 -offset_left = -190.0 -offset_right = -10.0 +offset_left = -216.0 +offset_right = -36.0 offset_bottom = 316.0 grow_horizontal = 0 theme_override_constants/separation = 10 @@ -76,9 +76,9 @@ anchor_left = 1.0 anchor_top = 0.5 anchor_right = 1.0 anchor_bottom = 0.5 -offset_left = -222.0 +offset_left = -26.0 offset_top = -52.0 -offset_right = -196.0 +offset_right = 0.0 offset_bottom = 52.0 grow_horizontal = 0 grow_vertical = 2 From c933d125044746f6f6d4a8d5708ca8b2055d13a0 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 10:04:57 -0500 Subject: [PATCH 13/13] docs: record editor-first UI-scene convention (ADR 0001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the direction change from M3-b: UI screens are authored editor-first as .tscn node trees (previewable/arrangeable in the Godot editor), scripts do on-load work only — never build the tree in _ready(). Add ADR 0001, a "UI scene conventions" how-to in client/docs, a §16 mandate in the charter, and mark M3-a/M3-b done in the roadmap. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 8 ++- client/docs/README.md | 43 +++++++++++++ docs/adr/0001-editor-first-ui-scenes.md | 86 +++++++++++++++++++++++++ docs/adr/README.md | 2 +- docs/roadmap.md | 6 +- 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0001-editor-first-ui-scenes.md diff --git a/CLAUDE.md b/CLAUDE.md index 8f7a753..91c208e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,7 +494,13 @@ quest log, shop, pause/settings, dialogue, combat HUD, main exploration window) plus a `README.md` handoff. **Not production code** — each screen is recreated as a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource (the README's palette, three fonts, and reusable styleboxes keep the look one -system). Each `.dc.html`'s `Component` class is the **behavior spec** — the +system). **Author these scenes editor-first:** the layout lives in the `.tscn` +as real Control nodes with the `Theme` and type-variations set in the editor, so +the scene *previews* and can be arranged in the Godot editor — do **not** +build the node tree in `_ready()`. Scripts hold only on-load work: `@onready` +node refs, data/DM wiring, animations, and binding state into fixed authored +nodes (see ADR [0001](docs/adr/0001-editor-first-ui-scenes.md)). Each +`.dc.html`'s `Component` class is the **behavior spec** — the combat turn manager, dialogue graph, shop economy, and point-buy creation are the highest-value logic to port; the seed-data maps (items, quests, locations, units, abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real diff --git a/client/docs/README.md b/client/docs/README.md index 597233b..feb472f 100644 --- a/client/docs/README.md +++ b/client/docs/README.md @@ -12,6 +12,49 @@ Put here: Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here. +## UI scene conventions (shell + all screens) + +Screens are **authored editor-first** — the layout lives in the `.tscn`, not in +code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why; +this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn` ++ `scripts/ui/shell/*.gd`. + +- **The `.tscn` owns the tree.** Author every node in the editor: containers, + panels, labels, buttons, art-slot placeholders. Assign the shared theme on the + scene root (`theme = game_theme.tres`) and set each node's `theme_type_variation` + (from `ThemeKeys`) in the editor, so the scene previews themed. Instance + sub-screens (`PackedScene` ext_resource) rather than rebuilding them. +- **The script does on-load work only.** `@onready var x := $Path/To/Node` refs, + signal/service wiring, animations, and binding state into the authored nodes + (label text, colours from `Palette`). Do **not** create nodes in `_ready()`. +- **Data-driven repeats = fixed authored slots, bound at runtime.** Author enough + turn-order tokens / consumable slots / etc. in the editor; the script fills the + first N from state and hides the rest. Previewable now; systems write them later. +- **Scene-root sizing.** Give the root a fixed size via offsets (e.g. 1920×1080) + for editor preview, but **no full-rect anchors on the root** — a root Control run + via F6 collapses to (0,0) under full-rect anchors. Keep `size = get_viewport_rect().size` + on `_ready` + a `size_changed` connection (the M3-a collapse lesson). +- **Button styling → Button-base variations only.** Never put a PanelContainer + variation (`DarkPanel`, `ParchmentCard`) on a `Button`; it has no `normal/hover/ + pressed` styleboxes so the button renders unskinned. Use the Button-base + variations (`PrimaryCTA`, `Tab`, `TabActive`, `Chip`, `DockButton`, `ParchmentButton`). +- **`RichTextLabel` prose** reads its dark serif ink from the theme's base + `RichTextLabel` style (it does *not* inherit `default_font`/`default_color`). + Just add the `RichTextLabel` — don't override its colour per-node. +- **Theme is generated.** All colours live in `palette.gd`; `game_theme.tres` is + built by `scripts/theme/build_game_theme.gd` (source of truth). To add/adjust a + variation, edit the builder + `theme_keys.gd`, then regenerate: + `godot --headless -s res://scripts/theme/build_game_theme.gd`. Never hand-edit + the `.tres` or put a hex literal in a script or `.tscn`. +- **Wiring the DM loop / injectable services.** Follow the shell: a `service` + var settable *between* `instantiate()` and `add_child()` lets tests inject a + `FakeTransport`-backed `DmService` so on-load calls stay hermetic; `_ready` + builds the real `HttpTransport`-backed one when none was injected. +- **Visual gate.** Headless GUT proves structure (nodes mount, sizes non-zero, + bindings run); it cannot prove rendering. Every screen needs a **human F6 + eyeball** before it's done. Note shader surfaces (`DarkBay`, vignette) render + their texture only on an F6 run, not in the editor. + ## Narrate live smoke (M2 client HTTP loop) The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate` diff --git a/docs/adr/0001-editor-first-ui-scenes.md b/docs/adr/0001-editor-first-ui-scenes.md new file mode 100644 index 0000000..6755ddc --- /dev/null +++ b/docs/adr/0001-editor-first-ui-scenes.md @@ -0,0 +1,86 @@ +# 0001 — UI screens are editor-authored scenes, not code-built + +- **Status:** accepted +- **Date:** 2026-07-11 (M3-b, Main Window shell) + +## Context + +The M3-b Main Window shell was first built by constructing every Control node in +`_ready()` (a pattern borrowed from `theme_showcase.gd`, where it was fine because +the showcase is a throwaway proof). For a real screen this was wrong: + +- **It does not preview in the Godot editor.** Opening the `.tscn` showed a bare + root Control — every widget only existed at runtime — so the human could not see + or arrange the layout in the editor. Tuning meant editing pixel offsets in code, + running, eyeballing, repeating. +- It buried layout, styling, and content in GDScript, where it is verbose and + hard to reason about, and it drifted from charter §16's own words: *"each screen + is recreated as a Godot 4.7 Control-node scene."* + +The human asked to be able to arrange the scene in the editor "as much as +possible," with code only for genuine on-load work. + +## Decision + +**UI screens (and their sub-scenes) are authored editor-first as `.tscn` node +trees.** The split: + +- **In the `.tscn` (editor):** the whole node tree — containers, panels, labels, + buttons, art-slot placeholders — with the shared `Theme` assigned on the scene + root and every `theme_type_variation` set on its node. Authored placeholder + text/values so the screen previews with representative content. Sub-screens are + **instanced** (`ExtResource` PackedScene), not rebuilt. +- **In the script (on-load only):** `@onready` references to named nodes; wiring + (signals, the DM loop, injected services); animations (e.g. the dock slide); + and **binding state into the authored nodes** (setting label text/colours from + the model). Never construct the tree in `_ready()`. +- **Data-driven repeats** (turn-order tokens, consumable slots, later inventory + grids) are a **fixed set of authored nodes** bound from state at runtime — + spare slots hide. This keeps them previewable; combat/inventory become the + writers later. (Author enough slots for the common case; if a screen ever needs + a genuinely unbounded list, revisit with a code-instanced container then.) + +Supporting conventions established alongside this decision: + +- **Scene root sizing.** Give the `.tscn` root a fixed size (e.g. 1920×1080) via + offsets so it previews, but **no full-rect anchors on the root** — a root + Control run via F6 is not sized by full-rect anchors and collapses to (0,0). + The script keeps `size = get_viewport_rect().size` on `_ready` + + `size_changed` (the M3-a lesson). +- **Button styling uses Button-base theme variations.** Never apply a + PanelContainer-base variation (`DarkPanel`, `ParchmentCard`) to a `Button` — a + Button looks up `normal/hover/pressed`, which a `panel`-only variation lacks, so + it renders unskinned. M3-b added Button-base `DockButton` and `ParchmentButton` + to the theme for exactly this. +- **RichTextLabel prose reads its style from the theme.** `RichTextLabel` does + **not** inherit `default_font`/`default_color`, so without a theme entry its + text renders near-white (unreadable on parchment). The theme now sets a base + `RichTextLabel` style (dark `INK_BODY` serif + italic face). All colours still + come from `Palette` via the theme builder — no hex in scripts or `.tscn`. + +## Consequences + +**Easier:** + +- The human arranges any screen directly in the Godot editor; layout persists in + the `.tscn`. Fine-tuning is drag-and-drop, not code-run-eyeball. +- Screens preview with themed, representative content on open. +- Scripts shrink to logic; less code, easier to review, focused files. +- The look stays one system (theme assigned per scene root). + +**Caveats / harder:** + +- **Shader surfaces render only at runtime.** `DarkBay`/`VignetteOverlay` set + their `ShaderMaterial` in `_ready()` (M3-a accepted deviation), so their + texture appears only on an F6 run — the editor shows layout but a plain panel + behind it. Layout still previews fine. +- Tests reference nodes by their authored `@onready` paths; renaming/moving a + node in the editor means updating the path (and any test that reads it). +- The visual gate stays a **human F6 eyeball** — headless tests prove structure + (nodes mount, sizes are non-zero, bindings run) but not rendering. + +## Reference implementation + +M3-b shell scenes: `client/scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn` +with their `client/scripts/ui/shell/*.gd` scripts. Practical how-to lives in +[`client/docs/README.md`](../../client/docs/README.md) → "UI scene conventions." diff --git a/docs/adr/README.md b/docs/adr/README.md index 2f31b60..b183a2b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -4,7 +4,7 @@ One file per decision: `NNNN-short-title.md`. Record the context, the decision, ## Index -- _none yet_ +- [0001 — UI screens are editor-authored scenes, not code-built](0001-editor-first-ui-scenes.md) (accepted)