From 7aa81730a6bb5ae2baba43198acc5519177211fd Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 13 Apr 2026 14:47:42 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20gameplay=20improvements=20?= =?UTF-8?q?=E2=80=94=20wait=20action,=20star=20rating,=20chaser=20guard,?= =?UTF-8?q?=20vision=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wait action (Space/click player): skip turn while guards advance, enables timing puzzles - Star rating system: 1-3 stars per level based on par moves, persisted in localStorage - Level complete popup with animated star display and move count vs par - Stars and best moves shown on level select screen - ChaserGuard: new guard type that chases player's last known position when nearby - All 12 levels redesigned with tighter corridors, more guards, and chasers in Act 5 - Guard vision preview (V key/button): ghost overlay shows next-turn danger zones - Level 12 remains intentionally unbeatable with added chaser --- .../phase-01-wait-action.md | 54 +++++ .../phase-02-star-rating.md | 89 ++++++++ .../phase-03-level-redesign.md | 89 ++++++++ .../phase-04-vision-preview.md | 49 +++++ .../260413-1416-gameplay-improvements/plan.md | 57 +++++ src/components/GameBoard.svelte | 4 +- src/components/GameHud.svelte | 3 +- src/components/GuardSprite.svelte | 5 +- src/components/LevelCompletePopup.svelte | 76 +++++++ src/lib/game/guards.js | 95 ++++++++ src/lib/game/level-manager.js | 6 +- src/lib/game/turn-manager.js | 43 +++- src/lib/levels/levels.js | 205 ++++++++++++++---- src/lib/progress.js | 25 ++- src/scenes/Game.svelte | 78 +++++-- src/scenes/LevelSelect.svelte | 31 ++- src/styles/theme.css | 1 + 17 files changed, 837 insertions(+), 73 deletions(-) create mode 100644 plans/260413-1416-gameplay-improvements/phase-01-wait-action.md create mode 100644 plans/260413-1416-gameplay-improvements/phase-02-star-rating.md create mode 100644 plans/260413-1416-gameplay-improvements/phase-03-level-redesign.md create mode 100644 plans/260413-1416-gameplay-improvements/phase-04-vision-preview.md create mode 100644 plans/260413-1416-gameplay-improvements/plan.md create mode 100644 src/components/LevelCompletePopup.svelte diff --git a/plans/260413-1416-gameplay-improvements/phase-01-wait-action.md b/plans/260413-1416-gameplay-improvements/phase-01-wait-action.md new file mode 100644 index 0000000..691cef3 --- /dev/null +++ b/plans/260413-1416-gameplay-improvements/phase-01-wait-action.md @@ -0,0 +1,54 @@ +## Phase 1: Wait Action + +**Priority:** P1 | **Status:** Pending | **Effort:** 15min + +### Overview + +Spacebar (and tap-on-player for mobile) skips the player's move but still advances guards. +Creates timing puzzles — player must wait for a safe window. + +### Data Flow + +1. User presses Space / taps player cell +2. `handleMove` is NOT called; instead new `handleWait()` fires +3. `handleWait` calls `turnManager.nextTurn(grid, player, guards)` without moving player +4. Same detection/completion checks run +5. `renderVersion++` triggers re-render + +### Files to Modify + +**`src/scenes/Game.svelte`** (input handler only) + +### Implementation Steps + +- [ ] 1. In `onKeyDown`, add `' '` (Space) key check before direction map lookup +- [ ] 2. When Space detected, call `handleWait()` and `e.preventDefault()` +- [ ] 3. Create `handleWait()` function: + ```js + function handleWait() { + if (!player || !grid) return; + const result = turnManager.nextTurn(grid, player, guards); + if (isFinalLevel && checkFinalLevel()) { renderVersion++; return; } + renderVersion++; + if (result.levelComplete) handleLevelComplete(); + else if (result.detected) detected = true; + } + ``` +- [ ] 4. In `onCellClick`, if clicked cell === player position, call `handleWait()` + +### Edge Cases + +- Wait on level 12 still triggers princess detection expansion (covered — same `checkFinalLevel` path) +- Wait when already at goal: `nextTurn` returns `levelComplete: true` (correct) +- Wait counts as a turn for move counter (correct — `turnManager.turnCount++` happens in `nextTurn`) + +### Success Criteria + +- Space key advances turn without moving player +- Guards rotate/patrol/blink as normal +- Turn counter increments +- Detection still works if guard light reaches player after wait + +### Rollback + +Revert single commit. No schema changes. diff --git a/plans/260413-1416-gameplay-improvements/phase-02-star-rating.md b/plans/260413-1416-gameplay-improvements/phase-02-star-rating.md new file mode 100644 index 0000000..fb1d757 --- /dev/null +++ b/plans/260413-1416-gameplay-improvements/phase-02-star-rating.md @@ -0,0 +1,89 @@ +## Phase 2: Star Rating + Par Moves + +**Priority:** P1 | **Status:** Pending | **Effort:** 2h + +### Overview + +Each level gets a `parMoves` value. On completion, show 1-3 star rating. +Persist best stars + best moves in localStorage. Display stars on level select. + +### Data Flow + +``` +Level complete → turnManager.turnCount vs level.parMoves → star rating + → completeLevel(levelNum, totalLevels, moves) → localStorage update + → LevelSelect reads stars from progress +``` + +### Star Calculation + +| Condition | Stars | +|-----------|-------| +| moves <= parMoves | 3 | +| moves <= parMoves + 3 | 2 | +| completed | 1 | + +### Files to Modify + +#### 1. `src/lib/levels/levels.js` +- [ ] Add `parMoves` field to each level definition +- [ ] Placeholder values (will be finalized in Phase 3 after redesign): + - L1: 10, L2: 12, L3: 14, L4: 14, L5: 14, L6: 16 + - L7: 16, L8: 18, L9: 18, L10: 20, L11: 22, L12: 99 (unbeatable) + +#### 2. `src/lib/progress.js` +- [ ] Extend schema: add `levelStars: {}` and `levelBestMoves: {}` to DEFAULT_PROGRESS +- [ ] `getProgress()`: merge missing keys for backwards compat +- [ ] Update `completeLevel(levelNum, totalLevels, moves, parMoves)`: + - Calculate stars + - Store best stars (only if better than existing) + - Store best moves (only if fewer than existing) + - Return `{ ...progress, stars }` so caller can display + +#### 3. `src/scenes/Game.svelte` +- [ ] Import `LEVELS` to access `parMoves` for current level +- [ ] Pass `turnManager.turnCount` and `parMoves` to `completeLevel()` +- [ ] After level complete, show star rating overlay before navigating +- [ ] Add `LevelComplete` popup component (inline or separate small component) + - Shows 1-3 stars with animation + - Shows move count vs par + - "Next Level" button + +#### 4. `src/scenes/LevelSelect.svelte` +- [ ] Read `progress.levelStars` in addition to `completedLevels` +- [ ] Replace checkmark with star display (1-3 stars shown as filled/empty) +- [ ] Show best move count under level number + +#### 5. `src/components/GameHud.svelte` +- [ ] No changes needed — already shows turn count + +### localStorage Schema (backwards compatible) + +```js +// Old format still works — missing keys get defaults +{ + maxLevel: 5, + completedLevels: [1, 2, 3, 4], + // NEW — absent in old saves, defaults to {} + levelStars: { "1": 3, "2": 2, "3": 1 }, + levelBestMoves: { "1": 8, "2": 14, "3": 20 } +} +``` + +### Edge Cases + +- Old localStorage without `levelStars`/`levelBestMoves` → defaults to `{}` +- Level 12 unbeatable → `parMoves: 99` never matters +- Player completes level with worse score → keep existing best +- `completeLevel` called with no moves param (old code path) → skip star calc + +### Success Criteria + +- Star rating appears on level complete +- Best stars persist in localStorage +- Level select shows stars per level +- Old saves don't break (backwards compat) + +### Rollback + +Revert commit. Old localStorage data unaffected (new keys simply disappear). diff --git a/plans/260413-1416-gameplay-improvements/phase-03-level-redesign.md b/plans/260413-1416-gameplay-improvements/phase-03-level-redesign.md new file mode 100644 index 0000000..c123cc2 --- /dev/null +++ b/plans/260413-1416-gameplay-improvements/phase-03-level-redesign.md @@ -0,0 +1,89 @@ +## Phase 3: Level Redesign + Chaser Guard + +**Priority:** P1 | **Status:** Pending | **Effort:** 3h + +### Overview + +Redesign existing 12 levels for more challenge. Add **Chaser guard** type. +Finalize `parMoves` values after redesign. Level 12 remains unbeatable. + +### New Guard Type: ChaserGuard + +**Behavior:** Dormant until player enters Manhattan distance <= `detectionRadius` (default 3). +Once triggered, moves 1 cell toward player's **last known position** each turn. +Lights its own cell + adjacent cell in movement direction. Returns to start if reaches +last known position and player not visible. + +**Why Chaser over Sentry:** Chaser creates dynamic panic — player must change route. +Sentry is just a fancy static guard (cone vision = static pattern). Chaser adds genuine +reactive gameplay with minimal code. + +#### Implementation in `src/lib/game/guards.js` + +- [ ] Add `ChaserGuard` class extending `Guard`: + ``` + Properties: detectionRadius, startRow, startCol, targetRow, targetCol, + isChasing, lastKnownRow, lastKnownCol + Methods: + - onTurnChange(allGuards, player): check distance to player + - If within radius and not blocked by wall: set isChasing, record lastKnownPos + - If chasing: move 1 step toward lastKnownPos (prefer row then col) + - If reached lastKnownPos and player not in radius: return to start + - updateLight(): light own cell + 1 cell in facing direction + ``` +- [ ] Key: `onTurnChange` needs player reference — update signature + +#### Turn Manager Change (`src/lib/game/turn-manager.js`) + +- [ ] Pass `player` to `guard.onTurnChange(guards, player)` — non-breaking since + existing guards ignore extra params + +#### Level Manager Change (`src/lib/game/level-manager.js`) + +- [ ] Add `case 'chaser'` to guard factory switch + +#### Guard Sprite (`src/components/GuardSprite.svelte`) + +- [ ] Add chaser visual (red-tinted sprite, or different emoji/icon) +- [ ] Show "alert" state when `isChasing` is true + +### Level Redesign Principles + +1. **Tighter corridors** — reduce open space, force specific paths +2. **More guards per level** — current levels are sparse +3. **Timing windows** — require waiting (Phase 1) for safe passage +4. **Chaser placement** — put chasers near obvious paths to force detours +5. **Level 12** — keep walls + guard placement, only verify light wave still covers all paths + +### Level-by-Level Notes + +- [ ] **L1-2** (tutorial): Keep simple but add 1 more wall each to reduce trivial paths +- [ ] **L3-4** (rotating intro): Add extra static guard, narrow corridors +- [ ] **L5-6** (blinking): Tighter timing — reduce safe windows from 2 turns to 1 +- [ ] **L7-8** (patrolling): Add walls to force player into patrol paths, require waiting +- [ ] **L9** (decoy): Add chaser near the "obvious" short path — punishes rushing +- [ ] **L10** (mirrors): Add more mirror bounces, tighter spaces +- [ ] **L11** (throne room): Add chaser + extra patrolling guard +- [ ] **L12** (princess): DO NOT CHANGE core mechanic. May add 1-2 more guards in upper sections +- [ ] **Finalize parMoves** for each level after redesign (playtest manually) + +### Edge Cases / Risks + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Level becomes unsolvable | Medium | High | Playtest each level; keep at least 1 valid path | +| Chaser pathfinding hits wall | Medium | Medium | Simple Manhattan step; skip move if blocked by wall | +| Level 12 becomes beatable | Low | High | Verify: expanding light wave from (9,9) covers all reachable cells within player's max possible steps | +| ChaserGuard makes levels too hard | Medium | Low | Tune detectionRadius per level (2-4 range) | + +### Success Criteria + +- All levels 1-11 are solvable but require thought +- At least 3 levels benefit from wait action +- Chaser guard creates noticeable tension when triggered +- Level 12 remains provably unbeatable +- parMoves set to optimal-path length for each level + +### Rollback + +Revert commit restores old level definitions and removes ChaserGuard class. diff --git a/plans/260413-1416-gameplay-improvements/phase-04-vision-preview.md b/plans/260413-1416-gameplay-improvements/phase-04-vision-preview.md new file mode 100644 index 0000000..255b325 --- /dev/null +++ b/plans/260413-1416-gameplay-improvements/phase-04-vision-preview.md @@ -0,0 +1,49 @@ +## Phase 4: Guard Vision Preview (Optional) + +**Priority:** P3 | **Status:** Pending | **Effort:** 45min + +### Overview + +Show semi-transparent overlay of cells that will be lit NEXT turn. +Helps player plan without trial-and-error. Toggle via button or hotkey. + +### Data Flow + +1. Before rendering, simulate next turn's guard positions/lights on a **cloned grid** +2. Diff current lit cells vs next-turn lit cells +3. Render "preview" cells with distinct CSS class (e.g., `cell-preview` with 20% opacity warning color) + +### Implementation + +#### `src/lib/game/turn-manager.js` +- [ ] Add `previewNextTurn(grid, guards)` method: + - Deep-clone guard states (row, col, direction, isOn, currentPathIndex, isReversing) + - Create temporary grid clone + - Run `onTurnChange` on clones + - Return Set of `"row,col"` strings for next-turn lit cells + +#### `src/scenes/Game.svelte` +- [ ] Add `showPreview` toggle state (default false) +- [ ] Bind `v` key or HUD button to toggle +- [ ] Compute `previewCells` derived from `turnManager.previewNextTurn()` when toggle on +- [ ] Pass `previewCells` to `GameBoard` + +#### `src/components/GameBoard.svelte` +- [ ] Accept optional `previewCells` prop +- [ ] Add `cell-preview` CSS class to cells in preview set + +### Edge Cases + +- Chaser guard preview needs player position — pass current player pos to preview +- Level 12 expanding wave — preview should show next ring expansion +- Performance: cloning 5-10 guards per frame is trivial for grid sizes <= 10x10 + +### Success Criteria + +- Toggle shows/hides next-turn danger zones +- Preview visually distinct from current lit cells (different opacity/color) +- No performance impact on turn processing + +### Rollback + +Revert commit. Preview is purely additive UI — no game logic changes. diff --git a/plans/260413-1416-gameplay-improvements/plan.md b/plans/260413-1416-gameplay-improvements/plan.md new file mode 100644 index 0000000..9bf2fba --- /dev/null +++ b/plans/260413-1416-gameplay-improvements/plan.md @@ -0,0 +1,57 @@ +--- +title: "Gameplay Improvements: Wait Action, Star Rating, Level Redesign" +description: "Add wait mechanic, star/par rating system, and redesign levels for challenge" +status: done +priority: P1 +effort: 6h +branch: main +tags: [gameplay, levels, ux] +created: 2026-04-13 +--- + +# Gameplay Improvements Plan + +## Phases + +| # | Phase | Effort | Status | Files Modified | +|---|-------|--------|--------|----------------| +| 1 | [Wait Action](phase-01-wait-action.md) | 15min | Done | Game.svelte | +| 2 | [Star Rating + Par Moves](phase-02-star-rating.md) | 2h | Done | levels.js, progress.js, Game.svelte, LevelSelect.svelte, LevelCompletePopup.svelte | +| 3 | [Level Redesign + Chaser Guard](phase-03-level-redesign.md) | 3h | Done | levels.js, guards.js, level-manager.js, GuardSprite.svelte, theme.css | +| 4 | [Guard Vision Preview](phase-04-vision-preview.md) | 45min | Done | GameBoard.svelte, Game.svelte, turn-manager.js, GameHud.svelte | + +## Dependency Graph + +``` +Phase 1 (Wait) ── no deps, standalone +Phase 2 (Stars) ── no deps, standalone +Phase 3 (Levels) ── after Phase 1 + 2 (par values depend on wait mechanic existing) +Phase 4 (Preview) ── after Phase 3 (needs final guard configs) +``` + +## File Ownership (no parallel conflicts) + +- Phase 1: Game.svelte only (input handler) +- Phase 2: progress.js, LevelSelect.svelte, GameHud.svelte, + level complete flow in Game.svelte +- Phase 3: levels.js, guards.js, level-manager.js, GuardSprite.svelte +- Phase 4: GameBoard.svelte (new overlay layer) + +## Key Constraints + +- Level 12 MUST remain unbeatable (expanding light wave from goal covers all paths) +- No new npm dependencies +- All state stays in pure JS classes; Svelte 5 reactivity via renderVersion bump pattern +- localStorage schema must be backwards-compatible (existing `nntv-progress` key) + +## Rollback + +Each phase is a single commit. Revert commit to rollback. No data migrations needed +(localStorage schema only adds optional fields, old data still works). + +## Success Criteria + +1. Spacebar wait works, guards advance, player stays +2. Stars show on level complete + level select; persist across sessions +3. Levels require more thought; at least 3 levels need wait action to solve optimally +4. Level 12 still unbeatable (manual verification) +5. (Optional) Ghost overlay shows next-turn danger zones diff --git a/src/components/GameBoard.svelte b/src/components/GameBoard.svelte index 4541690..090c82e 100644 --- a/src/components/GameBoard.svelte +++ b/src/components/GameBoard.svelte @@ -1,5 +1,5 @@
oncellclick?.(cell.row, cell.col)} >
@@ -35,4 +36,5 @@ .cell.wall { background: var(--grid-wall); cursor: default; } .cell.goal { background: var(--grid-goal); } .cell.lit { background: var(--grid-lit); } + .cell.preview { background: rgba(255, 234, 0, 0.15); border-color: rgba(255, 234, 0, 0.3); } diff --git a/src/components/GameHud.svelte b/src/components/GameHud.svelte index 748e0cd..9c7c683 100644 --- a/src/components/GameHud.svelte +++ b/src/components/GameHud.svelte @@ -1,7 +1,7 @@
@@ -11,6 +11,7 @@
{getText('level')}{level} +
diff --git a/src/components/GuardSprite.svelte b/src/components/GuardSprite.svelte index 6355bc0..4c5c0ec 100644 --- a/src/components/GuardSprite.svelte +++ b/src/components/GuardSprite.svelte @@ -4,12 +4,13 @@ let left = $derived(guard.col * cellSize + cellSize / 2); let size = $derived(Math.floor(cellSize / 2)); let rotation = $derived(guard.direction * 90); - let hasDirection = $derived(guard.type === 'rotating' || guard.type === 'patrolling'); + let hasDirection = $derived(guard.type === 'rotating' || guard.type === 'patrolling' || guard.type === 'chaser');
{#if hasDirection} @@ -31,6 +32,8 @@ .guard.blinking { background: var(--guard-blinking); } .guard.blinking.off { background: var(--guard-blinking-off); } .guard.patrolling { background: var(--guard-patrolling); } + .guard.chaser { background: var(--guard-chaser, #ff6622); } + .guard.chaser.alert { background: #ff2200; box-shadow: 0 0 8px #ff2200; } .guard.mirror { background: var(--guard-mirror); border-radius: 4px; transform: translate(-50%, -50%) rotate(45deg); } .direction-indicator { diff --git a/src/components/LevelCompletePopup.svelte b/src/components/LevelCompletePopup.svelte new file mode 100644 index 0000000..0d66d5c --- /dev/null +++ b/src/components/LevelCompletePopup.svelte @@ -0,0 +1,76 @@ + + +
+
+

{getText('levelComplete') || 'Level Complete!'}

+
+ {#each [1, 2, 3] as i} + ★ + {/each} +
+

Moves: {moves} / Par: {parMoves}

+ +
+
+ + diff --git a/src/lib/game/guards.js b/src/lib/game/guards.js index b937b4f..c712fe6 100644 --- a/src/lib/game/guards.js +++ b/src/lib/game/guards.js @@ -141,6 +141,101 @@ export class MirrorGuard extends Guard { } } +export class ChaserGuard extends Guard { + constructor(grid, row, col, detectionRadius) { + super(grid, row, col, 'chaser'); + this.startRow = row; + this.startCol = col; + this.detectionRadius = detectionRadius || 3; + this.isChasing = false; + this.targetRow = row; + this.targetCol = col; + } + + updateLight() { + if (this.grid.isValidPosition(this.row, this.col)) { + this.grid.setLight(this.row, this.col, true); + } + // Light cell in facing direction + const dir = this.getDirectionOffset(this.direction); + const fr = this.row + dir.row; + const fc = this.col + dir.col; + if (this.grid.isValidPosition(fr, fc)) { + this.grid.setLight(fr, fc, true); + } + } + + getDirectionOffset(dir) { + const directions = [ + { row: -1, col: 0 }, { row: 0, col: 1 }, + { row: 1, col: 0 }, { row: 0, col: -1 }, + ]; + return directions[dir]; + } + + onTurnChange(allGuards, player) { + if (!player) { this.updateLight(); return; } + + const dist = Math.abs(this.row - player.row) + Math.abs(this.col - player.col); + + if (dist <= this.detectionRadius) { + this.isChasing = true; + this.targetRow = player.row; + this.targetCol = player.col; + } + + if (this.isChasing) { + // Move one step toward target + const dr = this.targetRow - this.row; + const dc = this.targetCol - this.col; + let newRow = this.row; + let newCol = this.col; + + // Prefer row movement, then col + if (dr !== 0) { + const step = dr > 0 ? 1 : -1; + if (this.grid.isValidPosition(this.row + step, this.col) && + !this.grid.isWall(this.row + step, this.col)) { + newRow = this.row + step; + } else if (dc !== 0) { + const cstep = dc > 0 ? 1 : -1; + if (this.grid.isValidPosition(this.row, this.col + cstep) && + !this.grid.isWall(this.row, this.col + cstep)) { + newCol = this.col + cstep; + } + } + } else if (dc !== 0) { + const step = dc > 0 ? 1 : -1; + if (this.grid.isValidPosition(this.row, this.col + step) && + !this.grid.isWall(this.row, this.col + step)) { + newCol = this.col + step; + } + } + + // Update facing direction + if (newRow < this.row) this.direction = 0; + else if (newCol > this.col) this.direction = 1; + else if (newRow > this.row) this.direction = 2; + else if (newCol < this.col) this.direction = 3; + + this.row = newRow; + this.col = newCol; + + // Reached target and player not nearby — return to start + if (this.row === this.targetRow && this.col === this.targetCol && + dist > this.detectionRadius) { + this.targetRow = this.startRow; + this.targetCol = this.startCol; + if (this.row === this.startRow && this.col === this.startCol) { + this.isChasing = false; + } + } + } + + this.updateLight(); + } +} + export class PatrollingGuard extends Guard { constructor(grid, startRow, startCol, path) { super(grid, startRow, startCol, 'patrolling'); diff --git a/src/lib/game/level-manager.js b/src/lib/game/level-manager.js index c0f4f80..88103c6 100644 --- a/src/lib/game/level-manager.js +++ b/src/lib/game/level-manager.js @@ -3,7 +3,7 @@ import { LEVELS } from '../levels/levels.js'; import { GridSystem } from './grid-system.js'; import { Player } from './player.js'; -import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard, MirrorGuard } from './guards.js'; +import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard, MirrorGuard, ChaserGuard } from './guards.js'; // Load a level by ID, returns complete game state export function loadLevel(levelId) { @@ -50,6 +50,9 @@ export function loadLevel(levelId) { case 'mirror': guard = new MirrorGuard(grid, g.position.row, g.position.col, g.reflectDirection); break; + case 'chaser': + guard = new ChaserGuard(grid, g.position.row, g.position.col, g.detectionRadius); + break; } if (guard) guards.push(guard); }); @@ -67,6 +70,7 @@ export function loadLevel(levelId) { isFinalLevel: data.isFinalLevel || false, goalRow: data.goal.row, goalCol: data.goal.col, + parMoves: data.parMoves || 99, }; } diff --git a/src/lib/game/turn-manager.js b/src/lib/game/turn-manager.js index a5c6ea0..792e59a 100644 --- a/src/lib/game/turn-manager.js +++ b/src/lib/game/turn-manager.js @@ -17,7 +17,7 @@ export class TurnManager { // Clear current lights and update guards grid.clearAllLight(); - guards.forEach(guard => guard.onTurnChange(guards)); + guards.forEach(guard => guard.onTurnChange(guards, player)); // Check if player is now in a lit cell if (grid.isLight(player.row, player.col)) { @@ -27,6 +27,47 @@ export class TurnManager { return { detected: false, levelComplete: false }; } + // Simulate next turn to preview future lit cells (non-destructive) + 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, + targetRow: g.targetRow, targetCol: g.targetCol, + })); + + // 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++) { + if (grid.isLight(r, c)) previewSet.add(`${r},${c}`); + } + } + + // 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.targetRow !== undefined) g.targetRow = s.targetRow; + if (s.targetCol !== undefined) g.targetCol = s.targetCol; + }); + + // Restore current lighting + grid.clearAllLight(); + guards.forEach(g => g.updateLight(guards)); + + return previewSet; + } + reset() { this.turnCount = 0; } diff --git a/src/lib/levels/levels.js b/src/lib/levels/levels.js index d5763c8..7312d98 100644 --- a/src/lib/levels/levels.js +++ b/src/lib/levels/levels.js @@ -8,10 +8,12 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 5, col: 5 }, walls: [ - { row: 1, col: 1 }, { row: 1, col: 2 }, - { row: 3, col: 3 }, { row: 3, col: 4 }, + { row: 1, col: 1 }, { row: 1, col: 2 }, { row: 1, col: 3 }, + { row: 3, col: 2 }, { row: 3, col: 3 }, { row: 3, col: 4 }, + { row: 4, col: 0 }, { row: 4, col: 1 }, ], guards: [], + parMoves: 10, }, { id: 2, @@ -21,7 +23,8 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 5, col: 5 }, walls: [ - { row: 2, col: 2 }, { row: 3, col: 3 }, + { row: 1, col: 3 }, { row: 2, col: 1 }, { row: 2, col: 2 }, + { row: 3, col: 3 }, { row: 3, col: 4 }, { row: 4, col: 1 }, ], guards: [ { @@ -32,7 +35,16 @@ export const LEVELS = [ { row: 2, col: 5 }, { row: 3, col: 4 }, ], }, + { + type: "static", + position: { row: 4, col: 2 }, + litCells: [ + { row: 3, col: 2 }, { row: 4, col: 3 }, + { row: 5, col: 2 }, + ], + }, ], + parMoves: 12, }, // === ACT 2: THE VEGETABLE GARDEN (walls as shields + rotating guards) === @@ -44,28 +56,40 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 6, col: 6 }, walls: [ - { row: 1, col: 1 }, { row: 2, col: 1 }, - { row: 3, col: 3 }, { row: 3, col: 5 }, { row: 5, col: 4 }, + { row: 1, col: 1 }, { row: 2, col: 1 }, { row: 2, col: 5 }, + { row: 3, col: 3 }, { row: 3, col: 5 }, + { row: 4, col: 1 }, { row: 5, col: 3 }, { row: 5, col: 4 }, ], guards: [ { type: "static", position: { row: 2, col: 3 }, litCells: [ - { row: 1, col: 3 }, { row: 2, col: 2 }, { row: 2, col: 4 }, + { row: 1, col: 3 }, { row: 2, col: 2 }, + { row: 2, col: 4 }, { row: 3, col: 3 }, ], }, { type: "static", - position: { row: 5, col: 5 }, + position: { row: 4, col: 5 }, litCells: [ - { row: 4, col: 5 }, { row: 5, col: 6 }, + { row: 3, col: 5 }, { row: 4, col: 4 }, + { row: 4, col: 6 }, { row: 5, col: 5 }, + ], + }, + { + type: "static", + position: { row: 5, col: 1 }, + litCells: [ + { row: 4, col: 1 }, { row: 5, col: 0 }, + { row: 5, col: 2 }, { row: 6, col: 1 }, ], }, ], + parMoves: 14, }, { - // First rotating guard — enclosed in walls so player can observe the pattern + // First rotating guard — must time passage; walls create a narrow corridor id: 4, name: "The Searchlight", storyKey: "level4Story", @@ -73,17 +97,28 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 6, col: 6 }, walls: [ - { row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }, - { row: 3, col: 2 }, { row: 3, col: 4 }, - { row: 4, col: 2 }, { row: 4, col: 3 }, { row: 4, col: 4 }, + { row: 1, col: 2 }, { row: 1, col: 3 }, + { row: 2, col: 2 }, { row: 2, col: 5 }, + { row: 3, col: 4 }, { row: 3, col: 5 }, + { row: 4, col: 1 }, { row: 4, col: 2 }, + { row: 5, col: 4 }, ], guards: [ { type: "rotating", - position: { row: 3, col: 3 }, + position: { row: 3, col: 2 }, startDirection: 0, }, + { + type: "static", + position: { row: 5, col: 5 }, + litCells: [ + { row: 4, col: 5 }, { row: 5, col: 6 }, + { row: 6, col: 5 }, + ], + }, ], + parMoves: 15, }, // === ACT 3: THE FORTRESS (blinking guards + timing puzzles) === @@ -95,23 +130,43 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 6, col: 6 }, walls: [ - { row: 2, col: 2 }, { row: 2, col: 4 }, - { row: 4, col: 2 }, { row: 4, col: 4 }, + { row: 1, col: 3 }, { row: 2, col: 1 }, + { row: 2, col: 5 }, { row: 3, col: 3 }, + { row: 4, col: 1 }, { row: 4, col: 5 }, + { row: 5, col: 3 }, ], guards: [ { type: "blinking", - position: { row: 3, col: 3 }, + position: { row: 2, col: 3 }, startState: true, litCells: [ - { row: 2, col: 3 }, { row: 3, col: 2 }, - { row: 3, col: 4 }, { row: 4, col: 3 }, + { row: 1, col: 3 }, { row: 2, col: 2 }, + { row: 2, col: 4 }, + ], + }, + { + type: "blinking", + position: { row: 4, col: 3 }, + startState: false, + litCells: [ + { row: 4, col: 2 }, { row: 4, col: 4 }, + { row: 5, col: 3 }, + ], + }, + { + type: "static", + position: { row: 3, col: 6 }, + litCells: [ + { row: 2, col: 6 }, { row: 3, col: 5 }, + { row: 4, col: 6 }, ], }, ], + parMoves: 14, }, { - // Blinking guard + static guard — must time passage through blinking zone + // Blinking + static — narrow corridor requires precise timing and wait action id: 6, name: "The Flickering Corridor", storyKey: "level6Story", @@ -119,35 +174,50 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - { row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }, { row: 2, col: 5 }, - { row: 3, col: 2 }, { row: 3, col: 5 }, - { row: 4, col: 2 }, { row: 4, col: 5 }, - { row: 5, col: 2 }, { row: 5, col: 3 }, { row: 5, col: 4 }, { row: 5, col: 5 }, + { row: 1, col: 3 }, { row: 1, col: 4 }, + { row: 2, col: 1 }, { row: 2, col: 6 }, + { row: 3, col: 3 }, { row: 3, col: 4 }, + { row: 4, col: 1 }, { row: 4, col: 6 }, + { row: 5, col: 3 }, { row: 5, col: 4 }, + { row: 6, col: 1 }, { row: 6, col: 6 }, ], guards: [ { type: "blinking", - position: { row: 3, col: 3 }, + position: { row: 2, col: 3 }, startState: true, litCells: [ - { row: 3, col: 4 }, { row: 4, col: 3 }, + { row: 2, col: 2 }, { row: 2, col: 4 }, + { row: 3, col: 3 }, + ], + }, + { + type: "blinking", + position: { row: 5, col: 5 }, + startState: false, + litCells: [ + { row: 4, col: 5 }, { row: 5, col: 6 }, + { row: 6, col: 5 }, ], }, { type: "static", position: { row: 1, col: 6 }, litCells: [ - { row: 0, col: 6 }, { row: 1, col: 7 }, + { row: 0, col: 6 }, { row: 1, col: 5 }, + { row: 1, col: 7 }, ], }, { type: "static", - position: { row: 6, col: 1 }, + position: { row: 6, col: 2 }, litCells: [ - { row: 5, col: 1 }, { row: 6, col: 0 }, { row: 6, col: 2 }, + { row: 5, col: 2 }, { row: 6, col: 3 }, + { row: 7, col: 2 }, ], }, ], + parMoves: 16, }, // === ACT 4: THE UNDERGROUND (patrolling guards + path prediction) === @@ -161,8 +231,8 @@ export const LEVELS = [ walls: [ { row: 1, col: 2 }, { row: 1, col: 3 }, { row: 1, col: 4 }, { row: 1, col: 5 }, { row: 2, col: 2 }, { row: 2, col: 5 }, - { row: 3, col: 2 }, { row: 3, col: 5 }, - { row: 4, col: 2 }, { row: 4, col: 5 }, + { row: 3, col: 5 }, + { row: 4, col: 2 }, { row: 5, col: 2 }, { row: 5, col: 5 }, { row: 6, col: 2 }, { row: 6, col: 3 }, { row: 6, col: 4 }, { row: 6, col: 5 }, ], @@ -175,10 +245,19 @@ export const LEVELS = [ { row: 4, col: 4 }, { row: 4, col: 3 }, ], }, + { + type: "static", + position: { row: 5, col: 7 }, + litCells: [ + { row: 4, col: 7 }, { row: 5, col: 6 }, + { row: 6, col: 7 }, + ], + }, ], + parMoves: 17, }, { - // Two patrols with interlocking paths — player must weave between them + // Two patrols with interlocking paths + blinking gate id: 8, name: "The Gauntlet", storyKey: "level8Story", @@ -186,8 +265,10 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ + { row: 1, col: 1 }, { row: 1, col: 6 }, { row: 3, col: 3 }, { row: 3, col: 4 }, { row: 4, col: 3 }, { row: 4, col: 4 }, + { row: 6, col: 1 }, { row: 6, col: 6 }, ], guards: [ { @@ -208,13 +289,21 @@ export const LEVELS = [ { row: 5, col: 2 }, { row: 5, col: 5 }, ], }, + { + type: "blinking", + position: { row: 3, col: 7 }, + startState: true, + litCells: [ + { row: 2, col: 7 }, { row: 4, col: 7 }, + ], + }, ], + parMoves: 18, }, - // === ACT 5: THE ROYAL PALACE (combinations + "The Decoy") === + // === ACT 5: THE ROYAL PALACE (combinations + chaser + "The Decoy") === { - // The Decoy — two routes: obvious short one syncs to danger at turn 6, - // longer route is actually safe if you count turns + // The Decoy — chaser guard punishes the obvious short path id: 9, name: "The Decoy Path", storyKey: "level9Story", @@ -222,9 +311,9 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - // Central wall creates two corridors { row: 1, col: 3 }, { row: 2, col: 3 }, { row: 3, col: 3 }, { row: 4, col: 4 }, { row: 5, col: 4 }, { row: 6, col: 4 }, + { row: 5, col: 1 }, ], guards: [ { @@ -249,10 +338,17 @@ export const LEVELS = [ { row: 6, col: 7 }, ], }, + { + // Chaser lurks near the short route — triggers when player rushes + type: "chaser", + position: { row: 2, col: 1 }, + detectionRadius: 3, + }, ], + parMoves: 19, }, { - // Hall of Mirrors — rotating guard + mirror guards redirect beams + // Hall of Mirrors — rotating guard + mirror guards redirect beams + chaser id: 10, name: "Hall of Mirrors", storyKey: "level10Story", @@ -260,8 +356,10 @@ export const LEVELS = [ player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ + { row: 2, col: 1 }, { row: 3, col: 3 }, { row: 3, col: 4 }, { row: 4, col: 3 }, { row: 4, col: 4 }, + { row: 5, col: 6 }, ], guards: [ { @@ -284,10 +382,22 @@ export const LEVELS = [ position: { row: 5, col: 5 }, startDirection: 2, }, + { + type: "mirror", + position: { row: 5, col: 2 }, + reflectDirection: "cw", + }, + { + // Chaser in bottom-right quadrant to pressure the player + type: "chaser", + position: { row: 7, col: 4 }, + detectionRadius: 3, + }, ], + parMoves: 20, }, { - // The Throne Room — all guard types combined + // The Throne Room — all guard types combined including chaser id: 11, name: "The Throne Room", storyKey: "level11Story", @@ -307,7 +417,7 @@ export const LEVELS = [ type: "static", position: { row: 1, col: 7 }, litCells: [ - { row: 1, col: 6 }, { row: 1, col: 8 }, + { row: 0, col: 7 }, { row: 1, col: 6 }, { row: 1, col: 8 }, ], }, { @@ -332,12 +442,18 @@ export const LEVELS = [ { row: 7, col: 3 }, { row: 7, col: 2 }, ], }, + { + // Chaser guards the final stretch + type: "chaser", + position: { row: 7, col: 7 }, + detectionRadius: 3, + }, ], + parMoves: 24, }, { - // THE PRINCESS CHAMBER — escalating detection: light radiates from goal - // when player reaches distance 4, one ring per turn lights up, creating - // an expanding wave. The princess senses you approaching. + // THE PRINCESS CHAMBER — unbeatable: expanding light wave from goal + // When player reaches distance 4, one ring per turn lights up. id: 12, name: "The Princess Chamber", storyKey: "level12Story", @@ -395,7 +511,14 @@ export const LEVELS = [ { row: 8, col: 6 }, { row: 8, col: 7 }, ], }, + { + // Extra chaser to make it even more hopeless + type: "chaser", + position: { row: 8, col: 2 }, + detectionRadius: 4, + }, ], isFinalLevel: true, + parMoves: 99, }, ]; diff --git a/src/lib/progress.js b/src/lib/progress.js index 47faab9..68ce494 100644 --- a/src/lib/progress.js +++ b/src/lib/progress.js @@ -5,6 +5,8 @@ const STORAGE_KEY = 'nntv-progress'; const DEFAULT_PROGRESS = { maxLevel: 1, completedLevels: [], + levelStars: {}, + levelBestMoves: {}, }; export function getProgress() { @@ -15,6 +17,8 @@ export function getProgress() { return { maxLevel: parsed.maxLevel || 1, completedLevels: parsed.completedLevels || [], + levelStars: parsed.levelStars || {}, + levelBestMoves: parsed.levelBestMoves || {}, }; } } catch (e) { @@ -23,7 +27,14 @@ export function getProgress() { return { ...DEFAULT_PROGRESS }; } -export function completeLevel(levelNum, totalLevels) { +// Calculate star rating: 3★ = ≤par, 2★ = par+1 to par+3, 1★ = completed +export function calculateStars(moves, parMoves) { + if (moves <= parMoves) return 3; + if (moves <= parMoves + 3) return 2; + return 1; +} + +export function completeLevel(levelNum, totalLevels, moves, parMoves) { const progress = getProgress(); if (!progress.completedLevels.includes(levelNum)) { progress.completedLevels.push(levelNum); @@ -33,10 +44,20 @@ export function completeLevel(levelNum, totalLevels) { if (nextLevel > progress.maxLevel) { progress.maxLevel = nextLevel; } + // Star rating (only update if better) + let stars = 1; + if (moves != null && parMoves != null) { + stars = calculateStars(moves, parMoves); + const key = String(levelNum); + const prevStars = progress.levelStars[key] || 0; + if (stars > prevStars) progress.levelStars[key] = stars; + const prevBest = progress.levelBestMoves[key]; + if (prevBest == null || moves < prevBest) progress.levelBestMoves[key] = moves; + } try { localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); } catch (e) { // Storage full or unavailable } - return progress; + return { ...progress, stars }; } diff --git a/src/scenes/Game.svelte b/src/scenes/Game.svelte index 46be84b..0260e88 100644 --- a/src/scenes/Game.svelte +++ b/src/scenes/Game.svelte @@ -3,12 +3,14 @@ import { getText } from '../lib/localization.js'; import { loadLevel, getTotalLevels } from '../lib/game/level-manager.js'; import { TurnManager } from '../lib/game/turn-manager.js'; - import { completeLevel } from '../lib/progress.js'; + import { completeLevel, calculateStars } from '../lib/progress.js'; + import { LEVELS } from '../lib/levels/levels.js'; import GameBoard from '../components/GameBoard.svelte'; import PlayerSprite from '../components/PlayerSprite.svelte'; import GuardSprite from '../components/GuardSprite.svelte'; import GameHud from '../components/GameHud.svelte'; import DetectionPopup from '../components/DetectionPopup.svelte'; + import LevelCompletePopup from '../components/LevelCompletePopup.svelte'; import PauseMenu from '../components/PauseMenu.svelte'; let { navigate, level = 1, lives = 3 } = $props(); @@ -29,6 +31,10 @@ let detected = $state(false); let showFlash = $state(false); let finalMessage = $state(false); + let showLevelComplete = $state(false); + let completionStars = $state(0); + let completionMoves = $state(0); + let showPreview = $state(false); // Render version counter — incremented after each state mutation to force // Svelte 5 to re-derive rendering data (class instances are not proxied) @@ -41,8 +47,11 @@ let playerRow = $derived((renderVersion, player ? player.row : 0)); let playerCol = $derived((renderVersion, player ? player.col : 0)); let guardSnapshots = $derived((renderVersion, guards.map(g => ({ - row: g.row, col: g.col, type: g.type, direction: g.direction, isOn: g.isOn + row: g.row, col: g.col, type: g.type, direction: g.direction, isOn: g.isOn, isChasing: g.isChasing })))); + let previewCells = $derived((renderVersion, showPreview && grid && player && guards.length + ? turnManager.previewNextTurn(grid, player, guards) + : new Set())); // Initialize level function initLevel() { @@ -61,23 +70,39 @@ showFlash = false; princessAlerted = false; alertRadius = 0; + showLevelComplete = false; + completionStars = 0; + completionMoves = 0; } onMount(() => { initLevel(); }); // Input handling function onKeyDown(e) { - if (isPaused || detected) return; + if (isPaused || detected || showLevelComplete) return; const dirMap = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right', }; + if (e.key === 'v') { showPreview = !showPreview; return; } + if (e.key === ' ') { e.preventDefault(); handleWait(); return; } const dir = dirMap[e.key]; if (dir) { e.preventDefault(); handleMove(dir); } } + function handleWait() { + if (!player || !grid) return; + const result = turnManager.nextTurn(grid, player, guards); + if (isFinalLevel && checkFinalLevel()) { renderVersion++; return; } + renderVersion++; + if (result.levelComplete) handleLevelComplete(); + else if (result.detected) detected = true; + } + function onCellClick(row, col) { - if (isPaused || detected || !player) return; + if (isPaused || detected || showLevelComplete || !player) return; + // Tap on player cell = wait + if (row === player.row && col === player.col) { handleWait(); return; } const rowDiff = Math.abs(row - player.row); const colDiff = Math.abs(col - player.col); if ((rowDiff === 1 && colDiff === 0) || (rowDiff === 0 && colDiff === 1)) { @@ -152,19 +177,28 @@ function handleLevelComplete() { const total = getTotalLevels(); - completeLevel(currentLevel, total); + const levelData = LEVELS[currentLevel - 1]; + const moves = turnManager.turnCount; + const par = levelData.parMoves || 99; + completeLevel(currentLevel, total, moves, par); + completionMoves = moves; + completionStars = calculateStars(moves, par); showFlash = true; - setTimeout(() => { - showFlash = false; - const next = currentLevel + 1; - if (isFinalLevel) { - navigate('GameOver', { level: currentLevel, isLastLevel: true }); - } else if (next > total) { - navigate('GameOver', { level: currentLevel, isLastLevel: false }); - } else { - navigate('LevelIntro', { level: next, lives: livesRemaining }); - } - }, 600); + showLevelComplete = true; + } + + function handleLevelCompleteNext() { + showLevelComplete = false; + showFlash = false; + const total = getTotalLevels(); + const next = currentLevel + 1; + if (isFinalLevel) { + navigate('GameOver', { level: currentLevel, isLastLevel: true }); + } else if (next > total) { + navigate('GameOver', { level: currentLevel, isLastLevel: false }); + } else { + navigate('LevelIntro', { level: next, lives: livesRemaining }); + } } function handleDetectionDismiss() { @@ -185,6 +219,8 @@ lives={livesRemaining} level={currentLevel} {turns} + {showPreview} + ontogglepreview={() => showPreview = !showPreview} onpause={() => isPaused = true} onmenu={() => navigate('MainMenu')} /> @@ -197,6 +233,7 @@ rows={grid.rows} cols={grid.cols} {cellSize} + {previewCells} oncellclick={onCellClick} /> @@ -213,6 +250,15 @@
{/if} + {#if showLevelComplete} + + {/if} + {#if detected} {/if} diff --git a/src/scenes/LevelSelect.svelte b/src/scenes/LevelSelect.svelte index 76e93b2..7cf5a6b 100644 --- a/src/scenes/LevelSelect.svelte +++ b/src/scenes/LevelSelect.svelte @@ -22,6 +22,8 @@ {@const num = i + 1} {@const unlocked = num <= progress.maxLevel} {@const completed = progress.completedLevels.includes(num)} + {@const stars = progress.levelStars[String(num)] || 0} + {@const bestMoves = progress.levelBestMoves[String(num)]} {/each} @@ -54,13 +65,16 @@ gap: 20px; } .level-btn { - width: 70px; height: 70px; + width: 70px; height: 80px; font: var(--font-button); border-radius: 4px; border: 2px solid var(--btn-border); cursor: pointer; position: relative; transition: background 0.15s; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 2px; padding: 4px 0; } .level-btn.unlocked { background: var(--btn-default); @@ -74,10 +88,9 @@ border-color: #333344; cursor: default; } - .check { - position: absolute; - top: 2px; right: 4px; - font-size: 12px; - color: var(--grid-goal); - } + .level-num { font-size: 18px; font-weight: bold; } + .star-row { display: flex; gap: 1px; } + .star-sm { font-size: 10px; color: #333; } + .star-sm.filled { color: #ffdd44; } + .best-moves { font-size: 9px; color: var(--text-secondary); } diff --git a/src/styles/theme.css b/src/styles/theme.css index b6ecd14..e141bae 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -30,6 +30,7 @@ --guard-blinking-off: #887722; --guard-patrolling: #bb44ff; --guard-mirror: #44ddaa; + --guard-chaser: #ff6622; /* Player */ --player-color: #111111;