From 6499cec0c4b63932a87415b7f373a9cdebcd62cd Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:50:21 +0200 Subject: [PATCH 01/10] docs(dungeon): boss-fight HUD bar design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top strip: themed (darkened boss color) bar holding door + "Floor N/5 · domain" left and icon-only SFX/BGM right; boss name moves below. New bossHud composer + pure darken/formatRunLabel seams; domainShort added to boss config. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-05-30-boss-fight-hud-design.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-boss-fight-hud-design.md diff --git a/docs/superpowers/specs/2026-05-30-boss-fight-hud-design.md b/docs/superpowers/specs/2026-05-30-boss-fight-hud-design.md new file mode 100644 index 0000000..7e26b86 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-boss-fight-hud-design.md @@ -0,0 +1,142 @@ +# Boss-Fight HUD Bar — design + +**Date:** 2026-05-30 +**Status:** draft — awaiting user review +**Context:** Slay the Cert dungeon game (`public/dungeon/`). + +## Problem + +The top of the boss fight is a loose scatter: the boss name floats at y=30, the +back-to-menu door sits free at (28,28), and the audio toggles float top-right as +text+emoji buttons. There's no run context (which floor, which domain). This feature +gathers the top into a single **HUD bar** — a themed strip holding the door, run progress, +and audio controls — and moves the boss name just beneath it. + +Canvas is 960×720 (center x=480). + +## Design + +### The bar + +A full-width strip across the top: a rectangle `(480, 22)` sized `960×44`, filled with a +**darkened shade of the active boss's `environmentColor`** (RGB × ~0.5), plus a subtle 2px +bottom border (a slightly lighter shade) at y≈43 so it frames rather than distracts. The +bar renders at depth 900; all its contents at depth 1000. Per-boss this reads as a cohesive +extension of the scene (Tool-Smith forge `0x4e4e1b`, Memory-Kraken depths `0x1b2d4e`, etc.). + +### Left cluster + +- **Door** — the existing `mountMenuButton`, unchanged in behavior, nudged to the bar's + vertical center (~`(28, 22)`). `mountMenuButton` gains an optional `{ x, y }` position + (defaulting to today's `28,28`) so the HUD can place it. +- **Run label** — `Floor 1/5 · MCP` at ~`(64, 22)`, origin `(0, 0.5)`, monospace, parchment + tone (`#f5e4b3`). Floor = `floorsCleared + 1` / `bossOrder.length`. In isolated/debug + fights there is no campaign, so the label gracefully drops the floor part and shows just + the domain short (e.g. `MCP`). + +### Right cluster + +The **same** audio toggles, **icon-only**. `mountAudioToggles` gains an `iconOnly?: boolean` +(and a `y?` anchor) option: when set, the buttons render just the glyph (🎵/🔇 for BGM, +🔊/🔇 for SFX) with no "BGM"/"SFX" text, right-aligned inside the bar (~`bgm x=938`, +`sfx x=904`, vertically centered). All mute-state, registry, `sound.mute` sync, persistence, +and `onBgmToggle` logic is reused unchanged — the Hub keeps its labeled version (no +`iconOnly`). + +### Boss name + +Moves from `(480, 30)` to just below the bar at ~`(480, 66)`, same 24px parchment style, +still centered above the question bubble (bubble body starts ~y=130). + +### Structure + +A new `src/ui/bossHud.ts` exporting `mountBossHud(scene, opts)` composes the whole strip: +the bar rect + border, `mountMenuButton` (door), the run label, and `mountAudioToggles` +(icon-only). `BossFightScene.create()` replaces its separate `mountMenuButton` and +`mountAudioToggles` calls with a single `mountBossHud(...)`, and moves the boss-name text +down. + +``` +mountBossHud(scene, { + boss, // BossDefinition — for environmentColor + domainShort + campaign, // Campaign | undefined (from registry) — for the floor label + onExit, // () => void → BossFightScene.exitToHub + onBgmToggle, // (muted: boolean) => void → start/stop ProceduralBGM +}): void +``` + +### Pure seams (unit-tested) + +Two pure helpers fall out and are unit-tested (no Phaser): + +- `darken(color: number, factor: number): number` — channel-wise multiply with 0–255 clamp. + Lives in `bossHud.ts`. + ```ts + export function darken(color: number, factor: number): number { + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + const r = clamp(((color >> 16) & 0xff) * factor); + const g = clamp(((color >> 8) & 0xff) * factor); + const b = clamp((color & 0xff) * factor); + return (r << 16) | (g << 8) | b; + } + ``` +- `formatRunLabel(campaign, domainShort): string` — the with/without-campaign branch. + ```ts + export function formatRunLabel( + campaign: { floorsCleared: number; bossOrder: string[] } | undefined, + domainShort: string, + ): string { + if (!campaign) return domainShort; + return `Floor ${campaign.floorsCleared + 1}/${campaign.bossOrder.length} · ${domainShort}`; + } + ``` + +### Data: `domainShort` + +Add `domainShort: string` to the `BossDefinition` interface (`src/types.ts`) and to each of +the five bosses in `src/config.ts`: + +| boss | domain | domainShort | +|---|---|---| +| the-orchestrator | domain-1-agentic | `Agentic` | +| the-compiler-king | domain-2-claude-code | `Claude Code` | +| the-grammarian | domain-3-prompt-engineering | `Prompting` | +| the-tool-smith | domain-4-mcp | `MCP` | +| the-memory-kraken | domain-5-context | `Context` | + +## Scope / non-goals + +- **In:** the HUD bar (themed strip), run label (floor + domain short), icon-only audio + reuse, boss-name reposition, `domainShort` data, the `bossHud.ts` composer, and the two + pure helpers. +- **Out:** hero/boss HP readouts (they stay where they are, near the sprites); a settings + menu; any change to audio behavior or persistence; animating the bar; touching the Hub's + audio toggles. + +## Files touched + +- **Create** `src/ui/bossHud.ts` — `mountBossHud`, `darken`, `formatRunLabel`. +- **Create** `src/ui/bossHud.test.ts` — unit tests for `darken` + `formatRunLabel`. +- **Modify** `src/ui/audioToggles.ts` — add `iconOnly?: boolean` + `y?: number` options. +- **Modify** `src/ui/inFightNav.ts` — `mountMenuButton` accepts optional `{ x, y }`. +- **Modify** `src/types.ts` — `domainShort` on `BossDefinition`. +- **Modify** `src/config.ts` — `domainShort` on each boss. +- **Modify** `src/scenes/BossFightScene.ts` — call `mountBossHud(...)` (replacing the + separate door + audio mounts); move the boss-name text below the bar. + +## Testing + +- **Unit:** `darken` (e.g. `darken(0x4e4e1b, 0.5) === 0x27270e`; clamps; `factor 0` → black) + and `formatRunLabel` (with a 5-boss campaign at floor 0 → `"Floor 1/5 · MCP"`; `undefined` + campaign → `"MCP"`). +- **Unit (light):** `mountMenuButton`'s existing click test still passes with the new + optional position arg. +- **Manual:** in a real run, the bar spans the top in a darker boss-themed tone; door + + `Floor N/5 · ` on the left, icon-only SFX/BGM on the right (toggles still mute and + persist); boss name sits just below the bar; no overlap with the question bubble. Check a + couple of bosses for color cohesion, and a `?demo`/debug-isolated fight for the no-floor + fallback. Tune final pixel positions against the running dev server. + +## Branch / merge plan + +Standalone feature on `feat/boss-hud` off `main`. Its own PR. From 8ad93445025fd0bfd347a1680236a30aafcd5df5 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:56:37 +0200 Subject: [PATCH 02/10] docs(dungeon): boss-fight HUD implementation plan 6 tasks: darken+formatRunLabel helpers (TDD), domainShort on bosses, audio iconOnly option, mountMenuButton position, mountBossHud composer, BossFightScene wiring + manual verify. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-boss-fight-hud.md | 495 ++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-boss-fight-hud.md diff --git a/docs/superpowers/plans/2026-05-30-boss-fight-hud.md b/docs/superpowers/plans/2026-05-30-boss-fight-hud.md new file mode 100644 index 0000000..48a37bc --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-boss-fight-hud.md @@ -0,0 +1,495 @@ +# Boss-Fight HUD Bar 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:** Gather the top of the boss fight into a themed HUD bar — back-to-menu door + "Floor N/M · " on the left, icon-only SFX/BGM on the right — and move the boss name just beneath it. + +**Architecture:** A new `src/ui/bossHud.ts` exposes two pure, unit-tested helpers (`darken`, `formatRunLabel`) and a Phaser composer `mountBossHud(scene, opts)` that draws the bar and reuses the existing `mountMenuButton` (door) and `mountAudioToggles` (now with an `iconOnly` option). `BossFightScene.create()` swaps its separate door/audio mounts for one `mountBossHud(...)` call. + +**Tech Stack:** TypeScript, Phaser 3, Vite, Vitest, Biome. Paths/commands relative to `public/dungeon/` unless noted. + +**Source spec:** `docs/superpowers/specs/2026-05-30-boss-fight-hud-design.md`. + +--- + +## Working directory & conventions + +- Run `npx vitest` / `npx tsc` / `npm run dev` from `public/dungeon/`. +- **Lint:** the Biome binary lives in the **repo-root** node_modules. Lint changed files with + `/Users/Daniel_Sallai/dev/ai-kb/node_modules/.bin/biome check --write ` (NOT + `npx @biomejs/biome`, which pulls an incompatible 2.x). The CI command is `npm run lint` + from the repo root `/Users/Daniel_Sallai/dev/ai-kb`. A prior PR went red purely on Biome + formatting — never skip the lint step. +- Canvas is 960×720 (center x=480). The boss has `environmentColor` (hex int) and — after + Task 2 — `domainShort`. + +## File Structure + +- **Create** `src/ui/bossHud.ts` — `darken`, `formatRunLabel` (Task 1), `mountBossHud` (Task 5). +- **Create** `src/ui/bossHud.test.ts` — unit tests for the pure helpers + a `domainShort` guard. +- **Modify** `src/types.ts` — add `domainShort` to `BossDefinition` (Task 2). +- **Modify** `src/config.ts` — add `domainShort` to each boss (Task 2). +- **Modify** `src/ui/audioToggles.ts` — `iconOnly` + `y` options (Task 3). +- **Modify** `src/ui/inFightNav.ts` — `mountMenuButton` optional position (Task 4). +- **Modify** `src/scenes/BossFightScene.ts` — mount the HUD; move the boss name (Task 6). + +--- + +## Task 1: Pure helpers — `darken` + `formatRunLabel` + +**Files:** +- Create: `src/ui/bossHud.ts` +- Test: `src/ui/bossHud.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/ui/bossHud.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { darken, formatRunLabel } from './bossHud'; + +describe('darken', () => { + it('multiplies each channel and rounds', () => { + // 0x4e=78 → 39 (0x27); 0x1b=27 → 14 (0x0e) + expect(darken(0x4e4e1b, 0.5)).toBe(0x27270e); + }); + it('factor 0 yields black', () => { + expect(darken(0x4e4e1b, 0)).toBe(0x000000); + }); + it('clamps channels at 255', () => { + expect(darken(0xffffff, 2)).toBe(0xffffff); + }); +}); + +describe('formatRunLabel', () => { + it('shows floor (1-indexed) / total and domain when a campaign exists', () => { + const campaign = { floorsCleared: 0, bossOrder: ['a', 'b', 'c', 'd', 'e'] }; + expect(formatRunLabel(campaign, 'MCP')).toBe('Floor 1/5 · MCP'); + }); + it('uses the later floor number as the run advances', () => { + const campaign = { floorsCleared: 3, bossOrder: ['a', 'b', 'c', 'd', 'e'] }; + expect(formatRunLabel(campaign, 'Context')).toBe('Floor 4/5 · Context'); + }); + it('falls back to just the domain when there is no campaign (debug/isolated)', () => { + expect(formatRunLabel(undefined, 'MCP')).toBe('MCP'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/ui/bossHud.test.ts` +Expected: FAIL — module/exports missing. + +- [ ] **Step 3: Implement the helpers** + +Create `src/ui/bossHud.ts`: + +```typescript +/** + * Multiply each RGB channel of a hex color by `factor` (clamped to 0–255). + * factor < 1 darkens; used to derive the HUD bar shade from a boss's + * environmentColor. + */ +export function darken(color: number, factor: number): number { + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + const r = clamp(((color >> 16) & 0xff) * factor); + const g = clamp(((color >> 8) & 0xff) * factor); + const b = clamp((color & 0xff) * factor); + return (r << 16) | (g << 8) | b; +} + +/** + * Left-side HUD run label. With a campaign: "Floor / · " + * (n is 1-indexed). Without one (isolated/debug fight): just the domain. + */ +export function formatRunLabel( + campaign: { floorsCleared: number; bossOrder: string[] } | undefined, + domainShort: string, +): string { + if (!campaign) return domainShort; + return `Floor ${campaign.floorsCleared + 1}/${campaign.bossOrder.length} · ${domainShort}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/ui/bossHud.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Lint + commit** + +```bash +/Users/Daniel_Sallai/dev/ai-kb/node_modules/.bin/biome check --write src/ui/bossHud.ts src/ui/bossHud.test.ts +git add src/ui/bossHud.ts src/ui/bossHud.test.ts +git commit -m "feat(dungeon): darken + formatRunLabel helpers for boss HUD" +``` + +--- + +## Task 2: `domainShort` on every boss + +**Files:** +- Modify: `src/types.ts` (the `BossDefinition` interface) +- Modify: `src/config.ts` (each of the 5 `BOSSES`) +- Test: `src/ui/bossHud.test.ts` (append a guard) + +- [ ] **Step 1: Write the failing guard test** + +Append to `src/ui/bossHud.test.ts` (add `BOSSES` to a new import line at the top): + +```typescript +import { BOSSES } from '../config'; + +describe('boss domainShort', () => { + it('every boss declares a non-empty domainShort', () => { + for (const b of BOSSES) { + expect(b.domainShort.length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 2: Run it — expect a TYPE/compile failure** + +Run: `npx tsc --noEmit` +Expected: FAIL — `Property 'domainShort' does not exist on type 'BossDefinition'`. + +- [ ] **Step 3: Add the field to the type** + +In `src/types.ts`, add `domainShort` to `BossDefinition` (right after `domain`): + +```typescript +export interface BossDefinition { + id: string; + name: string; + domain: string; + domainShort: string; + theme: string; + taunts: { correct: string[]; wrong: string[] }; + environmentColor: number; // hex for Phaser fillRect, etc. +} +``` + +- [ ] **Step 4: Add the value to each boss** + +In `src/config.ts`, add a `domainShort` line immediately after the `domain:` line of each boss: + +- `the-orchestrator` (`domain-1-agentic`): ` domainShort: 'Agentic',` +- `the-compiler-king` (`domain-2-claude-code`): ` domainShort: 'Claude Code',` +- `the-grammarian` (`domain-3-prompt-engineering`): ` domainShort: 'Prompting',` +- `the-tool-smith` (`domain-4-mcp`): ` domainShort: 'MCP',` +- `the-memory-kraken` (`domain-5-context`): ` domainShort: 'Context',` + +- [ ] **Step 5: Verify type + test pass** + +Run: `npx tsc --noEmit && npx vitest run src/ui/bossHud.test.ts` +Expected: `tsc` clean; tests PASS (now 7). + +- [ ] **Step 6: Lint + commit** + +```bash +/Users/Daniel_Sallai/dev/ai-kb/node_modules/.bin/biome check --write src/types.ts src/config.ts src/ui/bossHud.test.ts +git add src/types.ts src/config.ts src/ui/bossHud.test.ts +git commit -m "feat(dungeon): add domainShort to every boss" +``` + +--- + +## Task 3: `iconOnly` option on the audio toggles + +**Files:** +- Modify: `src/ui/audioToggles.ts` + +No unit test: `audioToggles` is Phaser + localStorage + registry coupled and currently +untested by design; the change is a rendering option verified manually (Task 6) and exercised +through `mountBossHud`. Keep the change minimal. + +- [ ] **Step 1: Extend `MountOptions`** + +In `src/ui/audioToggles.ts`, change the `MountOptions` interface to: + +```typescript +interface MountOptions { + onBgmToggle?: (muted: boolean) => void; + /** Render glyph-only buttons (no "BGM"/"SFX" text), vertically centered. */ + iconOnly?: boolean; + /** Vertical anchor for the buttons. Defaults to 18 (top-right corner). */ + y?: number; +} +``` + +- [ ] **Step 2: Honor the options in the mount body** + +Inside `mountAudioToggles`, replace the `makeButton` definition, the two `*Label` functions, +and the two `makeButton(...)` calls (the block from `const makeButton = (x: number, ...` down +to `const sfx = makeButton(855, sfxLabel);`) with: + +```typescript + const iconOnly = opts.iconOnly === true; + const y = opts.y ?? 18; + + const makeButton = (x: number, label: () => string): Phaser.GameObjects.Text => { + const t = scene.add.text(x, y, label(), { + fontSize: '14px', + color: '#c0c0d0', + fontFamily: 'monospace', + backgroundColor: '#1a1a2a', + padding: { x: iconOnly ? 5 : 8, y: 4 }, + }); + t.setOrigin(1, iconOnly ? 0.5 : 0); + t.setInteractive({ useHandCursor: true }); + t.setScrollFactor(0); + t.setDepth(1000); + return t; + }; + + // '🔇' = 🔇 (muted), '🎵' = 🎵 (BGM), '🔊' = 🔊 (SFX). + const label = (icon: string, name: string, muted: boolean): string => { + const glyph = muted ? '🔇' : icon; + return iconOnly ? glyph : `${glyph} ${name}`; + }; + const bgmLabel = (): string => label('🎵', 'BGM', bgmMuted()); + const sfxLabel = (): string => label('🔊', 'SFX', sfxMuted()); + + const bgm = makeButton(iconOnly ? 938 : 935, bgmLabel); + const sfx = makeButton(iconOnly ? 904 : 855, sfxLabel); +``` + +(The rest of the function — the `bgm.on(...)`/`sfx.on(...)` handlers and the `return { bgm, sfx }` — is unchanged. The handlers already call `setText(bgmLabel())` / `setText(sfxLabel())`, so they pick up the icon-only labels automatically.) + +- [ ] **Step 3: Verify nothing broke** + +Run: `npx vitest run && npx tsc --noEmit` +Expected: all green (Hub still mounts the labeled version since it passes no `iconOnly`). + +- [ ] **Step 4: Lint + commit** + +```bash +/Users/Daniel_Sallai/dev/ai-kb/node_modules/.bin/biome check --write src/ui/audioToggles.ts +git add src/ui/audioToggles.ts +git commit -m "feat(dungeon): icon-only option for audio toggles" +``` + +--- + +## Task 4: Optional position for `mountMenuButton` + +**Files:** +- Modify: `src/ui/inFightNav.ts` + +- [ ] **Step 1: Add the optional position parameter** + +In `src/ui/inFightNav.ts`, change the `mountMenuButton` signature and the `scene.add.image` +call so the door position is configurable (defaulting to today's `28,28`): + +```typescript +export function mountMenuButton( + scene: Phaser.Scene, + onExit: () => void, + position: { x: number; y: number } = { x: 28, y: 28 }, +): void { + const door = scene.add + .image(position.x, position.y, 'td-tiles', DOOR_FRAME) + .setScale(2) + .setDepth(1000) + .setInteractive({ useHandCursor: true }); + + door.on('pointerover', () => door.setTint(0xffe070)); + door.on('pointerout', () => door.clearTint()); + door.on('pointerdown', onExit); +} +``` + +- [ ] **Step 2: Verify the existing test still passes** + +Run: `npx vitest run src/ui/inFightNav.test.ts` +Expected: PASS — the existing test calls `mountMenuButton(scene, onExit)` (no position), so the default is used; the scene fake ignores coordinates. + +- [ ] **Step 3: Lint + commit** + +```bash +/Users/Daniel_Sallai/dev/ai-kb/node_modules/.bin/biome check --write src/ui/inFightNav.ts +git add src/ui/inFightNav.ts +git commit -m "feat(dungeon): mountMenuButton accepts an optional position" +``` + +--- + +## Task 5: `mountBossHud` composer + +**Files:** +- Modify: `src/ui/bossHud.ts` (append the composer + its imports) + +No new unit test: this is Phaser scene wiring (draws rects, delegates to `mountMenuButton` / +`mountAudioToggles`). Its pure inputs (`darken`, `formatRunLabel`) are covered in Task 1; the +composed result is verified manually in Task 6. + +- [ ] **Step 1: Add imports at the top of `src/ui/bossHud.ts`** + +Add above the existing `darken` export: + +```typescript +import type Phaser from 'phaser'; +import type { BossDefinition } from '../types'; +import { mountAudioToggles } from './audioToggles'; +import { mountMenuButton } from './inFightNav'; + +const BAR_HEIGHT = 44; +const BAR_DARKEN = 0.5; // bar fill = boss color × this; bottom border is lighter +``` + +- [ ] **Step 2: Append the composer at the end of `src/ui/bossHud.ts`** + +```typescript +export interface BossHudOptions { + boss: BossDefinition; + campaign: { floorsCleared: number; bossOrder: string[] } | undefined; + onExit: () => void; + onBgmToggle: (muted: boolean) => void; +} + +/** + * Mount the boss-fight HUD: a themed top bar (darkened boss color) holding the + * back-to-menu door + "Floor N/M · " on the left and icon-only + * SFX/BGM on the right. Reuses mountMenuButton + mountAudioToggles so their + * behavior/persistence is unchanged. + */ +export function mountBossHud(scene: Phaser.Scene, opts: BossHudOptions): void { + const { boss, campaign, onExit, onBgmToggle } = opts; + const midY = BAR_HEIGHT / 2; + + // Themed bar fill + a slightly lighter bottom border so it frames cleanly. + scene.add + .rectangle(480, midY, 960, BAR_HEIGHT, darken(boss.environmentColor, BAR_DARKEN)) + .setDepth(900); + scene.add + .rectangle(480, BAR_HEIGHT - 1, 960, 2, darken(boss.environmentColor, 0.9)) + .setDepth(901); + + // Left: door + run label. + mountMenuButton(scene, onExit, { x: 28, y: midY }); + scene.add + .text(64, midY, formatRunLabel(campaign, boss.domainShort), { + fontSize: '14px', + color: '#f5e4b3', + fontFamily: 'monospace', + }) + .setOrigin(0, 0.5) + .setDepth(1000); + + // Right: icon-only SFX/BGM, vertically centered in the bar. + mountAudioToggles(scene, { iconOnly: true, y: midY, onBgmToggle }); +} +``` + +- [ ] **Step 3: Verify build + types + tests** + +Run: `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 4: Lint + commit** + +```bash +/Users/Daniel_Sallai/dev/ai-kb/node_modules/.bin/biome check --write src/ui/bossHud.ts +git add src/ui/bossHud.ts +git commit -m "feat(dungeon): mountBossHud composer (bar + door + run label + audio)" +``` + +--- + +## Task 6: Wire the HUD into the boss fight + verify + +**Files:** +- Modify: `src/scenes/BossFightScene.ts` + +- [ ] **Step 1: Fix the imports** + +In `src/scenes/BossFightScene.ts`: +- Change line 28 from `import { REGISTRY_BGM_MUTED, mountAudioToggles } from '../ui/audioToggles';` to `import { REGISTRY_BGM_MUTED } from '../ui/audioToggles';` (keep `REGISTRY_BGM_MUTED` — it's still used at the BGM-start guard ~line 398; drop `mountAudioToggles`). +- Delete the line `import { mountMenuButton } from '../ui/inFightNav';`. +- Add `import { mountBossHud } from '../ui/bossHud';` among the `../ui/*` imports. + +- [ ] **Step 2: Move the boss name below the bar** + +Find the "Boss name at top center" block and change the y from `30` to `66`: + +```typescript + // Boss name — sits just below the HUD bar (bar occupies y 0–44). + this.add + .text(480, 66, this.boss.name, { + fontSize: '24px', + color: '#f5e4b3', + fontFamily: 'monospace', + }) + .setOrigin(0.5); +``` + +- [ ] **Step 3: Replace the two mounts with `mountBossHud`** + +Replace the whole block from the `// Mute toggles (top-right).` comment through the +`mountMenuButton(this, () => this.exitToHub());` line (the `mountAudioToggles(this, {...});` +call and the `// Back-to-menu control …` comment + `mountMenuButton(...)` call) with: + +```typescript + // Top HUD bar: themed strip with the back-to-menu door + "Floor N/M · + // " on the left and icon-only SFX/BGM on the right. onBgmToggle + // starts/stops our procedural BGM; SFX mute flows through Phaser's sound + // manager automatically. + mountBossHud(this, { + boss: this.boss, + campaign: this.registry.get('campaign') as Campaign | undefined, + onExit: () => this.exitToHub(), + onBgmToggle: (muted) => { + if (muted) this.bgm.stop(); + else this.bgm.start(this.boss.id, this.sound as unknown as { context?: AudioContext }); + }, + }); +``` + +(`Campaign` is already imported in this file: `import type { Campaign } from '../game/dungeon';`.) + +- [ ] **Step 4: Verify build + types + full suite** + +Run: `npx vitest run && npx tsc --noEmit && npm run build 2>&1 | grep -E "built in|error"` +Expected: all green. `tsc` must show no unused-import error for `mountAudioToggles`/`mountMenuButton` (both removed from this file). + +- [ ] **Step 5: CI-parity lint (repo root)** + +Run (from `/Users/Daniel_Sallai/dev/ai-kb`): `npm run lint` +Expected: `Checked N files … No fixes applied.`, exit 0. If anything flags, run `npm run lint:fix`, re-verify, and amend. + +- [ ] **Step 6: Commit** + +```bash +git add src/scenes/BossFightScene.ts +git commit -m "feat(dungeon): mount boss HUD bar; move boss name below it" +``` + +- [ ] **Step 7: Manual verification in the browser** + +Run `npm run dev`, open the URL, start a real run, enter a boss fight, and confirm: +- A bar spans the top in a darker, boss-themed tone with a subtle bottom border. +- Left: the door, then `Floor 1/5 · ` (correct floor number and domain short). +- Right: SFX + BGM as **icons only**; clicking each still mutes and the state persists across a reload. +- The boss name sits just below the bar, not overlapping it or the question bubble. +- Advance a floor (beat a boss) → the floor number increments on the next fight. +- A `?demo` run shows the bar/label correctly; a debug-isolated boss preview shows just the domain (no "Floor") and doesn't crash. +- Check ≥2 different bosses for color cohesion (e.g. Tool-Smith vs Memory-Kraken). If the bar reads too dark/muddy, bump `BAR_DARKEN` in `bossHud.ts` (one number) and re-check. + +- [ ] **Step 8: No commit** (verification only). Record the manual result in the PR description. + +--- + +## Self-review notes (for the implementer) + +- Tasks 1–4 are prerequisites for Task 5 (`mountBossHud` uses `darken`, `formatRunLabel`, + `boss.domainShort`, the audio `iconOnly` option, and the door position arg). Do them in order. +- Depths: bar `900`, border `901`, all interactive contents `1000` — keep the door/label/audio + above the bar. +- Don't reintroduce a `mountAudioToggles` or `mountMenuButton` call directly in + `BossFightScene` — they're composed inside `mountBossHud` now. But keep the + `REGISTRY_BGM_MUTED` import (BGM-start guard still uses it). +- Pixel positions (x=64 label, x=904/938 audio, y=66 boss name) are starting values; nudge + them during Step 7 if spacing looks off — they don't affect tests. From aa530a16f1b54d33aab2d88b2089d26e8f391cbf Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:58:49 +0200 Subject: [PATCH 03/10] feat(dungeon): darken + formatRunLabel helpers for boss HUD Co-Authored-By: Claude Sonnet 4.6 --- public/dungeon/src/ui/bossHud.test.ts | 29 +++++++++++++++++++++++++++ public/dungeon/src/ui/bossHud.ts | 24 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 public/dungeon/src/ui/bossHud.test.ts create mode 100644 public/dungeon/src/ui/bossHud.ts diff --git a/public/dungeon/src/ui/bossHud.test.ts b/public/dungeon/src/ui/bossHud.test.ts new file mode 100644 index 0000000..079c3e3 --- /dev/null +++ b/public/dungeon/src/ui/bossHud.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { darken, formatRunLabel } from './bossHud'; + +describe('darken', () => { + it('multiplies each channel and rounds', () => { + // 0x4e=78 → 39 (0x27); 0x1b=27 → 14 (0x0e) + expect(darken(0x4e4e1b, 0.5)).toBe(0x27270e); + }); + it('factor 0 yields black', () => { + expect(darken(0x4e4e1b, 0)).toBe(0x000000); + }); + it('clamps channels at 255', () => { + expect(darken(0xffffff, 2)).toBe(0xffffff); + }); +}); + +describe('formatRunLabel', () => { + it('shows floor (1-indexed) / total and domain when a campaign exists', () => { + const campaign = { floorsCleared: 0, bossOrder: ['a', 'b', 'c', 'd', 'e'] }; + expect(formatRunLabel(campaign, 'MCP')).toBe('Floor 1/5 · MCP'); + }); + it('uses the later floor number as the run advances', () => { + const campaign = { floorsCleared: 3, bossOrder: ['a', 'b', 'c', 'd', 'e'] }; + expect(formatRunLabel(campaign, 'Context')).toBe('Floor 4/5 · Context'); + }); + it('falls back to just the domain when there is no campaign (debug/isolated)', () => { + expect(formatRunLabel(undefined, 'MCP')).toBe('MCP'); + }); +}); diff --git a/public/dungeon/src/ui/bossHud.ts b/public/dungeon/src/ui/bossHud.ts new file mode 100644 index 0000000..6e1de8f --- /dev/null +++ b/public/dungeon/src/ui/bossHud.ts @@ -0,0 +1,24 @@ +/** + * Multiply each RGB channel of a hex color by `factor` (clamped to 0–255). + * factor < 1 darkens; used to derive the HUD bar shade from a boss's + * environmentColor. + */ +export function darken(color: number, factor: number): number { + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + const r = clamp(((color >> 16) & 0xff) * factor); + const g = clamp(((color >> 8) & 0xff) * factor); + const b = clamp((color & 0xff) * factor); + return (r << 16) | (g << 8) | b; +} + +/** + * Left-side HUD run label. With a campaign: "Floor / · " + * (n is 1-indexed). Without one (isolated/debug fight): just the domain. + */ +export function formatRunLabel( + campaign: { floorsCleared: number; bossOrder: string[] } | undefined, + domainShort: string, +): string { + if (!campaign) return domainShort; + return `Floor ${campaign.floorsCleared + 1}/${campaign.bossOrder.length} · ${domainShort}`; +} From b94959aa5a02170ed4afb4fd34b4b51e57d3f95a Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 22:59:52 +0200 Subject: [PATCH 04/10] feat(dungeon): add domainShort to every boss Co-Authored-By: Claude Sonnet 4.6 --- public/dungeon/src/config.ts | 5 +++++ public/dungeon/src/types.ts | 1 + public/dungeon/src/ui/bossHud.test.ts | 9 +++++++++ 3 files changed, 15 insertions(+) diff --git a/public/dungeon/src/config.ts b/public/dungeon/src/config.ts index 1634498..3c93341 100644 --- a/public/dungeon/src/config.ts +++ b/public/dungeon/src/config.ts @@ -36,6 +36,7 @@ export const BOSSES: BossDefinition[] = [ id: 'the-orchestrator', name: 'The Orchestrator', domain: 'domain-1-agentic', + domainShort: 'Agentic', theme: 'Throne hall with chess-piece attendants', taunts: { correct: [ @@ -51,6 +52,7 @@ export const BOSSES: BossDefinition[] = [ id: 'the-compiler-king', name: 'The Compiler-King', domain: 'domain-2-claude-code', + domainShort: 'Claude Code', theme: 'Iron workshop; command sigils', taunts: { correct: ['Your config compiles.', 'The build succeeds.', 'Compilation: 0 errors.'], @@ -62,6 +64,7 @@ export const BOSSES: BossDefinition[] = [ id: 'the-grammarian', name: 'The Grammarian', domain: 'domain-3-prompt-engineering', + domainShort: 'Prompting', theme: 'Library of carved stone scrolls', taunts: { correct: ['Precise.', 'Your tags are sacred.', 'Structure holds.'], @@ -73,6 +76,7 @@ export const BOSSES: BossDefinition[] = [ id: 'the-tool-smith', name: 'The Tool-Smith', domain: 'domain-4-mcp', + domainShort: 'MCP', theme: 'Forge surrounded by schemas-as-runes', taunts: { correct: ['The schema holds.', 'Well-forged.', 'Your tools cut true.'], @@ -84,6 +88,7 @@ export const BOSSES: BossDefinition[] = [ id: 'the-memory-kraken', name: 'The Memory-Kraken', domain: 'domain-5-context', + domainShort: 'Context', theme: 'Flooded archive; sinking context-shelves', taunts: { correct: ['You remember.', 'The tide holds.', 'Context preserved.'], diff --git a/public/dungeon/src/types.ts b/public/dungeon/src/types.ts index f8dfd36..164eb53 100644 --- a/public/dungeon/src/types.ts +++ b/public/dungeon/src/types.ts @@ -41,6 +41,7 @@ export interface BossDefinition { id: string; name: string; domain: string; + domainShort: string; theme: string; taunts: { correct: string[]; wrong: string[] }; environmentColor: number; // hex for Phaser fillRect, etc. diff --git a/public/dungeon/src/ui/bossHud.test.ts b/public/dungeon/src/ui/bossHud.test.ts index 079c3e3..0240f9c 100644 --- a/public/dungeon/src/ui/bossHud.test.ts +++ b/public/dungeon/src/ui/bossHud.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { BOSSES } from '../config'; import { darken, formatRunLabel } from './bossHud'; describe('darken', () => { @@ -27,3 +28,11 @@ describe('formatRunLabel', () => { expect(formatRunLabel(undefined, 'MCP')).toBe('MCP'); }); }); + +describe('boss domainShort', () => { + it('every boss declares a non-empty domainShort', () => { + for (const b of BOSSES) { + expect(b.domainShort.length).toBeGreaterThan(0); + } + }); +}); From b054f9ed33ffc4f7cb306e21f8efc6105df9ad90 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 23:01:37 +0200 Subject: [PATCH 05/10] feat(dungeon): icon-only option for audio toggles --- public/dungeon/src/ui/audioToggles.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/public/dungeon/src/ui/audioToggles.ts b/public/dungeon/src/ui/audioToggles.ts index 6eb46f1..b16e06a 100644 --- a/public/dungeon/src/ui/audioToggles.ts +++ b/public/dungeon/src/ui/audioToggles.ts @@ -43,6 +43,10 @@ function savePreference(key: string, muted: boolean): void { interface MountOptions { onBgmToggle?: (muted: boolean) => void; + /** Render glyph-only buttons (no "BGM"/"SFX" text), vertically centered. */ + iconOnly?: boolean; + /** Vertical anchor for the buttons. Defaults to 18 (top-right corner). */ + y?: number; } /** @@ -73,26 +77,34 @@ export function mountAudioToggles( // subsequent play() calls respect it. scene.sound.mute = sfxMuted(); + const iconOnly = opts.iconOnly === true; + const y = opts.y ?? 18; + const makeButton = (x: number, label: () => string): Phaser.GameObjects.Text => { - const t = scene.add.text(x, 18, label(), { + const t = scene.add.text(x, y, label(), { fontSize: '14px', color: '#c0c0d0', fontFamily: 'monospace', backgroundColor: '#1a1a2a', - padding: { x: 8, y: 4 }, + padding: { x: iconOnly ? 5 : 8, y: 4 }, }); - t.setOrigin(1, 0); + t.setOrigin(1, iconOnly ? 0.5 : 0); t.setInteractive({ useHandCursor: true }); t.setScrollFactor(0); t.setDepth(1000); return t; }; - const bgmLabel = (): string => (bgmMuted() ? '\uD83D\uDD07 BGM' : '\uD83C\uDFB5 BGM'); - const sfxLabel = (): string => (sfxMuted() ? '\uD83D\uDD07 SFX' : '\uD83D\uDD0A SFX'); + // '\uD83D\uDD07' = \uD83D\uDD07 muted; '\uD83C\uDFB5' = \uD83C\uDFB5 BGM; '\uD83D\uDD0A' = \uD83D\uDD0A SFX. + const label = (icon: string, name: string, muted: boolean): string => { + const glyph = muted ? '\uD83D\uDD07' : icon; + return iconOnly ? glyph : `${glyph} ${name}`; + }; + const bgmLabel = (): string => label('\uD83C\uDFB5', 'BGM', bgmMuted()); + const sfxLabel = (): string => label('\uD83D\uDD0A', 'SFX', sfxMuted()); - const bgm = makeButton(935, bgmLabel); - const sfx = makeButton(855, sfxLabel); + const bgm = makeButton(iconOnly ? 938 : 935, bgmLabel); + const sfx = makeButton(iconOnly ? 904 : 855, sfxLabel); bgm.on('pointerdown', () => { const next = !bgmMuted(); From 18899380c1fda690f75352c8fb633ccab6d0335f Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 23:01:57 +0200 Subject: [PATCH 06/10] feat(dungeon): mountMenuButton accepts an optional position --- public/dungeon/src/ui/inFightNav.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/public/dungeon/src/ui/inFightNav.ts b/public/dungeon/src/ui/inFightNav.ts index 9971516..53b5a3f 100644 --- a/public/dungeon/src/ui/inFightNav.ts +++ b/public/dungeon/src/ui/inFightNav.ts @@ -8,9 +8,13 @@ const DOOR_FRAME = 45; * 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 { +export function mountMenuButton( + scene: Phaser.Scene, + onExit: () => void, + position: { x: number; y: number } = { x: 28, y: 28 }, +): void { const door = scene.add - .image(28, 28, 'td-tiles', DOOR_FRAME) + .image(position.x, position.y, 'td-tiles', DOOR_FRAME) .setScale(2) .setDepth(1000) .setInteractive({ useHandCursor: true }); From 912fccec111e5cecbad727416fc841cb9143b866 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 23:03:44 +0200 Subject: [PATCH 07/10] feat(dungeon): mountBossHud composer (bar + door + run label + audio) --- public/dungeon/src/ui/bossHud.ts | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/public/dungeon/src/ui/bossHud.ts b/public/dungeon/src/ui/bossHud.ts index 6e1de8f..517c2a4 100644 --- a/public/dungeon/src/ui/bossHud.ts +++ b/public/dungeon/src/ui/bossHud.ts @@ -1,3 +1,11 @@ +import type Phaser from 'phaser'; +import type { BossDefinition } from '../types'; +import { mountAudioToggles } from './audioToggles'; +import { mountMenuButton } from './inFightNav'; + +const BAR_HEIGHT = 44; +const BAR_DARKEN = 0.5; // bar fill = boss color × this; bottom border is lighter + /** * Multiply each RGB channel of a hex color by `factor` (clamped to 0–255). * factor < 1 darkens; used to derive the HUD bar shade from a boss's @@ -22,3 +30,43 @@ export function formatRunLabel( if (!campaign) return domainShort; return `Floor ${campaign.floorsCleared + 1}/${campaign.bossOrder.length} · ${domainShort}`; } + +export interface BossHudOptions { + boss: BossDefinition; + campaign: { floorsCleared: number; bossOrder: string[] } | undefined; + onExit: () => void; + onBgmToggle: (muted: boolean) => void; +} + +/** + * Mount the boss-fight HUD: a themed top bar (darkened boss color) holding the + * back-to-menu door + "Floor N/M · " on the left and icon-only + * SFX/BGM on the right. Reuses mountMenuButton + mountAudioToggles so their + * behavior/persistence is unchanged. + */ +export function mountBossHud(scene: Phaser.Scene, opts: BossHudOptions): void { + const { boss, campaign, onExit, onBgmToggle } = opts; + const midY = BAR_HEIGHT / 2; + + // Themed bar fill + a slightly lighter bottom border so it frames cleanly. + scene.add + .rectangle(480, midY, 960, BAR_HEIGHT, darken(boss.environmentColor, BAR_DARKEN)) + .setDepth(900); + scene.add + .rectangle(480, BAR_HEIGHT - 1, 960, 2, darken(boss.environmentColor, 0.9)) + .setDepth(901); + + // Left: door + run label. + mountMenuButton(scene, onExit, { x: 28, y: midY }); + scene.add + .text(64, midY, formatRunLabel(campaign, boss.domainShort), { + fontSize: '14px', + color: '#f5e4b3', + fontFamily: 'monospace', + }) + .setOrigin(0, 0.5) + .setDepth(1000); + + // Right: icon-only SFX/BGM, vertically centered in the bar. + mountAudioToggles(scene, { iconOnly: true, y: midY, onBgmToggle }); +} From a9d2de704a6ca57bef9cfbabbdbbb0bbba1a29d7 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 23:05:30 +0200 Subject: [PATCH 08/10] feat(dungeon): mount boss HUD bar; move boss name below it Co-Authored-By: Claude Sonnet 4.6 --- public/dungeon/src/scenes/BossFightScene.ts | 25 ++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/public/dungeon/src/scenes/BossFightScene.ts b/public/dungeon/src/scenes/BossFightScene.ts index adc8c46..e0441b3 100644 --- a/public/dungeon/src/scenes/BossFightScene.ts +++ b/public/dungeon/src/scenes/BossFightScene.ts @@ -25,9 +25,9 @@ import type { SessionLog, SpellId, } from '../types'; -import { REGISTRY_BGM_MUTED, mountAudioToggles } from '../ui/audioToggles'; +import { REGISTRY_BGM_MUTED } from '../ui/audioToggles'; +import { mountBossHud } from '../ui/bossHud'; 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'; @@ -194,9 +194,9 @@ export class BossFightScene extends Phaser.Scene { // before everything else so it sits at the bottom of the z-stack. renderBackdrop(this, this.boss.id); - // Boss name at top center + // Boss name — sits just below the HUD bar (bar occupies y 0–44). this.add - .text(480, 30, this.boss.name, { + .text(480, 66, this.boss.name, { fontSize: '24px', color: '#f5e4b3', fontFamily: 'monospace', @@ -401,21 +401,20 @@ export class BossFightScene extends Phaser.Scene { this.events.once('shutdown', () => this.bgm.stop()); this.events.once('destroy', () => this.bgm.stop()); - // Mute toggles (top-right). onBgmToggle starts/stops our procedural - // BGM as the user flips the control; SFX mute flows through Phaser's - // sound manager automatically. - mountAudioToggles(this, { + // Top HUD bar: themed strip with the back-to-menu door + "Floor N/M · + // " on the left and icon-only SFX/BGM on the right. onBgmToggle + // starts/stops our procedural BGM; SFX mute flows through Phaser's sound + // manager automatically. + mountBossHud(this, { + boss: this.boss, + campaign: this.registry.get('campaign') as Campaign | undefined, + onExit: () => this.exitToHub(), onBgmToggle: (muted) => { if (muted) this.bgm.stop(); else this.bgm.start(this.boss.id, this.sound as unknown as { context?: AudioContext }); }, }); - // 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 }); From 3fc850dde247cc0a74492430c7c0e911a682a78b Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 30 May 2026 23:14:46 +0200 Subject: [PATCH 09/10] refactor(dungeon): tighten HUD types + fix doc rot (review follow-ups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From /pr-review-toolkit:review-pr: - domainShort is now a DomainShort union (closed set of 5) — typos become compile errors instead of silent bad labels. - HUD's campaign subset is Pick via a type-only import (de-duped, drift-protected) instead of an inline literal. - Named BAR_BORDER_DARKEN constant for the border factor. - Fixed stale "top-left" doc on mountMenuButton (HUD repositions it); clarified iconOnly re-anchors layout; dropped the rot-prone "y 0–44" literal comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/dungeon/src/scenes/BossFightScene.ts | 2 +- public/dungeon/src/types.ts | 5 ++++- public/dungeon/src/ui/audioToggles.ts | 5 ++++- public/dungeon/src/ui/bossHud.ts | 17 ++++++++++------- public/dungeon/src/ui/inFightNav.ts | 7 ++++--- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/public/dungeon/src/scenes/BossFightScene.ts b/public/dungeon/src/scenes/BossFightScene.ts index e0441b3..952086f 100644 --- a/public/dungeon/src/scenes/BossFightScene.ts +++ b/public/dungeon/src/scenes/BossFightScene.ts @@ -194,7 +194,7 @@ export class BossFightScene extends Phaser.Scene { // before everything else so it sits at the bottom of the z-stack. renderBackdrop(this, this.boss.id); - // Boss name — sits just below the HUD bar (bar occupies y 0–44). + // Boss name — sits just below the HUD bar (mounted later in create()). this.add .text(480, 66, this.boss.name, { fontSize: '24px', diff --git a/public/dungeon/src/types.ts b/public/dungeon/src/types.ts index 164eb53..e16e4e5 100644 --- a/public/dungeon/src/types.ts +++ b/public/dungeon/src/types.ts @@ -37,11 +37,14 @@ export interface Bank { } // Boss types +// Short display label for a domain, paired 1:1 with `domain`. Closed set of 5. +export type DomainShort = 'Agentic' | 'Claude Code' | 'Prompting' | 'MCP' | 'Context'; + export interface BossDefinition { id: string; name: string; domain: string; - domainShort: string; + domainShort: DomainShort; theme: string; taunts: { correct: string[]; wrong: string[] }; environmentColor: number; // hex for Phaser fillRect, etc. diff --git a/public/dungeon/src/ui/audioToggles.ts b/public/dungeon/src/ui/audioToggles.ts index b16e06a..468afa4 100644 --- a/public/dungeon/src/ui/audioToggles.ts +++ b/public/dungeon/src/ui/audioToggles.ts @@ -43,7 +43,10 @@ function savePreference(key: string, muted: boolean): void { interface MountOptions { onBgmToggle?: (muted: boolean) => void; - /** Render glyph-only buttons (no "BGM"/"SFX" text), vertically centered. */ + /** + * Render glyph-only buttons (no "BGM"/"SFX" text), vertically centered and + * right-anchored tighter for a compact HUD bar (changes layout, not just text). + */ iconOnly?: boolean; /** Vertical anchor for the buttons. Defaults to 18 (top-right corner). */ y?: number; diff --git a/public/dungeon/src/ui/bossHud.ts b/public/dungeon/src/ui/bossHud.ts index 517c2a4..a0687b5 100644 --- a/public/dungeon/src/ui/bossHud.ts +++ b/public/dungeon/src/ui/bossHud.ts @@ -1,10 +1,16 @@ import type Phaser from 'phaser'; +import type { Campaign } from '../game/dungeon'; import type { BossDefinition } from '../types'; import { mountAudioToggles } from './audioToggles'; import { mountMenuButton } from './inFightNav'; const BAR_HEIGHT = 44; -const BAR_DARKEN = 0.5; // bar fill = boss color × this; bottom border is lighter +const BAR_DARKEN = 0.5; // bar fill = boss color × this factor (darker) +const BAR_BORDER_DARKEN = 0.9; // bottom border = boss color × this; lighter than the fill + +// The only Campaign fields the HUD needs. A type-only Pick keeps this UI module +// decoupled from game logic while still breaking if those fields ever rename. +type CampaignRunInfo = Pick; /** * Multiply each RGB channel of a hex color by `factor` (clamped to 0–255). @@ -23,17 +29,14 @@ export function darken(color: number, factor: number): number { * Left-side HUD run label. With a campaign: "Floor / · " * (n is 1-indexed). Without one (isolated/debug fight): just the domain. */ -export function formatRunLabel( - campaign: { floorsCleared: number; bossOrder: string[] } | undefined, - domainShort: string, -): string { +export function formatRunLabel(campaign: CampaignRunInfo | undefined, domainShort: string): string { if (!campaign) return domainShort; return `Floor ${campaign.floorsCleared + 1}/${campaign.bossOrder.length} · ${domainShort}`; } export interface BossHudOptions { boss: BossDefinition; - campaign: { floorsCleared: number; bossOrder: string[] } | undefined; + campaign: CampaignRunInfo | undefined; onExit: () => void; onBgmToggle: (muted: boolean) => void; } @@ -53,7 +56,7 @@ export function mountBossHud(scene: Phaser.Scene, opts: BossHudOptions): void { .rectangle(480, midY, 960, BAR_HEIGHT, darken(boss.environmentColor, BAR_DARKEN)) .setDepth(900); scene.add - .rectangle(480, BAR_HEIGHT - 1, 960, 2, darken(boss.environmentColor, 0.9)) + .rectangle(480, BAR_HEIGHT - 1, 960, 2, darken(boss.environmentColor, BAR_BORDER_DARKEN)) .setDepth(901); // Left: door + run label. diff --git a/public/dungeon/src/ui/inFightNav.ts b/public/dungeon/src/ui/inFightNav.ts index 53b5a3f..05b21ed 100644 --- a/public/dungeon/src/ui/inFightNav.ts +++ b/public/dungeon/src/ui/inFightNav.ts @@ -4,9 +4,10 @@ import type Phaser from 'phaser'; const DOOR_FRAME = 45; /** - * 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). + * Mount a small door icon that acts as a back-to-menu control (default + * top-left; the caller may reposition via `position` — the HUD bar centers it). + * Clicking it invokes onExit. Visuals only — the caller decides what leaving + * does (see BossFightScene.exitToHub). */ export function mountMenuButton( scene: Phaser.Scene, From 1b95ce0b629ca60b176e35220226383f415b1a05 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sun, 31 May 2026 00:18:15 +0200 Subject: [PATCH 10/10] feat(dungeon): center boss name in HUD + space out & scale the combatants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Boss name moves off the scene body into the HUD bar (amber, centered between the run label and audio cluster); the old top-center banner floated on the brick wall and clipped the question scroll. - Hero/boss clusters (sprite + HP + name/taunt) shifted outward (x 120/840 → 85/875) for breathing room around the central scroll; taunt wrap trimmed so it stays on-canvas at the new position. - Boss scaled up (4 → 6) for presence and grounded on the hero's floor line, so it looms ~2× the hero instead of reading as the same size. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/dungeon/src/scenes/BossFightScene.ts | 33 +++++++++++---------- public/dungeon/src/ui/bossHud.ts | 20 ++++++++++++- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/public/dungeon/src/scenes/BossFightScene.ts b/public/dungeon/src/scenes/BossFightScene.ts index 952086f..7d27c01 100644 --- a/public/dungeon/src/scenes/BossFightScene.ts +++ b/public/dungeon/src/scenes/BossFightScene.ts @@ -194,14 +194,9 @@ export class BossFightScene extends Phaser.Scene { // before everything else so it sits at the bottom of the z-stack. renderBackdrop(this, this.boss.id); - // Boss name — sits just below the HUD bar (mounted later in create()). - this.add - .text(480, 66, this.boss.name, { - fontSize: '24px', - color: '#f5e4b3', - fontFamily: 'monospace', - }) - .setOrigin(0.5); + // (Boss name now lives centered in the top HUD bar — see mountBossHud. + // The old top-center banner floated on the brick wall and clipped the top + // of the question scroll.) // --- Speech bubble (question area) --- // Filled rectangle body @@ -241,12 +236,12 @@ export class BossFightScene extends Phaser.Scene { .setOrigin(0.5, 0); // --- Hero sprite (left) --- - this.heroSprite = this.add.image(120, 330, 'hero').setScale(3); + this.heroSprite = this.add.image(85, 330, 'hero').setScale(3); // Hero HP hearts below sprite. Slightly smaller than before so the // label stack under the hero is a compact 2-line group. this.heroHpText = this.add - .text(120, 405, '', { + .text(85, 405, '', { fontSize: '12px', color: '#8bc34a', fontFamily: 'monospace', @@ -256,7 +251,7 @@ export class BossFightScene extends Phaser.Scene { // Hero name label \u2014 intentionally tiny so it reads as a subtitle // rather than competing with the HP text above it. this.add - .text(120, 420, 'WARLOCK', { + .text(85, 420, 'WARLOCK', { fontSize: '9px', color: '#808090', fontFamily: 'monospace', @@ -266,12 +261,19 @@ export class BossFightScene extends Phaser.Scene { // --- Boss sprite (right) --- const bossKey = `boss-${this.boss.id}`; const bossTexKey = this.textures.exists(bossKey) ? bossKey : 'hero'; - this.bossSprite = this.add.image(840, 330, bossTexKey).setScale(4); + // Boss is scaled up (6 vs hero's 3) for presence; y nudged up so its feet + // ground on the same floor line as the hero rather than sinking lower. + this.bossSprite = this.add.image(875, 306, bossTexKey).setScale(6); + + // (Boss name now lives centered in the top HUD bar — see mountBossHud / + // bossHud.ts. The bar is a themed strip above the brick wall and question + // scroll, so the name clips neither; a side/overhead plate in the scene + // body caught the near-full-width scroll's corner.) // Boss HP hearts below boss sprite \u2014 match the hero HP scale so both // sides read as symmetric status lines. this.bossHpText = this.add - .text(840, 405, '', { + .text(875, 405, '', { fontSize: '12px', color: '#ff6b6b', fontFamily: 'monospace', @@ -280,12 +282,12 @@ export class BossFightScene extends Phaser.Scene { // Taunt text below boss this.tauntText = this.add - .text(840, 450, '', { + .text(875, 450, '', { fontSize: '12px', color: '#d0c090', fontFamily: 'monospace', fontStyle: 'italic', - wordWrap: { width: 260 }, + wordWrap: { width: 160 }, align: 'center', }) .setOrigin(0.5, 0); @@ -407,6 +409,7 @@ export class BossFightScene extends Phaser.Scene { // manager automatically. mountBossHud(this, { boss: this.boss, + bossName: this.boss.name, campaign: this.registry.get('campaign') as Campaign | undefined, onExit: () => this.exitToHub(), onBgmToggle: (muted) => { diff --git a/public/dungeon/src/ui/bossHud.ts b/public/dungeon/src/ui/bossHud.ts index a0687b5..cf72dd0 100644 --- a/public/dungeon/src/ui/bossHud.ts +++ b/public/dungeon/src/ui/bossHud.ts @@ -39,6 +39,10 @@ export interface BossHudOptions { campaign: CampaignRunInfo | undefined; onExit: () => void; onBgmToggle: (muted: boolean) => void; + /** Boss name, rendered centered in the bar. Sitting on the themed strip + * keeps it clear of the brick wall and the near-full-width question scroll + * below, which a scene-body plate would clip. */ + bossName?: string; } /** @@ -48,7 +52,7 @@ export interface BossHudOptions { * behavior/persistence is unchanged. */ export function mountBossHud(scene: Phaser.Scene, opts: BossHudOptions): void { - const { boss, campaign, onExit, onBgmToggle } = opts; + const { boss, campaign, onExit, onBgmToggle, bossName } = opts; const midY = BAR_HEIGHT / 2; // Themed bar fill + a slightly lighter bottom border so it frames cleanly. @@ -70,6 +74,20 @@ export function mountBossHud(scene: Phaser.Scene, opts: BossHudOptions): void { .setOrigin(0, 0.5) .setDepth(1000); + // Center: boss name, in amber (brand accent). Centered between the left run + // label and the right audio cluster, on the themed strip — clears both the + // brick wall and the question scroll that side/overhead plates would clip. + if (bossName) { + scene.add + .text(480, midY, bossName, { + fontSize: '18px', + color: '#ffca28', + fontFamily: 'monospace', + }) + .setOrigin(0.5) + .setDepth(1000); + } + // Right: icon-only SFX/BGM, vertically centered in the bar. mountAudioToggles(scene, { iconOnly: true, y: midY, onBgmToggle }); }