feat: add gameplay improvements — wait action, star rating, chaser guard, vision preview

- 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
This commit is contained in:
tiennm99 committed 2026-04-13 14:47:42 +07:00
1 parent 710d7dec27
commit 7aa81730a6
17 files changed
+837 -73

No files matched your search

@@ -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.
@@ -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).
@@ -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.
@@ -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.
@@ -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
+3 -1
View File
@@ -1,5 +1,5 @@
<script>
let { cells = [], rows = 6, cols = 6, cellSize = 50, oncellclick } = $props();
let { cells = [], rows = 6, cols = 6, cellSize = 50, previewCells = new Set(), oncellclick } = $props();
</script>
<div
@@ -13,6 +13,7 @@
class:wall={cell.isWall}
class:goal={cell.isGoal}
class:lit={cell.isLight}
class:preview={previewCells.has(`${cell.row},${cell.col}`) && !cell.isLight}
role="gridcell"
onclick={() => oncellclick?.(cell.row, cell.col)}
></div>
@@ -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); }
</style>
+2 -1
View File
@@ -1,7 +1,7 @@
<script>
import { getText } from '../lib/localization.js';
import Button from './Button.svelte';
let { lives = 3, level = 1, turns = 0, onpause, onmenu } = $props();
let { lives = 3, level = 1, turns = 0, showPreview = false, ontogglepreview, onpause, onmenu } = $props();
</script>
<div class="hud">
@@ -11,6 +11,7 @@
</div>
<div class="hud-right">
<span>{getText('level')}{level}</span>
<Button text={showPreview ? 'V:ON' : 'V:OFF'} onclick={ontogglepreview} small />
<Button text={getText('pause')} onclick={onpause} small />
<Button text={getText('menu')} onclick={onmenu} small />
</div>
+4 -1
View File
@@ -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');
</script>
<div
class="guard {guard.type}"
class:off={guard.type === 'blinking' && !guard.isOn}
class:alert={guard.type === 'chaser' && guard.isChasing}
style="top: {top}px; left: {left}px; width: {size}px; height: {size}px;"
>
{#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 {
+76
View File
@@ -0,0 +1,76 @@
<script>
import { getText } from '../lib/localization.js';
let { stars = 0, moves = 0, parMoves = 0, onnext } = $props();
</script>
<div class="level-complete-overlay">
<div class="level-complete-card">
<h2>{getText('levelComplete') || 'Level Complete!'}</h2>
<div class="stars-row">
{#each [1, 2, 3] as i}
<span class="star" class:filled={i <= stars}
style="animation-delay: {i * 0.15}s">&#9733;</span>
{/each}
</div>
<p class="move-count">Moves: {moves} / Par: {parMoves}</p>
<button class="next-btn" onclick={onnext}>
{getText('continue') || 'Continue'}
</button>
</div>
</div>
<style>
.level-complete-overlay {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex; justify-content: center; align-items: center;
z-index: 100;
}
.level-complete-card {
background: var(--bg-panel, #1a1a2e);
border: 2px solid var(--grid-goal);
border-radius: 8px;
padding: 24px 32px;
text-align: center;
animation: pop-in 0.3s ease-out;
}
@keyframes pop-in {
0% { transform: scale(0.8); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.level-complete-card h2 {
font: var(--font-title);
color: var(--grid-goal);
margin: 0 0 12px;
}
.stars-row {
display: flex; justify-content: center; gap: 8px;
margin-bottom: 12px;
}
.star {
font-size: 32px;
color: #333;
animation: star-pop 0.3s ease-out both;
}
.star.filled { color: #ffdd44; }
@keyframes star-pop {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.3); }
100% { transform: scale(1); opacity: 1; }
}
.move-count {
font: var(--font-body);
color: var(--text-secondary);
margin-bottom: 16px;
}
.next-btn {
font: var(--font-button);
background: var(--btn-default);
color: var(--text-primary);
border: 2px solid var(--btn-border);
border-radius: 4px;
padding: 8px 24px;
cursor: pointer;
}
.next-btn:hover { background: var(--btn-hover); }
</style>
+95
View File
@@ -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');
+5 -1
View File
@@ -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,
};
}
+42 -1
View File
@@ -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;
}
+164 -41
View File
@@ -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,
},
];
+23 -2
View File
@@ -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 };
}
+62 -16
View File
@@ -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}
/>
<PlayerSprite row={playerRow} col={playerCol} {cellSize} />
@@ -213,6 +250,15 @@
</div>
{/if}
{#if showLevelComplete}
<LevelCompletePopup
stars={completionStars}
moves={completionMoves}
parMoves={LEVELS[currentLevel - 1]?.parMoves ?? 99}
onnext={handleLevelCompleteNext}
/>
{/if}
{#if detected}
<DetectionPopup onplayagain={handleDetectionDismiss} />
{/if}
+22 -9
View File
@@ -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)]}
<button
class="level-btn"
class:unlocked
@@ -30,8 +32,17 @@
onclick={() => selectLevel(num)}
disabled={!unlocked}
>
{num}
{#if completed}<span class="check">✓</span>{/if}
<span class="level-num">{num}</span>
{#if completed}
<span class="star-row">
{#each [1, 2, 3] as i}
<span class="star-sm" class:filled={i <= stars}>&#9733;</span>
{/each}
</span>
{/if}
{#if bestMoves != null}
<span class="best-moves">{bestMoves}m</span>
{/if}
</button>
{/each}
</div>
@@ -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); }
</style>
+1
View File
@@ -30,6 +30,7 @@
--guard-blinking-off: #887722;
--guard-patrolling: #bb44ff;
--guard-mirror: #44ddaa;
--guard-chaser: #ff6622;
/* Player */
--player-color: #111111;