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) <noreply@anthropic.com>
8.2 KiB
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
ShellStatemodel, 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 fromThemeKeys, colours fromPalette. SurfacesParchmentPanel/DarkBay/VignetteOverlay(client/scenes/theme/surfaces/). - DM loop:
DmService.narrate(canon_log) -> NarrateResult{display_text, facts, degraded}, viaHttpTransport(HTTPRequest)+FallbackLibrary.DmTransportis 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()):
theme = load(game_theme.tres); fit to viewport; connectsize_changed.- Build seed
ShellStateand seedCanonLog. - Build the world side (from
ShellState) and mountNarrationBook+SystemDock. - Construct
DmService(HttpTransport(HTTPRequest), FallbackLibrary)andConsideringIndicator. - Fire the initial narrate (below).
Narrate / refire:
- Disable the refire affordance.
indicator.start(status_label).var r := await service.narrate(seed_log).indicator.stop().book.show_prose(r)— set theRichTextLabel, surfacer.degradedsubtly.for f in r.facts: seed_log.add_fact(f)(§11 — the caller applies the state write, explicit).- 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_requestedrouting, 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_names need godot --headless --import before GUT sees them.
ShellState— seed shape;toggle_dock()flipsdock_open; turn-order and consumable entries have the expected fields/values.TurnEntry— field round-trip;side/is_active/is_downedflags.SystemDock— emitsscreen_requestedwith the correct id per button; toggling updates state (signal + state assertions, no render needed).NarrationBook— given aNarrateResult, sets the prose text and surfacesdegraded; the refire affordance invokes the injected service (drive withFakeTransport, assert prose updates + facts flow out).MainWindowShell— node-presence smoke (both panels + dock mount); viewport-fit yields non-zerosize.
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.