diff --git a/docs/code-standards.md b/docs/code-standards.md index e31b0db..5efc585 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -278,4 +278,4 @@ audio.toggleMute(); - [ ] Pixel color semantics (`--guard-*` CSS vars ↔ `NNTV.guard*`) kept in sync when modifying theme - [ ] Any edit to `src/lib/levels/levels.js` runs `npm run test:solvability` and passes - [ ] L12 remains unsolvable (solver asserts this — never "fix" it) -- [ ] No guard's `litCells` entry overlaps a wall cell in the same level +- [ ] No blinking-guard `litCells` entry overlaps a wall cell in the same level (static guards use initialRadius auras, not litCells) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index a49919b..c9bb8fc 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -103,7 +103,7 @@ NNTV is a turn-based stealth puzzle game built with Svelte 5 and Vite 6.x. The c ``` Guard (abstract base: grid, row, col, type, direction, isOn) -├── StaticGuard — lights fixed litCells array +├── StaticGuard — wilting tomato: Manhattan aura shrinks by 1 per turn (initialRadius → currentRadius) ├── RotatingGuard — rotates beam 90°/turn, castBeam with mirror bounce ├── BlinkingGuard — toggles isOn, lights litCells when on ├── MirrorGuard — lights own cell, stores reflectDirection (cw/ccw) @@ -122,11 +122,12 @@ Guard (abstract base: grid, row, col, type, direction, isOn) goal: { row: 5, col: 5 }, walls: [{ row: 1, col: 1 }, ...], guards: [ - { type: "static", position: { row: 2, col: 4 }, litCells: [...] }, + { type: "static", position: { row: 2, col: 4 }, initialRadius: 2 }, { type: "rotating", position: { row: 3, col: 3 }, startDirection: 0 }, { type: "blinking", position: {...}, litCells: [...], startState: true }, { type: "mirror", position: {...}, reflectDirection: "cw" }, { type: "patrolling", startPosition: {...}, path: [...] }, + { type: "chaser", position: {...}, detectionRadius: 3 }, ], isFinalLevel: false } diff --git a/docs/game-design.md b/docs/game-design.md index e970f93..d8d2732 100644 --- a/docs/game-design.md +++ b/docs/game-design.md @@ -43,7 +43,7 @@ You infiltrate the kingdom's outskirts, fight through gardens, fortress walls, u | Type | Color | Shape | Behavior | |------|-------|-------|----------| -| Static | Red | Circle | Lights fixed adjacent cells every turn | +| Static (Wilting Tomato) | Red | Circle | Emits a Manhattan aura of initialRadius cells; shrinks by 1 per turn until harmless. Lit cells = death. Reward patience — wait out the burn. | | Rotating | Blue | Circle + direction line | Rotates beam 90° clockwise each turn; 2-cell range; stopped by walls; reflects off mirrors | | Blinking | Yellow | Circle (dims when off) | Toggles lights on/off each turn; lights fixed cells when "on" | | Patrolling | Purple | Circle + direction line | Moves along predefined path; lights front + right cells relative to facing | @@ -77,12 +77,12 @@ You infiltrate the kingdom's outskirts, fight through gardens, fortress walls, u **Level 1 — Garden Path** (8x8, 0 guards) - Tutorial: movement only. Walls route the player through a winding corridor. No danger. -**Level 2 — The Watchtower** (8x8, 3 static guards) -- First guard encounter. Three static guards with two viable paths around them. -- Teaches: lit cells = danger, plan around fixed obstacles. +**Level 2 — The Watchtower** (8x8, 3 wilting tomatoes on the zigzag detour) +- First guard encounter. Three wilting tomatoes (initialRadius 2) each emit a Manhattan aura that shrinks by 1 per turn until harmless. +- Teaches: lit cells = danger, but they fade. Wait or detour — both are valid tactics. -**Level 3 — Vegetable Patrol** (9x9, 3 static + 1 rotating) -- Rotating guard introduced in the middle of the map; static guards flank the edges. +**Level 3 — Vegetable Patrol** (9x9, 3 wilting tomatoes + 1 rotating) +- Rotating guard introduced in the middle of the map; wilting tomatoes flank the edges. - Teaches: rotating beam cycles every 4 turns, read direction indicator. **Level 4 — The Searchlight** (9x9, rotating + blinking + 3 static) @@ -128,7 +128,7 @@ You infiltrate the kingdom's outskirts, fight through gardens, fortress walls, u - Goal always at (rows-1, cols-1) — bottom-right corner - Max 10 guards per level - Every level 1-11 must have at least one valid path from start to goal (solver-verified) -- No guard's `litCells` may overlap a wall cell (solver lints this) +- No guard's computed lit cells may overlap a wall cell (solver lints blinking-type `litCells`; static-type wilting auras naturally pass through walls but are set-but-never-checked on wall cells) - Walls should create interesting routing decisions, not dead ends - Each new mechanic gets a solo introduction level before combining - All `levels.js` edits must pass `npm run test:solvability` diff --git a/src/lib/game/guards.js b/src/lib/game/guards.js index 47001c8..c03a825 100644 --- a/src/lib/game/guards.js +++ b/src/lib/game/guards.js @@ -28,26 +28,47 @@ class Guard { } } +// Wilting tomato — emits a Manhattan aura that shrinks by 1 each turn. +// initialRadius 2 lights 13 cells at turn 0, 5 cells at turn 1, 1 cell at +// turn 2, harmless thereafter. Pairs naturally with the `wait` action: +// patient players can outlast an aura; impatient players must detour. export class StaticGuard extends Guard { - constructor(grid, row, col, litCells) { + constructor(grid, row, col, initialRadius) { super(grid, row, col, 'static'); - this.litCells = litCells || []; + this.initialRadius = initialRadius ?? 2; + this.currentRadius = this.initialRadius; } updateLight() { - if (this.grid.isValidPosition(this.row, this.col)) { - this.grid.setLight(this.row, this.col, true); - } - this.litCells.forEach(cell => { - if (this.grid.isValidPosition(cell.row, cell.col)) { - this.grid.setLight(cell.row, cell.col, true); + if (this.currentRadius < 0) return; + const r0 = this.row, c0 = this.col; + const rad = this.currentRadius; + for (let r = r0 - rad; r <= r0 + rad; r++) { + for (let c = c0 - rad; c <= c0 + rad; c++) { + const dist = Math.abs(r - r0) + Math.abs(c - c0); + if (dist > rad) continue; + if (this.grid.isValidPosition(r, c)) { + this.grid.setLight(r, c, true); + } } - }); + } } onTurnChange() { + // Clamp at -1 (fully wilted, harmless). Prevents unbounded state growth + // for BFS solver — beyond -1, behavior is identical so keys stay finite. + if (this.currentRadius >= 0) this.currentRadius--; this.updateLight(); } + + capture() { + return { ...super.capture(), currentRadius: this.currentRadius }; + } + + apply(s) { + super.apply(s); + this.currentRadius = s.currentRadius; + } } export class RotatingGuard extends Guard { diff --git a/src/lib/game/guards.test.js b/src/lib/game/guards.test.js index 8519ea7..4b64c52 100644 --- a/src/lib/game/guards.test.js +++ b/src/lib/game/guards.test.js @@ -10,7 +10,7 @@ describe('Guard.capture()/apply()', () => { beforeEach(() => { grid = new GridSystem(6, 6, 50); }); it('base fields round-trip on StaticGuard', () => { - const g = new StaticGuard(grid, 2, 2, []); + const g = new StaticGuard(grid, 2, 2, 2); g.direction = 2; g.isOn = false; const s = g.capture(); @@ -21,6 +21,34 @@ describe('Guard.capture()/apply()', () => { expect(g.isOn).toBe(false); }); + it('StaticGuard wilts — currentRadius decrements per turn', () => { + const g = new StaticGuard(grid, 2, 2, 2); + expect(g.currentRadius).toBe(2); + g.onTurnChange(); + expect(g.currentRadius).toBe(1); + g.onTurnChange(); + expect(g.currentRadius).toBe(0); + }); + + it('StaticGuard emits no light once wilted (currentRadius < 0)', () => { + const g = new StaticGuard(grid, 2, 2, 0); + g.updateLight(); + expect(grid.isLight(2, 2)).toBe(true); + g.onTurnChange(); // radius now -1 + grid.clearAllLight(); + g.updateLight(); + expect(grid.isLight(2, 2)).toBe(false); + }); + + it('StaticGuard currentRadius round-trips via capture/apply', () => { + const g = new StaticGuard(grid, 2, 2, 2); + g.currentRadius = 0; + const s = g.capture(); + g.currentRadius = 99; + g.apply(s); + expect(g.currentRadius).toBe(0); + }); + it('ChaserGuard preserves chase state', () => { const g = new ChaserGuard(grid, 0, 0, 3); g.isChasing = true; diff --git a/src/lib/game/level-manager.js b/src/lib/game/level-manager.js index e01b4e3..bf5697c 100644 --- a/src/lib/game/level-manager.js +++ b/src/lib/game/level-manager.js @@ -7,7 +7,7 @@ import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard, MirrorGuard // Guard type registry — maps type strings to factory functions const GUARD_REGISTRY = { - static: (grid, g) => new StaticGuard(grid, g.position.row, g.position.col, g.litCells), + static: (grid, g) => new StaticGuard(grid, g.position.row, g.position.col, g.initialRadius), rotating: (grid, g) => new RotatingGuard(grid, g.position.row, g.position.col, g.startDirection), blinking: (grid, g) => new BlinkingGuard(grid, g.position.row, g.position.col, g.litCells, g.startState), patrolling: (grid, g) => new PatrollingGuard(grid, g.startPosition.row, g.startPosition.col, g.path), diff --git a/src/lib/levels/levels.js b/src/lib/levels/levels.js index 3a0d514..40da0ea 100644 --- a/src/lib/levels/levels.js +++ b/src/lib/levels/levels.js @@ -1,8 +1,9 @@ export const LEVELS = [ // === ACT 1: THE OUTSKIRTS (movement + static guards) === { - // L1 Garden Path — 8x8, 0 guards. Teaches movement with a winding corridor - // that has no true "wrong" branches — every open cell leads somewhere useful. + // L1 Garden Path — 8x8, 0 guards. Real zigzag via two offset wall bands + // that force the player LEFT after going right (or vice versa), not just + // the Manhattan diagonal. Solver-verified ~24 moves optimal. id: 1, name: "Garden Path", storyKey: "level1Story", @@ -10,19 +11,18 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - { row: 1, col: 1 }, { row: 1, col: 2 }, { row: 1, col: 3 }, { row: 1, col: 4 }, - { row: 2, col: 4 }, { row: 2, col: 6 }, - { row: 3, col: 0 }, { row: 3, col: 1 }, { row: 3, col: 4 }, { row: 3, col: 6 }, - { row: 4, col: 4 }, { row: 4, col: 6 }, - { row: 5, col: 1 }, { row: 5, col: 2 }, { row: 5, col: 3 }, { row: 5, col: 4 }, { row: 5, col: 6 }, - { row: 6, col: 1 }, + { row: 1, col: 4 }, { row: 1, col: 5 }, { row: 1, col: 6 }, + { row: 3, col: 0 }, { row: 3, col: 1 }, { row: 3, col: 2 }, { row: 3, col: 3 }, { row: 3, col: 4 }, + { row: 5, col: 3 }, { row: 5, col: 4 }, { row: 5, col: 5 }, { row: 5, col: 6 }, { row: 5, col: 7 }, ], guards: [], - parMoves: 16, + parMoves: 26, }, { - // L2 Watchtower — 8x8, 3 static guards with TWO disjoint paths around them. - // Fixes the original L2 connectivity bug (right-side region unreachable). + // L2 Watchtower — 8x8, 3 wilting tomatoes (initialRadius 2). Same zigzag as + // L1 plus three tomato auras parked on the detour path. Player can EITHER + // wait 2-3 turns for each aura to shrink OR take a longer detour. Teaches + // the wilting mechanic + the Space (wait) action naturally. id: 2, name: "The Watchtower", storyKey: "level2Story", @@ -30,37 +30,28 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - { row: 1, col: 2 }, { row: 2, col: 1 }, { row: 2, col: 6 }, - { row: 3, col: 2 }, { row: 3, col: 5 }, - { row: 6, col: 3 }, { row: 6, col: 5 }, + { row: 1, col: 4 }, { row: 1, col: 5 }, { row: 1, col: 6 }, + { row: 3, col: 0 }, { row: 3, col: 1 }, { row: 3, col: 2 }, { row: 3, col: 3 }, { row: 3, col: 4 }, + { row: 5, col: 3 }, { row: 5, col: 4 }, { row: 5, col: 5 }, { row: 5, col: 6 }, { row: 5, col: 7 }, ], guards: [ { type: "static", - position: { row: 2, col: 4 }, - litCells: [ - { row: 1, col: 4 }, { row: 2, col: 3 }, - { row: 2, col: 5 }, { row: 3, col: 4 }, - ], + position: { row: 2, col: 6 }, + initialRadius: 2, }, { type: "static", - position: { row: 5, col: 2 }, - litCells: [ - { row: 4, col: 2 }, { row: 5, col: 1 }, - { row: 5, col: 3 }, { row: 6, col: 2 }, - ], + position: { row: 4, col: 1 }, + initialRadius: 2, }, { type: "static", - position: { row: 5, col: 6 }, - litCells: [ - { row: 4, col: 6 }, { row: 5, col: 5 }, - { row: 5, col: 7 }, { row: 6, col: 6 }, - ], + position: { row: 6, col: 4 }, + initialRadius: 2, }, ], - parMoves: 18, + parMoves: 30, }, // === ACT 2: THE VEGETABLE GARDEN (rotating + blinking intro) === @@ -85,10 +76,7 @@ export const LEVELS = [ { type: "static", position: { row: 2, col: 3 }, - litCells: [ - { row: 1, col: 3 }, { row: 2, col: 2 }, - { row: 2, col: 4 }, { row: 3, col: 2 }, - ], + initialRadius: 2, }, { type: "rotating", @@ -98,18 +86,12 @@ export const LEVELS = [ { type: "static", position: { row: 6, col: 5 }, - litCells: [ - { row: 5, col: 5 }, { row: 6, col: 4 }, - { row: 6, col: 6 }, { row: 7, col: 5 }, - ], + initialRadius: 2, }, { type: "static", position: { row: 5, col: 1 }, - litCells: [ - { row: 4, col: 1 }, { row: 5, col: 0 }, - { row: 5, col: 2 }, { row: 6, col: 1 }, - ], + initialRadius: 2, }, ], parMoves: 18, @@ -149,24 +131,17 @@ export const LEVELS = [ { type: "static", position: { row: 3, col: 3 }, - litCells: [ - { row: 3, col: 2 }, { row: 3, col: 4 }, - ], + initialRadius: 2, }, { type: "static", position: { row: 7, col: 2 }, - litCells: [ - { row: 6, col: 2 }, { row: 7, col: 1 }, - { row: 7, col: 3 }, { row: 8, col: 2 }, - ], + initialRadius: 2, }, { type: "static", position: { row: 6, col: 7 }, - litCells: [ - { row: 6, col: 6 }, { row: 6, col: 8 }, - ], + initialRadius: 2, }, ], parMoves: 19, @@ -194,10 +169,7 @@ export const LEVELS = [ { type: "static", position: { row: 2, col: 2 }, - litCells: [ - { row: 1, col: 1 }, { row: 2, col: 1 }, - { row: 2, col: 3 }, { row: 3, col: 2 }, - ], + initialRadius: 2, }, { type: "rotating", @@ -216,16 +188,12 @@ export const LEVELS = [ { type: "static", position: { row: 7, col: 7 }, - litCells: [ - { row: 6, col: 7 }, { row: 8, col: 7 }, - ], + initialRadius: 2, }, { type: "static", position: { row: 3, col: 8 }, - litCells: [ - { row: 3, col: 7 }, { row: 3, col: 9 }, - ], + initialRadius: 2, }, { type: "patrolling", @@ -278,18 +246,12 @@ export const LEVELS = [ { type: "static", position: { row: 4, col: 4 }, - litCells: [ - { row: 3, col: 4 }, { row: 4, col: 3 }, - { row: 4, col: 5 }, - ], + initialRadius: 2, }, { type: "static", position: { row: 8, col: 1 }, - litCells: [ - { row: 7, col: 2 }, { row: 8, col: 0 }, - { row: 8, col: 2 }, { row: 9, col: 1 }, - ], + initialRadius: 2, }, { type: "rotating", @@ -347,10 +309,7 @@ export const LEVELS = [ { type: "static", position: { row: 4, col: 4 }, - litCells: [ - { row: 3, col: 5 }, { row: 4, col: 3 }, - { row: 4, col: 5 }, { row: 5, col: 4 }, - ], + initialRadius: 2, }, { type: "patrolling", @@ -372,18 +331,12 @@ export const LEVELS = [ { type: "static", position: { row: 9, col: 9 }, - litCells: [ - { row: 8, col: 9 }, { row: 9, col: 8 }, - { row: 9, col: 10 }, - ], + initialRadius: 2, }, { type: "static", position: { row: 5, col: 6 }, - litCells: [ - { row: 4, col: 6 }, { row: 5, col: 5 }, - { row: 5, col: 7 }, { row: 6, col: 6 }, - ], + initialRadius: 2, }, ], parMoves: 22, @@ -455,10 +408,7 @@ export const LEVELS = [ { type: "static", position: { row: 9, col: 6 }, - litCells: [ - { row: 8, col: 6 }, { row: 9, col: 5 }, - { row: 9, col: 7 }, { row: 10, col: 6 }, - ], + initialRadius: 2, }, ], parMoves: 22, @@ -490,10 +440,7 @@ export const LEVELS = [ { type: "static", position: { row: 2, col: 5 }, - litCells: [ - { row: 1, col: 5 }, { row: 2, col: 4 }, - { row: 2, col: 6 }, { row: 3, col: 6 }, - ], + initialRadius: 2, }, { type: "rotating", @@ -520,10 +467,7 @@ export const LEVELS = [ { type: "static", position: { row: 9, col: 5 }, - litCells: [ - { row: 8, col: 5 }, { row: 9, col: 4 }, - { row: 9, col: 6 }, - ], + initialRadius: 2, }, { type: "blinking", @@ -537,10 +481,7 @@ export const LEVELS = [ { type: "static", position: { row: 10, col: 10 }, - litCells: [ - { row: 9, col: 11 }, { row: 10, col: 9 }, - { row: 10, col: 11 }, - ], + initialRadius: 2, }, { // Chaser ambushes central path — forces player to take perimeter @@ -599,10 +540,7 @@ export const LEVELS = [ { type: "static", position: { row: 4, col: 10 }, - litCells: [ - { row: 3, col: 10 }, { row: 4, col: 9 }, - { row: 4, col: 11 }, { row: 5, col: 11 }, - ], + initialRadius: 2, }, { type: "blinking", @@ -655,10 +593,7 @@ export const LEVELS = [ { type: "static", position: { row: 1, col: 2 }, - litCells: [ - { row: 0, col: 2 }, { row: 1, col: 1 }, - { row: 1, col: 3 }, { row: 2, col: 1 }, - ], + initialRadius: 2, }, { type: "rotating", @@ -690,10 +625,7 @@ export const LEVELS = [ { type: "static", position: { row: 8, col: 2 }, - litCells: [ - { row: 8, col: 1 }, { row: 8, col: 3 }, - { row: 9, col: 2 }, - ], + initialRadius: 2, }, { type: "rotating", @@ -750,9 +682,7 @@ export const LEVELS = [ { type: "static", position: { row: 1, col: 10 }, - litCells: [ - { row: 0, col: 10 }, { row: 1, col: 9 }, { row: 1, col: 11 }, - ], + initialRadius: 2, }, { type: "rotating", @@ -795,17 +725,12 @@ export const LEVELS = [ { type: "static", position: { row: 11, col: 11 }, - litCells: [ - { row: 10, col: 11 }, { row: 11, col: 10 }, - { row: 11, col: 12 }, { row: 12, col: 11 }, - ], + initialRadius: 2, }, { type: "static", position: { row: 12, col: 10 }, - litCells: [ - { row: 12, col: 9 }, { row: 12, col: 11 }, - ], + initialRadius: 2, }, { type: "chaser",