docs: add camera mode commands implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user