feat: add undo/redo, audio, mobile controls, BFS pathfinding, and accessibility

- Fix ChaserGuard with BFS pathfinding and proper chase/return states
- Add undo/redo system (Z/Y keys) with full state snapshots
- Add procedural audio via Web Audio API (move, wait, detection, complete)
- Add mobile swipe controls with touch gesture detection
- Add detection feedback (cell flash, player shake animation)
- Add CSS transitions on grid cells for smooth lighting changes
- Add ARIA accessibility labels on game board and cells
- Add controls overlay ("?" button) showing all keyboard/touch shortcuts
- Add mute toggle in HUD
- Update Guide scene with chaser/mirror guard descriptions and tips
- Replace guard switch statement with factory registry pattern
- Extract princess mechanic and touch controls into separate modules
- Localize all UI strings (EN/VI) including new controls and tips
- Update README for Svelte 5 architecture with all current features
- Update project docs (architecture, code standards, codebase summary)
This commit is contained in:
2026-04-13 18:24:46 +07:00
parent 7aa81730a6
commit 7233310662
22 changed files with 849 additions and 245 deletions
+51 -103
View File
@@ -16,21 +16,27 @@ In Night Ninja: Twilight Voyage, you play as a ninja rabbit trying to navigate t
- **Turn-based gameplay**: Each move you make triggers the vegetable guards to take their turn
- **Multiple guard types**:
- Static Vegetable Guards (red): Light up fixed cells around them
- Rotating Vegetable Guards (blue): Rotate and light up cells in different directions each turn
- Blinking Vegetable Guards (yellow): Toggle their lights on and off each turn
- Patrolling Pest Bugs (purple): Move along predefined paths, lighting cells around them
- **Progressive difficulty**: 12 levels with increasing complexity and new mechanics
- **Grid-based movement**: Move one cell at a time using arrow keys or by clicking adjacent cells
- Static Guards (red): Light up fixed cells around them
- Rotating Guards (blue): Rotate and light up cells in different directions each turn
- Blinking Guards (yellow): Toggle their lights on and off each turn
- Patrolling Guards (purple): Move along predefined paths, lighting cells around them
- Mirror Guards (green): Deflect rotating beams 90 degrees
- Chaser Guards (orange): Detect nearby players and hunt them using pathfinding
- **Progressive difficulty**: 12 levels across 6 acts with increasing complexity
- **Grid-based movement**: Move one cell at a time using arrow keys, WASD, clicking, or swiping
- **Stealth mechanics**: Avoid lit cells to remain undetected
- **Undo/redo**: Press Z to undo moves, Y to redo — experiment without full restarts
- **Turn preview**: Press V to see where lights will be next turn
- **Sound effects**: Procedural audio feedback for moves, detection, and completion
- **Mobile support**: Touch controls with swipe gestures
- **Accessibility**: ARIA labels on grid cells for screen readers
- **Bilingual**: English and Vietnamese language support
- **Lives system**: You have 3 lives to complete all levels
## Versions
## Tech Stack
This game is built with:
- [Phaser 3.88.2](https://github.com/phaserjs/phaser)
- [Vite 5.3.1](https://github.com/vitejs/vite)
- [Svelte 5](https://svelte.dev) — UI framework with runes reactivity
- [Vite 6](https://github.com/vitejs/vite) — Build tool and dev server
## Requirements
@@ -38,15 +44,20 @@ This game is built with:
## How to Play
- Use **arrow keys** to move your rabbit character one cell at a time
- Alternatively, **click** on an adjacent cell to move there
- Reach the **green goal cell** to complete each level and progress in your quest to rescue the missing carrot princess
- Use **arrow keys** or **WASD** to move your rabbit character one cell at a time
- **Click** on an adjacent cell or **swipe** on mobile to move
- Press **Space** to wait a turn without moving
- Press **V** to preview where lights will be next turn
- Press **Z** to undo your last move, **Y** to redo
- Reach the **green goal cell** to complete each level
- Avoid stepping on **yellow lit cells** or you'll be detected and lose a life
- Plan your moves carefully as each vegetable guard behaves differently:
- **Red vegetable guards** (Static): Always light the same cells
- **Blue vegetable guards** (Rotating): Change the direction they light each turn
- **Yellow vegetable guards** (Blinking): Turn their lights on and off each turn
- **Purple pest bugs** (Patrolling): Move along a path, lighting cells around them
- Plan your moves carefully as each guard behaves differently:
- **Red guards** (Static): Always light the same cells
- **Blue guards** (Rotating): Change the direction they light each turn
- **Yellow guards** (Blinking): Turn their lights on and off each turn
- **Purple guards** (Patrolling): Move along a path, lighting cells around them
- **Green mirrors**: Deflect light beams 90 degrees
- **Orange chasers**: Detect and hunt you using pathfinding
## Available Commands
@@ -55,104 +66,41 @@ This game is built with:
| `npm install` | Install project dependencies |
| `npm run dev` | Launch a development web server |
| `npm run build` | Create a production build in the `dist` folder |
| `npm run dev-nolog` | Launch a development web server without sending anonymous data (see "About log.js" below) |
| `npm run build-nolog` | Create a production build in the `dist` folder without sending anonymous data (see "About log.js" below) |
| `npm run preview` | Preview the production build locally |
## Development
After cloning the repo, run `npm install` from your project directory. Then, you can start the local development server by running `npm run dev`.
The local development server runs on `http://localhost:8080` by default. Please see the Vite documentation if you wish to change this, or add SSL support.
The local development server runs on `http://localhost:5173` by default. Vite will automatically recompile your code and reload the browser on changes.
Once the server is running you can edit any of the files in the `src` folder. Vite will automatically recompile your code and then reload the browser.
## Template Project Structure
We have provided a default project structure to get you started. This is as follows:
## Project Structure
| Path | Description |
|------------------------------|------------------------------------------------------------|
| `index.html` | A basic HTML page to contain the game. |
| `public/assets` | Game sprites, audio, etc. Served directly at runtime. |
| `public/style.css` | Global layout styles. |
| `src/main.js` | Application bootstrap. |
| `src/game` | Folder containing the game code. |
| `src/game/main.js` | Game entry point: configures and starts the game. |
| `src/game/scenes` | Folder with all Phaser game scenes. |
## Handling Assets
Vite supports loading assets via JavaScript module `import` statements.
This template provides support for both embedding assets and also loading them from a static folder. To embed an asset, you can import it at the top of the JavaScript file you are using it in:
```js
import logoImg from './assets/logo.png'
```
To load static files such as audio files, videos, etc place them into the `public/assets` folder. Then you can use this path in the Loader calls within Phaser:
```js
preload ()
{
// This is an example of an imported bundled image.
// Remember to import it at the top of this file
this.load.image('logo', logoImg);
// This is an example of loading a static image
// from the public/assets folder:
this.load.image('background', 'assets/bg.png');
}
```
When you issue the `npm run build` command, all static assets are automatically copied to the `dist/assets` folder.
## Deploying to Production
After you run the `npm run build` command, your code will be built into a single bundle and saved to the `dist` folder, along with any other assets your project imported, or stored in the public assets folder.
In order to deploy your game, you will need to upload *all* of the contents of the `dist` folder to a public facing web server.
## Customizing the Template
### Vite
If you want to customize your build, such as adding plugin (i.e. for loading CSS or fonts), you can modify the `vite/config.*.mjs` file for cross-project changes, or you can modify and/or create new configuration files and target them in specific npm tasks inside of `package.json`. Please see the [Vite documentation](https://vitejs.dev/) for more information.
| `index.html` | HTML entry point |
| `public/assets` | Static assets (images) served at runtime |
| `public/style.css` | Global layout styles and CSS variables |
| `src/main.js` | Application bootstrap |
| `src/App.svelte` | Scene router |
| `src/scenes/` | Game scenes (MainMenu, Game, Guide, Settings, etc.) |
| `src/components/` | Reusable UI components (GameBoard, PlayerSprite, etc.) |
| `src/lib/game/` | Pure JS game engine (grid, player, guards, turns, history) |
| `src/lib/levels/` | Level definitions (12 levels) |
| `src/lib/` | Audio, localization, progress persistence |
## Game Architecture
The game is built with a modular architecture:
Pure JS game engine (no framework dependency) with Svelte 5 rendering:
- **Grid System**: Manages the game grid, walls, goals, and lighting
- **Player**: Handles the rabbit character's movement and detection
- **Vegetable Guards**: Different types of vegetable guards with unique behaviors
- **Turn Manager**: Controls the turn-based gameplay
- **Lighting System**: Manages which cells are lit by vegetable guards
- **Level Manager**: Loads level data and sets up the vegetable kingdom environment
## Future Enhancements
Potential features for future development:
- Additional vegetable guard types with new behaviors
- Vegetable power-ups that provide temporary abilities
- Level editor for creating custom vegetable kingdom levels
- High score system
- Sound effects and background music
- Mobile-friendly controls
## About the Template
This game was built using the Phaser 3 Vite template. The template includes a logging feature that sends anonymous usage data to Phaser Studio. If you don't want to send this data, you can use the `-nolog` commands or disable the logging entirely.
- **GridSystem**: Cell state management (walls, goals, lighting)
- **Player**: Position and movement validation
- **Guards**: 6 guard types with distinct AI (static, rotating, blinking, patrolling, mirror, chaser)
- **TurnManager**: Turn cycle with preview simulation
- **GameHistory**: Undo/redo via state snapshots
- **Audio**: Procedural Web Audio API sound effects
## Credits
- Built with [Phaser 3](https://phaser.io)
- Developed as a turn-based stealth puzzle game
- Built with [Svelte 5](https://svelte.dev) and [Vite 6](https://vitejs.dev)
- Inspired by classic stealth games and vegetable kingdom stories
---
Game developed using Phaser 3. Phaser is an open source framework for Canvas and WebGL powered browser games.
Learn more about Phaser at [phaser.io](https://phaser.io)
+55 -3
View File
@@ -51,9 +51,12 @@ src/
│ ├── game/ # Pure JS game engine (no framework)
│ │ ├── grid-system.js
│ │ ├── player.js
│ │ ├── guards.js # Base + 5 guard subclasses
│ │ ├── guards.js # Base + 6 guard subclasses (including ChaserGuard)
│ │ ├── turn-manager.js
│ │ ── level-manager.js
│ │ ── level-manager.js # GUARD_REGISTRY factory pattern
│ │ ├── game-history.js # Undo/redo system
│ │ ├── princess-mechanic.js # Level 12 escalating detection
│ │ └── touch-controls.js # Mobile swipe support
│ │
│ ├── levels/
│ │ └── levels.js # 12 level definitions
@@ -61,6 +64,7 @@ src/
│ ├── locales/
│ │ ├── en.json
│ │ └── vi.json
│ ├── audio.js # Web Audio API sounds
│ ├── localization.js
│ └── progress.js
@@ -121,7 +125,8 @@ Guard (abstract base)
├── RotatingGuard — rotating beam + mirror reflection
├── BlinkingGuard — toggle on/off
├── MirrorGuard — redirects beams
── PatrollingGuard — path movement + directional light
── PatrollingGuard — path movement + directional light
└── ChaserGuard — BFS pathfinding + detection radius
```
**Base Guard contract:**
@@ -129,6 +134,17 @@ Guard (abstract base)
- `updateLight(allGuards?)`: Set lit cells on grid
- `onTurnChange(allGuards?)`: Update state then call updateLight
**Level Manager Pattern:**
Use `GUARD_REGISTRY` factory pattern in `level-manager.js`:
```javascript
const GUARD_REGISTRY = {
static: (grid, g) => new StaticGuard(...),
chaser: (grid, g) => new ChaserGuard(...),
// ... etc
};
```
This eliminates switch statements and allows easy guard type registration.
**Key rule**: Game engine classes are pure JS with no Svelte dependency. They operate on raw object references, not proxied state.
## Grid & Coordinate System
@@ -174,6 +190,39 @@ Key naming: camelCase matching JSON structure (`levelSelectTitle`, `enemyTypesCo
- **Messages**: Descriptive, explain "why" not just "what"
- **Example**: `fix: resolve Svelte 5 reactivity for class instances`
## New Patterns
### Undo/Redo System
```javascript
import { GameHistory } from '../lib/game/game-history.js';
const history = new GameHistory();
// Before player move
history.snapshot(player, guards, turnCount, princessAlerted, alertRadius);
// Z/Y key handlers
if (event.key === 'z') history.undo(player, guards);
if (event.key === 'y') history.redo(player, guards);
```
### Mobile Touch Controls
```javascript
import { TouchControls } from '../lib/game/touch-controls.js';
const touch = new TouchControls();
// In Game.svelte
<svelte:window ontouchstart={e => touch.onTouchStart(e)}
ontouchend={e => { const dir = touch.onTouchEnd(e); handleMove(dir); }} />
```
### Audio Feedback
```javascript
import * as audio from '../lib/audio.js';
audio.playMoveSound();
audio.playDetectionSound();
audio.toggleMute();
```
## Code Review Checklist
- [ ] Follows naming conventions (PascalCase components, kebab-case JS modules)
@@ -183,3 +232,6 @@ Key naming: camelCase matching JSON structure (`levelSelectTitle`, `enemyTypesCo
- [ ] Localization keys used for all user-facing strings
- [ ] File size under 200 lines
- [ ] Pure JS game logic has no Svelte imports
- [ ] Guard types registered in GUARD_REGISTRY (not switch statements)
- [ ] Touch input debounced/throttled if needed
- [ ] Audio context lazily initialized (autoplay policy compliance)
+21 -11
View File
@@ -43,9 +43,12 @@ NNTV is a turn-based stealth puzzle game built with Svelte 5 and Vite 6.x. The c
|------|---------|
| grid-system.js | GridSystem class: cell state, walls, goals, lighting |
| player.js | Player class: position, movement validation |
| guards.js | Guard base + 5 subclasses (Static, Rotating, Blinking, Mirror, Patrolling) |
| guards.js | Guard base + 6 subclasses (Static, Rotating, Blinking, Mirror, Patrolling, Chaser) |
| turn-manager.js | TurnManager: turn cycle, guard updates, detection |
| level-manager.js | loadLevel(): instantiate grid, player, guards from level data |
| level-manager.js | loadLevel(): GUARD_REGISTRY factory pattern for guard instantiation |
| game-history.js | GameHistory class: undo/redo snapshots (Z/Y keys) |
| princess-mechanic.js | Princess detection logic: escalating light rings on level 12 |
| touch-controls.js | TouchControls class: swipe gesture detection for mobile |
### Level Data (src/lib/levels/)
@@ -53,6 +56,12 @@ NNTV is a turn-based stealth puzzle game built with Svelte 5 and Vite 6.x. The c
|------|---------|
| levels.js | LEVELS array: 12 level definitions (grid, guards, walls, goals) |
### Audio System (src/lib/)
| File | Purpose |
|------|---------|
| audio.js | Web Audio API procedural sound: playTone, playMoveSound, playDetectionSound, playCompleteSound, toggleMute |
### Utilities (src/lib/)
| File | Purpose |
@@ -79,11 +88,12 @@ NNTV is a turn-based stealth puzzle game built with Svelte 5 and Vite 6.x. The c
```
Guard (abstract base: grid, row, col, type, direction, isOn)
├── StaticGuard — lights fixed litCells array
├── RotatingGuard — rotates beam 90°/turn, castBeam with mirror bounce
├── BlinkingGuard — toggles isOn, lights litCells when on
├── MirrorGuard — lights own cell, stores reflectDirection (cw/ccw)
── PatrollingGuard — follows path array, lights front + right cells
├── StaticGuard — lights fixed litCells array
├── RotatingGuard — rotates beam 90°/turn, castBeam with mirror bounce
├── BlinkingGuard — toggles isOn, lights litCells when on
├── MirrorGuard — lights own cell, stores reflectDirection (cw/ccw)
── PatrollingGuard — follows path array, lights front + right cells
└── ChaserGuard — BFS pathfinding to player, detectionRadius range
```
## Key Data Structures
@@ -182,10 +192,10 @@ All scenes → localization.js (for UI text)
| Metric | Value |
|--------|-------|
| Total Source Files | ~20 (8 scenes + 7 components + 5 engine + utils) |
| Number of Classes | 7 (GridSystem, Player, Guard + 5 subclasses, TurnManager) |
| Total Source Files | ~25 (8 scenes + 7 components + 8 engine + audio + utils) |
| Number of Classes | 10 (GridSystem, Player, Guard + 6 subclasses, TurnManager, GameHistory, TouchControls) |
| Number of Levels | 12 (across 6 acts) |
| Guard Types | 5 (Static, Rotating, Blinking, Mirror, Patrolling) |
| Localization Keys | ~55 per language |
| Guard Types | 6 (Static, Rotating, Blinking, Mirror, Patrolling, Chaser) |
| Localization Keys | ~67 per language |
| Max Grid Size | 10x10 |
| Max Guards/Level | 8 |
+17 -6
View File
@@ -27,14 +27,18 @@
| Requirement | Description | Status |
|---|---|---|
| Grid-based Movement | Player moves one cell per turn via arrow keys, WASD, or cell click | Complete |
| Guard AI System | 5 guard types (Static, Rotating, Blinking, Patrolling, Mirror) | Complete |
| Grid-based Movement | Player moves one cell per turn via arrow keys, WASD, cell click, or swipe | Complete |
| Guard AI System | 6 guard types (Static, Rotating, Blinking, Patrolling, Mirror, Chaser) | Complete |
| Mirror Reflection | Rotating guard beams bounce off mirror guards at 90 degrees | Complete |
| Detection System | Players lose a life when stepping on lit cells | Complete |
| Detection System | Players lose a life when stepping on lit cells; visual feedback with cell flash + player shake | Complete |
| Chaser Guard | BFS pathfinding with detection radius; advanced AI for late-game difficulty | Complete |
| Undo/Redo System | Z/Y keys rewind/forward through up to 50 previous turns | Complete |
| Level Progression | 12 difficulty-scaled levels with localStorage progress tracking | Complete |
| Win Condition | Reach goal cell to advance; level 12 is unbeatable (narrative twist) | Complete |
| Lives System | 3 lives per play session; game over at zero lives | Complete |
| Escalating Detection | Level 12: light radiates outward from princess when player approaches | Complete |
| Mobile Touch Controls | Swipe gestures (up/down/left/right) for full mobile gameplay support | Complete |
| Audio Feedback | Web Audio API procedural sounds for moves, detection, level completion | Complete |
### Non-Functional Requirements
@@ -53,12 +57,14 @@
- **Level Intro**: Level name, story text, continue button
- **Level Select**: Grid of level buttons with lock/complete states
- **Game HUD**: Current level, lives remaining, turn count
- **Game Board**: Grid cells rendered via Svelte component
- **Game Board**: Grid cells with CSS transitions for smooth lighting changes
- **Controls Overlay**: "?" button reveals all keyboard/touch controls
- **Settings Panel**: Language toggle (EN/VI)
- **Guide**: Game rules, controls, enemy types
- **Detection Popup**: Retry prompt on detection
- **Guide**: Game rules, controls, enemy types, chaser/mirror guard descriptions
- **Detection Popup**: Retry prompt with visual feedback (cell flash, player shake)
- **Pause Menu**: Resume, restart, main menu
- **Game Over Screen**: Retry or return to menu
- **Button Component**: Reusable styled button with disabled state support
### Game Mechanics
@@ -76,8 +82,13 @@
- **Blinking (yellow)**: Toggles lights on/off each turn
- **Patrolling (purple)**: Moves along predefined path, lights front + right cells
- **Mirror (green)**: Redirects rotating guard beams 90 degrees (cw or ccw)
- **Chaser (cyan)**: Uses BFS pathfinding to chase player, lights all cells within detection radius
- **Level 12 Special**: Princess detection — light radiates from goal at distance 4, expanding 1 ring per turn
**Player Abilities:**
- **Movement**: Arrow keys, WASD, cell click, or swipe gestures (mobile)
- **Undo/Redo**: Z key to undo, Y key to redo (up to 50 turns)
### Level Progression (6 Acts)
| Act | Levels | Focus |
+71 -3
View File
@@ -73,7 +73,8 @@ Game.svelte (state owner, input handler, render coordinator)
│ ├── RotatingGuard — rotates beam 90°/turn, reflects off mirrors
│ ├── BlinkingGuard — toggles lights on/off each turn
│ ├── MirrorGuard — redirects rotating beams (cw/ccw 90°)
── PatrollingGuard — follows path, lights front + right
── PatrollingGuard — follows path, lights front + right
│ └── ChaserGuard — BFS pathfinding, lights detectionRadius cells
├── TurnManager (pure JS class)
│ ├── turnCount tracking
@@ -168,14 +169,80 @@ checkFinalLevel() — called each turn on final level
|--------|------|---------|
| GridSystem | `src/lib/game/grid-system.js` | Grid state, cell queries, lighting |
| Player | `src/lib/game/player.js` | Movement validation, position |
| Guards | `src/lib/game/guards.js` | 5 guard types with AI logic |
| Guards | `src/lib/game/guards.js` | 6 guard types with AI logic (including BFS pathfinding) |
| TurnManager | `src/lib/game/turn-manager.js` | Turn cycle, detection checks |
| LevelManager | `src/lib/game/level-manager.js` | Level loading, guard instantiation |
| LevelManager | `src/lib/game/level-manager.js` | Level loading via GUARD_REGISTRY factory pattern |
| GameHistory | `src/lib/game/game-history.js` | Undo/redo snapshots (Z/Y keys), MAX_HISTORY=50 |
| PrincessMechanic | `src/lib/game/princess-mechanic.js` | Level 12 escalating light rings |
| TouchControls | `src/lib/game/touch-controls.js` | Swipe gesture detection (SWIPE_THRESHOLD=30px) |
| Audio | `src/lib/audio.js` | Web Audio API: tones, move/detection/complete sounds, mute toggle |
| Levels | `src/lib/levels/levels.js` | 12 level definitions |
| Localization | `src/lib/localization.js` | Multi-language string management |
| Progress | `src/lib/progress.js` | localStorage persistence |
| Theme | `src/styles/theme.css` | CSS variables for all colors/fonts |
## Undo/Redo System (GameHistory)
```javascript
const history = new GameHistory();
history.snapshot(player, guards, turnCount, princessAlerted, alertRadius);
const state = history.undo(player, guards); // Restores: row, col, direction, isOn per guard
const state = history.redo(player, guards); // Re-applies undone state
```
- Max history size: 50 snapshots
- Triggered by Z key (undo) / Y key (redo)
- Snapshots reset redo stack on any new action
- Restores: player position, guard positions/directions/states, turn count, princess alert state
## Chaser Guard (BFS Pathfinding)
ChaserGuard uses Breadth-First Search to calculate shortest path to player:
```javascript
new ChaserGuard(grid, row, col, detectionRadius);
```
- Rebuilds path each turn via BFS algorithm
- Lights all cells within `detectionRadius` Manhattan distance
- Accounts for walls in pathfinding
- Used for advanced AI in later levels
## Touch Controls & Mobile Support
```javascript
const touch = new TouchControls();
svelte:window ontouchstart={e => touch.onTouchStart(e)};
svelte:window ontouchend={e => { const dir = touch.onTouchEnd(e); }};
```
- Swipe threshold: 30px minimum movement
- Returns direction: 'up', 'down', 'left', 'right'
- Integrated in Game.svelte for full mobile support
## Audio System (Web Audio API)
```javascript
import * as audio from '../lib/audio.js';
audio.playMoveSound(); // Low F note
audio.playDetectionSound(); // Ascending pattern
audio.playCompleteSound(); // Celebration chord
audio.toggleMute(); // Toggle global mute
```
- Context lazily created on first user interaction (autoplay policy)
- Master gain: 0.3 (master volume control)
- Multiple sound effects mapped to game events
- Mute state persisted in component state
## CSS Transitions & Visual Feedback
- **Cell Flash**: `background-color` transitions on detection
- **Player Shake**: CSS animation on detection feedback
- **Light Transitions**: Smooth CSS transitions on grid lighting changes
- **ARIA Accessibility**: `role="grid"`, `aria-label` on cells for screen readers
- **Controls Overlay**: "?" button reveals all keyboard/touch controls
## Asset & Resource Management
- **No sprites/images**: Pure CSS rendering (colored divs, borders)
@@ -183,6 +250,7 @@ checkFinalLevel() — called each turn on final level
- **Levels**: JS objects in `levels.js`
- **Progress**: localStorage key `nntv-progress`
- **Language**: localStorage key `nntv-language`
- **Audio**: Web Audio API (no external files)
## Build & Deployment
+4 -3
View File
@@ -1,8 +1,8 @@
<script>
let { text, onclick, small = false } = $props();
let { text, onclick, small = false, disabled = false } = $props();
</script>
<button class="btn" class:small onclick={onclick}>
<button class="btn" class:small {disabled} onclick={onclick}>
{text}
</button>
@@ -18,7 +18,8 @@
cursor: pointer;
transition: background 0.15s;
}
.btn:hover { background: var(--btn-hover); }
.btn:hover:not(:disabled) { background: var(--btn-hover); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.small {
font: var(--font-button-small);
padding: 6px 16px;
+27 -2
View File
@@ -1,20 +1,37 @@
<script>
let { cells = [], rows = 6, cols = 6, cellSize = 50, previewCells = new Set(), oncellclick } = $props();
let { cells = [], rows = 6, cols = 6, cellSize = 50, previewCells = new Set(),
detectedCell = null, oncellclick } = $props();
function cellLabel(cell) {
let label = `Row ${cell.row + 1}, Column ${cell.col + 1}`;
if (cell.isWall) label += ', wall';
else if (cell.isGoal) label += ', goal';
if (cell.isLight) label += ', lit';
return label;
}
function isDetected(cell) {
return detectedCell && detectedCell.row === cell.row && detectedCell.col === cell.col;
}
</script>
<div
class="board"
role="grid"
aria-label="Game board, {rows} rows by {cols} columns"
style="grid-template-columns: repeat({cols}, {cellSize}px); grid-template-rows: repeat({rows}, {cellSize}px);"
>
{#each cells as cell (cell.row * cols + cell.col)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_interactive_supports_focus -->
<div
class="cell"
class:wall={cell.isWall}
class:goal={cell.isGoal}
class:lit={cell.isLight}
class:preview={previewCells.has(`${cell.row},${cell.col}`) && !cell.isLight}
class:detected-flash={isDetected(cell)}
role="gridcell"
aria-label={cellLabel(cell)}
onclick={() => oncellclick?.(cell.row, cell.col)}
></div>
{/each}
@@ -32,9 +49,17 @@
background: var(--grid-empty);
border: 1px solid var(--grid-border);
cursor: pointer;
transition: background 0.2s ease;
}
.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); }
.cell.detected-flash {
animation: cell-flash 0.4s ease-out;
}
@keyframes cell-flash {
0% { background: #ff0000; }
100% { background: var(--grid-lit); }
}
</style>
+13 -1
View File
@@ -1,7 +1,16 @@
<script>
import { getText } from '../lib/localization.js';
import { isMuted, setMuted, playClick } from '../lib/audio.js';
import Button from './Button.svelte';
let { lives = 3, level = 1, turns = 0, showPreview = false, ontogglepreview, onpause, onmenu } = $props();
let { lives = 3, level = 1, turns = 0, showPreview = false, canUndo = false,
ontogglepreview, onpause, onmenu, onundo, onshowcontrols } = $props();
let muted = $state(isMuted());
function toggleMute() {
muted = !muted;
setMuted(muted);
if (!muted) playClick();
}
</script>
<div class="hud">
@@ -11,7 +20,10 @@
</div>
<div class="hud-right">
<span>{getText('level')}{level}</span>
<Button text={canUndo ? 'Z' : '-'} onclick={onundo} small disabled={!canUndo} />
<Button text={muted ? 'MUTE' : 'SND'} onclick={toggleMute} small />
<Button text={showPreview ? 'V:ON' : 'V:OFF'} onclick={ontogglepreview} small />
<Button text="?" onclick={onshowcontrols} small />
<Button text={getText('pause')} onclick={onpause} small />
<Button text={getText('menu')} onclick={onmenu} small />
</div>
+1 -1
View File
@@ -12,7 +12,7 @@
style="animation-delay: {i * 0.15}s">&#9733;</span>
{/each}
</div>
<p class="move-count">Moves: {moves} / Par: {parMoves}</p>
<p class="move-count">{getText('moves')}: {moves} / Par: {parMoves}</p>
<button class="next-btn" onclick={onnext}>
{getText('continue') || 'Continue'}
</button>
+12 -1
View File
@@ -1,5 +1,5 @@
<script>
let { row = 0, col = 0, cellSize = 50 } = $props();
let { row = 0, col = 0, cellSize = 50, shake = false } = $props();
let top = $derived(row * cellSize + cellSize / 2);
let left = $derived(col * cellSize + cellSize / 2);
let size = $derived(Math.floor(cellSize / 1.5));
@@ -7,6 +7,7 @@
<div
class="player"
class:shake
style="top: {top}px; left: {left}px; width: {size}px; height: {size}px;"
></div>
@@ -20,4 +21,14 @@
z-index: 10;
pointer-events: none;
}
.player.shake {
animation: player-shake 0.3s ease-out;
}
@keyframes player-shake {
0%, 100% { translate: 0 0; }
20% { translate: -4px 0; }
40% { translate: 4px 0; }
60% { translate: -3px 0; }
80% { translate: 2px 0; }
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script>
import { fade } from 'svelte/transition';
import { getText } from '../lib/localization.js';
import Button from './Button.svelte';
let { onclose } = $props();
</script>
<div class="overlay" transition:fade={{ duration: 200 }}>
<div class="popup">
<h2>{getText('controlsTitle')}</h2>
<div class="controls-list">
<div class="control-row"><kbd>Arrow Keys</kbd><span>/ WASD — {getText('controlMove')}</span></div>
<div class="control-row"><kbd>Space</kbd><span>{getText('controlWait')}</span></div>
<div class="control-row"><kbd>V</kbd><span>{getText('controlPreview')}</span></div>
<div class="control-row"><kbd>Z</kbd><span>{getText('controlUndo')}</span></div>
<div class="control-row"><kbd>Y</kbd><span>{getText('controlRedo')}</span></div>
<div class="control-row"><kbd>Click</kbd><span>/ Tap — {getText('controlTap')}</span></div>
<div class="control-row"><kbd>Swipe</kbd><span>{getText('controlSwipe')}</span></div>
</div>
<Button text={getText('back')} onclick={onclose} />
</div>
</div>
<style>
.overlay {
position: absolute;
inset: 0;
background: var(--bg-overlay);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.popup {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
border: 2px solid var(--btn-border);
background: var(--bg-panel);
padding: 24px 32px;
border-radius: 8px;
max-width: 360px;
}
h2 {
font: var(--font-heading);
color: var(--text-title);
margin-bottom: 4px;
}
.controls-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.control-row {
display: flex;
align-items: center;
gap: 8px;
font: var(--font-small);
color: var(--text-secondary);
}
kbd {
background: var(--btn-default);
border: 1px solid var(--btn-border);
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 12px;
color: var(--text-primary);
min-width: 50px;
text-align: center;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
// Procedural audio system using Web Audio API
// Lazy AudioContext creation to comply with browser autoplay policy
let audioCtx = null;
let masterGain = null;
let muted = false;
function getContext() {
if (!audioCtx) {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.3;
masterGain.connect(audioCtx.destination);
} catch (e) { return null; }
}
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
function playTone(freq, duration, type = 'sine', volume = 1) {
if (muted) return;
const ctx = getContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
gain.gain.value = volume * 0.3;
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(masterGain);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
}
// Short soft tone for player movement
export function playMove() {
playTone(220, 0.06, 'sine', 0.4);
}
// Quieter tick for wait action
export function playWait() {
playTone(160, 0.04, 'sine', 0.2);
}
// Harsh alarm for detection
export function playDetection() {
if (muted) return;
const ctx = getContext();
if (!ctx) return;
[400, 600].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = freq;
gain.gain.value = 0.15;
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(masterGain);
osc.start(ctx.currentTime + i * 0.05);
osc.stop(ctx.currentTime + 0.3);
});
}
// Ascending three-note jingle for level completion
export function playLevelComplete() {
if (muted) return;
const notes = [330, 440, 660];
notes.forEach((freq, i) => {
setTimeout(() => playTone(freq, 0.15, 'sine', 0.5), i * 120);
});
}
// Short click for UI interactions
export function playClick() {
playTone(800, 0.02, 'square', 0.15);
}
// Undo sound — descending short tone
export function playUndo() {
playTone(300, 0.05, 'triangle', 0.3);
}
export function setMuted(value) {
muted = value;
}
export function isMuted() {
return muted;
}
+116
View File
@@ -0,0 +1,116 @@
// Undo/redo state history for game turns
// Snapshots player position, guard states, turn count, and princess alert state
const MAX_HISTORY = 50;
export class GameHistory {
constructor() {
this.undoStack = [];
this.redoStack = [];
}
// Create a snapshot object without pushing to history
createSnapshot(player, guards, turnCount, princessAlerted, alertRadius) {
return {
playerRow: player.row,
playerCol: player.col,
turnCount,
princessAlerted,
alertRadius,
guards: guards.map(g => this.snapshotGuard(g)),
};
}
// Push a pre-created snapshot to the undo stack
pushSnapshot(state) {
this.undoStack.push(state);
if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift();
// Any new action clears the redo stack
this.redoStack = [];
}
snapshotGuard(g) {
const snap = {
row: g.row, col: g.col, direction: g.direction, isOn: g.isOn,
};
if (g.type === 'patrolling') {
snap.currentPathIndex = g.currentPathIndex;
snap.isReversing = g.isReversing;
}
if (g.type === 'chaser') {
snap.isChasing = g.isChasing;
snap.isReturning = g.isReturning;
snap.targetRow = g.targetRow;
snap.targetCol = g.targetCol;
}
return snap;
}
// Restore game state from a snapshot
restoreGuard(guard, snap) {
guard.row = snap.row;
guard.col = snap.col;
guard.direction = snap.direction;
guard.isOn = snap.isOn;
if (snap.currentPathIndex !== undefined) guard.currentPathIndex = snap.currentPathIndex;
if (snap.isReversing !== undefined) guard.isReversing = snap.isReversing;
if (snap.isChasing !== undefined) guard.isChasing = snap.isChasing;
if (snap.isReturning !== undefined) guard.isReturning = snap.isReturning;
if (snap.targetRow !== undefined) guard.targetRow = snap.targetRow;
if (snap.targetCol !== undefined) guard.targetCol = snap.targetCol;
}
// Undo last move, returns restored state or null if nothing to undo
undo(player, guards, turnManager, princessAlerted, alertRadius) {
if (this.undoStack.length === 0) return null;
// Save current state to redo stack before undoing
this.redoStack.push({
playerRow: player.row,
playerCol: player.col,
turnCount: turnManager.turnCount,
princessAlerted,
alertRadius,
guards: guards.map(g => this.snapshotGuard(g)),
});
const state = this.undoStack.pop();
player.row = state.playerRow;
player.col = state.playerCol;
turnManager.turnCount = state.turnCount;
guards.forEach((g, i) => this.restoreGuard(g, state.guards[i]));
return state;
}
// Redo last undone move
redo(player, guards, turnManager, princessAlerted, alertRadius) {
if (this.redoStack.length === 0) return null;
// Save current state to undo stack
this.undoStack.push({
playerRow: player.row,
playerCol: player.col,
turnCount: turnManager.turnCount,
princessAlerted,
alertRadius,
guards: guards.map(g => this.snapshotGuard(g)),
});
const state = this.redoStack.pop();
player.row = state.playerRow;
player.col = state.playerCol;
turnManager.turnCount = state.turnCount;
guards.forEach((g, i) => this.restoreGuard(g, state.guards[i]));
return state;
}
canUndo() { return this.undoStack.length > 0; }
canRedo() { return this.redoStack.length > 0; }
reset() {
this.undoStack = [];
this.redoStack = [];
}
}
+61 -42
View File
@@ -148,6 +148,7 @@ export class ChaserGuard extends Guard {
this.startCol = col;
this.detectionRadius = detectionRadius || 3;
this.isChasing = false;
this.isReturning = false;
this.targetRow = row;
this.targetCol = col;
}
@@ -156,7 +157,6 @@ export class ChaserGuard extends Guard {
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;
@@ -173,62 +173,81 @@ export class ChaserGuard extends Guard {
return directions[dir];
}
// BFS pathfinding — finds shortest path around walls to target
bfsNextStep(targetRow, targetCol) {
if (this.row === targetRow && this.col === targetCol) return null;
const rows = this.grid.rows;
const cols = this.grid.cols;
const visited = Array.from({ length: rows }, () => Array(cols).fill(false));
// Store parent direction for path reconstruction
const parent = Array.from({ length: rows }, () => Array(cols).fill(null));
const queue = [{ row: this.row, col: this.col }];
visited[this.row][this.col] = true;
const dirs = [
{ row: -1, col: 0 }, { row: 0, col: 1 },
{ row: 1, col: 0 }, { row: 0, col: -1 },
];
while (queue.length > 0) {
const curr = queue.shift();
for (const d of dirs) {
const nr = curr.row + d.row;
const nc = curr.col + d.col;
if (!this.grid.isValidPosition(nr, nc)) continue;
if (visited[nr][nc] || this.grid.isWall(nr, nc)) continue;
visited[nr][nc] = true;
parent[nr][nc] = { row: curr.row, col: curr.col };
if (nr === targetRow && nc === targetCol) {
// Trace back to find the first step from current position
let step = { row: nr, col: nc };
while (parent[step.row][step.col].row !== this.row ||
parent[step.row][step.col].col !== this.col) {
step = parent[step.row][step.col];
}
return step;
}
queue.push({ row: nr, col: nc });
}
}
return null; // no path found
}
// Chaser has two states: hunting player or returning home
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) {
// Player within detection range — chase them
this.isChasing = true;
this.targetRow = player.row;
this.targetCol = player.col;
this.isReturning = false;
} else if (this.isChasing && !this.isReturning) {
// Player escaped detection range — switch to returning home
this.isReturning = true;
this.targetRow = this.startRow;
this.targetCol = this.startCol;
}
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;
const nextStep = this.bfsNextStep(this.targetRow, this.targetCol);
if (nextStep) {
if (nextStep.row < this.row) this.direction = 0;
else if (nextStep.col > this.col) this.direction = 1;
else if (nextStep.row > this.row) this.direction = 2;
else if (nextStep.col < this.col) this.direction = 3;
// 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;
}
this.row = nextStep.row;
this.col = nextStep.col;
}
// 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;
}
// If returning and reached home, stop chasing entirely
if (this.isReturning &&
this.row === this.startRow && this.col === this.startCol) {
this.isChasing = false;
this.isReturning = false;
}
}
+12 -26
View File
@@ -5,6 +5,16 @@ import { GridSystem } from './grid-system.js';
import { Player } from './player.js';
import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard, MirrorGuard, ChaserGuard } from './guards.js';
// Guard type registry — maps type strings to factory functions
const GUARD_REGISTRY = {
static: (grid, g) => new StaticGuard(grid, g.position.row, g.position.col, g.litCells),
rotating: (grid, g) => new RotatingGuard(grid, g.position.row, g.position.col, g.startDirection),
blinking: (grid, g) => new BlinkingGuard(grid, g.position.row, g.position.col, g.litCells, g.startState),
patrolling: (grid, g) => new PatrollingGuard(grid, g.startPosition.row, g.startPosition.col, g.path),
mirror: (grid, g) => new MirrorGuard(grid, g.position.row, g.position.col, g.reflectDirection),
chaser: (grid, g) => new ChaserGuard(grid, g.position.row, g.position.col, g.detectionRadius),
};
// Load a level by ID, returns complete game state
export function loadLevel(levelId) {
if (levelId < 1 || levelId > LEVELS.length) return null;
@@ -16,45 +26,21 @@ export function loadLevel(levelId) {
const grid = new GridSystem(rows, cols, cellSize);
// Set walls
if (data.walls) {
data.walls.forEach(w => grid.setWall(w.row, w.col, true));
}
// Set goal
if (data.goal) {
grid.setGoal(data.goal.row, data.goal.col, true);
}
// Create player
const player = new Player(grid, data.player.row, data.player.col);
// Create guards
const guards = [];
if (data.guards) {
data.guards.forEach(g => {
let guard = null;
switch (g.type) {
case 'static':
guard = new StaticGuard(grid, g.position.row, g.position.col, g.litCells);
break;
case 'rotating':
guard = new RotatingGuard(grid, g.position.row, g.position.col, g.startDirection);
break;
case 'blinking':
guard = new BlinkingGuard(grid, g.position.row, g.position.col, g.litCells, g.startState);
break;
case 'patrolling':
guard = new PatrollingGuard(grid, g.startPosition.row, g.startPosition.col, g.path);
break;
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);
const factory = GUARD_REGISTRY[g.type];
if (factory) guards.push(factory(grid, g));
});
}
+52
View File
@@ -0,0 +1,52 @@
// Princess escalating detection mechanic for final level
// Light radiates outward from goal in expanding Manhattan distance rings
export class PrincessMechanic {
constructor() {
this.alerted = false;
this.alertRadius = 0;
this.messageShown = false;
}
// Check if princess should activate or expand detection wave
// Returns { showMessage, detected } flags
update(grid, player, goalRow, goalCol) {
const distance = Math.abs(player.row - goalRow) + Math.abs(player.col - goalCol);
if (distance <= 4 && !this.alerted) {
this.alerted = true;
this.messageShown = true;
this.alertRadius = 1;
this.lightRing(grid, goalRow, goalCol, this.alertRadius);
return { showMessage: true, detected: false };
}
if (this.alerted) {
this.alertRadius++;
this.lightRing(grid, goalRow, goalCol, this.alertRadius);
if (grid.isLight(player.row, player.col)) {
return { showMessage: false, detected: true };
}
}
return { showMessage: false, detected: false };
}
// Light all non-wall cells within Manhattan distance of goal
lightRing(grid, goalRow, goalCol, radius) {
for (let r = 0; r < grid.rows; r++) {
for (let c = 0; c < grid.cols; c++) {
const dist = Math.abs(r - goalRow) + Math.abs(c - goalCol);
if (dist <= radius && !grid.isWall(r, c)) {
grid.setLight(r, c, true);
}
}
}
}
reset() {
this.alerted = false;
this.alertRadius = 0;
this.messageShown = false;
}
}
+35
View File
@@ -0,0 +1,35 @@
// Touch/swipe gesture detection for mobile controls
// Converts swipe gestures into directional input (up/down/left/right)
const SWIPE_THRESHOLD = 30;
export class TouchControls {
constructor() {
this.startX = 0;
this.startY = 0;
}
onTouchStart(e) {
if (e.touches.length !== 1) return;
this.startX = e.touches[0].clientX;
this.startY = e.touches[0].clientY;
}
// Returns direction string ('up','down','left','right') or null if not a valid swipe
onTouchEnd(e) {
const dx = e.changedTouches[0].clientX - this.startX;
const dy = e.changedTouches[0].clientY - this.startY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < SWIPE_THRESHOLD && absDy < SWIPE_THRESHOLD) return null;
// Prevent page scroll when a valid swipe is detected
e.preventDefault();
if (absDx > absDy) {
return dx > 0 ? 'right' : 'left';
}
return dy > 0 ? 'down' : 'up';
}
}
+2
View File
@@ -34,6 +34,7 @@ export class TurnManager {
row: g.row, col: g.col, direction: g.direction,
isOn: g.isOn, currentPathIndex: g.currentPathIndex,
isReversing: g.isReversing, isChasing: g.isChasing,
isReturning: g.isReturning,
targetRow: g.targetRow, targetCol: g.targetCol,
}));
@@ -57,6 +58,7 @@ export class TurnManager {
if (s.currentPathIndex !== undefined) g.currentPathIndex = s.currentPathIndex;
if (s.isReversing !== undefined) g.isReversing = s.isReversing;
if (s.isChasing !== undefined) g.isChasing = s.isChasing;
if (s.isReturning !== undefined) g.isReturning = s.isReturning;
if (s.targetRow !== undefined) g.targetRow = s.targetRow;
if (s.targetCol !== undefined) g.targetCol = s.targetCol;
});
+13
View File
@@ -35,6 +35,19 @@
"movementControlsContent": "- Arrow Keys: Move in four directions\n- WASD Keys: Alternative movement controls\n- Click/Tap: Move to an adjacent cell",
"enemyTypes": "ENEMY TYPES",
"enemyTypesContent": "- Red Guards (Static): Always light the same cells around them.\n- Blue Guards (Rotating): Shine a beam that rotates 90 degrees each turn.\n- Yellow Guards (Blinking): Toggle their lights on and off each turn.\n- Purple Guards (Patrolling): Move along a path, lighting cells ahead and to the right.\n- Green Mirrors: Deflect rotating beams 90 degrees. They cannot be avoided — only predicted.",
"advancedEnemyTypes": "ADVANCED ENEMIES",
"advancedEnemyTypesContent": "- Orange Chasers: Detect you within a radius. Once alerted, they hunt you using the shortest path — even around walls. They glow bright red when chasing. Escape their detection range and they return home.\n- Green Mirrors: Stationary diamond-shaped reflectors. They redirect rotating beams by 90 degrees. A single beam can bounce up to 3 times. Plan your path by predicting where reflected light will land.",
"gameTips": "TIPS",
"gameTipsContent": "- Press V to preview where lights will be next turn.\n- Press Space to wait a turn without moving.\n- Press Z to undo your last move.\n- Press Y to redo an undone move.\n- Swipe on mobile to move in that direction.\n- Tap an adjacent cell or swipe to move on mobile.",
"controlsTitle": "CONTROLS",
"controlMove": "Move",
"controlWait": "Wait one turn",
"controlPreview": "Toggle next-turn preview",
"controlUndo": "Undo last move",
"controlRedo": "Redo move",
"controlTap": "Move to adjacent cell",
"controlSwipe": "Move (mobile)",
"moves": "Moves",
"skip": "SKIP",
"continue": "CONTINUE",
"storyTitle": "THE MISSING CARROT PRINCESS",
+13
View File
@@ -35,6 +35,19 @@
"movementControlsContent": "- Phím mũi tên: Di chuyển theo bốn hướng\n- Phím WASD: Điều khiển di chuyển thay thế\n- Nhấp chuột: Di chuyển đến ô kề bên",
"enemyTypes": "CÁC LOẠI KẺ ĐỊCH",
"enemyTypesContent": "- Lính Đỏ (Cố định): Luôn chiếu sáng các ô cố định xung quanh.\n- Lính Xanh (Xoay): Chiếu tia sáng xoay 90 độ mỗi lượt.\n- Lính Vàng (Nhấp nháy): Bật tắt đèn mỗi lượt.\n- Lính Tím (Tuần tra): Di chuyển theo lộ trình, chiếu sáng phía trước và bên phải.\n- Gương Xanh Lá: Phản chiếu tia sáng xoay 90 độ. Không thể tránh — chỉ có thể dự đoán.",
"advancedEnemyTypes": "KẺ ĐỊCH NÂNG CAO",
"advancedEnemyTypesContent": "- Lính Cam (Truy đuổi): Phát hiện bạn trong bán kính nhất định. Khi cảnh giác, chúng truy đuổi bạn theo đường ngắn nhất — kể cả vòng qua tường. Chúng phát sáng đỏ rực khi truy đuổi. Thoát ra ngoài bán kính phát hiện và chúng sẽ quay về vị trí ban đầu.\n- Gương Xanh Lá: Vật phản chiếu hình thoi cố định. Chúng đổi hướng tia sáng xoay 90 độ. Một tia sáng có thể phản chiếu tối đa 3 lần. Lên kế hoạch đường đi bằng cách dự đoán ánh sáng phản chiếu sẽ rơi vào đâu.",
"gameTips": "MẸO CHƠI",
"gameTipsContent": "- Nhấn V để xem trước ánh sáng lượt tiếp theo.\n- Nhấn Space để đợi một lượt mà không di chuyển.\n- Nhấn Z để hoàn tác bước đi cuối.\n- Nhấn Y để làm lại bước đã hoàn tác.\n- Vuốt trên điện thoại để di chuyển theo hướng đó.\n- Chạm vào ô kề bên hoặc vuốt để di chuyển trên điện thoại.",
"controlsTitle": "ĐIỀU KHIỂN",
"controlMove": "Di chuyển",
"controlWait": "Đợi một lượt",
"controlPreview": "Xem trước ánh sáng lượt sau",
"controlUndo": "Hoàn tác bước đi",
"controlRedo": "Làm lại bước đi",
"controlTap": "Di chuyển đến ô kề bên",
"controlSwipe": "Di chuyển (điện thoại)",
"moves": "Bước đi",
"skip": "BỎ QUA",
"continue": "TIẾP TỤC",
"storyTitle": "CÔNG CHÚA CÀ RỐT MẤT TÍCH",
+102 -43
View File
@@ -3,8 +3,12 @@
import { getText } from '../lib/localization.js';
import { loadLevel, getTotalLevels } from '../lib/game/level-manager.js';
import { TurnManager } from '../lib/game/turn-manager.js';
import { GameHistory } from '../lib/game/game-history.js';
import { PrincessMechanic } from '../lib/game/princess-mechanic.js';
import { TouchControls } from '../lib/game/touch-controls.js';
import { completeLevel, calculateStars } from '../lib/progress.js';
import { LEVELS } from '../lib/levels/levels.js';
import { playMove, playWait, playDetection, playLevelComplete, playUndo } from '../lib/audio.js';
import GameBoard from '../components/GameBoard.svelte';
import PlayerSprite from '../components/PlayerSprite.svelte';
import GuardSprite from '../components/GuardSprite.svelte';
@@ -12,6 +16,7 @@
import DetectionPopup from '../components/DetectionPopup.svelte';
import LevelCompletePopup from '../components/LevelCompletePopup.svelte';
import PauseMenu from '../components/PauseMenu.svelte';
import ControlsOverlay from '../components/controls-overlay.svelte';
let { navigate, level = 1, lives = 3 } = $props();
@@ -20,6 +25,9 @@
let player = $state(null);
let guards = $state([]);
let turnManager = $state(new TurnManager());
let history = $state(new GameHistory());
let princess = $state(new PrincessMechanic());
let touch = new TouchControls();
let currentLevel = $state(level); // svelte-ignore state_referenced_locally
let livesRemaining = $state(lives); // svelte-ignore state_referenced_locally
let isFinalLevel = $state(false);
@@ -29,12 +37,15 @@
// UI state
let isPaused = $state(false);
let detected = $state(false);
let playerShake = $state(false);
let detectedCell = $state(null);
let showFlash = $state(false);
let finalMessage = $state(false);
let showLevelComplete = $state(false);
let completionStars = $state(0);
let completionMoves = $state(0);
let showPreview = $state(false);
let showControls = $state(false);
// Render version counter — incremented after each state mutation to force
// Svelte 5 to re-derive rendering data (class instances are not proxied)
@@ -52,6 +63,7 @@
let previewCells = $derived((renderVersion, showPreview && grid && player && guards.length
? turnManager.previewNextTurn(grid, player, guards)
: new Set()));
let canUndo = $derived((renderVersion, history.canUndo()));
// Initialize level
function initLevel() {
@@ -64,12 +76,15 @@
goalRow = state.goalRow;
goalCol = state.goalCol;
turnManager = new TurnManager();
history = new GameHistory();
princess = new PrincessMechanic();
detected = false;
isPaused = false;
finalMessage = false;
showFlash = false;
princessAlerted = false;
alertRadius = 0;
showControls = false;
playerShake = false;
detectedCell = null;
showLevelComplete = false;
completionStars = 0;
completionMoves = 0;
@@ -77,8 +92,19 @@
onMount(() => { initLevel(); });
// Capture current state as a snapshot object (does not push to history)
function captureState() {
return history.createSnapshot(player, guards, turnManager.turnCount, princess.alerted, princess.alertRadius);
}
// Capture and push snapshot in one step (for wait action)
function snapshotBeforeAction() {
history.pushSnapshot(captureState());
}
// Input handling
function onKeyDown(e) {
if (showControls) { if (e.key === 'Escape') showControls = false; return; }
if (isPaused || detected || showLevelComplete) return;
const dirMap = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
@@ -86,22 +112,25 @@
};
if (e.key === 'v') { showPreview = !showPreview; return; }
if (e.key === ' ') { e.preventDefault(); handleWait(); return; }
if (e.key === 'z') { handleUndo(); return; }
if (e.key === 'y') { handleRedo(); return; }
const dir = dirMap[e.key];
if (dir) { e.preventDefault(); handleMove(dir); }
}
function handleWait() {
if (!player || !grid) return;
snapshotBeforeAction();
playWait();
const result = turnManager.nextTurn(grid, player, guards);
if (isFinalLevel && checkFinalLevel()) { renderVersion++; return; }
renderVersion++;
if (result.levelComplete) handleLevelComplete();
else if (result.detected) detected = true;
else if (result.detected) triggerDetection();
}
function onCellClick(row, col) {
if (isPaused || detected || showLevelComplete || !player) return;
// Tap on player cell = wait
if (isPaused || detected || showLevelComplete || showControls || !player) return;
if (row === player.row && col === player.col) { handleWait(); return; }
const rowDiff = Math.abs(row - player.row);
const colDiff = Math.abs(col - player.col);
@@ -117,64 +146,83 @@
function handleMove(direction) {
if (!player || !grid) return;
// Capture pre-move state, attempt move, discard snapshot if move fails
const preSnapshot = captureState();
if (!player.move(direction)) return;
history.pushSnapshot(preSnapshot);
playMove();
const result = turnManager.nextTurn(grid, player, guards);
// Escalating princess detection — expands light wave after guard updates
if (isFinalLevel && checkFinalLevel()) {
renderVersion++;
return;
}
// Bump version to trigger re-derivation of cells/turns/guard positions
renderVersion++;
if (result.levelComplete) {
handleLevelComplete();
} else if (result.detected) {
detected = true;
triggerDetection();
}
}
// Escalating detection: light radiates outward from goal one ring per turn
let princessAlerted = $state(false);
let alertRadius = $state(0);
// Detection feedback — flash cell, shake player, play sound
function triggerDetection() {
detectedCell = { row: player.row, col: player.col };
playerShake = true;
playDetection();
detected = true;
setTimeout(() => { playerShake = false; detectedCell = null; }, 400);
}
// Undo/redo handlers
function handleUndo() {
if (!player || !grid) return;
const state = history.undo(player, guards, turnManager, princess.alerted, princess.alertRadius);
if (!state) return;
princess.alerted = state.princessAlerted || false;
princess.alertRadius = state.alertRadius || 0;
finalMessage = princess.alerted;
grid.clearAllLight();
guards.forEach(g => g.updateLight(guards));
if (princess.alerted) princess.lightRing(grid, goalRow, goalCol, princess.alertRadius);
playUndo();
renderVersion++;
}
function handleRedo() {
if (!player || !grid) return;
const state = history.redo(player, guards, turnManager, princess.alerted, princess.alertRadius);
if (!state) return;
princess.alerted = state.princessAlerted || false;
princess.alertRadius = state.alertRadius || 0;
finalMessage = princess.alerted;
grid.clearAllLight();
guards.forEach(g => g.updateLight(guards));
if (princess.alerted) princess.lightRing(grid, goalRow, goalCol, princess.alertRadius);
renderVersion++;
}
// Touch/swipe controls for mobile
function onTouchStart(e) { touch.onTouchStart(e); }
function onTouchEnd(e) {
if (isPaused || detected || showLevelComplete || showControls) return;
const dir = touch.onTouchEnd(e);
if (dir) handleMove(dir);
}
// Escalating princess detection (final level)
function checkFinalLevel() {
const distance = Math.abs(player.row - goalRow) + Math.abs(player.col - goalCol);
if (distance <= 4 && !princessAlerted) {
princessAlerted = true;
finalMessage = true;
alertRadius = 1;
lightRing(alertRadius);
renderVersion++;
return false; // don't block — let the wave chase the player
}
if (princessAlerted) {
alertRadius++;
lightRing(alertRadius);
renderVersion++;
// Check if expanding light reached the player
if (grid.isLight(player.row, player.col)) {
detected = true;
return true;
}
}
const result = princess.update(grid, player, goalRow, goalCol);
if (result.showMessage) { finalMessage = true; renderVersion++; return false; }
if (result.detected) { triggerDetection(); renderVersion++; return true; }
if (princess.alerted) renderVersion++;
return false;
}
function lightRing(radius) {
for (let r = 0; r < grid.rows; r++) {
for (let c = 0; c < grid.cols; c++) {
const dist = Math.abs(r - goalRow) + Math.abs(c - goalCol);
if (dist <= radius && !grid.isWall(r, c)) {
grid.setLight(r, c, true);
}
}
}
}
function handleLevelComplete() {
const total = getTotalLevels();
const levelData = LEVELS[currentLevel - 1];
@@ -185,6 +233,7 @@
completionStars = calculateStars(moves, par);
showFlash = true;
showLevelComplete = true;
playLevelComplete();
}
function handleLevelCompleteNext() {
@@ -214,15 +263,20 @@
<svelte:window onkeydown={onKeyDown} />
<div class="game-scene" class:flash={showFlash}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="game-scene" class:flash={showFlash}
ontouchstart={onTouchStart} ontouchend={onTouchEnd}>
<GameHud
lives={livesRemaining}
level={currentLevel}
{turns}
{showPreview}
{canUndo}
ontogglepreview={() => showPreview = !showPreview}
onpause={() => isPaused = true}
onmenu={() => navigate('MainMenu')}
onundo={handleUndo}
onshowcontrols={() => showControls = true}
/>
{#if grid && player}
@@ -234,9 +288,10 @@
cols={grid.cols}
{cellSize}
{previewCells}
{detectedCell}
oncellclick={onCellClick}
/>
<PlayerSprite row={playerRow} col={playerCol} {cellSize} />
<PlayerSprite row={playerRow} col={playerCol} {cellSize} shake={playerShake} />
{#each guardSnapshots as guard}
<GuardSprite {guard} {cellSize} />
{/each}
@@ -270,6 +325,10 @@
onmainmenu={() => navigate('MainMenu')}
/>
{/if}
{#if showControls}
<ControlsOverlay onclose={() => showControls = false} />
{/if}
</div>
<style>
+6
View File
@@ -16,6 +16,12 @@
<h2>{getText('enemyTypes')}</h2>
<p>{getText('enemyTypesContent')}</p>
<h2>{getText('advancedEnemyTypes')}</h2>
<p>{getText('advancedEnemyTypesContent')}</p>
<h2>{getText('gameTips')}</h2>
<p>{getText('gameTipsContent')}</p>
</div>
<Button text={getText('back')} onclick={() => navigate('MainMenu')} />