From 7233310662a3d2c7818d9be06cf46f201aba2fd2 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 13 Apr 2026 18:24:46 +0700 Subject: [PATCH] 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) --- README.md | 154 ++++++++--------------- docs/code-standards.md | 58 ++++++++- docs/codebase-summary.md | 32 +++-- docs/project-overview-pdr.md | 23 +++- docs/system-architecture.md | 74 ++++++++++- src/components/Button.svelte | 7 +- src/components/GameBoard.svelte | 29 ++++- src/components/GameHud.svelte | 14 ++- src/components/LevelCompletePopup.svelte | 2 +- src/components/PlayerSprite.svelte | 13 +- src/components/controls-overlay.svelte | 74 +++++++++++ src/lib/audio.js | 91 ++++++++++++++ src/lib/game/game-history.js | 116 +++++++++++++++++ src/lib/game/guards.js | 103 ++++++++------- src/lib/game/level-manager.js | 38 ++---- src/lib/game/princess-mechanic.js | 52 ++++++++ src/lib/game/touch-controls.js | 35 ++++++ src/lib/game/turn-manager.js | 2 + src/lib/locales/en.json | 13 ++ src/lib/locales/vi.json | 13 ++ src/scenes/Game.svelte | 145 ++++++++++++++------- src/scenes/Guide.svelte | 6 + 22 files changed, 849 insertions(+), 245 deletions(-) create mode 100644 src/components/controls-overlay.svelte create mode 100644 src/lib/audio.js create mode 100644 src/lib/game/game-history.js create mode 100644 src/lib/game/princess-mechanic.js create mode 100644 src/lib/game/touch-controls.js diff --git a/README.md b/README.md index a7c1985..7b45fb1 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/code-standards.md b/docs/code-standards.md index 072e7a2..f5b02c6 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -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 + 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) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index ee9c856..e980ffb 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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 | diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 1aba25b..3718d56 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -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 | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index ed84125..1c03836 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -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 diff --git a/src/components/Button.svelte b/src/components/Button.svelte index 3461faa..e021f10 100644 --- a/src/components/Button.svelte +++ b/src/components/Button.svelte @@ -1,8 +1,8 @@ - @@ -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; diff --git a/src/components/GameBoard.svelte b/src/components/GameBoard.svelte index 090c82e..3e7c3c7 100644 --- a/src/components/GameBoard.svelte +++ b/src/components/GameBoard.svelte @@ -1,20 +1,37 @@
{#each cells as cell (cell.row * cols + cell.col)} - +
oncellclick?.(cell.row, cell.col)} >
{/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); } + } diff --git a/src/components/GameHud.svelte b/src/components/GameHud.svelte index 9c7c683..0fc602c 100644 --- a/src/components/GameHud.svelte +++ b/src/components/GameHud.svelte @@ -1,7 +1,16 @@
@@ -11,7 +20,10 @@
{getText('level')}{level} +
diff --git a/src/components/LevelCompletePopup.svelte b/src/components/LevelCompletePopup.svelte index 0d66d5c..05e1da8 100644 --- a/src/components/LevelCompletePopup.svelte +++ b/src/components/LevelCompletePopup.svelte @@ -12,7 +12,7 @@ style="animation-delay: {i * 0.15}s">★ {/each}
-

Moves: {moves} / Par: {parMoves}

+

{getText('moves')}: {moves} / Par: {parMoves}

diff --git a/src/components/PlayerSprite.svelte b/src/components/PlayerSprite.svelte index 21ad5a4..714226c 100644 --- a/src/components/PlayerSprite.svelte +++ b/src/components/PlayerSprite.svelte @@ -1,5 +1,5 @@ + +
+ +
+ + diff --git a/src/lib/audio.js b/src/lib/audio.js new file mode 100644 index 0000000..9ee7897 --- /dev/null +++ b/src/lib/audio.js @@ -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; +} diff --git a/src/lib/game/game-history.js b/src/lib/game/game-history.js new file mode 100644 index 0000000..58657e5 --- /dev/null +++ b/src/lib/game/game-history.js @@ -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 = []; + } +} diff --git a/src/lib/game/guards.js b/src/lib/game/guards.js index c712fe6..601d3f7 100644 --- a/src/lib/game/guards.js +++ b/src/lib/game/guards.js @@ -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; } } diff --git a/src/lib/game/level-manager.js b/src/lib/game/level-manager.js index 88103c6..e01b4e3 100644 --- a/src/lib/game/level-manager.js +++ b/src/lib/game/level-manager.js @@ -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)); }); } diff --git a/src/lib/game/princess-mechanic.js b/src/lib/game/princess-mechanic.js new file mode 100644 index 0000000..3eeb1b2 --- /dev/null +++ b/src/lib/game/princess-mechanic.js @@ -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; + } +} diff --git a/src/lib/game/touch-controls.js b/src/lib/game/touch-controls.js new file mode 100644 index 0000000..4b2d686 --- /dev/null +++ b/src/lib/game/touch-controls.js @@ -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'; + } +} diff --git a/src/lib/game/turn-manager.js b/src/lib/game/turn-manager.js index 792e59a..851b249 100644 --- a/src/lib/game/turn-manager.js +++ b/src/lib/game/turn-manager.js @@ -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; }); diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index c9f0610..05d9bb0 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index 0bb29ed..ce6d2bd 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -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", diff --git a/src/scenes/Game.svelte b/src/scenes/Game.svelte index 0260e88..421a9fe 100644 --- a/src/scenes/Game.svelte +++ b/src/scenes/Game.svelte @@ -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 @@ -
+ +
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} /> - + {#each guardSnapshots as guard} {/each} @@ -270,6 +325,10 @@ onmainmenu={() => navigate('MainMenu')} /> {/if} + + {#if showControls} + showControls = false} /> + {/if}