docs: add camera mode commands implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 13:32:25 +00:00
parent 7dfd9e55df
commit a074683e4c
@@ -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<Position>(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<Position>(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<Position>(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.