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. 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. 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/scenes/BossFightScene.ts b/public/dungeon/src/scenes/BossFightScene.ts index adc8c46..7d27c01 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,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 at top center - this.add - .text(480, 30, 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); @@ -401,21 +403,21 @@ 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, + bossName: this.boss.name, + 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 }); diff --git a/public/dungeon/src/types.ts b/public/dungeon/src/types.ts index f8dfd36..e16e4e5 100644 --- a/public/dungeon/src/types.ts +++ b/public/dungeon/src/types.ts @@ -37,10 +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: 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 6eb46f1..468afa4 100644 --- a/public/dungeon/src/ui/audioToggles.ts +++ b/public/dungeon/src/ui/audioToggles.ts @@ -43,6 +43,13 @@ function savePreference(key: string, muted: boolean): void { interface MountOptions { onBgmToggle?: (muted: boolean) => void; + /** + * 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; } /** @@ -73,26 +80,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(); diff --git a/public/dungeon/src/ui/bossHud.test.ts b/public/dungeon/src/ui/bossHud.test.ts new file mode 100644 index 0000000..0240f9c --- /dev/null +++ b/public/dungeon/src/ui/bossHud.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { BOSSES } from '../config'; +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'); + }); +}); + +describe('boss domainShort', () => { + it('every boss declares a non-empty domainShort', () => { + for (const b of BOSSES) { + expect(b.domainShort.length).toBeGreaterThan(0); + } + }); +}); diff --git a/public/dungeon/src/ui/bossHud.ts b/public/dungeon/src/ui/bossHud.ts new file mode 100644 index 0000000..cf72dd0 --- /dev/null +++ b/public/dungeon/src/ui/bossHud.ts @@ -0,0 +1,93 @@ +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 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). + * 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: CampaignRunInfo | undefined, domainShort: string): string { + if (!campaign) return domainShort; + return `Floor ${campaign.floorsCleared + 1}/${campaign.bossOrder.length} · ${domainShort}`; +} + +export interface BossHudOptions { + boss: BossDefinition; + 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; +} + +/** + * 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, bossName } = 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, BAR_BORDER_DARKEN)) + .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); + + // 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 }); +} diff --git a/public/dungeon/src/ui/inFightNav.ts b/public/dungeon/src/ui/inFightNav.ts index 9971516..05b21ed 100644 --- a/public/dungeon/src/ui/inFightNav.ts +++ b/public/dungeon/src/ui/inFightNav.ts @@ -4,13 +4,18 @@ 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, 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 });