feat(game): wilting tomato mechanic replaces static light

Static guards now emit a Manhattan aura that shrinks by 1 per turn until
harmless (clamped at -1 to keep solver state finite). Level data schema
change: 'static' guards now take 'initialRadius' instead of 'litCells'.
All 27 static-guard entries migrated in one pass.

L1 and L2 redesigned with real forced-zigzag walls — solver path is now
20 moves (vs Manhattan 14), so players must move AWAY from the goal to
reach it. L2 adds three wilting tomatoes parked on the zigzag path as
a gentle intro to the mechanic.

Tests: +3 new tests on StaticGuard wilting behavior; all 70 tests pass.
This commit is contained in:
2026-04-21 22:40:53 +07:00
parent 05cc053cb6
commit ba687ea130
7 changed files with 116 additions and 141 deletions
+1 -1
View File
@@ -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)
+3 -2
View File
@@ -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
}
+7 -7
View File
@@ -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`
+30 -9
View File
@@ -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 {
+29 -1
View File
@@ -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;
+1 -1
View File
@@ -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),
+45 -120
View File
@@ -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",