mirror of
https://github.com/tiennm99/nntv.git
synced 2026-09-03 18:17:00 +00:00
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:
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
style="animation-delay: {i * 0.15}s">★</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
Reference in New Issue
Block a user