From 628418a2a4826e07d078336d98459f403bed769b Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 21:52:57 +0200 Subject: [PATCH 1/5] docs(dungeon): in-fight navigation design (menu + codex round-trip) Two-phase spec: (1) back-to-menu exit button using a td-tiles door sprite, leaning on the existing mid-fight save for resume; (2) in-fight Codex via a TomeScene overlay (scene.pause + returnTo param) so combat state survives. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-in-fight-navigation-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-in-fight-navigation-design.md diff --git a/docs/superpowers/specs/2026-05-30-in-fight-navigation-design.md b/docs/superpowers/specs/2026-05-30-in-fight-navigation-design.md new file mode 100644 index 0000000..41dbbf7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-in-fight-navigation-design.md @@ -0,0 +1,118 @@ +# In-Fight Navigation (Menu + Codex) — design + +**Date:** 2026-05-30 +**Status:** draft — awaiting user review +**Context:** Slay the Cert dungeon game (`public/dungeon/`). + +## Problem + +During an active boss fight there is currently **no way to leave to the Hub or open +the Codex** — `BossFightScene` only transitions to the Hub when a fight *ends* +([BossFightScene.ts:797,807,853](../../../public/dungeon/src/scenes/BossFightScene.ts#L797)), +and the Codex (`TomeScene`) is reachable **only** from the Hub, always returning to the +Hub ([TomeScene.ts:60-61](../../../public/dungeon/src/scenes/TomeScene.ts#L60-L61)). A +player mid-fight is trapped until they win or die. This feature adds two in-fight controls, +built in two phases. + +The game already auto-saves mid-fight (`writeSave` on every answer), so leaving a fight is +a **resumable pause**, not a forfeit — the Hub already offers "Continue (… mid-fight)". + +## Phase 1 — Back-to-Menu button (build first) + +### Behavior +A small icon button in the boss fight returns the player to the Hub. Because progress is +already persisted, this is a non-destructive pause: the Hub shows "Continue (Floor N · +Boss, mid-fight)" and `resumeActiveRun` restores the exact fight state. **No confirm modal** +— nothing is lost. (Isolated debug fights and demo runs don't persist a save; for those, +returning to the Hub simply abandons the throwaway fight, which is the desired behavior — +a demo started via `?demo` self-restarts from Tool-Smith.) + +### Icon (sprite from the existing sheet) +Use a **door/exit tile** from the `td-tiles` spritesheet (`assets/tilemap_packed.png`, 16×16 +frames; row 3, frames 36–47, contains "doors + stairs" per +[backdrops.ts:10](../../../public/dungeon/src/scenes/backdrops.ts#L10)). The exact frame is +selected during implementation by rendering the row-3 candidates and picking the clearest +door read (same preview discipline used for `DEMO_SEED`). Rendered at `setScale(2)` (32px, +consistent with backdrop props) in the **top-left** corner (top-right is taken by the audio +toggles, [BossFightScene.ts:28](../../../public/dungeon/src/scenes/BossFightScene.ts#L28)). +A small "Menu" text label sits beside it for clarity. Hover brightens it, matching the +existing button-hover idiom (`attachRectHover` / `attachTextHover`). + +### Mechanism +A new private `BossFightScene.exitToHub()`: +1. `this.writeSave()` — flush current mid-fight state (no-op for isolated/demo, by the + existing gate). +2. `fadeToScene(this, 'HubScene')`. + +Mounted in `create()` behind a small helper (e.g. `mountMenuButton(this)` co-located in a +new `src/ui/inFightNav.ts`, or inline if it stays tiny). It must sit above the backdrop +(high depth) and not overlap the HP readouts or spell row — placement verified manually. + +## Phase 2 — In-fight Codex (tome) round-trip (build second) + +### Behavior +A Codex button in the fight opens the spell reference and returns the player **straight back +into the fight** (not the Hub), with combat state perfectly intact. + +### Icon +Reuse the Hub's Codex glyph 📖 (`HubScene` already labels the Codex with 📖) for visual +consistency, as a text button. (If a clean book/tome tile is found in `td-tiles` during the +Phase-1 preview, it may be used instead — decided then.) + +### Mechanism — overlay, not scene-swap +Opening the Codex from a fight must not destroy combat state, so use Phaser scene layering +rather than a full transition: +- In `BossFightScene`: `this.scene.launch('TomeScene', { returnTo: 'BossFightScene' })` then + `this.scene.pause()`. The fight scene freezes underneath, holding all state. +- `TomeScene` gains an optional `init(data: { returnTo?: string })`. Its back button and ESC + handler branch: + - `returnTo` set → `this.scene.stop()` then `this.scene.resume(returnTo)` (un-pauses and + re-displays the live fight; no save/restore needed). + - `returnTo` absent (the Hub entry path) → current behavior: `fadeToScene(this, + 'HubScene', {})`. +- The Hub's Codex launch (`fadeToScene(this, 'TomeScene', {})`, + [HubScene.ts:206](../../../public/dungeon/src/scenes/HubScene.ts#L206)) stays unchanged + (no `returnTo` → defaults to Hub). + +Caveat to verify: `TomeScene` rendered over a paused `BossFightScene` must fully occlude it +(it already paints a full-screen background) so the frozen fight doesn't bleed through; if +not, add an opaque backing rect in `TomeScene` when `returnTo` is set. + +## Scope / non-goals + +- **In:** a menu/exit icon button (Phase 1) and an in-fight Codex overlay (Phase 2); a + `returnTo` param on `TomeScene`; one new sprite-icon usage from `td-tiles`. +- **Out:** a forfeit/abandon-with-discard action (the existing "New Game" on the Hub already + covers discarding); a pause menu with settings; any change to the auto-save cadence; any + new art assets (icons come from the existing sheet / existing glyphs). + +## Files touched + +- `src/scenes/BossFightScene.ts` — mount menu + Codex buttons; `exitToHub()`; Codex + launch-with-pause. +- `src/scenes/TomeScene.ts` — `init({ returnTo })`; branch back/ESC on `returnTo`. +- `src/ui/inFightNav.ts` (new, optional) — the two button-mounting helpers, kept out of the + already-large `BossFightScene`. +- Possibly `src/scenes/backdrops.ts` doc reference only (frame map) — no code change. + +## Testing + +- **Unit:** `TomeScene` return-target logic is the testable seam — extract a pure + `tomeReturnTarget(data)` (returns `{ kind: 'resume', scene }` vs `{ kind: 'hub' }`) and + unit-test both branches, so the Hub-vs-fight routing can't silently regress. +- **Manual (Phase 1):** start a fight → click Menu → land on Hub → "Continue (mid-fight)" → + resume restores HP/question/pool. Confirm the icon doesn't overlap HP/spells. Confirm a + `?demo` fight's Menu returns cleanly (and re-triggers the demo). +- **Manual (Phase 2):** in a fight → open Codex → spell pages render over the frozen fight → + back/ESC returns to the *same* fight with identical state (HP, current question, spell + charges). Confirm opening the Codex from the Hub still returns to the Hub. + +## Branch / merge plan + +This is a **general game feature, separate from the scripted-demo PR (#30)**. Per the user: +the branch for this flow will **also bring in the two currently-untracked talk docs** — +`public/dungeon/README.md` and `public/talks/2026-06-03-slay-the-cert/prep/HANDOUT.md` — as +part of the same PR. Because it edits `BossFightScene.ts` / `HubScene.ts` / `TomeScene.ts` +that PR #30 also touches, the branch stacks on the current `feat/scripted-demo-flow` HEAD +(or is rebased onto `main` after #30 merges) to avoid conflicts. Exact base decided at +branch-creation time. From 552cfcfefe9a96906d882e1217752280f542c526 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:18:47 +0200 Subject: [PATCH 2/5] =?UTF-8?q?docs(dungeon):=20Phase=201=20plan=20?= =?UTF-8?q?=E2=80=94=20in-fight=20back-to-menu=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 tasks: mountMenuButton helper (TDD w/ scene fakes, door frame 45), BossFightScene exitToHub wiring (writeSave + fadeToScene), CI-parity lint + manual verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-05-30-in-fight-navigation-phase1-menu.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-in-fight-navigation-phase1-menu.md diff --git a/docs/superpowers/plans/2026-05-30-in-fight-navigation-phase1-menu.md b/docs/superpowers/plans/2026-05-30-in-fight-navigation-phase1-menu.md new file mode 100644 index 0000000..4872d1a --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-in-fight-navigation-phase1-menu.md @@ -0,0 +1,253 @@ +# In-Fight Navigation — Phase 1 (Back-to-Menu) 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:** Add a small "Menu" exit control (door icon + label) to the boss fight that saves mid-fight progress and returns to the Hub, where the existing "Continue (mid-fight)" resumes the fight. + +**Architecture:** A tiny presentation helper `mountMenuButton(scene, onExit)` (unit-tested with minimal Phaser fakes, matching the existing `optionFeedback.test.ts` style) renders the control; `BossFightScene.exitToHub()` flushes the resumable save via the existing `writeSave()` and transitions to the Hub. No new save/resume machinery — Phase 1 leans entirely on the mid-fight save that already exists. + +**Tech Stack:** TypeScript, Phaser 3, Vite, Vitest, Biome (lint). All paths/commands are relative to `public/dungeon/` unless noted. + +**Source spec:** `docs/superpowers/specs/2026-05-30-in-fight-navigation-design.md` (Phase 1 only; Phase 2 — the in-fight Codex overlay — is a separate later plan). + +--- + +## Working directory & conventions + +- Run `npx vitest`, `npx tsc`, `npm run dev` from `public/dungeon/`. +- **Lint runs in CI** (`npm run lint` = `biome check` from the **repo root** `/Users/Daniel_Sallai/dev/ai-kb`). Every task that changes `.ts` MUST end by running `npx @biomejs/biome check --write ` so formatting matches CI. (A prior PR went red purely on Biome formatting — do not skip this.) +- The `td-tiles` spritesheet (`assets/tilemap_packed.png`, 16×16 frames) is already loaded by `BootScene` and used by `backdrops.ts`. **Frame 45 is a single brown wooden door** — the menu/exit icon (verified by inspecting the sheet). +- Boss fights already auto-save on every answer; `writeSave()` (with default options) writes a full resumable `inBoss` snapshot, and is a no-op for isolated/demo runs ([BossFightScene.ts:441-468](../../../public/dungeon/src/scenes/BossFightScene.ts#L441-L468)). + +## File Structure + +- **Create** `src/ui/inFightNav.ts` — `mountMenuButton(scene, onExit)`; one clear responsibility (render the exit control + wire its click). Keeps the already-large `BossFightScene` from growing. +- **Create** `src/ui/inFightNav.test.ts` — unit test with minimal scene fake. +- **Modify** `src/scenes/BossFightScene.ts` — add `exitToHub()` private method; call `mountMenuButton` in `create()`. + +--- + +## Task 1: `mountMenuButton` helper + +**Files:** +- Create: `src/ui/inFightNav.ts` +- Test: `src/ui/inFightNav.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/ui/inFightNav.test.ts`: + +```typescript +import type Phaser from 'phaser'; +import { describe, expect, it, vi } from 'vitest'; +import { mountMenuButton } from './inFightNav'; + +// Minimal chainable fakes — mountMenuButton only calls a small surface on the +// scene's add factory and on the returned game objects. We capture the `on` +// handlers so the test can fire a pointerdown and assert onExit ran. Mirrors +// the fake style in optionFeedback.test.ts. +function makeGameObjectFake() { + const handlers: Record void> = {}; + const obj = { + handlers, + setScale: () => obj, + setDepth: () => obj, + setOrigin: () => obj, + setInteractive: () => obj, + setTint: () => obj, + clearTint: () => obj, + setBackgroundColor: () => obj, + setColor: () => obj, + on(event: string, fn: () => void) { + handlers[event] = fn; + return obj; + }, + }; + return obj; +} + +function makeSceneFake() { + const image = makeGameObjectFake(); + const text = makeGameObjectFake(); + return { + image, + text, + add: { + image: () => image, + text: () => text, + }, + }; +} + +describe('mountMenuButton', () => { + it('invokes onExit when the door icon is clicked', () => { + const scene = makeSceneFake(); + const onExit = vi.fn(); + mountMenuButton(scene as unknown as Phaser.Scene, onExit); + scene.image.handlers.pointerdown?.(); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('invokes onExit when the Menu label is clicked', () => { + const scene = makeSceneFake(); + const onExit = vi.fn(); + mountMenuButton(scene as unknown as Phaser.Scene, onExit); + scene.text.handlers.pointerdown?.(); + expect(onExit).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/ui/inFightNav.test.ts` +Expected: FAIL — `mountMenuButton` not exported / module not found. + +- [ ] **Step 3: Implement the helper** + +Create `src/ui/inFightNav.ts`: + +```typescript +import type Phaser from 'phaser'; +import { attachTextHover } from './buttonHover'; + +// Single wooden door in the td-tiles spritesheet — reads as "exit/leave". +const DOOR_FRAME = 45; + +/** + * Mount a small "Menu" exit control (door icon + label) in the top-left of a + * boss fight. Clicking either the icon or the label invokes onExit. Visuals + * only — the caller decides what leaving does (see BossFightScene.exitToHub). + */ +export function mountMenuButton(scene: Phaser.Scene, onExit: () => void): void { + const door = scene.add + .image(28, 28, 'td-tiles', DOOR_FRAME) + .setScale(2) + .setDepth(1000) + .setInteractive({ useHandCursor: true }); + + const label = scene.add + .text(48, 28, 'Menu', { + fontSize: '13px', + color: '#c0c0d0', + fontFamily: 'monospace', + backgroundColor: '#1a1a2a', + padding: { x: 6, y: 3 }, + }) + .setOrigin(0, 0.5) + .setDepth(1000) + .setInteractive({ useHandCursor: true }); + + attachTextHover(label, { bg: '#1a1a2a', color: '#c0c0d0' }, { bg: '#2a2a3a', color: '#ffffff' }); + door.on('pointerover', () => door.setTint(0xffe070)); + door.on('pointerout', () => door.clearTint()); + + door.on('pointerdown', onExit); + label.on('pointerdown', onExit); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/ui/inFightNav.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Lint + commit** + +```bash +npx @biomejs/biome check --write src/ui/inFightNav.ts src/ui/inFightNav.test.ts +git add src/ui/inFightNav.ts src/ui/inFightNav.test.ts +git commit -m "feat(dungeon): mountMenuButton exit control (door icon + label)" +``` + +--- + +## Task 2: Wire the exit control into the boss fight + +**Files:** +- Modify: `src/scenes/BossFightScene.ts` (import; a `mountMenuButton` call in `create()`; new `exitToHub()` method) + +No unit test: this is scene wiring (object creation + a scene transition), which the codebase verifies manually — the testable seam (`mountMenuButton`) is already covered in Task 1. The regression suite + manual check (Task 3) are the gate. + +- [ ] **Step 1: Add the import** + +In `src/scenes/BossFightScene.ts`, add alongside the other `../ui/*` imports (e.g. just after the `import { mountAudioToggles } from '../ui/audioToggles';`-area imports near the top): + +```typescript +import { mountMenuButton } from '../ui/inFightNav'; +``` + +- [ ] **Step 2: Mount the button in `create()`** + +In `create()`, immediately AFTER the `mountAudioToggles(this, { ... });` call (around [BossFightScene.ts:406-411](../../../public/dungeon/src/scenes/BossFightScene.ts#L406-L411)), add: + +```typescript + // Back-to-menu control (top-left). Leaving flushes a resumable mid-fight + // save, so the Hub offers "Continue (… mid-fight)". No-op save for + // isolated/demo runs — those just abandon the throwaway fight. + mountMenuButton(this, () => this.exitToHub()); +``` + +- [ ] **Step 3: Add the `exitToHub()` method** + +Add this private method to the `BossFightScene` class, immediately after the existing `writeSave(...)` method (after its closing brace, ~[BossFightScene.ts:469](../../../public/dungeon/src/scenes/BossFightScene.ts#L469)): + +```typescript + private exitToHub(): void { + // Persist the current fight so the Hub can resume it (no-op for + // isolated/demo). The scene's existing 'shutdown' handler stops the BGM + // on transition, so we don't stop it here. + this.writeSave(); + fadeToScene(this, 'HubScene'); + } +``` + +(`fadeToScene` is already imported in this file — see the existing `import { fadeIn, fadeToScene } from '../ui/transitions';`.) + +- [ ] **Step 4: Verify build + types + full suite** + +Run: +```bash +npx vitest run && npx tsc --noEmit && npm run build 2>&1 | grep -E "built in|error" +``` +Expected: all tests pass, `tsc` clean, build succeeds. + +- [ ] **Step 5: Lint + commit** + +```bash +npx @biomejs/biome check --write src/scenes/BossFightScene.ts +git add src/scenes/BossFightScene.ts +git commit -m "feat(dungeon): in-fight Menu button returns to Hub (resumable)" +``` + +--- + +## Task 3: Final verification (CI parity + manual) + +- [ ] **Step 1: Run the exact CI lint command from the repo root** + +Run (from `/Users/Daniel_Sallai/dev/ai-kb`): `npm run lint` +Expected: `Checked N files … No fixes applied.` and exit 0. If Biome reports any file, run `npm run lint:fix`, re-verify, and amend the relevant commit. + +- [ ] **Step 2: Full suite + typecheck once more** + +Run (from `public/dungeon/`): `npx vitest run && npx tsc --noEmit` +Expected: all green. + +- [ ] **Step 3: Manual verification in the browser** + +Run `npm run dev`, open the printed URL, start a real (non-`?demo`) run, enter a boss fight, and verify ALL: +- A door icon + "Menu" chip appear top-left and don't overlap the boss name, HP readouts, or spell row. +- Hover brightens both the door (tint) and the label. +- Clicking Menu mid-fight (answer 0–1 questions first) returns to the Hub, which shows **"Continue (Floor N · Boss, mid-fight)"**; clicking Continue restores the same boss, question index, and HP. +- In a `?demo` run, the Menu button returns to the Hub cleanly (the demo re-launches from Tool-Smith; no stale "Continue" leaks — demo writes no save). + +- [ ] **Step 4: No commit** (verification only). Record the manual result in the PR description. + +--- + +## Self-review notes (for the implementer) + +- Frame `45` is the door icon; if it doesn't read clearly in-game, the other door tiles are `46`/`47` (double-door halves) — but 45 (single door) was chosen deliberately. Don't change it without a visual check. +- `mountMenuButton` is visuals + click-wiring only; the leave behavior lives in `exitToHub` so the helper stays trivially testable and reusable (Phase 2 can mount a Codex button beside it the same way). +- Do not add a confirm modal — leaving is a resumable pause, not a forfeit (per spec). The Hub's "New Game" already covers discarding a run. From 50fdd5ba66f5f5f61a6c3437218eeaa850168bb0 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:23:22 +0200 Subject: [PATCH 3/5] feat(dungeon): mountMenuButton exit control (door icon + label) Co-Authored-By: Claude Sonnet 4.6 --- public/dungeon/src/ui/inFightNav.test.ts | 58 ++++++++++++++++++++++++ public/dungeon/src/ui/inFightNav.ts | 37 +++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 public/dungeon/src/ui/inFightNav.test.ts create mode 100644 public/dungeon/src/ui/inFightNav.ts diff --git a/public/dungeon/src/ui/inFightNav.test.ts b/public/dungeon/src/ui/inFightNav.test.ts new file mode 100644 index 0000000..1f589ab --- /dev/null +++ b/public/dungeon/src/ui/inFightNav.test.ts @@ -0,0 +1,58 @@ +import type Phaser from 'phaser'; +import { describe, expect, it, vi } from 'vitest'; +import { mountMenuButton } from './inFightNav'; + +// Minimal chainable fakes — mountMenuButton only calls a small surface on the +// scene's add factory and on the returned game objects. We capture the `on` +// handlers so the test can fire a pointerdown and assert onExit ran. Mirrors +// the fake style in optionFeedback.test.ts. +function makeGameObjectFake() { + const handlers: Record void> = {}; + const obj = { + handlers, + setScale: () => obj, + setDepth: () => obj, + setOrigin: () => obj, + setInteractive: () => obj, + setTint: () => obj, + clearTint: () => obj, + setBackgroundColor: () => obj, + setColor: () => obj, + on(event: string, fn: () => void) { + handlers[event] = fn; + return obj; + }, + }; + return obj; +} + +function makeSceneFake() { + const image = makeGameObjectFake(); + const text = makeGameObjectFake(); + return { + image, + text, + add: { + image: () => image, + text: () => text, + }, + }; +} + +describe('mountMenuButton', () => { + it('invokes onExit when the door icon is clicked', () => { + const scene = makeSceneFake(); + const onExit = vi.fn(); + mountMenuButton(scene as unknown as Phaser.Scene, onExit); + scene.image.handlers.pointerdown?.(); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('invokes onExit when the Menu label is clicked', () => { + const scene = makeSceneFake(); + const onExit = vi.fn(); + mountMenuButton(scene as unknown as Phaser.Scene, onExit); + scene.text.handlers.pointerdown?.(); + expect(onExit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/dungeon/src/ui/inFightNav.ts b/public/dungeon/src/ui/inFightNav.ts new file mode 100644 index 0000000..8211c54 --- /dev/null +++ b/public/dungeon/src/ui/inFightNav.ts @@ -0,0 +1,37 @@ +import type Phaser from 'phaser'; +import { attachTextHover } from './buttonHover'; + +// Single wooden door in the td-tiles spritesheet — reads as "exit/leave". +const DOOR_FRAME = 45; + +/** + * Mount a small "Menu" exit control (door icon + label) in the top-left of a + * boss fight. Clicking either the icon or the label invokes onExit. Visuals + * only — the caller decides what leaving does (see BossFightScene.exitToHub). + */ +export function mountMenuButton(scene: Phaser.Scene, onExit: () => void): void { + const door = scene.add + .image(28, 28, 'td-tiles', DOOR_FRAME) + .setScale(2) + .setDepth(1000) + .setInteractive({ useHandCursor: true }); + + const label = scene.add + .text(48, 28, 'Menu', { + fontSize: '13px', + color: '#c0c0d0', + fontFamily: 'monospace', + backgroundColor: '#1a1a2a', + padding: { x: 6, y: 3 }, + }) + .setOrigin(0, 0.5) + .setDepth(1000) + .setInteractive({ useHandCursor: true }); + + attachTextHover(label, { bg: '#1a1a2a', color: '#c0c0d0' }, { bg: '#2a2a3a', color: '#ffffff' }); + door.on('pointerover', () => door.setTint(0xffe070)); + door.on('pointerout', () => door.clearTint()); + + door.on('pointerdown', onExit); + label.on('pointerdown', onExit); +} From d6e98f4f90a61bb35b4d9b49687e58d787ec13c9 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:25:21 +0200 Subject: [PATCH 4/5] feat(dungeon): in-fight Menu button returns to Hub (resumable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire mountMenuButton into BossFightScene.create() and add exitToHub() which calls writeSave() before fadeToScene so the Hub can offer "Continue (… mid-fight)" on re-entry. No-op for isolated/demo runs. Co-Authored-By: Claude Sonnet 4.6 --- public/dungeon/src/scenes/BossFightScene.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/public/dungeon/src/scenes/BossFightScene.ts b/public/dungeon/src/scenes/BossFightScene.ts index 6a8b45f..adc8c46 100644 --- a/public/dungeon/src/scenes/BossFightScene.ts +++ b/public/dungeon/src/scenes/BossFightScene.ts @@ -27,6 +27,7 @@ import type { } from '../types'; import { REGISTRY_BGM_MUTED, mountAudioToggles } from '../ui/audioToggles'; import { attachRectHover, attachTextHover } from '../ui/buttonHover'; +import { mountMenuButton } from '../ui/inFightNav'; import { NarratorDispatcher } from '../ui/narrator/NarratorDispatcher'; import { NarratorOverlay } from '../ui/narrator/NarratorOverlay'; import { LinePool } from '../ui/narrator/linePool'; @@ -410,6 +411,11 @@ export class BossFightScene extends Phaser.Scene { }, }); + // Back-to-menu control (top-left). Leaving flushes a resumable mid-fight + // save, so the Hub offers "Continue (… mid-fight)". No-op save for + // isolated/demo runs — those just abandon the throwaway fight. + mountMenuButton(this, () => this.exitToHub()); + // Install Feel Pack — hit-stop, shake grading, squash-stretch, stagger-back, ambient dust. installFeelPack(this, { heroSprite: this.heroSprite, bossSprite: this.bossSprite }); @@ -468,6 +474,14 @@ export class BossFightScene extends Phaser.Scene { }); } + private exitToHub(): void { + // Persist the current fight so the Hub can resume it (no-op for + // isolated/demo). The scene's existing 'shutdown' handler stops the BGM + // on transition, so we don't stop it here. + this.writeSave(); + fadeToScene(this, 'HubScene'); + } + private refreshSpellUI(): void { const ids: SpellId[] = ['echo', 'study-the-tome', 'memorize', 'amplify', 'doubleshot']; ids.forEach((id, i) => { From 25e5e3a12e1111a889d9f06a7bcb06ab5e597bd9 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:34:11 +0200 Subject: [PATCH 5/5] =?UTF-8?q?feat(dungeon):=20drop=20"Menu"=20label=20?= =?UTF-8?q?=E2=80=94=20door=20icon=20alone=20is=20the=20exit=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- public/dungeon/src/ui/inFightNav.test.ts | 26 ++++++------------------ public/dungeon/src/ui/inFightNav.ts | 22 +++----------------- 2 files changed, 9 insertions(+), 39 deletions(-) diff --git a/public/dungeon/src/ui/inFightNav.test.ts b/public/dungeon/src/ui/inFightNav.test.ts index 1f589ab..2361005 100644 --- a/public/dungeon/src/ui/inFightNav.test.ts +++ b/public/dungeon/src/ui/inFightNav.test.ts @@ -2,22 +2,19 @@ import type Phaser from 'phaser'; import { describe, expect, it, vi } from 'vitest'; import { mountMenuButton } from './inFightNav'; -// Minimal chainable fakes — mountMenuButton only calls a small surface on the -// scene's add factory and on the returned game objects. We capture the `on` -// handlers so the test can fire a pointerdown and assert onExit ran. Mirrors -// the fake style in optionFeedback.test.ts. -function makeGameObjectFake() { +// Minimal chainable fake — mountMenuButton only calls a small surface on the +// scene's add factory and on the returned image. We capture the `on` handlers +// so the test can fire a pointerdown and assert onExit ran. Mirrors the fake +// style in optionFeedback.test.ts. +function makeImageFake() { const handlers: Record void> = {}; const obj = { handlers, setScale: () => obj, setDepth: () => obj, - setOrigin: () => obj, setInteractive: () => obj, setTint: () => obj, clearTint: () => obj, - setBackgroundColor: () => obj, - setColor: () => obj, on(event: string, fn: () => void) { handlers[event] = fn; return obj; @@ -27,14 +24,11 @@ function makeGameObjectFake() { } function makeSceneFake() { - const image = makeGameObjectFake(); - const text = makeGameObjectFake(); + const image = makeImageFake(); return { image, - text, add: { image: () => image, - text: () => text, }, }; } @@ -47,12 +41,4 @@ describe('mountMenuButton', () => { scene.image.handlers.pointerdown?.(); expect(onExit).toHaveBeenCalledTimes(1); }); - - it('invokes onExit when the Menu label is clicked', () => { - const scene = makeSceneFake(); - const onExit = vi.fn(); - mountMenuButton(scene as unknown as Phaser.Scene, onExit); - scene.text.handlers.pointerdown?.(); - expect(onExit).toHaveBeenCalledTimes(1); - }); }); diff --git a/public/dungeon/src/ui/inFightNav.ts b/public/dungeon/src/ui/inFightNav.ts index 8211c54..9971516 100644 --- a/public/dungeon/src/ui/inFightNav.ts +++ b/public/dungeon/src/ui/inFightNav.ts @@ -1,13 +1,12 @@ import type Phaser from 'phaser'; -import { attachTextHover } from './buttonHover'; // Single wooden door in the td-tiles spritesheet — reads as "exit/leave". const DOOR_FRAME = 45; /** - * Mount a small "Menu" exit control (door icon + label) in the top-left of a - * boss fight. Clicking either the icon or the label invokes onExit. Visuals - * only — the caller decides what leaving does (see BossFightScene.exitToHub). + * Mount a small door icon in the top-left of a boss fight that acts as a + * back-to-menu control. Clicking it invokes onExit. Visuals only — the caller + * decides what leaving does (see BossFightScene.exitToHub). */ export function mountMenuButton(scene: Phaser.Scene, onExit: () => void): void { const door = scene.add @@ -16,22 +15,7 @@ export function mountMenuButton(scene: Phaser.Scene, onExit: () => void): void { .setDepth(1000) .setInteractive({ useHandCursor: true }); - const label = scene.add - .text(48, 28, 'Menu', { - fontSize: '13px', - color: '#c0c0d0', - fontFamily: 'monospace', - backgroundColor: '#1a1a2a', - padding: { x: 6, y: 3 }, - }) - .setOrigin(0, 0.5) - .setDepth(1000) - .setInteractive({ useHandCursor: true }); - - attachTextHover(label, { bg: '#1a1a2a', color: '#c0c0d0' }, { bg: '#2a2a3a', color: '#ffffff' }); door.on('pointerover', () => door.setTint(0xffe070)); door.on('pointerout', () => door.clearTint()); - door.on('pointerdown', onExit); - label.on('pointerdown', onExit); }