refactor: address code review — capture/apply contract, tests, docs

- Guards: add capture()/apply() dynamic-state snapshot contract; removes fragile per-type field lists in previewNextTurn and game-history
- TurnManager.previewNextTurn: use guard.capture/apply so new dynamic fields are picked up automatically
- PrincessMechanic: add capture()/apply() and include messageShown in undo snapshots
- Game.svelte: navigate to MainMenu when loadLevel returns null; track detection setTimeout for unmount cleanup
- LevelSelect: use getTotalLevels() instead of hardcoded 12
- audio.js: swallow resume() promise rejections
- art-scenes: norm() pads short rows with '.' transparent instead of last-char bleed
- Rename controls-overlay.svelte → ControlsOverlay.svelte (match PascalCase convention)
- Add vitest + tests for grid, player, guards, princess, turn-manager, game-history (46 tests)
- Update docs (codebase-summary, system-architecture, code-standards, README) for pixel pipeline and capture/apply contract
This commit is contained in:
tiennm99 committed 2026-04-20 22:22:03 +07:00
1 parent 9eced8a87f
commit 97a0a747a7
22 files changed
+1306 -161

No files matched your search

+3 -2
View File
@@ -79,15 +79,16 @@ The local development server runs on `http://localhost:5173` by default. Vite wi
| Path | Description |
|------------------------------|------------------------------------------------------------|
| `index.html` | HTML entry point |
| `public/assets` | Static assets (images) served at runtime |
| `public/style.css` | Global layout styles and CSS variables |
| `public/assets` | Static assets + pixel-art authoring source (JSX canvas) |
| `src/main.js` | Application bootstrap |
| `src/App.svelte` | Scene router |
| `src/scenes/` | Game scenes (MainMenu, Game, Guide, Settings, etc.) |
| `src/components/` | Reusable UI components (GameBoard, PlayerSprite, etc.) |
| `src/lib/game/` | Pure JS game engine (grid, player, guards, turns, history) |
| `src/lib/pixel/` | Pixel-art renderer + palette + sprite/tile/UI/scene art |
| `src/lib/levels/` | Level definitions (12 levels) |
| `src/lib/` | Audio, localization, progress persistence |
| `src/styles/theme.css` | Global layout styles and CSS variables |
## Game Architecture
+54 -13
View File
@@ -40,24 +40,34 @@ src/
│
├── components/ # Reusable UI components
│ ├── Button.svelte
│ ├── GameBoard.svelte # CSS grid rendering
│ ├── GameHud.svelte # Level/lives/turns display
│ ├── PlayerSprite.svelte # Positioned player div
│ ├── GuardSprite.svelte # Colored guard circle/diamond
│ ├── GameBoard.svelte # Grid of pixel tiles
│ ├── GameHud.svelte # Pixel hearts + pixel icons + level/turns
│ ├── PlayerSprite.svelte # Pixel rabbit
│ ├── GuardSprite.svelte # Pixel veggie dispatched by guard.type
│ ├── PauseMenu.svelte
│ └── DetectionPopup.svelte
│ ├── DetectionPopup.svelte
│ ├── LevelCompletePopup.svelte
│ └── ControlsOverlay.svelte
│
├── lib/
│ ├── game/ # Pure JS game engine (no framework)
│ │ ├── grid-system.js
│ │ ├── player.js
│ │ ├── guards.js # Base + 6 guard subclasses (including ChaserGuard)
│ │ ├── turn-manager.js
│ │ ├── guards.js # Base + 6 guard subclasses; capture()/apply() contract
│ │ ├── turn-manager.js # Uses guard.capture()/apply() for non-destructive preview
│ │ ├── level-manager.js # GUARD_REGISTRY factory pattern
│ │ ├── game-history.js # Undo/redo system
│ │ ├── princess-mechanic.js # Level 12 escalating detection
│ │ ├── princess-mechanic.js # Level 12 escalating detection; capture()/apply()
│ │ └── touch-controls.js # Mobile swipe support
│ │
│ ├── pixel/ # Pixel-art rendering pipeline
│ │ ├── Pixel.svelte # String-art + palette → SVG rects
│ │ ├── palette.js # NNTV color constants
│ │ ├── art-characters.js # Rabbit, princess, 6 veggie guards
│ │ ├── art-tiles.js # 16×16 board tiles
│ │ ├── art-ui.js # Hearts, moon, logo, icons
│ │ └── art-scenes.js # 80×N act backdrops + sceneForLevel(n)
│ │
│ ├── levels/
│ │ └── levels.js # 12 level definitions
│ │
@@ -166,6 +176,8 @@ All visual constants centralized in `src/styles/theme.css` as CSS variables:
Component-scoped `<style>` blocks reference these variables. No inline color values.
**Pixel-art color coupling:** Guard colors are duplicated in `src/lib/pixel/palette.js` as `NNTV.guardStatic`, `NNTV.guardRotating`, etc. These MUST stay in sync with the `--guard-*` CSS variables because gameplay readability depends on consistent color semantics across CSS cells and SVG sprites. Change both sides together.
## Error Handling
- **Try-catch**: localStorage operations (`progress.js`, `localization.js`)
@@ -198,13 +210,40 @@ import { GameHistory } from '../lib/game/game-history.js';
const history = new GameHistory();
// Before player move
history.snapshot(player, guards, turnCount, princessAlerted, alertRadius);
const snap = history.createSnapshot(player, guards, turnCount, princess.capture());
history.pushSnapshot(snap);
// Z/Y key handlers
if (event.key === 'z') history.undo(player, guards);
if (event.key === 'y') history.redo(player, guards);
if (event.key === 'z') {
const state = history.undo(player, guards, turnManager, princess.capture());
if (state) princess.apply(state.princess);
}
if (event.key === 'y') {
const state = history.redo(player, guards, turnManager, princess.capture());
if (state) princess.apply(state.princess);
}
```
### Guard capture()/apply() Contract
Every guard exposes `capture()` (returns dynamic state object) and `apply(state)` (restores). Used by both `GameHistory` and `TurnManager.previewNextTurn` — new dynamic fields picked up automatically without touching either caller.
```javascript
class Guard {
capture() { return { row, col, direction, isOn }; }
apply(s) { this.row = s.row; ... }
}
// Subclasses override via super.capture(), adding their own fields (isChasing, currentPathIndex, etc.)
```
### Pixel Sprite Usage
```svelte
import Pixel from '../lib/pixel/Pixel.svelte';
import { RABBIT_ART, RABBIT_PAL } from '../lib/pixel/art-characters.js';
<Pixel art={RABBIT_ART} palette={RABBIT_PAL} width={size} height={size} />
```
Art data is string arrays of equal length; palette maps single characters → hex colors; `.`/` ` is transparent.
### Mobile Touch Controls
```javascript
import { TouchControls } from '../lib/game/touch-controls.js';
@@ -228,10 +267,12 @@ audio.toggleMute();
- [ ] Follows naming conventions (PascalCase components, kebab-case JS modules)
- [ ] No dead code or commented-out blocks
- [ ] Class mutations followed by `renderVersion++`
- [ ] No hardcoded colors (use CSS variables)
- [ ] No hardcoded colors (use CSS variables or NNTV palette constants)
- [ ] Localization keys used for all user-facing strings
- [ ] File size under 200 lines
- [ ] File size under 200 lines (pixel art data files exempt)
- [ ] Pure JS game logic has no Svelte imports
- [ ] Guard types registered in GUARD_REGISTRY (not switch statements)
- [ ] Guards extending the hierarchy override `capture()/apply()` if they add dynamic state
- [ ] Touch input debounced/throttled if needed
- [ ] Audio context lazily initialized (autoplay policy compliance)
- [ ] Pixel color semantics (`--guard-*` CSS vars ↔ `NNTV.guard*`) kept in sync when modifying theme
+45 -10
View File
@@ -30,12 +30,14 @@ NNTV is a turn-based stealth puzzle game built with Svelte 5 and Vite 6.x. The c
| File | Purpose |
|------|---------|
| Button.svelte | Reusable styled button |
| GameBoard.svelte | CSS grid rendering of cells |
| GameHud.svelte | Level, lives, turns display bar |
| PlayerSprite.svelte | Absolutely positioned player div |
| GuardSprite.svelte | Colored circle (or diamond for mirror) |
| GameBoard.svelte | CSS grid of cells, each rendering a pixel-art tile |
| GameHud.svelte | Level, lives (pixel hearts), turns, pixel-icon action buttons |
| PlayerSprite.svelte | Pixel-art ninja rabbit sprite |
| GuardSprite.svelte | Pixel-art veggie sprite dispatched by guard.type + direction indicator |
| DetectionPopup.svelte | "Detected!" overlay with retry |
| LevelCompletePopup.svelte | Star rating, best moves, next-level action |
| PauseMenu.svelte | Resume / restart / main menu |
| ControlsOverlay.svelte | Keyboard / tap / swipe reference overlay |
### Game Engine (src/lib/game/) — Pure JS, no Svelte
@@ -56,6 +58,17 @@ NNTV is a turn-based stealth puzzle game built with Svelte 5 and Vite 6.x. The c
|------|---------|
| levels.js | LEVELS array: 12 level definitions (grid, guards, walls, goals) |
### Pixel-Art Pipeline (src/lib/pixel/)
| File | Purpose |
|------|---------|
| Pixel.svelte | SVG renderer: string-art + palette → run-length-merged `<rect>`s |
| palette.js | NNTV color constants (mirrors theme.css guard colors; extended atmosphere palette) |
| art-characters.js | 32×32 sprites: rabbit, princess, 6 guard veggies + GUARD_SPRITES map |
| art-tiles.js | 16×16 board tiles: empty, wall, goal, lit, mirror, preview |
| art-ui.js | Heart (full/empty), moon, logo, pixel icons (undo/redo/eye/pause/settings/lang/arrow) |
| art-scenes.js | 80×N act backdrops + SCENE_BY_LEVEL mapping (garden/walls/fortress/underground/palace/chamber) |
### Audio System (src/lib/)
| File | Purpose |
@@ -148,9 +161,28 @@ new Player(grid, row, col)
### Guards
```
All: .updateLight(allGuards?), .onTurnChange(allGuards?)
All: .capture() → state, .apply(state) # dynamic-state snapshot for undo/preview
RotatingGuard: .castBeam(dir, fromRow, fromCol, range, allGuards, depth)
PatrollingGuard: .checkIfCircularPath(), path traversal with reversing
MirrorGuard: .reflectDirection ('cw' or 'ccw')
ChaserGuard: .bfsNextStep(targetRow, targetCol), hunting/returning state
```
### PrincessMechanic
```
new PrincessMechanic()
.update(grid, player, goalRow, goalCol) → { showMessage, detected }
.lightRing(grid, goalRow, goalCol, radius)
.capture() → { alerted, alertRadius, messageShown }, .apply(state)
.reset()
```
### GameHistory
```
new GameHistory()
.createSnapshot(player, guards, turnCount, princessState) → snapshot
.pushSnapshot(snap), .undo(...), .redo(...)
.canUndo(), .canRedo(), .reset()
```
### TurnManager
@@ -177,14 +209,17 @@ App.svelte → all scenes
Game.svelte (central hub)
├── lib/game/level-manager.js → grid-system, player, guards, levels
├── lib/game/turn-manager.js
├── lib/progress.js
├── lib/localization.js
├── lib/game/game-history.js, princess-mechanic.js, touch-controls.js
├── lib/audio.js, lib/progress.js, lib/localization.js
├── lib/pixel/Pixel.svelte, lib/pixel/art-scenes.js (act backdrop)
├── components/GameBoard, PlayerSprite, GuardSprite, GameHud
├── components/DetectionPopup, PauseMenu
├── components/DetectionPopup, LevelCompletePopup, PauseMenu, ControlsOverlay
└── renderVersion pattern for reactivity
LevelSelect.svelte → levels.js, progress.js, localization.js
LevelIntro.svelte → levels.js, localization.js
LevelSelect.svelte → level-manager.getTotalLevels(), progress.js, localization.js
LevelIntro.svelte → levels.js, lib/pixel/art-scenes.js, localization.js
MainMenu.svelte → lib/pixel/{Pixel, art-ui, art-characters}
Pixel components (PlayerSprite/GuardSprite/GameBoard/GameHud) → lib/pixel/Pixel + art-*
All scenes → localization.js (for UI text)
```
@@ -192,7 +227,7 @@ All scenes → localization.js (for UI text)
| Metric | Value |
|--------|-------|
| Total Source Files | ~25 (8 scenes + 7 components + 8 engine + audio + utils) |
| Total Source Files | ~36 (8 scenes + 9 components + 8 engine + 6 pixel + audio + utils) |
| Number of Classes | 10 (GridSystem, Player, Guard + 6 subclasses, TurnManager, GameHistory, TouchControls) |
| Number of Levels | 12 (across 6 acts) |
| Guard Types | 6 (Static, Rotating, Blinking, Mirror, Patrolling, Chaser) |
+25 -9
View File
@@ -81,12 +81,22 @@ Game.svelte (state owner, input handler, render coordinator)
│ └── nextTurn(): clear lights → update guards → detect
│
└── Svelte Components (rendering only)
├── GameBoard.svelte — CSS grid of cells
├── PlayerSprite.svelte — positioned div
├── GuardSprite.svelte — colored circle/diamond
├── GameHud.svelte — level, lives, turns display
├── GameBoard.svelte — CSS grid; each cell wraps a <Pixel> tile sprite
├── PlayerSprite.svelte — <Pixel> ninja rabbit (32×32)
├── GuardSprite.svelte — <Pixel> veggie sprite dispatched by guard.type
├── GameHud.svelte — pixel hearts + pixel icons + level/turns text
├── DetectionPopup.svelte — retry prompt
├── LevelCompletePopup.svelte — star rating + next-level action
├── ControlsOverlay.svelte — keyboard/tap/swipe reference
└── PauseMenu.svelte — resume/restart/menu
Pixel Pipeline (src/lib/pixel/)
├── Pixel.svelte — string-art + palette → inline SVG <rect>s (run-length merged, pixelated rendering)
├── palette.js — NNTV color constants; semantic guard colors mirror theme.css
├── art-characters.js — player, princess, 6 guard veggies
├── art-tiles.js — board tiles (empty/wall/goal/lit/mirror/preview)
├── art-ui.js — hearts, moon, logo, pixel icons
└── art-scenes.js — 6 act backdrops + sceneForLevel(n)
```
## Svelte 5 Reactivity Pattern
@@ -185,15 +195,18 @@ checkFinalLevel() — called each turn on final level
```javascript
const history = new GameHistory();
history.snapshot(player, guards, turnCount, princessAlerted, alertRadius);
const state = history.undo(player, guards); // Restores: row, col, direction, isOn per guard
const state = history.redo(player, guards); // Re-applies undone state
const snap = history.createSnapshot(player, guards, turnCount, princess.capture());
history.pushSnapshot(snap);
const state = history.undo(player, guards, turnManager, princess.capture()); // restores via g.apply(state.guards[i])
const state = history.redo(player, guards, turnManager, princess.capture());
princess.apply(state.princess); // { alerted, alertRadius, messageShown }
```
- Max history size: 50 snapshots
- Triggered by Z key (undo) / Y key (redo)
- Snapshots reset redo stack on any new action
- Restores: player position, guard positions/directions/states, turn count, princess alert state
- Restores: player position, guards via `capture()/apply()` (dynamic state only), turn count, full princess state (including `messageShown`)
- Same `capture()/apply()` contract is used by `TurnManager.previewNextTurn` — any new dynamic guard field picked up automatically
## Chaser Guard (BFS Pathfinding)
@@ -245,12 +258,15 @@ audio.toggleMute(); // Toggle global mute
## Asset & Resource Management
- **No sprites/images**: Pure CSS rendering (colored divs, borders)
- **Pixel art**: Inlined as string-art + palette constants in `src/lib/pixel/art-*.js`; rendered via `Pixel.svelte` as SVG `<rect>`s with run-length merging. No external image files.
- **Semantic color coupling**: Guard colors duplicated between `theme.css` CSS variables and `NNTV.guard*` JS constants — both sides must stay in sync for gameplay readability.
- **Act backdrops**: `sceneForLevel(n)` maps level → 80×N scene; used by `LevelIntro` and `Game` as opacity-dimmed backdrop.
- **Localization**: JSON files (`src/lib/locales/en.json`, `vi.json`)
- **Levels**: JS objects in `levels.js`
- **Progress**: localStorage key `nntv-progress`
- **Language**: localStorage key `nntv-language`
- **Audio**: Web Audio API (no external files)
- **Source-of-truth pixel authoring**: `public/assets/src/*.jsx` (Figma-style JSX canvas preview; ported manually into `src/lib/pixel/`)
## Build & Deployment
+371 -13
View File
@@ -13,7 +13,8 @@
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"vite": "^6.3.6"
"vite": "^6.3.6",
"vitest": "^4.1.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -766,6 +767,13 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
@@ -814,6 +822,24 @@
"vite": "^6.3.0 || ^7.0.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -826,6 +852,119 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -847,6 +986,16 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -856,6 +1005,16 @@
"node": ">= 0.4"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -865,6 +1024,13 @@
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -881,6 +1047,13 @@
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"license": "MIT"
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
@@ -945,12 +1118,35 @@
}
}
},
"node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -1029,6 +1225,13 @@
],
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1037,9 +1240,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1118,6 +1321,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1128,6 +1338,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svelte": {
"version": "5.55.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz",
@@ -1155,15 +1379,32 @@
"node": ">=18"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -1172,6 +1413,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vite": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
@@ -1267,6 +1518,113 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+5 -2
View File
@@ -8,11 +8,14 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"vite": "^6.3.6"
"vite": "^6.3.6",
"vitest": "^4.1.4"
},
"dependencies": {
"svelte": "^5.55.3"
@@ -0,0 +1,132 @@
# Code Review: Night Ninja: Twilight Voyage (full project)
## Summary
Small, well-scoped Svelte 5 + Vite 6 stealth puzzle (~4k LOC). Pure-JS game engine in `lib/game/` is cleanly separated from Svelte rendering. Recent pixel-art integration (commit 9eced8a) introduces `lib/pixel/` with a simple string→SVG renderer; it works but adds ~94KB of inlined art data. Build succeeds with two `state_referenced_locally` warnings (silenced). Docs drifted after pixel integration and still describe the pre-pixel codebase. Zero automated tests; several pure-JS modules are cheaply testable and worth covering. No critical security/correctness bugs; a handful of real but low-impact issues around stale state on bad level ids, undo/redo discarding princess state partially, and preview-simulation side effects on real guard state.
## Strengths
- Clean engine/render separation; `renderVersion` pattern is documented and consistently applied.
- All user-facing strings localized via `getText`; XSS-safe by default (Svelte interpolation, no `{@html}`).
- `try/catch` around all `localStorage` reads/writes (`progress.js`, `localization.js`).
- Pure-JS classes (`GridSystem`, `Player`, `TurnManager`, `GameHistory`, `TouchControls`, guard hierarchy) have tight, single-responsibility APIs.
- GUARD_REGISTRY factory pattern in `level-manager.js` removes switch boilerplate.
- File sizes largely under 200 lines; only `Game.svelte` (396), `guards.js` (347), `levels.js` (524, data), and `art-characters.js` (330, data) exceed — data files are acceptable.
## Issues by severity
### Critical
None.
### High
- **`src/scenes/Game.svelte:74`** — If `loadLevel(currentLevel)` returns `null` (invalid id), `initLevel` returns early leaving `grid`, `player`, `guards` from the previous level (or `null`, triggering crashes when handlers run). No user-facing guard.
Fix: when `state` is null, navigate to `MainMenu` or clamp to 1.
- **`src/lib/game/turn-manager.js:31-71` (`previewNextTurn`)** — "Preview" mutates real guard state, collects lit cells, then restores from snapshot. If any guard subclass gains state fields not in the snapshot list (e.g., future `cooldown`, `lastSeen`), the preview will silently corrupt the real game. Also `grid.clearAllLight()` + `updateLight(guards)` at the end re-runs mirror reflection but the beam order now differs from a fresh turn-start state.
Fix: deep-clone guards into sandbox copies, run on the sandbox, don't mutate live instances. Or add explicit "all snapshotted fields" contract per guard type.
- **`src/scenes/Game.svelte:186-208` (`handleUndo`/`handleRedo`)** — After restoring player/guards, the code calls `grid.clearAllLight()` then `g.updateLight(guards)` for every guard. But guards with direction-dependent light (rotating, patrolling, chaser) need their light recomputed based on direction — which IS restored correctly — so this generally works. BUT `princess.alerted` is restored from `state.princessAlerted`, which was **never captured** at push time for move snapshots: `createSnapshot` at line 100-101 passes `princess.alerted, princess.alertRadius`, but the snapshot is created BEFORE the move's effects — so the restored princess state matches the moment before the move, which is correct. Exception: on final level, if princess was alerted BEFORE the move, undoing restores pre-move alerted=true/radius=N; but the `lightRing` is recomputed with that radius. Fine, but `princess.messageShown` is never snapshotted/restored → first re-alert after an undo back across the alert boundary will skip `showMessage: true` because `alerted` is already true. Minor UX bug.
Fix: snapshot `messageShown`, or tie message display strictly to `!prevAlerted && nowAlerted`.
- **`src/scenes/Game.svelte:74` / `src/lib/game/turn-manager.js:14`** — `nextTurn` checks `isGoal` BEFORE guard update, so reaching goal never triggers the final guard turn. That's by design, but combined with the preview path (which runs real guard turn), the preview and the actual "step onto goal" disagree. Not wrong, just subtle — confirm this is intended behavior.
- **Build warnings `state_referenced_locally` (`Game.svelte:33, 34`)** — Silenced with `svelte-ignore`, but the underlying pattern (`$state(level)` reading the prop's initial value only) means if the parent remounts Game with new props without scene-key change (unlikely given `{#key currentScene}` pattern), the state would desync. With the current navigate flow each Game instance is a fresh mount, so this is safe. Prefer `$derived(level)` if the goal is "latest prop", or a clarifying comment noting "value is only read on mount; parent remounts scene via {#key}".
### Medium
- **`src/lib/game/turn-manager.js:15-16`** — When `player` is at goal, `nextTurn` returns early without clearing lights. Next turn's `grid.clearAllLight()` will run, but intermediate renders show stale lights. Low impact, but combined with the LevelComplete popup it usually isn't visible.
- **`src/lib/game/princess-mechanic.js:24-30`** — `update` increments `alertRadius` on every call once alerted, even if `update` is called twice in one turn. `Game.svelte` only calls it via `checkFinalLevel` per turn, so fine today, but class is not idempotent.
- **`src/lib/game/game-history.js:32-47`** — `snapshotGuard` stores `currentPathIndex/isReversing` only for `patrolling`, `isChasing/isReturning/target*` only for `chaser`. If a rotating guard were ever given a state field, snapshot would silently drop it. Fragile. Use `Object.assign({}, g)` filtered to primitives, or have each guard class expose `snapshot()/restore(s)`.
- **`src/scenes/LevelSelect.svelte:7`** — `const totalLevels = 12;` hardcoded. `getTotalLevels()` already exists. Source of future drift when adding levels.
Fix: `import { getTotalLevels } from '../lib/game/level-manager.js';` and call.
- **`src/scenes/Game.svelte:59`** — `cellSize = $derived(grid ? Math.min(50, Math.floor(500 / grid.rows)) : 50);` — fixed 500px divisor ignores viewport width and `grid.cols`. On narrow mobile or non-square grids (levels use up to 10×10 but most are ≤7×7), the board can overflow horizontally. No viewport query.
Fix: compute from `Math.min(availableW / cols, availableH / rows)` or read container size.
- **`src/components/GameBoard.svelte:41` + inline Svelte `{@const}`** — Two `previewCells.has(...)` calls per cell (`class:preview` and `{#if}`). Extract once via `{@const}`. Micro, but cleanup.
- **`src/scenes/Settings.svelte:7-14`** — `tick++` + `{#key tick}` used to force re-render on language change. With Svelte 5 runes, `getText` is not reactive because translations are pulled from a module-level `currentLanguage` variable. This works by mounting the entire subtree — acceptable but note that other scenes don't pick up the change until remount (MainMenu does via App's `{#key currentScene}`, same for Game). If user flips language mid-game and resumes, loaded story text stays until navigation. Low priority.
- **`src/lib/audio.js:17`** — `audioCtx.resume()` is unawaited; returns a Promise. Chrome/Safari fine in practice, but on some mobile browsers first few tones may be silent.
Fix: `await` inside an async wrapper, or schedule notes after resume resolves.
- **`src/lib/pixel/Pixel.svelte:4`** — `scale = 6` prop is unused when `width`/`height` are provided (always, in current callers). Dead default.
- **`src/lib/pixel/art-scenes.js:5`** — `norm` helper pads with `r.at(-1)` (last char of prior row) if row too short. Silent padding with an unrelated palette char can produce artifacts if an author miscounts. Assert or pad with `.` (transparent).
### Low
- **File naming inconsistency** — `components/` mixes PascalCase (`Button.svelte`, `GameBoard.svelte`) with kebab-case (`controls-overlay.svelte`). `docs/code-standards.md` says PascalCase for components. Either rename `controls-overlay.svelte` → `ControlsOverlay.svelte` (already imported as `ControlsOverlay`) or update the doc convention.
- **`src/scenes/Game.svelte:53-54`** — Comment "class instances are not proxied" accurate; good. Consider extracting the `renderVersion` + `$derived` pattern into a helper once more modules need it (YAGNI for now).
- **`src/lib/game/guards.js:177-213` (`bfsNextStep`)** — Standard BFS, correct. `queue.shift()` is O(n); for 10×10 grids (<100 cells) fine.
- **`src/lib/localization.js:9`** — `currentLanguage = 'vi'` hardcoded default; docstring says same. If requirements change to EN default, two places to update. Minor.
- **`src/scenes/StoryIntro.svelte:9-11`** — `document.querySelector('.story-scroll')` from `onMount`. Works because CSS animation uses `animationend`. Fragile if selector collides; use a `bind:this` ref.
- **`src/scenes/Game.svelte:180`** — `setTimeout(..., 400)` without ref; if user navigates mid-timeout the component is destroyed and the callback writes to dead state. Svelte 5 is tolerant but use `onMount` cleanup or `$effect` with cleanup.
- **`src/components/GameHud.svelte:8`** — `lives` default `3`, but `MAX_LIVES` constant is also `3`. If `lives` prop ever exceeds `MAX_LIVES`, hearts are silently truncated. Cosmetic.
- **Perf (pixel backdrop)** — `LevelIntro` and `Game` each render a `Pixel` backdrop via SVG. After run-length compression ≤1-2KB of rects. Re-renders only on level change (due to `$derived` on `currentLevel`). No action needed.
- **Bundle size** — 129KB JS (40KB gzip) is fine for a small game. Art data (~8KB compressed) inlined in the JS chunk. Not worth code-splitting given total size.
## Docs drift
All three docs pre-date the pixel integration (commit 9eced8a). Required updates:
- **`docs/codebase-summary.md`**
- Line 28-38 Components table: missing `LevelCompletePopup.svelte`, `controls-overlay.svelte`; `GuardSprite.svelte` described as "Colored circle" (now a Pixel sprite).
- Missing the `src/lib/pixel/` subsystem entirely (5 files, 773 LOC).
- File Dependency Map (line 171) doesn't mention `Pixel`, `art-*`, `palette`.
- Stats (line 193): "Total Source Files ~25" — actually ~35 now.
- "No sprites/images" (line 248 of architecture) is now wrong; pixel-art sprites are present but inlined as JS strings.
- **`docs/system-architecture.md`**
- Line 83-90 "Svelte Components" still says "PlayerSprite — positioned div", "GuardSprite — colored circle/diamond". Both now wrap `<Pixel>`.
- Line 246-253 "Asset & Resource Management — No sprites/images: Pure CSS rendering". Outdated.
- Missing section on pixel rendering pipeline (string-art + palette + SVG rect merge).
- **`docs/code-standards.md`**
- Line 41-48 Components table missing `LevelCompletePopup.svelte`, `controls-overlay.svelte`.
- Directory Structure (line 26-73) missing `src/lib/pixel/` entirely.
- File-naming rule says "PascalCase" for components but `controls-overlay.svelte` exists. Resolve one way.
- "No sprites/images" implication in theme section still holds but worth clarifying the pixel pipeline's relationship to theme.css guard colors (duplicated as `NNTV.guard*` constants — document the coupling).
- **`README.md`** Project Structure table (line 79-91) — does not mention `src/lib/pixel/`. Minor.
## Test coverage gap
No tests, no framework installed. Priority targets (pure JS, deterministic, cheap):
1. **`src/lib/game/grid-system.js`** — bounds, setters, `getAllCells`. Smoke-level only.
2. **`src/lib/game/player.js`** — `move` direction mapping, wall/bounds rejection.
3. **`src/lib/game/turn-manager.js`** — especially `previewNextTurn` (state restoration is the highest-risk code in the engine). Covers the Medium/High fragility noted above.
4. **`src/lib/game/game-history.js`** — undo/redo round-trip for each guard type. Would catch the missing `messageShown` snapshot.
5. **`src/lib/game/guards.js`** — `RotatingGuard.castBeam` with mirror bounce (depth cap, boundary, wall), `ChaserGuard.bfsNextStep` on wall mazes, `PatrollingGuard` circular vs reversing path. Most logic-heavy module, highest defect risk.
6. **`src/lib/game/princess-mechanic.js`** — ring expansion, detection at each radius.
7. **`src/lib/progress.js`** — star calculation, localStorage serialization survives corruption.
Svelte component tests (via `@testing-library/svelte` or `vitest` + `jsdom`) are worth deferring — the components are thin and mostly props-through.
Recommended: add `vitest` as a dev dep, create `src/lib/game/*.test.js` for items 1-7. Target: 60-70% statement coverage of `lib/game/`. Skip UI/scene tests initially.
## Unresolved questions
- Is `previewNextTurn` mutating real guards intentional (for perf), or a candidate for immediate refactor to sandbox copies?
- Is `currentLanguage = 'vi'` the permanent default, or should it detect `navigator.language`?
- Is `isFinalLevel` behavior of skipping guard detection after princess detect intended? (Currently both code paths can fire in sequence — guard detect wins the popup order.)
- Should `LevelSelect`'s hardcoded `totalLevels = 12` become reactive to `getTotalLevels()` now, anticipating level additions?
---
**Status:** DONE_WITH_CONCERNS
**Summary:** Project is in good shape architecturally; pixel integration is clean but docs weren't updated to match. One high-impact correctness risk in `previewNextTurn` state mutation, one High issue with `initLevel` silent-fail on invalid level, and undo state incompleteness for princess `messageShown`. Docs and tests are the primary gaps.
**Concerns/Blockers:** None blocking; recommend addressing the High items before the next feature phase.
File renamed without changes.
+5 -1
View File
@@ -14,7 +14,11 @@ function getContext() {
masterGain.connect(audioCtx.destination);
} catch (e) { return null; }
}
if (audioCtx.state === 'suspended') audioCtx.resume();
if (audioCtx.state === 'suspended') {
// Resume returns a Promise — swallow rejections so we don't emit
// unhandled-rejection warnings on browsers that block autoplay.
audioCtx.resume().catch(() => {});
}
return audioCtx;
}
+13 -63
View File
@@ -1,5 +1,5 @@
// Undo/redo state history for game turns
// Snapshots player position, guard states, turn count, and princess alert state
// Undo/redo state history for game turns.
// Snapshots player, guards (via guard.capture()), turn count, and princess state.
const MAX_HISTORY = 50;
@@ -9,99 +9,49 @@ export class GameHistory {
this.redoStack = [];
}
// Create a snapshot object without pushing to history
createSnapshot(player, guards, turnCount, princessAlerted, alertRadius) {
// Create a snapshot object without pushing to history.
// princessState: { alerted, alertRadius, messageShown } via princess.capture()
createSnapshot(player, guards, turnCount, princessState) {
return {
playerRow: player.row,
playerCol: player.col,
turnCount,
princessAlerted,
alertRadius,
guards: guards.map(g => this.snapshotGuard(g)),
princess: { ...princessState },
guards: guards.map(g => g.capture()),
};
}
// Push a pre-created snapshot to the undo stack
pushSnapshot(state) {
this.undoStack.push(state);
if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift();
// Any new action clears the redo stack
this.redoStack = [];
}
snapshotGuard(g) {
const snap = {
row: g.row, col: g.col, direction: g.direction, isOn: g.isOn,
};
if (g.type === 'patrolling') {
snap.currentPathIndex = g.currentPathIndex;
snap.isReversing = g.isReversing;
}
if (g.type === 'chaser') {
snap.isChasing = g.isChasing;
snap.isReturning = g.isReturning;
snap.targetRow = g.targetRow;
snap.targetCol = g.targetCol;
}
return snap;
}
// Restore game state from a snapshot
restoreGuard(guard, snap) {
guard.row = snap.row;
guard.col = snap.col;
guard.direction = snap.direction;
guard.isOn = snap.isOn;
if (snap.currentPathIndex !== undefined) guard.currentPathIndex = snap.currentPathIndex;
if (snap.isReversing !== undefined) guard.isReversing = snap.isReversing;
if (snap.isChasing !== undefined) guard.isChasing = snap.isChasing;
if (snap.isReturning !== undefined) guard.isReturning = snap.isReturning;
if (snap.targetRow !== undefined) guard.targetRow = snap.targetRow;
if (snap.targetCol !== undefined) guard.targetCol = snap.targetCol;
}
// Undo last move, returns restored state or null if nothing to undo
undo(player, guards, turnManager, princessAlerted, alertRadius) {
undo(player, guards, turnManager, princessState) {
if (this.undoStack.length === 0) return null;
// Save current state to redo stack before undoing
this.redoStack.push({
playerRow: player.row,
playerCol: player.col,
turnCount: turnManager.turnCount,
princessAlerted,
alertRadius,
guards: guards.map(g => this.snapshotGuard(g)),
});
this.redoStack.push(this.createSnapshot(player, guards, turnManager.turnCount, princessState));
const state = this.undoStack.pop();
player.row = state.playerRow;
player.col = state.playerCol;
turnManager.turnCount = state.turnCount;
guards.forEach((g, i) => this.restoreGuard(g, state.guards[i]));
guards.forEach((g, i) => g.apply(state.guards[i]));
return state;
}
// Redo last undone move
redo(player, guards, turnManager, princessAlerted, alertRadius) {
redo(player, guards, turnManager, princessState) {
if (this.redoStack.length === 0) return null;
// Save current state to undo stack
this.undoStack.push({
playerRow: player.row,
playerCol: player.col,
turnCount: turnManager.turnCount,
princessAlerted,
alertRadius,
guards: guards.map(g => this.snapshotGuard(g)),
});
this.undoStack.push(this.createSnapshot(player, guards, turnManager.turnCount, princessState));
const state = this.redoStack.pop();
player.row = state.playerRow;
player.col = state.playerCol;
turnManager.turnCount = state.turnCount;
guards.forEach((g, i) => this.restoreGuard(g, state.guards[i]));
guards.forEach((g, i) => g.apply(state.guards[i]));
return state;
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GridSystem } from './grid-system.js';
import { Player } from './player.js';
import { TurnManager } from './turn-manager.js';
import { GameHistory } from './game-history.js';
import { PrincessMechanic } from './princess-mechanic.js';
import { RotatingGuard, ChaserGuard, PatrollingGuard } from './guards.js';
describe('GameHistory', () => {
let grid, player, tm, history, princess;
beforeEach(() => {
grid = new GridSystem(5, 5, 50);
player = new Player(grid, 0, 0);
tm = new TurnManager();
history = new GameHistory();
princess = new PrincessMechanic();
});
it('round-trips player position through undo', () => {
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture());
history.pushSnapshot(snap);
player.row = 2;
player.col = 3;
tm.turnCount = 5;
const state = history.undo(player, [], tm, princess.capture());
expect(state).not.toBeNull();
expect(player.row).toBe(0);
expect(player.col).toBe(0);
expect(tm.turnCount).toBe(0);
});
it('round-trips guard state through undo', () => {
const g = new RotatingGuard(grid, 2, 2, 0);
const snap = history.createSnapshot(player, [g], tm.turnCount, princess.capture());
history.pushSnapshot(snap);
g.direction = 2;
history.undo(player, [g], tm, princess.capture());
expect(g.direction).toBe(0);
});
it('preserves chaser dynamic state (isChasing, targetRow)', () => {
const g = new ChaserGuard(grid, 0, 0, 3);
const snap = history.createSnapshot(player, [g], tm.turnCount, princess.capture());
history.pushSnapshot(snap);
g.isChasing = true;
g.targetRow = 4;
g.targetCol = 4;
history.undo(player, [g], tm, princess.capture());
expect(g.isChasing).toBe(false);
expect(g.targetRow).toBe(0);
expect(g.targetCol).toBe(0);
});
it('preserves patrolling guard path index + reversing', () => {
const path = [{ row: 0, col: 0 }, { row: 0, col: 1 }];
const g = new PatrollingGuard(grid, 0, 0, path);
const snap = history.createSnapshot(player, [g], tm.turnCount, princess.capture());
history.pushSnapshot(snap);
g.currentPathIndex = 1;
g.isReversing = true;
history.undo(player, [g], tm, princess.capture());
expect(g.currentPathIndex).toBe(0);
expect(g.isReversing).toBe(false);
});
it('preserves princess.messageShown on undo', () => {
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture());
history.pushSnapshot(snap);
princess.alerted = true;
princess.messageShown = true;
princess.alertRadius = 3;
const state = history.undo(player, [], tm, princess.capture());
// Apply returned princess state — matches the pre-alert snapshot
princess.apply(state.princess);
expect(princess.messageShown).toBe(false);
expect(princess.alerted).toBe(false);
expect(princess.alertRadius).toBe(0);
});
it('undo then redo restores post-mutation state', () => {
const snap1 = history.createSnapshot(player, [], tm.turnCount, princess.capture());
history.pushSnapshot(snap1);
player.row = 3;
tm.turnCount = 7;
history.undo(player, [], tm, princess.capture());
expect(player.row).toBe(0);
history.redo(player, [], tm, princess.capture());
expect(player.row).toBe(3);
expect(tm.turnCount).toBe(7);
});
it('new action clears redo stack', () => {
const snap1 = history.createSnapshot(player, [], tm.turnCount, princess.capture());
history.pushSnapshot(snap1);
player.row = 2;
history.undo(player, [], tm, princess.capture());
expect(history.canRedo()).toBe(true);
const snap2 = history.createSnapshot(player, [], tm.turnCount, princess.capture());
history.pushSnapshot(snap2);
expect(history.canRedo()).toBe(false);
});
it('caps undo stack at MAX_HISTORY', () => {
for (let i = 0; i < 60; i++) {
const snap = history.createSnapshot(player, [], i, princess.capture());
history.pushSnapshot(snap);
}
expect(history.undoStack.length).toBe(50);
});
it('undo returns null on empty stack', () => {
expect(history.undo(player, [], tm, princess.capture())).toBeNull();
expect(history.redo(player, [], tm, princess.capture())).toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { GridSystem } from './grid-system.js';
describe('GridSystem', () => {
it('creates empty grid with given dimensions', () => {
const g = new GridSystem(3, 4, 50);
expect(g.rows).toBe(3);
expect(g.cols).toBe(4);
expect(g.grid.length).toBe(3);
expect(g.grid[0].length).toBe(4);
});
it('rejects out-of-bounds positions', () => {
const g = new GridSystem(3, 3, 50);
expect(g.isValidPosition(-1, 0)).toBe(false);
expect(g.isValidPosition(0, -1)).toBe(false);
expect(g.isValidPosition(3, 0)).toBe(false);
expect(g.isValidPosition(0, 3)).toBe(false);
expect(g.isValidPosition(0, 0)).toBe(true);
expect(g.isValidPosition(2, 2)).toBe(true);
});
it('ignores setters on invalid positions', () => {
const g = new GridSystem(2, 2, 50);
g.setWall(-1, 0, true);
g.setLight(5, 5, true);
expect(g.isWall(-1, 0)).toBe(false);
expect(g.isLight(5, 5)).toBe(false);
});
it('clearAllLight resets every cell', () => {
const g = new GridSystem(2, 2, 50);
g.setLight(0, 0, true);
g.setLight(1, 1, true);
g.clearAllLight();
expect(g.isLight(0, 0)).toBe(false);
expect(g.isLight(1, 1)).toBe(false);
});
it('getAllCells returns flat array with positions', () => {
const g = new GridSystem(2, 3, 50);
g.setWall(1, 2, true);
const cells = g.getAllCells();
expect(cells.length).toBe(6);
const wall = cells.find(c => c.row === 1 && c.col === 2);
expect(wall.isWall).toBe(true);
});
});
+45
View File
@@ -13,6 +13,19 @@ class Guard {
updateLight() {}
onTurnChange() {}
// Dynamic state snapshot — override in subclasses to add per-type fields.
// Used by GameHistory (undo/redo) and TurnManager.previewNextTurn.
capture() {
return { row: this.row, col: this.col, direction: this.direction, isOn: this.isOn };
}
apply(s) {
this.row = s.row;
this.col = s.col;
this.direction = s.direction;
this.isOn = s.isOn;
}
}
export class StaticGuard extends Guard {
@@ -212,6 +225,24 @@ export class ChaserGuard extends Guard {
return null; // no path found
}
capture() {
return {
...super.capture(),
isChasing: this.isChasing,
isReturning: this.isReturning,
targetRow: this.targetRow,
targetCol: this.targetCol,
};
}
apply(s) {
super.apply(s);
this.isChasing = s.isChasing;
this.isReturning = s.isReturning;
this.targetRow = s.targetRow;
this.targetCol = s.targetCol;
}
// Chaser has two states: hunting player or returning home
onTurnChange(allGuards, player) {
if (!player) { this.updateLight(); return; }
@@ -315,6 +346,20 @@ export class PatrollingGuard extends Guard {
else if (newCol < oldCol) this.direction = 3;
}
capture() {
return {
...super.capture(),
currentPathIndex: this.currentPathIndex,
isReversing: this.isReversing,
};
}
apply(s) {
super.apply(s);
this.currentPathIndex = s.currentPathIndex;
this.isReversing = s.isReversing;
}
onTurnChange() {
if (this.path.length <= 1) return;
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GridSystem } from './grid-system.js';
import {
StaticGuard, RotatingGuard, BlinkingGuard,
MirrorGuard, PatrollingGuard, ChaserGuard,
} from './guards.js';
describe('Guard.capture()/apply()', () => {
let grid;
beforeEach(() => { grid = new GridSystem(6, 6, 50); });
it('base fields round-trip on StaticGuard', () => {
const g = new StaticGuard(grid, 2, 2, []);
g.direction = 2;
g.isOn = false;
const s = g.capture();
g.direction = 0;
g.isOn = true;
g.apply(s);
expect(g.direction).toBe(2);
expect(g.isOn).toBe(false);
});
it('ChaserGuard preserves chase state', () => {
const g = new ChaserGuard(grid, 0, 0, 3);
g.isChasing = true;
g.isReturning = false;
g.targetRow = 4;
g.targetCol = 5;
const s = g.capture();
g.isChasing = false;
g.targetRow = 0;
g.apply(s);
expect(g.isChasing).toBe(true);
expect(g.targetRow).toBe(4);
expect(g.targetCol).toBe(5);
});
it('PatrollingGuard preserves path index + reversing', () => {
const path = [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }];
const g = new PatrollingGuard(grid, 0, 0, path);
g.currentPathIndex = 2;
g.isReversing = true;
const s = g.capture();
g.currentPathIndex = 0;
g.isReversing = false;
g.apply(s);
expect(g.currentPathIndex).toBe(2);
expect(g.isReversing).toBe(true);
});
});
describe('RotatingGuard beam + mirror', () => {
it('casts beam up to range and stops at wall', () => {
const grid = new GridSystem(5, 5, 50);
grid.setWall(2, 3, true);
const g = new RotatingGuard(grid, 2, 0, 1); // facing right
g.updateLight([g]);
expect(grid.isLight(2, 0)).toBe(true);
expect(grid.isLight(2, 1)).toBe(true);
expect(grid.isLight(2, 2)).toBe(true);
expect(grid.isLight(2, 3)).toBe(false); // wall blocks
});
it('rotates direction on turn change', () => {
const grid = new GridSystem(5, 5, 50);
const g = new RotatingGuard(grid, 2, 2, 0);
expect(g.direction).toBe(0);
g.onTurnChange([g]);
expect(g.direction).toBe(1);
g.onTurnChange([g]); g.onTurnChange([g]); g.onTurnChange([g]);
expect(g.direction).toBe(0); // wraps
});
it('bounces off mirror (cw)', () => {
const grid = new GridSystem(5, 5, 50);
const rot = new RotatingGuard(grid, 2, 0, 1); // facing right
const mir = new MirrorGuard(grid, 2, 2, 'cw');
rot.updateLight([rot, mir]);
// beam right→mirror at (2,2)→reflected 90° cw → row+0,col+- rotated: cw of {0,1} = {1,0} (down)
// So cells below mirror lit
expect(grid.isLight(2, 0)).toBe(true);
expect(grid.isLight(2, 2)).toBe(true); // mirror cell
expect(grid.isLight(3, 2)).toBe(true); // reflected beam
});
});
describe('BlinkingGuard', () => {
it('toggles isOn on turn change', () => {
const grid = new GridSystem(3, 3, 50);
const g = new BlinkingGuard(grid, 1, 1, [{ row: 0, col: 1 }], true);
expect(g.isOn).toBe(true);
g.onTurnChange();
expect(g.isOn).toBe(false);
g.onTurnChange();
expect(g.isOn).toBe(true);
});
it('lights cells only when on', () => {
const grid = new GridSystem(3, 3, 50);
const g = new BlinkingGuard(grid, 1, 1, [{ row: 0, col: 1 }], false);
g.updateLight();
expect(grid.isLight(0, 1)).toBe(false);
g.isOn = true;
g.updateLight();
expect(grid.isLight(0, 1)).toBe(true);
});
});
describe('ChaserGuard BFS', () => {
it('finds direct step toward adjacent target', () => {
const grid = new GridSystem(5, 5, 50);
const g = new ChaserGuard(grid, 0, 0, 3);
const step = g.bfsNextStep(0, 2);
expect(step).toEqual({ row: 0, col: 1 });
});
it('routes around walls', () => {
const grid = new GridSystem(3, 3, 50);
// Wall blocks direct path from (0,0) to (2,0)
grid.setWall(1, 0, true);
const g = new ChaserGuard(grid, 0, 0, 5);
const step = g.bfsNextStep(2, 0);
expect(step).not.toEqual({ row: 1, col: 0 });
expect([{ row: 0, col: 1 }]).toContainEqual(step);
});
it('returns null when no path exists', () => {
const grid = new GridSystem(3, 3, 50);
grid.setWall(0, 1, true);
grid.setWall(1, 0, true);
const g = new ChaserGuard(grid, 0, 0, 5);
const step = g.bfsNextStep(2, 2);
expect(step).toBeNull();
});
});
describe('PatrollingGuard', () => {
it('reverses at end of non-circular path', () => {
const grid = new GridSystem(3, 3, 50);
const path = [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }];
const g = new PatrollingGuard(grid, 0, 0, path);
g.onTurnChange(); // index 1
g.onTurnChange(); // index 2 (end) → triggers reverse
expect(g.col).toBe(2);
g.onTurnChange(); // reversing, back to index 1
expect(g.isReversing).toBe(true);
expect(g.col).toBe(1);
});
it('loops when path is circular', () => {
const grid = new GridSystem(3, 3, 50);
const path = [
{ row: 0, col: 0 }, { row: 0, col: 1 },
{ row: 1, col: 1 }, { row: 0, col: 0 },
];
const g = new PatrollingGuard(grid, 0, 0, path);
expect(g.isCircularPath).toBe(true);
g.onTurnChange(); g.onTurnChange(); g.onTurnChange();
// After 3 steps should wrap
expect(g.isReversing).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GridSystem } from './grid-system.js';
import { Player } from './player.js';
describe('Player', () => {
let grid;
beforeEach(() => { grid = new GridSystem(4, 4, 50); });
it('maps direction strings to row/col delta', () => {
const p = new Player(grid, 1, 1);
expect(p.move('up') && [p.row, p.col]).toEqual([0, 1]);
expect(p.move('right') && [p.row, p.col]).toEqual([0, 2]);
expect(p.move('down') && [p.row, p.col]).toEqual([1, 2]);
expect(p.move('left') && [p.row, p.col]).toEqual([1, 1]);
});
it('rejects moves into walls', () => {
grid.setWall(0, 1, true);
const p = new Player(grid, 0, 0);
expect(p.move('right')).toBe(false);
expect(p.row).toBe(0);
expect(p.col).toBe(0);
});
it('rejects moves out of bounds', () => {
const p = new Player(grid, 0, 0);
expect(p.move('up')).toBe(false);
expect(p.move('left')).toBe(false);
expect(p.row).toBe(0);
expect(p.col).toBe(0);
});
it('detects lit cell and goal cell', () => {
const p = new Player(grid, 0, 0);
expect(p.isInLitCell()).toBe(false);
grid.setLight(0, 0, true);
expect(p.isInLitCell()).toBe(true);
expect(p.isAtGoal()).toBe(false);
grid.setGoal(0, 0, true);
expect(p.isAtGoal()).toBe(true);
});
});
+14
View File
@@ -49,4 +49,18 @@ export class PrincessMechanic {
this.alertRadius = 0;
this.messageShown = false;
}
capture() {
return {
alerted: this.alerted,
alertRadius: this.alertRadius,
messageShown: this.messageShown,
};
}
apply(s) {
this.alerted = s.alerted;
this.alertRadius = s.alertRadius;
this.messageShown = s.messageShown;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GridSystem } from './grid-system.js';
import { PrincessMechanic } from './princess-mechanic.js';
// Minimal player stub — mechanic only reads row/col
const player = (row, col) => ({ row, col });
describe('PrincessMechanic', () => {
let grid, p;
beforeEach(() => {
grid = new GridSystem(8, 8, 50);
p = new PrincessMechanic();
});
it('does not alert if player is far from goal', () => {
const result = p.update(grid, player(0, 0), 7, 7);
expect(result.showMessage).toBe(false);
expect(p.alerted).toBe(false);
});
it('alerts and shows message when within Manhattan distance 4', () => {
const result = p.update(grid, player(5, 5), 7, 7);
expect(p.alerted).toBe(true);
expect(p.alertRadius).toBe(1);
expect(result.showMessage).toBe(true);
expect(p.messageShown).toBe(true);
});
it('expands radius each subsequent call', () => {
p.update(grid, player(5, 5), 7, 7); // alert
p.update(grid, player(5, 5), 7, 7); // radius 2
expect(p.alertRadius).toBe(2);
p.update(grid, player(5, 5), 7, 7);
expect(p.alertRadius).toBe(3);
});
it('detects player when they step into expanded ring', () => {
p.update(grid, player(5, 5), 7, 7); // alert, radius 1, lights (7,7) area
// Grow ring until it touches player at (4,4) — distance 6 from (7,7)
let detected = false;
for (let i = 0; i < 8 && !detected; i++) {
const r = p.update(grid, player(5, 5), 7, 7);
if (r.detected) detected = true;
}
expect(detected).toBe(true);
});
it('lightRing respects walls', () => {
grid.setWall(7, 6, true);
p.lightRing(grid, 7, 7, 1);
expect(grid.isLight(7, 7)).toBe(true);
expect(grid.isLight(7, 6)).toBe(false); // wall
expect(grid.isLight(6, 7)).toBe(true);
});
it('capture/apply round-trips all 3 fields including messageShown', () => {
p.alerted = true;
p.alertRadius = 3;
p.messageShown = true;
const s = p.capture();
p.reset();
expect(p.messageShown).toBe(false);
p.apply(s);
expect(p.alerted).toBe(true);
expect(p.alertRadius).toBe(3);
expect(p.messageShown).toBe(true);
});
});
+5 -28
View File
@@ -10,16 +10,13 @@ export class TurnManager {
nextTurn(grid, player, guards) {
this.turnCount++;
// Check if player reached goal
if (grid.isGoal(player.row, player.col)) {
return { detected: false, levelComplete: true };
}
// Clear current lights and update guards
grid.clearAllLight();
guards.forEach(guard => guard.onTurnChange(guards, player));
// Check if player is now in a lit cell
if (grid.isLight(player.row, player.col)) {
return { detected: true, levelComplete: false };
}
@@ -27,22 +24,15 @@ export class TurnManager {
return { detected: false, levelComplete: false };
}
// Simulate next turn to preview future lit cells (non-destructive)
// Simulate next turn to preview future lit cells (non-destructive).
// Uses each guard's capture()/apply() so new dynamic fields are picked up
// automatically without touching this method.
previewNextTurn(grid, player, guards) {
// Snapshot guard states
const snapshots = guards.map(g => ({
row: g.row, col: g.col, direction: g.direction,
isOn: g.isOn, currentPathIndex: g.currentPathIndex,
isReversing: g.isReversing, isChasing: g.isChasing,
isReturning: g.isReturning,
targetRow: g.targetRow, targetCol: g.targetCol,
}));
const snaps = guards.map(g => g.capture());
// Run one turn on real guards
grid.clearAllLight();
guards.forEach(g => g.onTurnChange(guards, player));
// Collect lit cells
const previewSet = new Set();
for (let r = 0; r < grid.rows; r++) {
for (let c = 0; c < grid.cols; c++) {
@@ -50,20 +40,7 @@ export class TurnManager {
}
}
// Restore guard states
guards.forEach((g, i) => {
const s = snapshots[i];
g.row = s.row; g.col = s.col; g.direction = s.direction;
g.isOn = s.isOn;
if (s.currentPathIndex !== undefined) g.currentPathIndex = s.currentPathIndex;
if (s.isReversing !== undefined) g.isReversing = s.isReversing;
if (s.isChasing !== undefined) g.isChasing = s.isChasing;
if (s.isReturning !== undefined) g.isReturning = s.isReturning;
if (s.targetRow !== undefined) g.targetRow = s.targetRow;
if (s.targetCol !== undefined) g.targetCol = s.targetCol;
});
// Restore current lighting
guards.forEach((g, i) => g.apply(snaps[i]));
grid.clearAllLight();
guards.forEach(g => g.updateLight(guards));
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GridSystem } from './grid-system.js';
import { Player } from './player.js';
import { TurnManager } from './turn-manager.js';
import { RotatingGuard, PatrollingGuard, ChaserGuard, BlinkingGuard } from './guards.js';
describe('TurnManager.nextTurn', () => {
it('reports levelComplete when player is on goal', () => {
const grid = new GridSystem(3, 3, 50);
grid.setGoal(2, 2, true);
const player = new Player(grid, 2, 2);
const tm = new TurnManager();
const r = tm.nextTurn(grid, player, []);
expect(r.levelComplete).toBe(true);
});
it('reports detected when guards light up the player cell', () => {
const grid = new GridSystem(3, 3, 50);
const player = new Player(grid, 2, 0);
const g = new RotatingGuard(grid, 2, 2, 3); // facing left
const tm = new TurnManager();
const r = tm.nextTurn(grid, player, [g]);
// After rotation (3 → 0 = up), beam no longer points at player
expect(r.detected).toBe(false);
});
it('increments turn count', () => {
const grid = new GridSystem(3, 3, 50);
const player = new Player(grid, 0, 0);
const tm = new TurnManager();
tm.nextTurn(grid, player, []);
tm.nextTurn(grid, player, []);
expect(tm.turnCount).toBe(2);
});
});
describe('TurnManager.previewNextTurn — non-destructive', () => {
it('does not mutate rotating guard direction', () => {
const grid = new GridSystem(5, 5, 50);
const player = new Player(grid, 0, 0);
const g = new RotatingGuard(grid, 2, 2, 0);
const tm = new TurnManager();
tm.previewNextTurn(grid, player, [g]);
expect(g.direction).toBe(0); // unchanged
});
it('does not mutate blinking guard isOn', () => {
const grid = new GridSystem(3, 3, 50);
const player = new Player(grid, 0, 0);
const g = new BlinkingGuard(grid, 1, 1, [{ row: 0, col: 1 }], true);
const tm = new TurnManager();
tm.previewNextTurn(grid, player, [g]);
expect(g.isOn).toBe(true);
});
it('does not mutate patrolling guard path index', () => {
const grid = new GridSystem(3, 3, 50);
const player = new Player(grid, 0, 0);
const path = [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }];
const g = new PatrollingGuard(grid, 0, 0, path);
g.currentPathIndex = 1;
const tm = new TurnManager();
tm.previewNextTurn(grid, player, [g]);
expect(g.currentPathIndex).toBe(1);
expect(g.isReversing).toBe(false);
});
it('does not mutate chaser guard position or state', () => {
const grid = new GridSystem(5, 5, 50);
const player = new Player(grid, 0, 1); // adjacent to chaser
const g = new ChaserGuard(grid, 0, 0, 3);
const tm = new TurnManager();
tm.previewNextTurn(grid, player, [g]);
expect(g.row).toBe(0);
expect(g.col).toBe(0);
expect(g.isChasing).toBe(false);
});
it('restores current turn lights after preview', () => {
const grid = new GridSystem(5, 5, 50);
const player = new Player(grid, 0, 0);
const g = new RotatingGuard(grid, 2, 2, 0); // up initially
g.updateLight([g]);
const litBeforePreview = new Set();
for (let r = 0; r < grid.rows; r++)
for (let c = 0; c < grid.cols; c++)
if (grid.isLight(r, c)) litBeforePreview.add(`${r},${c}`);
const tm = new TurnManager();
tm.previewNextTurn(grid, player, [g]);
const litAfterPreview = new Set();
for (let r = 0; r < grid.rows; r++)
for (let c = 0; c < grid.cols; c++)
if (grid.isLight(r, c)) litAfterPreview.add(`${r},${c}`);
expect(litAfterPreview).toEqual(litBeforePreview);
});
it('returned preview set matches the next real turn lights', () => {
const grid = new GridSystem(5, 5, 50);
const player = new Player(grid, 0, 0);
const g = new RotatingGuard(grid, 2, 2, 0);
const tm = new TurnManager();
const predicted = tm.previewNextTurn(grid, player, [g]);
tm.nextTurn(grid, player, [g]);
const actual = new Set();
for (let r = 0; r < grid.rows; r++)
for (let c = 0; c < grid.cols; c++)
if (grid.isLight(r, c)) actual.add(`${r},${c}`);
expect(actual).toEqual(predicted);
});
});
+3 -1
View File
@@ -2,7 +2,9 @@
import { NNTV } from './palette.js';
// Normalize helper — ensure every row is exactly 80 chars.
const norm = (rows) => rows.map(r => (r.length >= 80 ? r.slice(0, 80) : r.padEnd(80, r.at(-1) || '.')));
// Pad with '.' (transparent) so a short-authored row never silently bleeds
// an unrelated palette char across the rest of the scene.
const norm = (rows) => rows.map(r => (r.length >= 80 ? r.slice(0, 80) : r.padEnd(80, '.')));
// ── ACT 1: GARDEN ──────────────────────────────────────────────────────
export const BG_GARDEN = norm([
+31 -18
View File
@@ -1,5 +1,5 @@
<script>
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { getText } from '../lib/localization.js';
import { loadLevel, getTotalLevels } from '../lib/game/level-manager.js';
import { TurnManager } from '../lib/game/turn-manager.js';
@@ -16,7 +16,7 @@
import DetectionPopup from '../components/DetectionPopup.svelte';
import LevelCompletePopup from '../components/LevelCompletePopup.svelte';
import PauseMenu from '../components/PauseMenu.svelte';
import ControlsOverlay from '../components/controls-overlay.svelte';
import ControlsOverlay from '../components/ControlsOverlay.svelte';
import Pixel from '../lib/pixel/Pixel.svelte';
import { sceneForLevel } from '../lib/pixel/art-scenes.js';
@@ -68,10 +68,17 @@
let canUndo = $derived((renderVersion, history.canUndo()));
let scene = $derived(sceneForLevel(currentLevel));
// Detection feedback timer — tracked so we can clear on unmount / re-init
let detectionTimeout = null;
// Initialize level
function initLevel() {
const state = loadLevel(currentLevel);
if (!state) return;
if (!state) {
// Invalid level id — bail to main menu instead of leaving stale state
navigate('MainMenu');
return;
}
grid = state.grid;
player = state.player;
guards = state.guards;
@@ -94,10 +101,13 @@
}
onMount(() => { initLevel(); });
onDestroy(() => {
if (detectionTimeout) clearTimeout(detectionTimeout);
});
// Capture current state as a snapshot object (does not push to history)
function captureState() {
return history.createSnapshot(player, guards, turnManager.turnCount, princess.alerted, princess.alertRadius);
return history.createSnapshot(player, guards, turnManager.turnCount, princess.capture());
}
// Capture and push snapshot in one step (for wait action)
@@ -177,34 +187,37 @@
playerShake = true;
playDetection();
detected = true;
setTimeout(() => { playerShake = false; detectedCell = null; }, 400);
if (detectionTimeout) clearTimeout(detectionTimeout);
detectionTimeout = setTimeout(() => {
playerShake = false;
detectedCell = null;
detectionTimeout = null;
}, 400);
}
// Undo/redo handlers
function handleUndo() {
if (!player || !grid) return;
const state = history.undo(player, guards, turnManager, princess.alerted, princess.alertRadius);
if (!state) return;
princess.alerted = state.princessAlerted || false;
princess.alertRadius = state.alertRadius || 0;
function applyHistoryState(state) {
princess.apply(state.princess);
finalMessage = princess.alerted;
grid.clearAllLight();
guards.forEach(g => g.updateLight(guards));
if (princess.alerted) princess.lightRing(grid, goalRow, goalCol, princess.alertRadius);
}
function handleUndo() {
if (!player || !grid) return;
const state = history.undo(player, guards, turnManager, princess.capture());
if (!state) return;
applyHistoryState(state);
playUndo();
renderVersion++;
}
function handleRedo() {
if (!player || !grid) return;
const state = history.redo(player, guards, turnManager, princess.alerted, princess.alertRadius);
const state = history.redo(player, guards, turnManager, princess.capture());
if (!state) return;
princess.alerted = state.princessAlerted || false;
princess.alertRadius = state.alertRadius || 0;
finalMessage = princess.alerted;
grid.clearAllLight();
guards.forEach(g => g.updateLight(guards));
if (princess.alerted) princess.lightRing(grid, goalRow, goalCol, princess.alertRadius);
applyHistoryState(state);
renderVersion++;
}
+2 -1
View File
@@ -1,10 +1,11 @@
<script>
import { getText } from '../lib/localization.js';
import { getProgress } from '../lib/progress.js';
import { getTotalLevels } from '../lib/game/level-manager.js';
import Button from '../components/Button.svelte';
let { navigate } = $props();
const totalLevels = 12;
const totalLevels = getTotalLevels();
let progress = $state(getProgress());
function selectLevel(num) {