mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-16 06:14:38 +00:00
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:
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
|
||||
@@ -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>
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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">★</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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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}>★</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>
|
||||
@@ -30,6 +30,7 @@
|
||||
--guard-blinking-off: #887722;
|
||||
--guard-patrolling: #bb44ff;
|
||||
--guard-mirror: #44ddaa;
|
||||
--guard-chaser: #ff6622;
|
||||
|
||||
/* Player */
|
||||
--player-color: #111111;
|
||||
|
||||
Reference in new issue
Block a user