From a074683e4cd08ef4f2a1ff8b882711016e381c80 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 13:32:25 +0000 Subject: [PATCH] docs: add camera mode commands implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-07-camera-mode-commands-plan.md | 655 ++++++++++++++++++ 1 file changed, 655 insertions(+) create mode 100644 docs/plans/2026-03-07-camera-mode-commands-plan.md diff --git a/docs/plans/2026-03-07-camera-mode-commands-plan.md b/docs/plans/2026-03-07-camera-mode-commands-plan.md new file mode 100644 index 0000000..1f9d32a --- /dev/null +++ b/docs/plans/2026-03-07-camera-mode-commands-plan.md @@ -0,0 +1,655 @@ +# Camera Mode Commands Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a spawn-NPC command (hotkey `1`) in camera mode, with an EarthBound-styled command panel UI. + +**Architecture:** New `spawn-npc` socket event from client to server. Server validates NPC cap and finds walkable tile near camera center. Client gets a new `CommandPanel` HTML overlay visible only in camera mode. + +**Tech Stack:** TypeScript, Socket.io, Phaser 3, HTML/CSS overlays, vitest + +--- + +### Task 1: Add shared types and constants + +**Files:** +- Modify: `shared/src/constants.ts:61` (append) +- Modify: `shared/src/types.ts:118-120` (extend ClientEvents) + +**Step 1: Add MAX_NPC_COUNT constant** + +In `shared/src/constants.ts`, append after line 60: + +```typescript +// Camera mode commands +export const MAX_NPC_COUNT = 50; +``` + +**Step 2: Add spawn-npc to ClientEvents** + +In `shared/src/types.ts`, change the `ClientEvents` interface (lines 118-120) to: + +```typescript +// Client -> Server events +export interface ClientEvents { + 'player-input': (data: PlayerInput) => void; + 'spawn-npc': (data: { x: number; y: number }) => void; +} +``` + +**Step 3: Rebuild shared package** + +Run: `npm -w shared run build` +Expected: Clean build, no errors. + +**Step 4: Commit** + +```bash +git add shared/src/constants.ts shared/src/types.ts shared/dist/ +git commit -m "feat: add spawn-npc event type and MAX_NPC_COUNT constant" +``` + +--- + +### Task 2: Add findNearestWalkable to GameMap + +**Files:** +- Modify: `server/src/map/GameMap.ts:48` (add method before `getRandomWalkable`) +- Test: `server/src/map/__tests__/GameMap.test.ts` (new file) + +**Step 1: Write the failing tests** + +Create `server/src/map/__tests__/GameMap.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { GameMap } from '../GameMap.js'; + +describe('GameMap.findNearestWalkable', () => { + it('returns the exact tile if it is walkable', () => { + const map = new GameMap(10, 10); + const result = map.findNearestWalkable(5, 5, 3); + expect(result).toEqual({ x: 5, y: 5 }); + }); + + it('returns a nearby walkable tile if center is obstacle', () => { + const map = new GameMap(10, 10); + map.setObstacle(5, 5); + const result = map.findNearestWalkable(5, 5, 3); + expect(result).not.toBeNull(); + // Should be within radius 3 + const dx = Math.abs(result!.x - 5); + const dy = Math.abs(result!.y - 5); + expect(dx + dy).toBeLessThanOrEqual(3); + expect(map.isWalkable(result!.x, result!.y)).toBe(true); + }); + + it('returns null if no walkable tile within radius', () => { + const map = new GameMap(5, 5); + // Fill entire area around (2,2) with obstacles within radius 1 + for (let x = 1; x <= 3; x++) { + for (let y = 1; y <= 3; y++) { + map.setObstacle(x, y); + } + } + const result = map.findNearestWalkable(2, 2, 1); + expect(result).toBeNull(); + }); + + it('returns null for out-of-bounds center', () => { + const map = new GameMap(10, 10); + const result = map.findNearestWalkable(-5, -5, 1); + expect(result).toBeNull(); + }); + + it('clamps search to map boundaries', () => { + const map = new GameMap(10, 10); + map.setObstacle(0, 0); + const result = map.findNearestWalkable(0, 0, 3); + expect(result).not.toBeNull(); + expect(result!.x).toBeGreaterThanOrEqual(0); + expect(result!.y).toBeGreaterThanOrEqual(0); + expect(result!.x).toBeLessThan(10); + expect(result!.y).toBeLessThan(10); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run server/src/map/__tests__/GameMap.test.ts` +Expected: FAIL — `findNearestWalkable` is not a function. + +**Step 3: Implement findNearestWalkable** + +In `server/src/map/GameMap.ts`, add this method before `getRandomWalkable()` (before line 48): + +```typescript + findNearestWalkable(centerX: number, centerY: number, radius: number): Position | null { + // Check center first + if (this.isWalkable(centerX, centerY)) { + return { x: centerX, y: centerY }; + } + // Spiral outward by Manhattan distance + for (let dist = 1; dist <= radius; dist++) { + for (let dx = -dist; dx <= dist; dx++) { + const dyRange = dist - Math.abs(dx); + for (const dy of [-dyRange, dyRange]) { + const x = centerX + dx; + const y = centerY + dy; + if (this.isWalkable(x, y)) { + return { x, y }; + } + } + } + } + return null; + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run server/src/map/__tests__/GameMap.test.ts` +Expected: All 5 tests PASS. + +**Step 5: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass, no regressions. + +**Step 6: Commit** + +```bash +git add server/src/map/GameMap.ts server/src/map/__tests__/GameMap.test.ts +git commit -m "feat: add findNearestWalkable method to GameMap" +``` + +--- + +### Task 3: Add positionHint to spawnNPC + +**Files:** +- Modify: `server/src/game/spawner.ts:7` (add optional parameter) +- Test: `server/src/game/__tests__/spawner.test.ts` (new file) + +**Step 1: Write the failing tests** + +Create `server/src/game/__tests__/spawner.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { spawnNPC } from '../spawner.js'; +import type { Position } from '@dflike/shared'; + +describe('spawnNPC', () => { + it('spawns at random position when no hint given', () => { + const world = new World(); + const map = new GameMap(); + const entity = spawnNPC(world, map); + const pos = world.getComponent(entity, 'position')!; + expect(pos).toBeDefined(); + expect(pos.x).toBeGreaterThanOrEqual(0); + expect(pos.y).toBeGreaterThanOrEqual(0); + }); + + it('spawns at hint position when walkable', () => { + const world = new World(); + const map = new GameMap(); + const entity = spawnNPC(world, map, { x: 20, y: 20 }); + const pos = world.getComponent(entity, 'position')!; + expect(pos).toEqual({ x: 20, y: 20 }); + }); + + it('spawns at random position when hint is not walkable', () => { + const world = new World(); + const map = new GameMap(); + map.setObstacle(20, 20); + const entity = spawnNPC(world, map, { x: 20, y: 20 }); + const pos = world.getComponent(entity, 'position')!; + expect(pos).toBeDefined(); + // Should not be the obstacle tile + expect(pos.x !== 20 || pos.y !== 20).toBe(true); + }); + + it('creates all required components', () => { + const world = new World(); + const map = new GameMap(); + const entity = spawnNPC(world, map); + expect(world.getComponent(entity, 'position')).toBeDefined(); + expect(world.getComponent(entity, 'needs')).toBeDefined(); + expect(world.getComponent(entity, 'movement')).toBeDefined(); + expect(world.getComponent(entity, 'npcBrain')).toBeDefined(); + expect(world.getComponent(entity, 'appearance')).toBeDefined(); + expect(world.getComponent(entity, 'name')).toBeDefined(); + expect(world.getComponent(entity, 'socialState')).toBeDefined(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run server/src/game/__tests__/spawner.test.ts` +Expected: FAIL — `spawnNPC` doesn't accept 3rd arg / hint test fails. + +**Step 3: Modify spawnNPC to accept positionHint** + +In `server/src/game/spawner.ts`, change the function signature and position logic (lines 7-9): + +```typescript +export function spawnNPC(world: World, map: GameMap, positionHint?: Position): EntityId { + const entity = world.createEntity(); + const pos = positionHint && map.isWalkable(positionHint.x, positionHint.y) + ? positionHint + : map.getRandomWalkable(); +``` + +Also add `Position` to the imports if not already there (it is already imported on line 5). + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run server/src/game/__tests__/spawner.test.ts` +Expected: All 4 tests PASS. + +**Step 5: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass. + +**Step 6: Commit** + +```bash +git add server/src/game/spawner.ts server/src/game/__tests__/spawner.test.ts +git commit -m "feat: add optional positionHint to spawnNPC" +``` + +--- + +### Task 4: Add spawn-npc socket handler + +**Files:** +- Modify: `server/src/network/SocketServer.ts:57` (add handler inside connection block) + +**Step 1: Add the spawn-npc handler** + +In `server/src/network/SocketServer.ts`, add the import for `spawnNPC` and `MAX_NPC_COUNT` at the top: + +```typescript +import { Direction, MAX_NPC_COUNT } from '@dflike/shared'; +import { spawnNPC } from '../game/spawner.js'; +``` + +Then inside the `connection` callback, after the `socket.on('player-input', ...)` block (after line 59), add: + +```typescript + // Handle NPC spawn requests + socket.on('spawn-npc', (data: { x: number; y: number }) => { + const npcCount = world.query('npcBrain').length; + if (npcCount >= MAX_NPC_COUNT) return; + + const target = map.findNearestWalkable(data.x, data.y, 3) + ?? map.getRandomWalkable(); + spawnNPC(world, map, target); + }); +``` + +**Step 2: Verify server compiles** + +Run: `npx -w server tsx --no-warnings server/src/main.ts &` then kill it, or just run `npx -w server tsc --noEmit` + +Actually, simpler — just run the test suite to confirm no compilation issues: + +Run: `npm -w server run test` +Expected: All tests pass. + +**Step 3: Commit** + +```bash +git add server/src/network/SocketServer.ts +git commit -m "feat: handle spawn-npc socket event with cap and position logic" +``` + +--- + +### Task 5: Add spawnNpc method to SocketClient + +**Files:** +- Modify: `client/src/network/SocketClient.ts:44` (add method) + +**Step 1: Add the spawnNpc method** + +In `client/src/network/SocketClient.ts`, add after the `sendInput` method (after line 46): + +```typescript + spawnNpc(x: number, y: number): void { + this.socket.emit('spawn-npc', { x, y }); + } +``` + +**Step 2: Verify client builds** + +Run: `npm -w client run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add client/src/network/SocketClient.ts +git commit -m "feat: add spawnNpc method to SocketClient" +``` + +--- + +### Task 6: Create CommandPanel UI + +**Files:** +- Create: `client/src/ui/CommandPanel.ts` + +**Step 1: Create the CommandPanel class** + +Use the `frontend-design` skill to create this file. It should match the EarthBound aesthetic from `NpcInfoPanel.ts`. + +Create `client/src/ui/CommandPanel.ts`: + +```typescript +// EarthBound-inspired color palette (matches NpcInfoPanel) +const EB = { + bgDeep: '#0c0824', + bgPanel: '#141038', + borderOuter: '#7878d8', + borderInner: '#5858b8', + borderGap: '#1c1450', + textPrimary: '#f0f0ff', + textSecondary: '#9898d0', + textMuted: '#6868a8', + shine: 'rgba(200,200,255,0.08)', +}; + +interface Command { + key: string; + label: string; +} + +export class CommandPanel { + private outerFrame: HTMLDivElement; + private listContainer: HTMLDivElement; + private visible = false; + + constructor() { + // Outer frame (doubled border, same as NpcInfoPanel) + this.outerFrame = document.createElement('div'); + this.outerFrame.id = 'command-panel'; + this.outerFrame.style.cssText = ` + position: fixed; + bottom: 16px; + right: 16px; + min-width: 160px; + border-radius: 12px; + border: 3px solid ${EB.borderOuter}; + background: ${EB.borderGap}; + z-index: 1000; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: none; + box-shadow: + 0 0 16px rgba(80, 60, 160, 0.3), + 0 4px 20px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(160, 160, 255, 0.15); + `; + + // Inner container + const container = document.createElement('div'); + container.style.cssText = ` + margin: 3px; + border-radius: 8px; + border: 2px solid ${EB.borderInner}; + background: ${EB.bgPanel}; + overflow: hidden; + position: relative; + padding: 8px 12px 10px; + font-family: 'Press Start 2P', monospace; + `; + + // Shine overlay + const shine = document.createElement('div'); + shine.style.cssText = ` + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: linear-gradient( + 135deg, + ${EB.shine} 0%, + transparent 40%, + transparent 60%, + rgba(0,0,0,0.05) 100% + ); + pointer-events: none; + z-index: 1; + `; + container.appendChild(shine); + + // Title + const title = document.createElement('div'); + title.style.cssText = ` + font-size: 6px; + color: ${EB.textMuted}; + text-align: center; + letter-spacing: 4px; + padding-bottom: 6px; + user-select: none; + position: relative; + z-index: 2; + `; + title.textContent = '\u25C6 COMMANDS \u25C6'; + container.appendChild(title); + + // Commands list + this.listContainer = document.createElement('div'); + this.listContainer.style.cssText = ` + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + z-index: 2; + `; + container.appendChild(this.listContainer); + + this.outerFrame.appendChild(container); + document.body.appendChild(this.outerFrame); + + // Add default commands + this.addCommand({ key: '1', label: 'Spawn NPC' }); + } + + private addCommand(cmd: Command): void { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + `; + + const keyBadge = document.createElement('div'); + keyBadge.style.cssText = ` + font-size: 7px; + color: ${EB.bgDeep}; + background: ${EB.borderOuter}; + border-radius: 3px; + padding: 2px 5px; + min-width: 12px; + text-align: center; + text-shadow: none; + font-weight: bold; + box-shadow: 0 1px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.2); + `; + keyBadge.textContent = cmd.key; + + const label = document.createElement('div'); + label.style.cssText = ` + font-size: 7px; + color: ${EB.textSecondary}; + letter-spacing: 0.5px; + `; + label.textContent = cmd.label; + + row.appendChild(keyBadge); + row.appendChild(label); + this.listContainer.appendChild(row); + } + + show(): void { + if (this.visible) return; + this.visible = true; + this.outerFrame.style.opacity = '1'; + this.outerFrame.style.transform = 'translateY(0)'; + this.outerFrame.style.pointerEvents = 'auto'; + } + + hide(): void { + if (!this.visible) return; + this.visible = false; + this.outerFrame.style.opacity = '0'; + this.outerFrame.style.transform = 'translateY(20px)'; + this.outerFrame.style.pointerEvents = 'none'; + } + + isVisible(): boolean { + return this.visible; + } + + destroy(): void { + this.outerFrame.remove(); + } +} +``` + +**Step 2: Verify client builds** + +Run: `npm -w client run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add client/src/ui/CommandPanel.ts +git commit -m "feat: create EarthBound-styled CommandPanel UI component" +``` + +--- + +### Task 7: Wire up GameScene — hotkey and panel visibility + +**Files:** +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Import CommandPanel and TILE_SIZE constant** + +At the top of `GameScene.ts`, add the import (after the NpcInfoPanel import, line 5): + +```typescript +import { CommandPanel } from '../ui/CommandPanel.js'; +``` + +**Step 2: Add commandPanel field** + +Add to the class fields (after line 30, the emojiManager field): + +```typescript + private commandPanel!: CommandPanel; +``` + +**Step 3: Instantiate CommandPanel in create()** + +In the `create()` method, after `this.emojiManager = new InteractionEmojiManager();` (line 45), add: + +```typescript + this.commandPanel = new CommandPanel(); + this.commandPanel.show(); // Start in camera mode, so show immediately +``` + +**Step 4: Add `1` key listener in create()** + +After the TAB key listener block (after line 80), add: + +```typescript + // Spawn NPC command (camera mode only) + this.input.keyboard!.addKey('ONE').on('down', () => { + if (this.mode !== 'camera') return; + const cam = this.cameras.main; + const centerTileX = Math.floor((cam.scrollX + cam.width / 2) / TILE_SIZE); + const centerTileY = Math.floor((cam.scrollY + cam.height / 2) / TILE_SIZE); + this.client.spawnNpc(centerTileX, centerTileY); + }); +``` + +**Step 5: Show/hide CommandPanel on mode transitions** + +In the TAB key handler (lines 65-80), update to also manage CommandPanel visibility. The handler should become: + +```typescript + this.input.keyboard!.addKey('TAB').on('down', () => { + const prevMode = this.mode; + if (this.mode === 'camera') { + this.mode = 'follow'; + } else { + this.mode = 'camera'; + } + this.updateModeText(); + + // Panel visibility + if (this.mode === 'follow') { + this.showFollowPanel(); + this.commandPanel.hide(); + } else if (prevMode === 'follow') { + this.npcInfoPanel.hide(); + } + + // Command panel visibility + if (this.mode === 'camera') { + this.commandPanel.show(); + } else { + this.commandPanel.hide(); + } + }); +``` + +**Step 6: Verify client builds** + +Run: `npm -w client run build` +Expected: Clean build. + +**Step 7: Commit** + +```bash +git add client/src/scenes/GameScene.ts +git commit -m "feat: wire spawn hotkey and command panel visibility in GameScene" +``` + +--- + +### Task 8: Manual integration test + +**Step 1: Start server** + +Run: `npm -w server run dev` + +**Step 2: Start client** + +Run: `npm -w client run dev` + +**Step 3: Verify in browser** + +1. Open browser to localhost:3000 +2. Confirm you're in camera mode — command panel should be visible bottom-right with `[1] Spawn NPC` +3. Pan camera with WASD/arrows +4. Press `1` — a new NPC should appear near camera center within ~300ms +5. Press `1` repeatedly — NPCs keep spawning (up to 50 total) +6. Press TAB to switch to follow mode — command panel should hide, NPC info panel should appear +7. Press TAB again to return to camera mode — command panel should reappear + +**Step 4: Run full server test suite** + +Run: `npm -w server run test` +Expected: All tests pass.