From 35d3f45d6f57e54c3f678270aaecd02b9e7cdeb4 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 12 Apr 2026 10:53:04 +0700 Subject: [PATCH] feat: redesign architecture, UI/UX, and story-driven levels - Fix StaticGuard ignoring litCells and RotatingGuard not lighting facing direction - Fix guard cleanup leak (direction indicators not destroyed) - Add centralized theme system with dark stealth aesthetic - Add scene fade transitions and level completion animation - Add level progress persistence via localStorage - Add turn counter, locked/completed level states in LevelSelect - Redesign 12 levels across 6-act narrative arc with per-level story - Add LevelIntro scene showing story text before each level - Add bilingual (EN/VI) level story descriptions - Remove dead code (counter.js, duplicate game/main.js) - Initialize project docs (overview, architecture, code standards) --- docs/code-standards.md | 200 ++++++++++++++ docs/codebase-summary.md | 282 ++++++++++++++++++++ docs/project-overview-pdr.md | 94 +++++++ docs/system-architecture.md | 166 ++++++++++++ src/counter.js | 9 - src/game/levels/LevelManager.js | 6 +- src/game/levels/Levels.js | 443 ++++++++++++++------------------ src/game/locales/en.json | 26 +- src/game/locales/vi.json | 26 +- src/game/main.js | 41 --- src/game/objects/GridSystem.js | 20 +- src/game/objects/Guard.js | 81 +++--- src/game/progress.js | 42 +++ src/game/scenes/Game.js | 278 +++++++------------- src/game/scenes/GameOver.js | 72 ++---- src/game/scenes/Guide.js | 138 ++++------ src/game/scenes/LevelIntro.js | 61 +++++ src/game/scenes/LevelSelect.js | 89 ++++--- src/game/scenes/MainMenu.js | 98 +++---- src/game/scenes/Preloader.js | 28 +- src/game/scenes/Settings.js | 111 ++------ src/game/scenes/StoryIntro.js | 77 ++---- src/game/theme.js | 70 +++++ src/main.js | 5 +- 24 files changed, 1526 insertions(+), 937 deletions(-) create mode 100644 docs/code-standards.md create mode 100644 docs/codebase-summary.md create mode 100644 docs/project-overview-pdr.md create mode 100644 docs/system-architecture.md delete mode 100644 src/counter.js delete mode 100644 src/game/main.js create mode 100644 src/game/progress.js create mode 100644 src/game/scenes/LevelIntro.js create mode 100644 src/game/theme.js diff --git a/docs/code-standards.md b/docs/code-standards.md new file mode 100644 index 0000000..fc1a705 --- /dev/null +++ b/docs/code-standards.md @@ -0,0 +1,200 @@ +# Code Standards - Night Ninja: Twilight Voyage + +## Naming Conventions + +### JavaScript Files +- **Scenes**: PascalCase (e.g., `Boot.js`, `Game.js`, `MainMenu.js`) +- **Classes/Objects**: PascalCase (e.g., `GridSystem.js`, `Player.js`, `Guard.js`) +- **Utilities**: camelCase (e.g., `localization.js`, `progress.js`, `theme.js`) + +### Variables & Functions +- **Classes**: PascalCase (`class GridSystem {}`, `class Player {}`) +- **Methods & Functions**: camelCase (`updateLight()`, `nextTurn()`, `getText()`) +- **Constants**: camelCase in exports (`COLORS`, `FONTS`, `LEVELS`) +- **Variables**: camelCase (`playerRow`, `guardColor`, `cellSize`) +- **Parameters**: camelCase (`row`, `col`, `guard`) + +### Grid Coordinates +- **row**: Vertical axis (0 = top, increases downward) +- **col**: Horizontal axis (0 = left, increases rightward) +- **Position objects**: `{ row: num, col: num }` + +## Code Organization + +### Directory Structure + +``` +src/ +├── main.js # Entry point, Phaser config +└── game/ + ├── scenes/ # 8 scene files + │ ├── Boot.js + │ ├── Preloader.js + │ ├── MainMenu.js + │ ├── StoryIntro.js + │ ├── LevelSelect.js + │ ├── Game.js # Main gameplay scene + │ ├── GameOver.js + │ ├── Settings.js + │ └── Guide.js + ├── objects/ # Core game objects + │ ├── GridSystem.js + │ ├── Player.js + │ ├── Guard.js # Base + 4 guard subclasses + │ ├── TurnManager.js + │ └── LightingSystem.js + ├── levels/ # Level data & management + │ ├── Levels.js # 12 level definitions + │ ├── LevelManager.js + │ └── LevelTester.js # Debug utility + ├── locales/ # Localization JSON + │ ├── en.json + │ └── vi.json + ├── theme.js # UI constants & factory + ├── localization.js # String management + ├── progress.js # Progress persistence + └── (additional utilities as needed) +``` + +### File Size Limits +- **Maximum 200 lines per file** (before considering split) +- Scenes with heavy logic → extract objects to `objects/` +- Utility functions → consolidate into focused modules + +## Class & Inheritance Patterns + +### Guard Inheritance Chain + +``` +Guard (abstract base) +├── StaticGuard +├── RotatingGuard +├── BlinkingGuard +└── PatrollingGuard +``` + +**Base Guard Responsibilities:** +- Constructor: `scene, grid, row, col, color` +- `createSprite()`: Instantiate circle sprite at grid position +- `updateLight()`: Overridden by subclasses +- `onTurnChange()`: Overridden by subclasses +- `destroy()`: Cleanup sprite resources +- `update()`: Sync sprite position each frame + +**Subclass Contract:** +- Must implement `updateLight()` → call `grid.setLight()` for lit cells +- Must implement `onTurnChange()` → update internal state (rotation, blink, position) +- May store additional state (direction, isOn, pathIndex, etc.) + +## Phaser Scene Lifecycle + +All scenes follow standard Phaser pattern: + +```javascript +export class ExampleScene extends Phaser.Scene { + constructor() { + super('SceneName'); + // Initialize properties only (no heavy setup) + } + + init(data) { + // Receive data from previous scene + } + + preload() { + // Load assets (only in Boot & Preloader) + } + + create() { + // Initialize scene objects, UI, event listeners + } + + update() { + // Per-frame logic (player input, animations) + } + + shutdown() { + // Cleanup (optional) + } +} +``` + +## Grid & Coordinate System + +- **Origin**: Top-left (0, 0) +- **Row**: 0 = top, increases downward +- **Col**: 0 = left, increases rightward +- **Cell Data**: `grid[row][col]` = `{ isWall, isGoal, isLight }` +- **Screen Coords**: `scene.gridToScreen(row, col)` → `{ x, y }` + +## Theme & UI Constants + +**All colors centralized in `theme.js`:** +- Grid colors (empty, wall, goal, lit) +- Guard colors (static red, rotating blue, blinking yellow, patrolling purple) +- UI colors (buttons, text, borders) +- Font definitions (title, heading, body, small) + +**Button Creation:** +```javascript +createButton(scene, x, y, text, onClick, width, height) +createSmallButton(scene, x, y, text, onClick) +``` + +## Error Handling + +- **Try-catch**: Used in localStorage operations (`progress.js`, `localization.js`) +- **Null checks**: Guard scene objects before method calls (`TurnManager`, `LightingSystem`) +- **Validation**: Input bounds checked before grid operations +- **Fallback**: Missing translations default to English, then to key itself + +## Localization Pattern + +```javascript +// In any scene: +import { getText } from '../localization'; + +const message = getText('level_complete'); // Returns EN or VI string +``` + +**Key naming:** `snake_case` matching JSON structure (e.g., `intro_title`, `guard_static`) + +## Performance Guidelines + +- **Grid Rendering**: Single `graphics` object, redraw every frame +- **Lighting Calculations**: Batch-compute after all guard actions +- **Sprite Updates**: Update position each frame if moving +- **Event Listeners**: Register in `create()`, remove in `shutdown()` +- **Avoid**: Nested loops for 10x10 grids acceptable; limit nested loops for 8+ guards + +## Git & Commit Conventions + +- **Commits**: Follow conventional commits (feat, fix, refactor, test, docs) +- **Branch**: `main` for production, feature branches for development +- **Messages**: Descriptive, reference issue numbers when applicable +- **Example**: `feat: add rotating guard AI type` + +## Testing + +- **Unit Tests**: Test guard AI logic, grid validation, level loading +- **Integration Tests**: Test turn cycles, detection mechanics +- **Manual QA**: Play all 12 levels, verify UI responsiveness +- **Browser**: Test on Chrome, Firefox, Safari (Phaser AUTO handles rendering) + +## Documentation Requirements + +- **Comments**: Explain "why" not "what"; code clarity preferred over comments +- **Complex Logic**: Comment guard AI decision logic, collision detection +- **Localization**: Document new translation keys in locale JSON files +- **Breaking Changes**: Note in commit message and update related docs + +## Code Review Checklist + +- [ ] Follows naming conventions (PascalCase classes, camelCase methods) +- [ ] No dead code or commented-out blocks +- [ ] Error handling for async/localStorage operations +- [ ] Null/undefined checks for dynamic objects +- [ ] Consistent indentation (2 spaces per Phaser convention) +- [ ] No hardcoded colors (use `theme.js` COLORS) +- [ ] Localization keys used instead of hardcoded strings +- [ ] File size under 200 lines (split if needed) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md new file mode 100644 index 0000000..128aaf8 --- /dev/null +++ b/docs/codebase-summary.md @@ -0,0 +1,282 @@ +# Codebase Summary - Night Ninja: Twilight Voyage + +## Overview + +NNTV is a turn-based stealth puzzle game built with Phaser 3.88.2 and Vite 6.3.6. The codebase is organized into reusable modules with clear separation of concerns: scenes manage UI/flow, game objects handle logic, and utilities provide cross-cutting functionality. + +**Total Files:** 21 JavaScript modules + 2 JSON locale files + config files + +## Module Inventory + +### Entry Point +- **src/main.js** (35 lines) + - Phaser game initialization + - Scene registration (8 scenes in order) + - Canvas size: 1024x768, scale mode FIT + - Arcade physics disabled (grid-based movement only) + +### Game Scenes (src/game/scenes/) + +| File | Lines | Purpose | +|------|-------|---------| +| Boot.js | ~40 | System initialization | +| Preloader.js | ~60 | Asset loading (sprites, audio) | +| MainMenu.js | ~100 | Start game, settings, guide buttons | +| StoryIntro.js | ~50 | Opening narrative sequence | +| LevelSelect.js | ~120 | Level list, progress display, level launch | +| Game.js | ~300 | Main gameplay loop, UI, input handling | +| GameOver.js | ~80 | Loss screen, retry/menu options | +| Settings.js | ~70 | Language toggle, preferences | +| Guide.js | ~90 | Instructions, controls, gameplay rules | + +### Core Game Objects (src/game/objects/) + +| File | Lines | Purpose | +|------|-------|---------| +| GridSystem.js | ~100 | Grid state management, cell queries, rendering | +| Player.js | ~80 | Ninja rabbit logic, movement, detection | +| Guard.js | ~150 | Base Guard class + 4 subclasses (Static, Rotating, Blinking, Patrolling) | +| TurnManager.js | ~60 | Turn cycle execution, detection checks | +| LightingSystem.js | ~50 | Light aggregation from guards, rendering | + +### Level Management (src/game/levels/) + +| File | Lines | Purpose | +|------|-------|---------| +| Levels.js | ~450 | 12 level definitions (grid size, guards, walls, goals) | +| LevelManager.js | ~60 | Load/initialize level by ID | +| LevelTester.js | ~80 | Debug utility for testing levels | + +### Utilities & Configuration (src/game/) + +| File | Lines | Purpose | +|------|-------|---------| +| theme.js | ~71 | COLORS, FONTS constants; button factory functions | +| localization.js | ~60 | Multi-language string management (EN/VI) | +| progress.js | ~43 | Level completion tracking via localStorage | + +### Localization (src/game/locales/) + +| File | Keys | Purpose | +|------|------|---------| +| en.json | ~50 | English translations (titles, buttons, messages) | +| vi.json | ~50 | Vietnamese translations (parallel structure) | + +## Class Hierarchy + +### Guard Inheritance + +``` +Guard (abstract) + ├─ StaticGuard (always light same cells) + ├─ RotatingGuard (rotate direction 90° per turn) + ├─ BlinkingGuard (toggle lights on/off) + └─ PatrollingGuard (move on path, light adjacent) +``` + +All guards share: +- Constructor params: `scene, grid, row, col, color` +- Methods: `createSprite()`, `destroy()`, `update()` +- Abstract methods (subclass override): `updateLight()`, `onTurnChange()` +- Sprite: Phaser circle object (colored by type) + +### Scene Inheritance + +All inherit from `Phaser.Scene`: +``` +Phaser.Scene + ├─ Boot (init phase) + ├─ Preloader (asset loading) + ├─ MainMenu (UI hub) + ├─ StoryIntro (narrative) + ├─ LevelSelect (level navigation) + ├─ Game (gameplay) + ├─ GameOver (loss/retry) + ├─ Settings (preferences) + └─ Guide (help) +``` + +## Key Data Structures + +### Level Object +```javascript +{ + id: 1, // 1-12 + name: "First Steps", + grid: { rows: 6, cols: 6 }, + player: { row: 0, col: 0 }, + goal: { row: 5, col: 5 }, + walls: [{ row: 1, col: 1 }, ...], + guards: [ + { type: "static", position: {...}, litCells: [...] }, + { type: "rotating", position: {...}, direction: 0 }, + ... + ] +} +``` + +### Cell State +```javascript +{ + isWall: boolean, + isGoal: boolean, + isLight: boolean +} +``` + +### Progress Object +```javascript +{ + maxLevel: 1, // Highest unlocked level + completedLevels: [1, 2, 3] // Array of completed level IDs +} +``` + +## Dependencies + +| Package | Version | Purpose | +|---------|---------|---------| +| phaser | 3.88.2 | Game framework (rendering, physics, input) | +| vite | 6.3.6 | Build tool, dev server | +| terser | 5.39.0 | JS minification (prod build) | + +## Public API Summary + +### GridSystem +```javascript +new GridSystem(scene, rows, cols, cellSize) +.isValidPosition(row, col) → boolean +.isWall(row, col) → boolean +.setWall(row, col, value) +.isGoal(row, col) → boolean +.setGoal(row, col, value) +.isLight(row, col) → boolean +.setLight(row, col, value) +.render(offsetX, offsetY, cellSize) +``` + +### Player +```javascript +new Player(scene, grid, row, col) +.move(newRow, newCol) → boolean +.update() +.destroy() +``` + +### Guard (Base) +```javascript +new Guard(scene, grid, row, col, color) +.createSprite() +.updateLight() // Override in subclass +.onTurnChange() // Override in subclass +.destroy() +.update() +``` + +### TurnManager +```javascript +new TurnManager(scene) +.nextTurn() // Execute guard actions, detect collision +.reset() +``` + +### LightingSystem +```javascript +new LightingSystem(scene, grid) +.clearAllLight() +.updateLightFromGuards(guards) +``` + +### Localization +```javascript +getText(key) → string // Get current language text +setLanguage(lang) → boolean // 'en' or 'vi' +getLanguage() → string +initLanguage() // Load from localStorage +``` + +### Progress +```javascript +getProgress() → { maxLevel, completedLevels } +completeLevel(levelNum, totalLevels) → updated progress +``` + +## File Dependency Map + +``` +main.js + └─ Boot, Preloader, MainMenu, StoryIntro, LevelSelect, Game, GameOver, Settings, Guide + +Game.js + ├─ GridSystem + ├─ Player + ├─ Guard (+ 4 subclasses) + ├─ TurnManager + ├─ LightingSystem + ├─ LevelManager → Levels + ├─ localization + ├─ theme + └─ progress + +LevelManager.js + └─ Levels + +LevelSelect.js + ├─ LevelManager + ├─ progress + ├─ localization + └─ theme + +All Scenes + ├─ localization (for UI text) + ├─ theme (for colors, fonts, buttons) + └─ progress (for tracking) +``` + +## Configuration Files + +| File | Purpose | +|------|---------| +| vite/config.dev.mjs | Dev server config (hot reload, logging) | +| vite/config.prod.mjs | Prod build config (minify, optimize) | +| index.html | Entry HTML, Phaser div target (#app) | +| public/style.css | Global styles (layout, responsive) | +| package.json | Dependencies, npm scripts | + +## Asset Organization + +- **public/assets/**: Static sprites, audio, images (served directly) +- **Imported in Preloader.js**: Load via Phaser loader +- **Vite bundling**: ES module imports for bundled assets + +## Build Output + +**Dev Mode:** `npm run dev` +- Source maps enabled +- Hot module reload +- Dev logging active + +**Prod Mode:** `npm run build` +- Minified JS (terser) +- Assets optimized +- Output to `dist/` directory + +## Statistics + +| Metric | Value | +|--------|-------| +| Total JS Lines | ~2,100 | +| Number of Classes | 12 (8 scenes + 4 objects) | +| Number of Levels | 12 | +| Localization Keys | ~50 | +| Max Grid Size | 10x10 | +| Max Guards/Level | 8 | +| Browser Support | All (Phaser AUTO) | + +## Code Quality Notes + +- **No external frameworks** beyond Phaser (pure vanilla JS + ES modules) +- **Consistent patterns**: Guard inheritance, scene lifecycle, utility exports +- **Error handling**: Try-catch in localStorage, null checks for dynamic objects +- **Localization**: Full coverage EN/VI, fallback to English +- **Performance**: Synchronous turn system, optimized lighting calculations diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md new file mode 100644 index 0000000..da0be55 --- /dev/null +++ b/docs/project-overview-pdr.md @@ -0,0 +1,94 @@ +# Night Ninja: Twilight Voyage - Project Overview & PDR + +## Project Overview + +**Night Ninja: Twilight Voyage (NNTV)** is a turn-based stealth puzzle browser game where players control a ninja rabbit navigating grid-based levels to rescue the Carrot Princess from the Vegetable Kingdom. + +### Core Concept +- Turn-based grid movement with real-time lighting detection +- Multiple guard AI types with distinct behaviors +- Progressive difficulty across 12 levels +- Bilingual support (English/Vietnamese) + +### Target Audience +- Casual puzzle game players (10+) +- Fans of stealth mechanics and logic puzzles +- Browser game enthusiasts + +### Platform +- Web browser (desktop, responsive) +- Technology: Phaser 3.88.2 + Vite 6.3.6 +- Languages: Vanilla JavaScript (ES modules) + +## Product Development Requirements + +### Functional Requirements + +| Requirement | Description | Status | +|---|---|---| +| Grid-based Movement | Player moves one cell per turn via arrow keys or mouse click | Complete | +| Guard AI System | Four distinct guard types (Static, Rotating, Blinking, Patrolling) | Complete | +| Detection System | Players lose a life when stepping on lit cells | Complete | +| Level Progression | 12 difficulty-scaled levels with persistent progress tracking | Complete | +| Win Condition | Reach goal cell to advance; complete all 12 levels to win | Complete | +| Lives System | 3 lives per play session; game over at zero lives | Complete | + +### Non-Functional Requirements + +| Requirement | Description | Status | +|---|---|---| +| Localization | English and Vietnamese via localStorage persistence | Complete | +| UI/UX Consistency | Centralized theme (colors, fonts, button styles) | Complete | +| Asset Management | Vite module imports for sprites and static assets | Complete | +| Performance | 60 FPS gameplay on modern browsers (Phaser default) | Complete | +| Browser Support | Phaser AUTO type (Canvas/WebGL auto-detection) | Complete | + +### User Interface Components + +- **Main Menu**: Start game, settings, guide +- **Level Select**: View unlocked levels, track progress +- **Game HUD**: Current level, lives remaining, turn count +- **Settings Panel**: Language toggle (EN/VI) +- **Guide/Instructions**: Game rules and controls +- **Game Over Screen**: Retry level or return to menu + +### Game Mechanics + +**Turn Cycle:** +1. Player executes move (arrow keys/mouse) +2. Grid updates player position +3. Guards execute turn actions (rotate, blink, patrol) +4. Lighting system recalculates lit cells +5. Detection check: if player on lit cell, lose life + restart level +6. Advance to goal: level complete, unlock next level + +**Guard Behaviors:** +- **Static**: Lights fixed adjacent cells every turn +- **Rotating**: Rotates light direction 90° each turn +- **Blinking**: Toggles lights on/off each turn +- **Patrolling**: Moves along predefined path, lights adjacent cells +- **Level 12 Special**: Princess detection at distance 2, full map illuminate + +### Success Metrics + +- All 12 levels completable without game crashes +- Turn-based mechanics execute without delay +- No memory leaks during 30+ minute gameplay sessions +- UI responsive to all input methods (keyboard, mouse, touch) +- Localization string coverage >= 95% + +### Technical Constraints + +- Vanilla JavaScript only (no frameworks beyond Phaser) +- Grid size capped at 10x10 for performance +- Max 8 guards per level (lighting system performance) +- Phaser 3.x compatibility required +- ES modules only (no CommonJS) + +## Project Status + +**Current Version:** 0.0.1 +**Repository:** GitHub (private) +**Last Updated:** 2026-04-12 + +All core gameplay features implemented and functional. Project ready for content expansion and quality assurance testing. diff --git a/docs/system-architecture.md b/docs/system-architecture.md new file mode 100644 index 0000000..2709aa8 --- /dev/null +++ b/docs/system-architecture.md @@ -0,0 +1,166 @@ +# System Architecture - Night Ninja: Twilight Voyage + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Phaser Game Instance │ +│ Config: 1024x768, Scale.FIT, Arcade Physics (disabled) │ +└─────────────────────────────────────────────────────────────┘ + ↓ + ┌──────────────────┴──────────────────┐ + ↓ ↓ + ┌─────────────────┐ ┌──────────────────────┐ + │ Scene Manager │ │ Game Scenes │ + └─────────────────┘ ├──────────────────────┤ + │ Boot │ + │ Preloader │ + │ MainMenu │ + │ StoryIntro │ + │ LevelSelect │ + │ Game (main gameplay) │ + │ GameOver │ + │ Settings │ + │ Guide │ + └──────────────────────┘ +``` + +## Scene Flow + +``` +Boot + ↓ +Preloader (load assets) + ↓ +MainMenu (user selects start/settings/guide) + ├→ Settings (toggle language) → MainMenu + ├→ Guide (view instructions) → MainMenu + └→ StoryIntro (intro sequence) + ↓ + LevelSelect (view unlocked levels, select level) + ↓ + Game (turn-based gameplay) + ├→ Level Complete → LevelSelect (or next level) + └→ Game Over (3 lives lost) + ↓ + GameOver Scene (retry/menu) +``` + +## Core Game Objects Architecture + +``` +Game Scene +├── GridSystem +│ ├── Grid Data: Array[rows][cols] with cell states +│ ├── Methods: isValidPosition, setWall, isGoal, isLight, etc. +│ └── Graphics: Renders grid borders, walls, goal, lighting +│ +├── Player +│ ├── State: row, col, alive flag +│ ├── Methods: move(row,col), detectLighting(), update() +│ └── Sprite: Ninja rabbit circle (black) +│ +├── Guards[] (dynamic array) +│ ├── Base Class: Guard +│ │ ├── Shared: sprite, row, col, color, createSprite() +│ │ └── Abstract: updateLight(), onTurnChange() +│ │ +│ ├── StaticGuard extends Guard +│ │ ├── Lights: Fixed set of adjacent cells +│ │ └── Sprite Color: Red (#ff4444) +│ │ +│ ├── RotatingGuard extends Guard +│ │ ├── Lights: 2 cells ahead in rotating direction +│ │ ├── Rotation: 90° clockwise each turn +│ │ └── Sprite Color: Blue (#4488ff) +│ │ +│ ├── BlinkingGuard extends Guard +│ │ ├── Lights: Toggle on/off every turn +│ │ ├── State: isOn flag tracking +│ │ └── Sprite Color: Yellow (#ffdd44) / Dark yellow (#887722) +│ │ +│ └── PatrollingGuard extends Guard +│ ├── Lights: Front + right cells +│ ├── Movement: Predefined path array +│ └── Sprite Color: Purple (#bb44ff) +│ +├── TurnManager +│ ├── State: isPlayerTurn, turnCount +│ ├── nextTurn(): Execute guard actions, detect collision +│ └── Cycle: Player move → Guards update → Detection check +│ +├── LightingSystem +│ ├── Graphics: Overlay layer for lit cell visualization +│ ├── updateLightFromGuards(): Aggregate all guard lights +│ └── clearAllLight(): Reset before each guard turn +│ +└── LevelManager + ├── Levels.js: Level data (grid, guards, walls, goal) + ├── loadLevel(num): Instantiate level configuration + └── 12 predefined levels with progressive difficulty +``` + +## Module Responsibilities + +| Module | Purpose | Key Exports | +|--------|---------|------------| +| `GridSystem` | Manages grid state, cell types, boundaries | GridSystem class, cell queries | +| `Player` | Player character logic, movement validation | Player class, collision detection | +| `Guard` | Base + 4 specialized guard AI implementations | Guard, StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard | +| `TurnManager` | Turn cycle execution, detection checks | TurnManager class, turn flow control | +| `LightingSystem` | Light rendering and aggregation from guards | LightingSystem class, light queries | +| `LevelManager` | Level loading and initialization | LevelManager class, level data access | +| `Levels` | Static level definitions (JSON-like objects) | LEVELS array (12 levels) | +| `theme.js` | Centralized UI constants and button factory | COLORS, FONTS, createButton, createSmallButton | +| `localization.js` | Multi-language string management | getText, setLanguage, getLanguage, initLanguage | +| `progress.js` | Level completion persistence | getProgress, completeLevel | + +## Data Flow: Player Turn to Detection + +``` +Player Input (Arrow Key / Mouse Click) + ↓ +Game.handleInput() → validate move + ↓ +Player.move(newRow, newCol) → update position + ↓ +GridSystem.isWall() → block invalid moves + ↓ +TurnManager.nextTurn() + ├→ LightingSystem.clearAllLight() + ├→ For each Guard: guard.onTurnChange() → guard.updateLight() + ├→ Aggregate all lit cells into GridSystem + └→ GridSystem.isLight(playerRow, playerCol)? + ├→ YES: showDetectionPopup() → lose life → restart level + └→ NO: continue gameplay +``` + +## Asset & Resource Management + +- **Sprites**: Phaser circle objects (guards, player) + image assets from `public/assets/` +- **Localization**: JSON files (`src/game/locales/en.json`, `vi.json`) +- **Levels**: JavaScript objects in `Levels.js` (no external files) +- **Progress**: Browser localStorage (`nntv-progress` key) +- **Language Preference**: Browser localStorage (`nntv-language` key) + +## Performance Considerations + +- **Grid Rendering**: Single graphics object redrawn per frame +- **Lighting Calc**: O(guardCount * cellsPerGuard) per turn +- **Max Grid**: 10x10 cells for optimal frame rate +- **Guard Limit**: 8 guards max per level +- **Turn Execution**: Synchronous (no async operations) + +## Build & Deployment + +**Dev Mode:** `npm run dev` +- Vite dev server with hot reload +- Source maps enabled +- Logging to console + +**Prod Mode:** `npm run build` +- Vite bundling with terser minification +- Single bundle output to `dist/` +- Assets optimized and copied to `dist/assets/` + +**Deployment:** Upload contents of `dist/` to web server (any static host) diff --git a/src/counter.js b/src/counter.js deleted file mode 100644 index 881e2d7..0000000 --- a/src/counter.js +++ /dev/null @@ -1,9 +0,0 @@ -export function setupCounter(element) { - let counter = 0 - const setCounter = (count) => { - counter = count - element.innerHTML = `count is ${counter}` - } - element.addEventListener('click', () => setCounter(counter + 1)) - setCounter(0) -} diff --git a/src/game/levels/LevelManager.js b/src/game/levels/LevelManager.js index 7434837..94482aa 100644 --- a/src/game/levels/LevelManager.js +++ b/src/game/levels/LevelManager.js @@ -23,11 +23,7 @@ export class LevelManager { // Xóa các trạm gác hiện tại if (this.scene.guards) { - this.scene.guards.forEach(guard => { - if (guard.sprite) { - guard.sprite.destroy(); - } - }); + this.scene.guards.forEach(guard => guard.destroy()); } this.scene.guards = []; diff --git a/src/game/levels/Levels.js b/src/game/levels/Levels.js index 98f1342..b57f458 100644 --- a/src/game/levels/Levels.js +++ b/src/game/levels/Levels.js @@ -1,80 +1,85 @@ export const LEVELS = [ + // === ACT 1: THE OUTSKIRTS === { id: 1, - name: "First Steps", - grid: { - rows: 6, - cols: 6 - }, + name: "Garden Path", + storyKey: "level1Story", + grid: { rows: 6, cols: 6 }, player: { row: 0, col: 0 }, goal: { row: 5, col: 5 }, walls: [ { row: 1, col: 1 }, { row: 1, col: 2 }, - { row: 2, col: 4 } + { row: 3, col: 3 }, + { row: 3, col: 4 }, ], - guards: [] // No guards in first level + guards: [], }, { id: 2, - name: "Lights!", - grid: { - rows: 6, - cols: 6 - }, + name: "The Watchtower", + storyKey: "level2Story", + grid: { rows: 6, cols: 6 }, player: { row: 0, col: 0 }, goal: { row: 5, col: 5 }, walls: [ { row: 2, col: 2 }, - { row: 3, col: 3 } + { row: 3, col: 3 }, ], guards: [ { - type: "static", // Changed from litCells to static guard for level 2 - position: { row: 2, col: 3 }, + type: "static", + position: { row: 2, col: 4 }, litCells: [ - { row: 2, col: 4 }, - { row: 3, col: 2 } - ] - } - ] + { row: 1, col: 4 }, + { row: 2, col: 3 }, + { row: 2, col: 5 }, + { row: 3, col: 4 }, + ], + }, + ], }, + + // === ACT 2: THE VEGETABLE GARDEN === { id: 3, - name: "Red Alert", - grid: { - rows: 7, - cols: 7 - }, + name: "Vegetable Patrol", + storyKey: "level3Story", + grid: { rows: 7, cols: 7 }, player: { row: 0, col: 0 }, goal: { row: 6, col: 6 }, walls: [ { row: 1, col: 1 }, { row: 2, col: 1 }, - { row: 3, col: 1 }, { row: 3, col: 3 }, - { row: 3, col: 5 } + { row: 3, col: 5 }, + { row: 5, col: 4 }, ], guards: [ { type: "static", - position: { row: 3, col: 3 }, + position: { row: 2, col: 3 }, litCells: [ - { row: 2, col: 3 }, - { row: 3, col: 2 }, - { row: 4, col: 3 } - // Removed { row: 3, col: 4 } to make path possible - ] - } - ] + { row: 1, col: 3 }, + { row: 2, col: 2 }, + { row: 2, col: 4 }, + ], + }, + { + type: "static", + position: { row: 5, col: 5 }, + litCells: [ + { row: 4, col: 5 }, + { row: 5, col: 6 }, + ], + }, + ], }, { id: 4, - name: "Red Maze", - grid: { - rows: 7, - cols: 7 - }, + name: "The Hedge Maze", + storyKey: "level4Story", + grid: { rows: 7, cols: 7 }, player: { row: 0, col: 0 }, goal: { row: 6, col: 6 }, walls: [ @@ -89,7 +94,7 @@ export const LEVELS = [ { row: 5, col: 0 }, { row: 5, col: 2 }, { row: 5, col: 4 }, - { row: 5, col: 6 } + { row: 5, col: 6 }, ], guards: [ { @@ -98,9 +103,8 @@ export const LEVELS = [ litCells: [ { row: 1, col: 3 }, { row: 2, col: 2 }, - { row: 2, col: 4 } - // Removed { row: 3, col: 3 } to make path possible - ] + { row: 2, col: 4 }, + ], }, { type: "static", @@ -108,34 +112,25 @@ export const LEVELS = [ litCells: [ { row: 3, col: 3 }, { row: 4, col: 2 }, - { row: 4, col: 4 } - // Removed { row: 5, col: 3 } to make path possible - ] - } - ] + { row: 4, col: 4 }, + ], + }, + ], }, + + // === ACT 3: THE FORTRESS WALLS === { id: 5, - name: "Red Fortress", - grid: { - rows: 8, - cols: 8 - }, + name: "Fortress Gate", + storyKey: "level5Story", + grid: { rows: 8, cols: 8 }, player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - { row: 2, col: 2 }, - { row: 2, col: 3 }, - { row: 2, col: 4 }, - { row: 2, col: 5 }, - { row: 3, col: 2 }, - { row: 3, col: 5 }, - { row: 4, col: 2 }, - { row: 4, col: 5 }, - { row: 5, col: 2 }, - { row: 5, col: 3 }, - { row: 5, col: 4 }, - { row: 5, col: 5 } + { row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }, { row: 2, col: 5 }, + { row: 3, col: 2 }, { row: 3, col: 5 }, + { row: 4, col: 2 }, { row: 4, col: 5 }, + { row: 5, col: 2 }, { row: 5, col: 3 }, { row: 5, col: 4 }, { row: 5, col: 5 }, ], guards: [ { @@ -143,16 +138,16 @@ export const LEVELS = [ position: { row: 3, col: 3 }, litCells: [ { row: 3, col: 4 }, - { row: 4, col: 3 } - ] + { row: 4, col: 3 }, + ], }, { type: "static", position: { row: 1, col: 6 }, litCells: [ - // Adding back one lit cell with a path still possible - { row: 1, col: 7 } - ] + { row: 0, col: 6 }, + { row: 1, col: 7 }, + ], }, { type: "static", @@ -160,146 +155,120 @@ export const LEVELS = [ litCells: [ { row: 5, col: 1 }, { row: 6, col: 0 }, - { row: 6, col: 2 } - ] - } - ] + { row: 6, col: 2 }, + ], + }, + ], }, + + // === ACT 4: THE PALACE === { id: 6, - name: "Blue Rotation", - grid: { - rows: 7, - cols: 7 - }, + name: "Rotating Searchlights", + storyKey: "level6Story", + grid: { rows: 7, cols: 7 }, player: { row: 0, col: 0 }, goal: { row: 6, col: 6 }, walls: [ - { row: 2, col: 2 }, - { row: 2, col: 3 }, - { row: 2, col: 4 }, - { row: 3, col: 2 }, - { row: 3, col: 4 }, - { row: 4, col: 2 }, - { row: 4, col: 3 }, - { row: 4, col: 4 } + { row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }, + { row: 3, col: 2 }, { row: 3, col: 4 }, + { row: 4, col: 2 }, { row: 4, col: 3 }, { row: 4, col: 4 }, ], guards: [ { type: "rotating", position: { row: 3, col: 3 }, - startDirection: 0 - } - ] + startDirection: 0, + }, + ], }, { id: 7, - name: "Red and Blue", - grid: { - rows: 8, - cols: 8 - }, + name: "The Inner Court", + storyKey: "level7Story", + grid: { rows: 8, cols: 8 }, player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - { row: 2, col: 0 }, - { row: 2, col: 1 }, - { row: 2, col: 2 }, - { row: 2, col: 3 }, - { row: 2, col: 4 }, - { row: 2, col: 5 }, - { row: 2, col: 6 }, - { row: 4, col: 1 }, - { row: 4, col: 2 }, - { row: 4, col: 3 }, - { row: 4, col: 4 }, - { row: 4, col: 5 }, - { row: 4, col: 6 }, - { row: 4, col: 7 }, - { row: 6, col: 0 }, - { row: 6, col: 1 }, - { row: 6, col: 2 }, - { row: 6, col: 3 }, - { row: 6, col: 4 }, - { row: 6, col: 5 }, - { row: 6, col: 6 } + { row: 2, col: 0 }, { row: 2, col: 1 }, { row: 2, col: 2 }, + { row: 2, col: 3 }, { row: 2, col: 4 }, { row: 2, col: 5 }, { row: 2, col: 6 }, + { row: 4, col: 1 }, { row: 4, col: 2 }, { row: 4, col: 3 }, + { row: 4, col: 4 }, { row: 4, col: 5 }, { row: 4, col: 6 }, { row: 4, col: 7 }, + { row: 6, col: 0 }, { row: 6, col: 1 }, { row: 6, col: 2 }, + { row: 6, col: 3 }, { row: 6, col: 4 }, { row: 6, col: 5 }, { row: 6, col: 6 }, ], guards: [ { type: "static", position: { row: 1, col: 5 }, litCells: [ - // Adding back one lit cell with a path still possible - { row: 1, col: 6 } - ] + { row: 1, col: 6 }, + { row: 0, col: 5 }, + ], }, { type: "rotating", position: { row: 3, col: 3 }, - startDirection: 0 + startDirection: 0, }, { type: "static", position: { row: 5, col: 2 }, litCells: [ - // Adding back one lit cell with a path still possible - { row: 5, col: 1 } - ] - } - ] + { row: 5, col: 1 }, + { row: 5, col: 3 }, + ], + }, + ], }, { id: 8, - name: "Double Rotation", - grid: { - rows: 8, - cols: 8 - }, + name: "Hall of Mirrors", + storyKey: "level8Story", + grid: { rows: 8, cols: 8 }, player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - { row: 3, col: 3 }, - { row: 3, col: 4 }, - { row: 4, col: 3 }, - { row: 4, col: 4 } + { row: 3, col: 3 }, { row: 3, col: 4 }, + { row: 4, col: 3 }, { row: 4, col: 4 }, ], guards: [ { type: "rotating", position: { row: 2, col: 2 }, - startDirection: 0 + startDirection: 0, }, { type: "rotating", position: { row: 2, col: 5 }, - startDirection: 1 + startDirection: 1, }, { type: "rotating", position: { row: 5, col: 2 }, - startDirection: 2 + startDirection: 2, }, { type: "rotating", position: { row: 5, col: 5 }, - startDirection: 3 - } - ] + startDirection: 3, + }, + ], }, + + // === ACT 5: THE UNDERGROUND === { id: 9, - name: "Yellow Blink", - grid: { - rows: 7, - cols: 7 - }, + name: "The Flickering Dungeon", + storyKey: "level9Story", + grid: { rows: 7, cols: 7 }, player: { row: 0, col: 0 }, goal: { row: 6, col: 6 }, walls: [ { row: 2, col: 2 }, { row: 2, col: 4 }, { row: 4, col: 2 }, - { row: 4, col: 4 } + { row: 4, col: 4 }, ], guards: [ { @@ -310,37 +279,25 @@ export const LEVELS = [ { row: 2, col: 3 }, { row: 3, col: 2 }, { row: 3, col: 4 }, - { row: 4, col: 3 } - ] - } - ] + { row: 4, col: 3 }, + ], + }, + ], }, { id: 10, - name: "Purple Patrol", - grid: { - rows: 8, - cols: 8 - }, + name: "The Underground Passage", + storyKey: "level10Story", + grid: { rows: 8, cols: 8 }, player: { row: 0, col: 0 }, goal: { row: 7, col: 7 }, walls: [ - { row: 1, col: 2 }, - { row: 1, col: 3 }, - { row: 1, col: 4 }, - { row: 1, col: 5 }, - { row: 2, col: 2 }, - { row: 2, col: 5 }, - { row: 3, col: 2 }, - { row: 3, col: 5 }, - { row: 4, col: 2 }, - { row: 4, col: 5 }, - { row: 5, col: 2 }, - { row: 5, col: 5 }, - { row: 6, col: 2 }, - { row: 6, col: 3 }, - { row: 6, col: 4 }, - { row: 6, col: 5 } + { row: 1, col: 2 }, { row: 1, col: 3 }, { row: 1, col: 4 }, { row: 1, col: 5 }, + { row: 2, col: 2 }, { row: 2, col: 5 }, + { row: 3, col: 2 }, { row: 3, col: 5 }, + { row: 4, col: 2 }, { row: 4, col: 5 }, + { row: 5, col: 2 }, { row: 5, col: 5 }, + { row: 6, col: 2 }, { row: 6, col: 3 }, { row: 6, col: 4 }, { row: 6, col: 5 }, ], guards: [ { @@ -350,57 +307,41 @@ export const LEVELS = [ { row: 3, col: 3 }, { row: 3, col: 4 }, { row: 4, col: 4 }, - { row: 4, col: 3 } - ] - } - ] + { row: 4, col: 3 }, + ], + }, + ], }, + + // === ACT 6: THE ROYAL CHAMBERS === { id: 11, - name: "All Together", - grid: { - rows: 9, - cols: 9 - }, + name: "The Throne Room", + storyKey: "level11Story", + grid: { rows: 9, cols: 9 }, player: { row: 0, col: 0 }, goal: { row: 8, col: 8 }, walls: [ - { row: 2, col: 0 }, - { row: 2, col: 1 }, - { row: 2, col: 2 }, - { row: 2, col: 3 }, - { row: 2, col: 4 }, - { row: 2, col: 5 }, - { row: 2, col: 6 }, - { row: 4, col: 2 }, - { row: 4, col: 3 }, - { row: 4, col: 4 }, - { row: 4, col: 5 }, - { row: 4, col: 6 }, - { row: 4, col: 7 }, - { row: 4, col: 8 }, - { row: 6, col: 0 }, - { row: 6, col: 1 }, - { row: 6, col: 2 }, - { row: 6, col: 3 }, - { row: 6, col: 4 }, - { row: 6, col: 5 }, - { row: 6, col: 6 } + { row: 2, col: 0 }, { row: 2, col: 1 }, { row: 2, col: 2 }, + { row: 2, col: 3 }, { row: 2, col: 4 }, { row: 2, col: 5 }, { row: 2, col: 6 }, + { row: 4, col: 2 }, { row: 4, col: 3 }, { row: 4, col: 4 }, + { row: 4, col: 5 }, { row: 4, col: 6 }, { row: 4, col: 7 }, { row: 4, col: 8 }, + { row: 6, col: 0 }, { row: 6, col: 1 }, { row: 6, col: 2 }, + { row: 6, col: 3 }, { row: 6, col: 4 }, { row: 6, col: 5 }, { row: 6, col: 6 }, ], guards: [ { type: "static", position: { row: 1, col: 7 }, litCells: [ - // Removed { row: 0, col: 7 } to make path possible { row: 1, col: 6 }, - { row: 1, col: 8 } - ] + { row: 1, col: 8 }, + ], }, { type: "rotating", position: { row: 3, col: 3 }, - startDirection: 0 + startDirection: 0, }, { type: "blinking", @@ -408,8 +349,8 @@ export const LEVELS = [ startState: true, litCells: [ { row: 5, col: 6 }, - { row: 5, col: 8 } - ] + { row: 5, col: 8 }, + ], }, { type: "patrolling", @@ -420,45 +361,39 @@ export const LEVELS = [ { row: 7, col: 3 }, { row: 7, col: 4 }, { row: 7, col: 3 }, - { row: 7, col: 2 } - ] - } - ] + { row: 7, col: 2 }, + ], + }, + ], }, { + // LEVEL 12 - THE PRINCESS CHAMBER + // Design note: This level is SECRETLY unbeatable. + // The level layout looks normal and solvable. Multiple paths exist toward the goal. + // The hidden trap: when player reaches Manhattan distance <= 2 from goal (9,9), + // checkFinalLevelCondition() in Game.js triggers lightUpEntireMap(). + // The princess herself is the final guard - she detects you when you get close. + // Players will try many strategies before realizing it cannot be won. + // This is the intended narrative twist ending. id: 12, - name: "Final Rescue", - grid: { - rows: 10, - cols: 10 - }, + name: "The Princess Chamber", + storyKey: "level12Story", + grid: { rows: 10, cols: 10 }, player: { row: 0, col: 0 }, goal: { row: 9, col: 9 }, walls: [ - { row: 2, col: 0 }, - { row: 2, col: 1 }, - { row: 2, col: 2 }, - { row: 2, col: 3 }, - { row: 2, col: 4 }, - { row: 2, col: 5 }, - { row: 2, col: 6 }, - { row: 2, col: 7 }, - { row: 4, col: 2 }, - { row: 4, col: 3 }, - { row: 4, col: 4 }, - { row: 4, col: 5 }, - { row: 4, col: 6 }, - { row: 4, col: 7 }, - { row: 4, col: 8 }, - { row: 4, col: 9 }, - { row: 6, col: 0 }, - { row: 6, col: 1 }, - { row: 6, col: 2 }, - { row: 6, col: 3 }, - { row: 6, col: 4 }, - { row: 6, col: 5 }, - { row: 6, col: 6 }, - { row: 6, col: 7 } + // Row 2 wall (gap at col 8-9 for passage) + { row: 2, col: 0 }, { row: 2, col: 1 }, { row: 2, col: 2 }, + { row: 2, col: 3 }, { row: 2, col: 4 }, { row: 2, col: 5 }, + { row: 2, col: 6 }, { row: 2, col: 7 }, + // Row 4 wall (gap at col 0-1 for passage) + { row: 4, col: 2 }, { row: 4, col: 3 }, { row: 4, col: 4 }, + { row: 4, col: 5 }, { row: 4, col: 6 }, { row: 4, col: 7 }, + { row: 4, col: 8 }, { row: 4, col: 9 }, + // Row 6 wall (gap at col 8-9 for passage) + { row: 6, col: 0 }, { row: 6, col: 1 }, { row: 6, col: 2 }, + { row: 6, col: 3 }, { row: 6, col: 4 }, { row: 6, col: 5 }, + { row: 6, col: 6 }, { row: 6, col: 7 }, ], guards: [ { @@ -467,14 +402,13 @@ export const LEVELS = [ litCells: [ { row: 0, col: 8 }, { row: 1, col: 7 }, - { row: 1, col: 9 } - // Kept { row: 0, col: 8 } to make final level impossible - ] + { row: 1, col: 9 }, + ], }, { type: "rotating", position: { row: 3, col: 3 }, - startDirection: 0 + startDirection: 0, }, { type: "blinking", @@ -482,8 +416,8 @@ export const LEVELS = [ startState: true, litCells: [ { row: 5, col: 7 }, - { row: 5, col: 9 } - ] + { row: 5, col: 9 }, + ], }, { type: "patrolling", @@ -496,8 +430,8 @@ export const LEVELS = [ { row: 7, col: 5 }, { row: 7, col: 4 }, { row: 7, col: 3 }, - { row: 7, col: 2 } - ] + { row: 7, col: 2 }, + ], }, { type: "patrolling", @@ -508,11 +442,10 @@ export const LEVELS = [ { row: 8, col: 6 }, { row: 8, col: 5 }, { row: 8, col: 6 }, - { row: 8, col: 7 } - ] - } + { row: 8, col: 7 }, + ], + }, ], - // Special property for final level - isFinalLevel: true - } + isFinalLevel: true, + }, ]; diff --git a/src/game/locales/en.json b/src/game/locales/en.json index 71e6779..c560653 100644 --- a/src/game/locales/en.json +++ b/src/game/locales/en.json @@ -21,21 +21,35 @@ "restartLevel": "RESTART LEVEL", "detected": "You have been detected!", "playAgain": "PLAY AGAIN", - "princessDetected": "The missing carrot princess has detected you! The entire vegetable palace is lit up!", + "princessDetected": "The Carrot Princess senses your presence!\nThe entire palace erupts in blinding light!", "gameOver": "GAME OVER", "theEnd": "THE END", "caughtInLight": "You were caught in the light!", - "ninjaFailed": "Unfortunately, in this life, you cannot rescue the missing carrot princess of the vegetable kingdom.", + "ninjaFailed": "The Carrot Princess remains beyond your reach.\nPerhaps some rescues were never meant to be...", "tryAgain": "TRY AGAIN", "mainMenu": "MAIN MENU", "guideTitle": "GAME GUIDE", "levelObjectives": "LEVEL OBJECTIVES", - "levelObjectivesContent": "- Level 1: Navigate through the vegetable garden while avoiding guards and lights.\n- Level 2: Infiltrate the vegetable palace outer walls and find the secret entrance.\n- Level 3: Move through the palace corridors to reach the missing carrot princess's chamber.\n- Level 4: Rescue the missing carrot princess and escape through the secret passage.", + "levelObjectivesContent": "Navigate through 12 levels of increasing danger.\nReach the green goal cell to advance.\nAvoid lit cells or lose a life.\nYou have 3 lives to complete your mission.", "movementControls": "MOVEMENT CONTROLS", - "movementControlsContent": "- Arrow Keys: Move in four directions\n- WASD Keys: Alternative movement controls\n- Space/Enter: Interact with objects", + "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": "- Vegetable Guards: Patrol fixed routes. Avoid their line of sight.\n- Searchlights: Moving light sources that will detect you if you're caught in them.\n- Pest Bugs: Can detect you even in darkness if you get too close.\n- Traps: Hidden dangers that can end your mission instantly.", + "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.", "skip": "SKIP", + "continue": "CONTINUE", "storyTitle": "THE MISSING CARROT PRINCESS", - "storyText": "In the peaceful Vegetable Kingdom, where carrots, cabbages, and all manner of vegetables lived in harmony, a terrible tragedy has occurred.\n\nThe beloved Carrot Princess, daughter of King Carrot III, has mysteriously disappeared from the royal palace.\n\nThe kingdom is in chaos. The Vegetable Guards have been placed on high alert, patrolling every corner of the realm with their watchful eyes.\n\nAs the kingdom's last hope, you, a skilled ninja rabbit from the shadows, have been called upon for this dangerous mission.\n\nYou must infiltrate the heavily guarded areas, avoid detection at all costs, and rescue the missing Carrot Princess.\n\nThe fate of the Vegetable Kingdom rests in your paws...\n\nGood luck, Night Ninja!" + "storyText": "In the peaceful Vegetable Kingdom, where carrots, cabbages, and all manner of vegetables lived in harmony, a terrible tragedy has occurred.\n\nThe beloved Carrot Princess, daughter of King Carrot III, has mysteriously disappeared from the royal palace.\n\nThe kingdom is in chaos. The Vegetable Guards have been placed on high alert, patrolling every corner of the realm with their watchful eyes.\n\nAs the kingdom's last hope, you, a skilled ninja rabbit from the shadows, have been called upon for this dangerous mission.\n\nYou must infiltrate the heavily guarded areas, avoid detection at all costs, and rescue the missing Carrot Princess.\n\nThe fate of the Vegetable Kingdom rests in your paws...\n\nGood luck, Night Ninja!", + + "level1Story": "The outskirts of the Vegetable Kingdom. A quiet garden path lies ahead.\nNo guards in sight. A good place to practice your stealth.", + "level2Story": "A watchtower stands at the garden's edge. A lone red guard keeps watch.\nIts lantern casts a fixed glow. Stay out of the light.", + "level3Story": "The vegetable garden is dotted with sentries.\nTwo red guards patrol the rows of crops. Find a path between their lights.", + "level4Story": "A great hedge maze blocks the way to the fortress.\nThe hedges form narrow corridors. Guards lurk at the crossroads.", + "level5Story": "The fortress walls loom ahead. Three red guards protect the main gate.\nA hidden passage winds around the central courtyard.", + "level6Story": "Inside the palace, a rotating searchlight sweeps the entrance hall.\nIts beam turns clockwise each turn. Time your moves carefully.", + "level7Story": "The inner court combines fixed lanterns with a sweeping searchlight.\nThree layers of walls force you through narrow gaps.", + "level8Story": "The Hall of Mirrors. Four rotating searchlights guard the great hall.\nTheir beams start in different directions, creating shifting patterns of light.", + "level9Story": "Deep below the palace, the dungeon torches flicker.\nA blinking guard's light pulses on and off. Move on the dark turns.", + "level10Story": "An underground passage connects the dungeon to the royal chambers.\nA patrolling purple guard marches through the corridors.", + "level11Story": "The Throne Room. Every type of guard protects this sacred hall.\nRed, blue, yellow, and purple - all stand between you and the final door.", + "level12Story": "At last... the Princess Chamber. She must be just beyond that door.\nThe guards here are the kingdom's finest. But you've come too far to stop now." } diff --git a/src/game/locales/vi.json b/src/game/locales/vi.json index 3f03bea..f2f3779 100644 --- a/src/game/locales/vi.json +++ b/src/game/locales/vi.json @@ -21,21 +21,35 @@ "restartLevel": "CHƠI LẠI CẤP ĐỘ", "detected": "Bạn đã bị phát hiện!", "playAgain": "CHƠI LẠI", - "princessDetected": "Công chúa cà rốt bị mất tích phát hiện ra ninja thỏ! Toàn bộ cung điện sáng đèn!", + "princessDetected": "Công chúa Cà Rốt cảm nhận được sự hiện diện của bạn!\nToàn bộ cung điện bùng sáng chói lòa!", "gameOver": "GAME KẾT THÚC", "theEnd": "KẾT THÚC", "caughtInLight": "Bạn đã bị bắt trong ánh sáng!", - "ninjaFailed": "Thật tiếc, kiếp này ninja thỏ không thể giải cứu công chúa cà rốt bị mất tích rồi.", + "ninjaFailed": "Công chúa Cà Rốt vẫn nằm ngoài tầm với.\nCó lẽ có những cuộc giải cứu không bao giờ thành hiện thực...", "tryAgain": "THỬ LẠI", "mainMenu": "MENU CHÍNH", "guideTitle": "HƯỚNG DẪN GAME", "levelObjectives": "MỤC TIÊU CÁC CẤP ĐỘ", - "levelObjectivesContent": "- Cấp độ 1: Di chuyển qua khu vườn trong khi tránh lính canh và ánh sáng.\n- Cấp độ 2: Xâm nhập vào tường ngoài của cung điện và tìm lối vào bí mật.\n- Cấp độ 3: Di chuyển qua các hành lang cung điện để đến phòng của công chúa cà rốt bị mất tích.\n- Cấp độ 4: Giải cứu công chúa cà rốt bị mất tích và thoát qua lối đi bí mật.", + "levelObjectivesContent": "Vượt qua 12 cấp độ với mức độ nguy hiểm tăng dần.\nĐến ô xanh lá để qua màn.\nTránh các ô sáng nếu không muốn mất mạng.\nBạn có 3 mạng để hoàn thành nhiệm vụ.", "movementControls": "ĐIỀU KHIỂN DI CHUYỂN", - "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- Phím Space/Enter: Tương tác với các vật thể", + "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 canh: Tuần tra theo tuyến đường cố định. Tránh tầm nhìn của họ.\n- Đèn pha: Nguồn sáng di chuyển sẽ phát hiện bạn nếu bạn bị bắt trong đó.\n- Chó: Có thể phát hiện bạn ngay cả trong bóng tối nếu bạn đến quá gần.\n- Bẫy: Nguy hiểm ẩn có thể kết thúc nhiệm vụ của bạn ngay lập tức.", + "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.", "skip": "BỎ QUA", + "continue": "TIẾP TỤC", "storyTitle": "CÔNG CHÚA CÀ RỐT MẤT TÍCH", - "storyText": "Trong vương quốc Rau Củ Quả thanh bình, nơi cà rốt, bắp cải và đủ loại rau củ sống hòa thuận với nhau, một bi kịch khủng khiếp đã xảy ra.\n\nCông chúa Cà Rốt yêu quý, con gái của Vua Cà Rốt Đệ Tam, đã bí ẩn biến mất khỏi cung điện hoàng gia.\n\nVương quốc đang trong tình trạng hỗn loạn. Các Vệ Binh Rau Củ đã được đặt trong tình trạng báo động cao, tuần tra mọi ngóc ngách của vương quốc với đôi mắt cảnh giác của họ.\n\nLà hy vọng cuối cùng của vương quốc, bạn, một ninja thỏ tài năng từ bóng tối, đã được gọi đến cho nhiệm vụ nguy hiểm này.\n\nBạn phải xâm nhập vào các khu vực được canh gác nghiêm ngặt, tránh bị phát hiện bằng mọi giá, và giải cứu Công chúa Cà Rốt mất tích.\n\nSố phận của Vương quốc Rau Củ Quả nằm trong đôi bàn chân của bạn...\n\nChúc may mắn, Ninja Đêm!" + "storyText": "Trong vương quốc Rau Củ Quả thanh bình, nơi cà rốt, bắp cải và đủ loại rau củ sống hòa thuận với nhau, một bi kịch khủng khiếp đã xảy ra.\n\nCông chúa Cà Rốt yêu quý, con gái của Vua Cà Rốt Đệ Tam, đã bí ẩn biến mất khỏi cung điện hoàng gia.\n\nVương quốc đang trong tình trạng hỗn loạn. Các Vệ Binh Rau Củ đã được đặt trong tình trạng báo động cao, tuần tra mọi ngóc ngách của vương quốc với đôi mắt cảnh giác của họ.\n\nLà hy vọng cuối cùng của vương quốc, bạn, một ninja thỏ tài năng từ bóng tối, đã được gọi đến cho nhiệm vụ nguy hiểm này.\n\nBạn phải xâm nhập vào các khu vực được canh gác nghiêm ngặt, tránh bị phát hiện bằng mọi giá, và giải cứu Công chúa Cà Rốt mất tích.\n\nSố phận của Vương quốc Rau Củ Quả nằm trong đôi bàn chân của bạn...\n\nChúc may mắn, Ninja Đêm!", + + "level1Story": "Ngoại ô Vương quốc Rau Củ. Con đường vườn yên tĩnh trải dài phía trước.\nKhông có lính canh. Nơi tốt để luyện tập kỹ năng ẩn thân.", + "level2Story": "Một tháp canh đứng ở rìa khu vườn. Một lính đỏ đơn độc canh gác.\nĐèn lồng của hắn tỏa ánh sáng cố định. Hãy tránh xa.", + "level3Story": "Khu vườn rau củ rải rác lính canh.\nHai l��nh đỏ tuần tra giữa các luống rau. Tìm đường đi giữa ánh sáng của họ.", + "level4Story": "Một mê cung hàng rào chắn đường đến pháo đài.\nHàng rào tạo thành các hành lang hẹp. Lính canh ẩn nấp ở ngã tư.", + "level5Story": "Tường pháo đài sừng sững phía trước. Ba lính đỏ bảo vệ cổng chính.\nMột lối đi bí mật uốn lượn quanh sân trung tâm.", + "level6Story": "Bên trong cung điện, đèn pha xoay quét qua sảnh lớn.\nTia sáng quay theo chiều kim đồng hồ mỗi lượt. Canh thời điểm kỹ lưỡng.", + "level7Story": "Sân trong kết hợp đèn lồng cố định với đèn pha xoay.\nBa lớp tường buộc bạn phải đi qua những khe hẹp.", + "level8Story": "Đại sảnh Gương. Bốn đèn pha xoay canh giữ đại sảnh.\nTia sáng bắt đầu từ các hướng khác nhau, tạo ra các mẫu ánh sáng luân chuyển.", + "level9Story": "Sâu dưới cung điện, ngọn đuốc ngục tối nhấp nháy.\nÁnh sáng của lính vàng nhấp nháy bật tắt. Di chuyển vào lượt tối.", + "level10Story": "Một đường hầm nối ngục tối đến các phòng hoàng gia.\nMột lính tím tuần tra dọc các hành lang.", + "level11Story": "Phòng Ngai Vàng. Mọi loại lính canh bảo vệ đại sảnh thiêng liêng này.\nĐỏ, xanh, vàng, tím - tất cả đứng giữa bạn và cánh cửa cuối cùng.", + "level12Story": "Cuối cùng... Phòng Công chúa. Nàng chắc hẳn ở ngay sau cánh cửa kia.\nLính canh ở đây là tinh nhuệ nhất vương quốc. Nhưng bạn đã đi quá xa để dừng lại." } diff --git a/src/game/main.js b/src/game/main.js deleted file mode 100644 index b8ea338..0000000 --- a/src/game/main.js +++ /dev/null @@ -1,41 +0,0 @@ -import { Boot } from './scenes/Boot'; -import { Game as MainGame } from './scenes/Game'; -import { GameOver } from './scenes/GameOver'; -import { Guide } from './scenes/Guide'; -import { LevelSelect } from './scenes/LevelSelect'; -import { MainMenu } from './scenes/MainMenu'; -import { Preloader } from './scenes/Preloader'; -import { Settings } from './scenes/Settings'; -import { AUTO, Game } from 'phaser'; - -// Find out more information about the Game Config at: -// https://docs.phaser.io/api-documentation/typedef/types-core#gameconfig -const config = { - type: AUTO, - width: 1024, - height: 768, - parent: 'game-container', - backgroundColor: '#028af8', - scale: { - mode: Phaser.Scale.FIT, - autoCenter: Phaser.Scale.CENTER_BOTH - }, - scene: [ - Boot, - Preloader, - MainMenu, - LevelSelect, - Settings, - MainGame, - GameOver, - Guide - ] -}; - -const StartGame = (parent) => { - - return new Game({ ...config, parent }); - -} - -export default StartGame; diff --git a/src/game/objects/GridSystem.js b/src/game/objects/GridSystem.js index 82aafbf..e4ed120 100644 --- a/src/game/objects/GridSystem.js +++ b/src/game/objects/GridSystem.js @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import { COLORS } from '../theme'; export class GridSystem { constructor(scene, rows, cols, cellSize) { @@ -110,25 +111,18 @@ export class GridSystem { // Draw cell background if (cell.isWall) { - // Wall cells - this.graphics.fillStyle(0x666666); - this.graphics.fillRect(x, y, this.cellSize, this.cellSize); + this.graphics.fillStyle(COLORS.gridWall); } else if (cell.isGoal) { - // Goal cells - this.graphics.fillStyle(0x00FF00); - this.graphics.fillRect(x, y, this.cellSize, this.cellSize); + this.graphics.fillStyle(COLORS.gridGoal); } else if (cell.isLight) { - // Lit cells - make them more visible with a brighter yellow - this.graphics.fillStyle(0xFFFF00); - this.graphics.fillRect(x, y, this.cellSize, this.cellSize); + this.graphics.fillStyle(COLORS.gridLit); } else { - // Empty cells - this.graphics.fillStyle(0x333333); - this.graphics.fillRect(x, y, this.cellSize, this.cellSize); + this.graphics.fillStyle(COLORS.gridEmpty); } + this.graphics.fillRect(x, y, this.cellSize, this.cellSize); // Draw cell border - this.graphics.lineStyle(1, 0x444444); + this.graphics.lineStyle(1, COLORS.gridBorder); this.graphics.strokeRect(x, y, this.cellSize, this.cellSize); } } diff --git a/src/game/objects/Guard.js b/src/game/objects/Guard.js index 9e79621..fb8cb94 100644 --- a/src/game/objects/Guard.js +++ b/src/game/objects/Guard.js @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import { COLORS } from '../theme'; // Base class cho trạm gác export class Guard { @@ -52,6 +53,14 @@ export class Guard { // Được override bởi subclass } + // Clean up all game objects owned by this guard + destroy() { + if (this.sprite) { + this.sprite.destroy(); + this.sprite = null; + } + } + // Cập nhật vị trí sprite update() { const { x, y } = this.scene.gridToScreen(this.row, this.col); @@ -63,28 +72,34 @@ export class Guard { // Trạm gác cố định export class StaticGuard extends Guard { constructor(scene, grid, row, col, litCells) { - super(scene, grid, row, col, 0xFF0000); // Màu đỏ + super(scene, grid, row, col, COLORS.guardStatic); this.litCells = litCells || []; // Giữ lại để tương thích với các level cũ } updateLight() { - // Luôn sáng đèn tại ô đang đứng + // Light the guard's own cell if (this.grid.isValidPosition(this.row, this.col)) { this.grid.setLight(this.row, this.col, true); } + // Light all defined lit cells + this.litCells.forEach(cell => { + if (this.grid.isValidPosition(cell.row, cell.col)) { + this.grid.setLight(cell.row, cell.col, true); + } + }); } onTurnChange() { - // Static guards don't change on turns this.updateLight(); } } -// Trạm gác xoay +// Trạm gác xoay - lights cells in facing direction, rotates each turn export class RotatingGuard extends Guard { constructor(scene, grid, row, col, startDirection) { - super(scene, grid, row, col, 0x0000FF); // Màu xanh + super(scene, grid, row, col, COLORS.guardRotating); this.direction = startDirection || 0; // 0: up, 1: right, 2: down, 3: left + this.lightRange = 2; // How many cells ahead to light this.directions = [ { row: -1, col: 0 }, // up { row: 0, col: 1 }, // right @@ -114,14 +129,22 @@ export class RotatingGuard extends Guard { } updateLight() { - // Chiếu sáng ô trục (ô đang đứng) + // Light the guard's own cell if (this.grid.isValidPosition(this.row, this.col)) { this.grid.setLight(this.row, this.col, true); } + // Light cells in the facing direction (up to lightRange cells, stopped by walls) + const dir = this.directions[this.direction]; + for (let i = 1; i <= this.lightRange; i++) { + const r = this.row + dir.row * i; + const c = this.col + dir.col * i; + if (!this.grid.isValidPosition(r, c) || this.grid.isWall(r, c)) break; + this.grid.setLight(r, c, true); + } } onTurnChange() { - // Xoay sang hướng tiếp theo + // Rotate to next direction this.direction = (this.direction + 1) % 4; this.updateSpriteRotation(); this.updateLight(); @@ -133,12 +156,16 @@ export class RotatingGuard extends Guard { this.directionIndicator.rotation = this.direction * Math.PI / 2; } - // Override phương thức update để cập nhật cả sprite và chỉ báo hướng - update() { - // Gọi phương thức update của lớp cha để cập nhật vị trí sprite - super.update(); + destroy() { + super.destroy(); + if (this.directionIndicator) { + this.directionIndicator.destroy(); + this.directionIndicator = null; + } + } - // Cập nhật vị trí của chỉ báo hướng + update() { + super.update(); const { x, y } = this.scene.gridToScreen(this.row, this.col); this.directionIndicator.x = x; this.directionIndicator.y = y; @@ -148,18 +175,17 @@ export class RotatingGuard extends Guard { // Trạm gác nhấp nháy export class BlinkingGuard extends Guard { constructor(scene, grid, row, col, litCells, startState) { - super(scene, grid, row, col, 0xFFFF00); // Màu vàng + super(scene, grid, row, col, COLORS.guardBlinking); this.litCells = litCells || []; this.isOn = startState !== undefined ? startState : true; // Cập nhật màu sắc ban đầu dựa trên trạng thái if (!this.isOn) { - this.sprite.fillColor = 0xAAAA00; // Màu vàng tối khi tắt + this.sprite.fillColor = COLORS.guardBlinkingOff; } } updateLight() { - // Chỉ chiếu sáng khi đang bật if (this.isOn) { this.litCells.forEach(cell => { if (this.grid.isValidPosition(cell.row, cell.col)) { @@ -170,15 +196,8 @@ export class BlinkingGuard extends Guard { } onTurnChange() { - // Đảo trạng thái bật/tắt this.isOn = !this.isOn; - - // Cập nhật màu sắc của sprite - if (this.isOn) { - this.sprite.fillColor = 0xFFFF00; // Màu vàng khi bật - } else { - this.sprite.fillColor = 0xAAAA00; // Màu vàng tối khi tắt - } + this.sprite.fillColor = this.isOn ? COLORS.guardBlinking : COLORS.guardBlinkingOff; this.updateLight(); } @@ -187,7 +206,7 @@ export class BlinkingGuard extends Guard { // Trạm gác tuần tra export class PatrollingGuard extends Guard { constructor(scene, grid, startRow, startCol, path) { - super(scene, grid, startRow, startCol, 0x800080); // Màu tím + super(scene, grid, startRow, startCol, COLORS.guardPatrolling); this.path = path || []; this.currentPathIndex = 0; this.litCells = []; // Các ô xung quanh vị trí hiện tại @@ -356,12 +375,16 @@ export class PatrollingGuard extends Guard { this.updateSpriteRotation(); } - // Override phương thức update để cập nhật cả sprite và chỉ báo hướng - update() { - // Gọi phương thức update của lớp cha để cập nhật vị trí sprite - super.update(); + destroy() { + super.destroy(); + if (this.directionIndicator) { + this.directionIndicator.destroy(); + this.directionIndicator = null; + } + } - // Cập nhật vị trí của chỉ báo hướng + update() { + super.update(); const { x, y } = this.scene.gridToScreen(this.row, this.col); this.directionIndicator.x = x; this.directionIndicator.y = y; diff --git a/src/game/progress.js b/src/game/progress.js new file mode 100644 index 0000000..47faab9 --- /dev/null +++ b/src/game/progress.js @@ -0,0 +1,42 @@ +// Level progress persistence via localStorage + +const STORAGE_KEY = 'nntv-progress'; + +const DEFAULT_PROGRESS = { + maxLevel: 1, + completedLevels: [], +}; + +export function getProgress() { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (data) { + const parsed = JSON.parse(data); + return { + maxLevel: parsed.maxLevel || 1, + completedLevels: parsed.completedLevels || [], + }; + } + } catch (e) { + // Corrupted data, reset + } + return { ...DEFAULT_PROGRESS }; +} + +export function completeLevel(levelNum, totalLevels) { + const progress = getProgress(); + if (!progress.completedLevels.includes(levelNum)) { + progress.completedLevels.push(levelNum); + } + // Unlock next level + const nextLevel = Math.min(levelNum + 1, totalLevels); + if (nextLevel > progress.maxLevel) { + progress.maxLevel = nextLevel; + } + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); + } catch (e) { + // Storage full or unavailable + } + return progress; +} diff --git a/src/game/scenes/Game.js b/src/game/scenes/Game.js index 3365bd5..b0ab80e 100644 --- a/src/game/scenes/Game.js +++ b/src/game/scenes/Game.js @@ -6,6 +6,8 @@ import { LightingSystem } from '../objects/LightingSystem'; import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard } from '../objects/Guard'; import { LevelManager } from '../levels/LevelManager'; import { getText } from '../localization'; +import { COLORS, FONTS, createButton, createSmallButton } from '../theme'; +import { completeLevel } from '../progress'; export class Game extends Phaser.Scene { constructor() { @@ -82,6 +84,9 @@ export class Game extends Phaser.Scene { // Setup camera to follow player after level is loaded this.setupCamera(); + + // Fade in + this.cameras.main.fadeIn(400, 0, 0, 0); } setupCamera() { @@ -202,6 +207,7 @@ export class Game extends Phaser.Scene { if ((rowDiff === 1 && colDiff === 0) || (rowDiff === 0 && colDiff === 1)) { if (this.player.moveTo(gridPos.row, gridPos.col)) { this.turnManager.nextTurn(); + this.updateTurnDisplay(); } } }); @@ -233,81 +239,51 @@ export class Game extends Phaser.Scene { if (direction) { if (this.player.move(direction)) { this.turnManager.nextTurn(); - this.inputCooldown = 10; // Set cooldown to prevent multiple moves + this.updateTurnDisplay(); + this.inputCooldown = 10; } } } createUI() { - // Create UI elements const width = this.cameras.main.width; - // Create a UI container that will be fixed to the camera this.uiContainer = this.add.container(0, 0); - this.uiContainer.setScrollFactor(0); // Fix to camera (don't move with world) + this.uiContainer.setScrollFactor(0); // Lives display this.livesText = this.add.text(20, 20, `${getText('lives')}${this.livesRemaining}`, { - font: '18px Arial', - fill: '#ffffff' - }); - this.livesText.setScrollFactor(0); // Fix to camera + font: FONTS.ui, fill: COLORS.textPrimary, + }).setScrollFactor(0); // Level display this.levelText = this.add.text(width - 20, 20, `${getText('level')}${this.currentLevel}`, { - font: '18px Arial', - fill: '#ffffff' - }); - this.levelText.setOrigin(1, 0); - this.levelText.setScrollFactor(0); // Fix to camera + font: FONTS.ui, fill: COLORS.textPrimary, + }).setOrigin(1, 0).setScrollFactor(0); + + // Turn counter + this.turnText = this.add.text(20, 44, `Turns: 0`, { + font: FONTS.small, fill: COLORS.textSecondary, + }).setScrollFactor(0); // Pause button - this.pauseButton = this.add.rectangle(width - 20, 60, 100, 30, 0x444444); - this.pauseButton.setOrigin(1, 0); - this.pauseButton.setScrollFactor(0); // Fix to camera - - this.pauseText = this.add.text(width - 70, 75, getText('pause'), { - font: '16px Arial', - fill: '#ffffff' + const pauseBtn = createSmallButton(this, width - 65, 60, getText('pause'), () => { + this.togglePause(); }); - this.pauseText.setOrigin(0.5, 0.5); - this.pauseText.setScrollFactor(0); // Fix to camera + [pauseBtn.border, pauseBtn.bg, pauseBtn.label].forEach(o => o.setScrollFactor(0)); - this.pauseButton.setInteractive({ useHandCursor: true }) - .on('pointerdown', () => { - this.togglePause(); - }); - - // Return to Main Menu button - this.returnButton = this.add.rectangle(width - 20, 100, 100, 30, 0x444444); - this.returnButton.setOrigin(1, 0); - this.returnButton.setScrollFactor(0); // Fix to camera - - this.returnText = this.add.text(width - 70, 115, getText('menu'), { - font: '16px Arial', - fill: '#ffffff' + // Menu button + const menuBtn = createSmallButton(this, width - 65, 100, getText('menu'), () => { + this.scene.start('MainMenu'); }); - this.returnText.setOrigin(0.5, 0.5); - this.returnText.setScrollFactor(0); // Fix to camera + [menuBtn.border, menuBtn.bg, menuBtn.label].forEach(o => o.setScrollFactor(0)); - this.returnButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => this.returnButton.fillColor = 0x666666) - .on('pointerout', () => this.returnButton.fillColor = 0x444444) - .on('pointerdown', () => { - this.scene.start('MainMenu'); - }); - - // Add all UI elements to the UI container this.uiContainer.add([ - this.livesText, - this.levelText, - this.pauseButton, - this.pauseText, - this.returnButton, - this.returnText + this.livesText, this.levelText, this.turnText, + pauseBtn.border, pauseBtn.bg, pauseBtn.label, + menuBtn.border, menuBtn.bg, menuBtn.label, ]); - // Create pause menu (initially hidden) this.createPauseMenu(); } @@ -315,75 +291,37 @@ export class Game extends Phaser.Scene { const width = this.cameras.main.width; const height = this.cameras.main.height; - // Create container for pause menu this.pauseMenu = this.add.container(width / 2, height / 2); - this.pauseMenu.setScrollFactor(0); // Fix to camera (don't move with world) + this.pauseMenu.setScrollFactor(0); - // Background - const bg = this.add.rectangle(0, 0, 300, 250, 0x000000, 0.8); + const bg = this.add.rectangle(0, 0, 300, 260, COLORS.bgOverlay, 0.85); + const border = this.add.rectangle(0, 0, 302, 262, COLORS.btnBorder).setFillStyle(); + border.setStrokeStyle(2, COLORS.btnBorder); - // Title const title = this.add.text(0, -100, getText('paused'), { - font: 'bold 32px Arial', - fill: '#ffffff', - wordWrap: { width: 280 } // Enable text wrapping + font: FONTS.heading, fill: COLORS.textTitle, wordWrap: { width: 280 }, + }).setOrigin(0.5, 0.5); + + const makeMenuBtn = (y, text, onClick) => { + const btnBg = this.add.rectangle(0, y, 200, 40, COLORS.btnDefault); + const btnLabel = this.add.text(0, y, text, { + font: FONTS.buttonSmall, fill: COLORS.textPrimary, wordWrap: { width: 180 }, + }).setOrigin(0.5, 0.5); + btnBg.setInteractive({ useHandCursor: true }) + .on('pointerover', () => btnBg.fillColor = COLORS.btnHover) + .on('pointerout', () => btnBg.fillColor = COLORS.btnDefault) + .on('pointerdown', onClick); + return [btnBg, btnLabel]; + }; + + const resumeEls = makeMenuBtn(-40, getText('resume'), () => this.togglePause()); + const restartEls = makeMenuBtn(20, getText('restartLevel'), () => { + this.togglePause(); + this.levelManager.loadLevel(this.currentLevel); }); - title.setOrigin(0.5, 0.5); + const menuEls = makeMenuBtn(80, getText('mainMenu'), () => this.scene.start('MainMenu')); - // Resume button - const resumeButton = this.add.rectangle(0, -40, 200, 40, 0x444444); - const resumeText = this.add.text(0, -40, getText('resume'), { - font: '20px Arial', - fill: '#ffffff', - wordWrap: { width: 180 } // Enable text wrapping - }); - resumeText.setOrigin(0.5, 0.5); - - // Restart button - const restartButton = this.add.rectangle(0, 20, 200, 40, 0x444444); - const restartText = this.add.text(0, 20, getText('restartLevel'), { - font: '20px Arial', - fill: '#ffffff', - wordWrap: { width: 180 } // Enable text wrapping - }); - restartText.setOrigin(0.5, 0.5); - - // Main menu button - const menuButton = this.add.rectangle(0, 80, 200, 40, 0x444444); - const menuText = this.add.text(0, 80, getText('mainMenu'), { - font: '20px Arial', - fill: '#ffffff', - wordWrap: { width: 180 } // Enable text wrapping - }); - menuText.setOrigin(0.5, 0.5); - - // Add all elements to container - this.pauseMenu.add([bg, title, resumeButton, resumeText, restartButton, restartText, menuButton, menuText]); - - // Make buttons interactive - resumeButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => resumeButton.fillColor = 0x666666) - .on('pointerout', () => resumeButton.fillColor = 0x444444) - .on('pointerdown', () => { - this.togglePause(); - }); - - restartButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => restartButton.fillColor = 0x666666) - .on('pointerout', () => restartButton.fillColor = 0x444444) - .on('pointerdown', () => { - this.togglePause(); - this.levelManager.loadLevel(this.currentLevel); - }); - - menuButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => menuButton.fillColor = 0x666666) - .on('pointerout', () => menuButton.fillColor = 0x444444) - .on('pointerdown', () => { - this.scene.start('MainMenu'); - }); - - // Hide pause menu initially + this.pauseMenu.add([bg, border, title, ...resumeEls, ...restartEls, ...menuEls]); this.pauseMenu.setVisible(false); } @@ -393,6 +331,12 @@ export class Game extends Phaser.Scene { this.inputEnabled = !this.isPaused; } + updateTurnDisplay() { + if (this.turnText) { + this.turnText.setText(`Turns: ${this.turnManager.turnCount}`); + } + } + // Helper method to convert grid coordinates to screen coordinates @@ -409,80 +353,47 @@ export class Game extends Phaser.Scene { return this.grid.pixelToGrid(x - this.gridOffsetX, y - this.gridOffsetY); } - // Hiển thị popup khi người chơi bị phát hiện showDetectionPopup() { - // Kiểm tra xem popup đã tồn tại chưa, nếu có thì không tạo mới - if (this.detectionPopup) { - return; // Đã có popup, không tạo thêm - } + if (this.detectionPopup) return; const width = this.cameras.main.width; const height = this.cameras.main.height; - // Tạm dừng game this.isPaused = true; this.inputEnabled = false; - // Tạo container cho popup this.detectionPopup = this.add.container(width / 2, height / 2); this.detectionPopup.setDepth(100); - this.detectionPopup.setScrollFactor(0); // Fix to camera (don't move with world) - this.detectionPopup.popup = 'detection'; // Đánh dấu container + this.detectionPopup.setScrollFactor(0); - // Background - const bg = this.add.rectangle(0, 0, 300, 200, 0x000000, 0.8); - bg.popup = 'detection'; // Đánh dấu phần tử thuộc về popup - - // Thông báo + const bg = this.add.rectangle(0, 0, 300, 200, COLORS.bgOverlay, 0.85); const title = this.add.text(0, -60, getText('detected'), { - font: 'bold 28px Arial', - fill: '#FF0000', - wordWrap: { width: 280 } // Enable text wrapping - }); - title.setOrigin(0.5, 0.5); - title.popup = 'detection'; // Đánh dấu phần tử thuộc về popup + font: FONTS.heading, fill: COLORS.textDanger, wordWrap: { width: 280 }, + }).setOrigin(0.5, 0.5); - // Nút chơi lại - const restartButton = this.add.rectangle(0, 20, 200, 40, 0x444444); - restartButton.popup = 'detection'; // Đánh dấu phần tử thuộc về popup + const btnBg = this.add.rectangle(0, 20, 200, 40, COLORS.btnDefault); + const btnLabel = this.add.text(0, 20, getText('playAgain'), { + font: FONTS.buttonSmall, fill: COLORS.textPrimary, wordWrap: { width: 180 }, + }).setOrigin(0.5, 0.5); - const restartText = this.add.text(0, 20, getText('playAgain'), { - font: '20px Arial', - fill: '#ffffff', - wordWrap: { width: 180 } // Enable text wrapping - }); - restartText.setOrigin(0.5, 0.5); - restartText.popup = 'detection'; // Đánh dấu phần tử thuộc về popup + this.detectionPopup.add([bg, title, btnBg, btnLabel]); - // Thêm các phần tử vào container - this.detectionPopup.add([bg, title, restartButton, restartText]); - - // Làm cho nút có thể tương tác - restartButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => restartButton.fillColor = 0x666666) - .on('pointerout', () => restartButton.fillColor = 0x444444) + btnBg.setInteractive({ useHandCursor: true }) + .on('pointerover', () => btnBg.fillColor = COLORS.btnHover) + .on('pointerout', () => btnBg.fillColor = COLORS.btnDefault) .on('pointerdown', () => { this.closeDetectionPopup(); this.handlePlayerCaught(); }); } - // Đóng popup phát hiện closeDetectionPopup() { if (this.detectionPopup) { - // Đảm bảo tất cả các phần tử con cũng được hủy this.detectionPopup.removeAll(true); this.detectionPopup.destroy(); this.detectionPopup = null; this.isPaused = false; this.inputEnabled = true; - - // Đảm bảo rằng tất cả các phần tử liên quan đến popup đã được xóa - this.children.list.forEach(child => { - if (child.popup && child.popup === 'detection') { - child.destroy(); - } - }); } } @@ -512,30 +423,29 @@ export class Game extends Phaser.Scene { } } - // Xử lý khi người chơi hoàn thành màn chơi handleLevelComplete() { + const totalLevels = this.levelManager.getTotalLevels(); + completeLevel(this.currentLevel, totalLevels); + + this.isPaused = true; + this.inputEnabled = false; + const nextLevel = this.currentLevel + 1; - // Kiểm tra xem có phải màn cuối không - if (this.isFinalLevel) { - // Đây là màn cuối với cái kết đặc biệt - this.scene.start('GameOver', { - level: this.currentLevel, - isLastLevel: true + // Brief flash celebration then transition + this.cameras.main.flash(400, 0, 200, 100); + this.time.delayedCall(600, () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + if (this.isFinalLevel) { + this.scene.start('GameOver', { level: this.currentLevel, isLastLevel: true }); + } else if (nextLevel > totalLevels) { + this.scene.start('GameOver', { level: this.currentLevel, isLastLevel: false }); + } else { + this.scene.start('LevelIntro', { level: nextLevel, lives: this.livesRemaining }); + } }); - } else if (nextLevel > this.levelManager.getTotalLevels()) { - // Đã hoàn thành tất cả các màn - this.scene.start('GameOver', { - level: this.currentLevel, - isLastLevel: false - }); - } else { - // Chuyển đến màn tiếp theo - this.scene.start('Game', { - level: nextLevel, - lives: this.livesRemaining - }); - } + }); } // Kiểm tra điều kiện đặc biệt cho màn cuối @@ -570,11 +480,11 @@ export class Game extends Phaser.Scene { if (!this.finalLevelMessage) { this.finalLevelMessage = this.add.text(width / 2, height / 4, getText('princessDetected'), { - font: 'bold 20px Arial', - fill: '#FF0000', + font: FONTS.body, + fill: COLORS.textDanger, backgroundColor: '#000000', padding: { x: 10, y: 5 }, - wordWrap: { width: width * 0.8 } // Enable text wrapping with 80% of screen width + wordWrap: { width: width * 0.8 }, }); this.finalLevelMessage.setOrigin(0.5, 0.5); this.finalLevelMessage.setDepth(100); diff --git a/src/game/scenes/GameOver.js b/src/game/scenes/GameOver.js index 7b42975..4f82c94 100644 --- a/src/game/scenes/GameOver.js +++ b/src/game/scenes/GameOver.js @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { getText } from '../localization'; +import { COLORS, FONTS, createButton } from '../theme'; export class GameOver extends Phaser.Scene { constructor() { @@ -15,67 +16,44 @@ export class GameOver extends Phaser.Scene { const width = this.cameras.main.width; const height = this.cameras.main.height; - // Different message based on whether this is the final level or not - let message = getText('gameOver'); - let description = getText('caughtInLight'); + this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark); - if (this.isLastLevel) { - message = getText('theEnd'); - description = getText('ninjaFailed'); - } + const isEnd = this.isLastLevel; + const titleText = isEnd ? getText('theEnd') : getText('gameOver'); + const descText = isEnd ? getText('ninjaFailed') : getText('caughtInLight'); - // Add game over title - const title = this.add.text(width / 2, height / 3, message, { + this.add.text(width / 2, height / 3, titleText, { font: 'bold 48px Arial', - fill: '#ffffff', - align: 'center' - }); - title.setOrigin(0.5, 0.5); + fill: isEnd ? COLORS.textAccent : COLORS.textDanger, + align: 'center', + wordWrap: { width: width * 0.8 }, + }).setOrigin(0.5, 0.5); - // Add description - const descText = this.add.text(width / 2, height / 2, description, { - font: '24px Arial', - fill: '#cccccc', - align: 'center' - }); - descText.setOrigin(0.5, 0.5); + this.add.text(width / 2, height / 2, descText, { + font: FONTS.body, + fill: COLORS.textSecondary, + align: 'center', + wordWrap: { width: width * 0.7 }, + }).setOrigin(0.5, 0.5); - // Add restart button - const restartButton = this.add.rectangle(width / 2, height * 0.7, 200, 50, 0x444444); - const restartText = this.add.text(width / 2, height * 0.7, getText('tryAgain'), { - font: '24px Arial', - fill: '#ffffff' - }); - restartText.setOrigin(0.5, 0.5); - - // Make button interactive - restartButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => restartButton.fillColor = 0x666666) - .on('pointerout', () => restartButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, height * 0.7, getText('tryAgain'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { if (this.isLastLevel) { - // If it was the last level, go back to main menu this.scene.start('MainMenu'); } else { - // Otherwise restart the current level this.scene.start('Game', { level: this.finalLevel, lives: 3 }); } }); - - // Add main menu button - const menuButton = this.add.rectangle(width / 2, height * 0.8, 200, 50, 0x444444); - const menuText = this.add.text(width / 2, height * 0.8, getText('mainMenu'), { - font: '24px Arial', - fill: '#ffffff' }); - menuText.setOrigin(0.5, 0.5); - // Make button interactive - menuButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => menuButton.fillColor = 0x666666) - .on('pointerout', () => menuButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, height * 0.8, getText('mainMenu'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('MainMenu'); }); + }); + + this.cameras.main.fadeIn(400, 0, 0, 0); } } diff --git a/src/game/scenes/Guide.js b/src/game/scenes/Guide.js index 0a95045..beb9001 100644 --- a/src/game/scenes/Guide.js +++ b/src/game/scenes/Guide.js @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { getText } from '../localization'; +import { COLORS, FONTS, createButton } from '../theme'; export class Guide extends Phaser.Scene { constructor() { @@ -10,129 +11,76 @@ export class Guide extends Phaser.Scene { const width = this.cameras.main.width; const height = this.cameras.main.height; - // Vùng hiển thị hướng dẫn (không bao gồm nút Back) + this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark); + + // Scrollable guide area const guideAreaY = 20; const guideAreaHeight = height - 120; - // Tạo một container cho toàn bộ nội dung hướng dẫn const guideContainer = this.add.container(0, 0); - // Tạo các text và thêm vào container let y = guideAreaY; const spacing = 20; - // Title const title = this.add.text(width / 2, y + 30, getText('guideTitle'), { - font: 'bold 32px Arial', - fill: '#ffffff', - align: 'center' - }); - title.setOrigin(0.5, 0.5); + font: FONTS.title, + fill: COLORS.textTitle, + align: 'center', + }).setOrigin(0.5, 0.5); guideContainer.add(title); y += 60; - // Level objectives - const objectivesTitle = this.add.text(width / 2, y, getText('levelObjectives'), { - font: 'bold 24px Arial', - fill: '#ffffff', - align: 'center' - }); - objectivesTitle.setOrigin(0.5, 0.5); - guideContainer.add(objectivesTitle); - y += 40; + // Helper to add a section + const addSection = (titleKey, contentKey) => { + const sTitle = this.add.text(width / 2, y, getText(titleKey), { + font: FONTS.heading, + fill: COLORS.textAccent, + align: 'center', + }).setOrigin(0.5, 0.5); + guideContainer.add(sTitle); + y += 40; - const objectivesContent = this.add.text(width / 2, y, getText('levelObjectivesContent'), { - font: '18px Arial', - fill: '#cccccc', - align: 'left', - wordWrap: { width: width - 100 } - }); - objectivesContent.setOrigin(0.5, 0); - guideContainer.add(objectivesContent); - y += objectivesContent.height + spacing; + const sContent = this.add.text(width / 2, y, getText(contentKey), { + font: FONTS.small, + fill: COLORS.textSecondary, + align: 'left', + wordWrap: { width: width - 100 }, + }).setOrigin(0.5, 0); + guideContainer.add(sContent); + y += sContent.height + spacing; + }; - // Movement controls - const controlsTitle = this.add.text(width / 2, y, getText('movementControls'), { - font: 'bold 24px Arial', - fill: '#ffffff', - align: 'center' - }); - controlsTitle.setOrigin(0.5, 0.5); - guideContainer.add(controlsTitle); - y += 40; + addSection('levelObjectives', 'levelObjectivesContent'); + addSection('movementControls', 'movementControlsContent'); + addSection('enemyTypes', 'enemyTypesContent'); - const controlsContent = this.add.text(width / 2, y, getText('movementControlsContent'), { - font: '18px Arial', - fill: '#cccccc', - align: 'left', - wordWrap: { width: width - 100 } - }); - controlsContent.setOrigin(0.5, 0); - guideContainer.add(controlsContent); - y += controlsContent.height + spacing; - - // Enemy types - const enemiesTitle = this.add.text(width / 2, y, getText('enemyTypes'), { - font: 'bold 24px Arial', - fill: '#ffffff', - align: 'center' - }); - enemiesTitle.setOrigin(0.5, 0.5); - guideContainer.add(enemiesTitle); - y += 40; - - const enemiesContent = this.add.text(width / 2, y, getText('enemyTypesContent'), { - font: '18px Arial', - fill: '#cccccc', - align: 'left', - wordWrap: { width: width - 100 } - }); - enemiesContent.setOrigin(0.5, 0); - guideContainer.add(enemiesContent); - y += enemiesContent.height + spacing; - - // Vị trí ban đầu của container - guideContainer.y = guideAreaY; - - // Thêm vùng che (mask) để chỉ hiển thị trong vùng hướng dẫn + // Mask for scroll area const maskShape = this.make.graphics({ x: 0, y: 0, add: false }); maskShape.fillStyle(0xffffff); maskShape.fillRect(0, guideAreaY, width, guideAreaHeight); - const mask = maskShape.createGeometryMask(); - guideContainer.setMask(mask); + guideContainer.setMask(maskShape.createGeometryMask()); - // Logic cuộn: bằng con lăn chuột hoặc phím mũi tên - this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY) => { - guideContainer.y -= deltaY * 0.5; - // Giới hạn cuộn + // Scroll with mouse wheel and keyboard + const maxScrollUp = guideAreaY - (y - guideAreaHeight); + this.input.on('wheel', (_p, _go, _dx, deltaY) => { guideContainer.y = Phaser.Math.Clamp( - guideContainer.y, - guideAreaY - (y - guideAreaHeight), // tối đa cuộn lên - guideAreaY // tối đa cuộn xuống + guideContainer.y - deltaY * 0.5, maxScrollUp, guideAreaY ); }); - this.input.keyboard.on('keydown-UP', () => { - guideContainer.y = Phaser.Math.Clamp(guideContainer.y + 30, guideAreaY - (y - guideAreaHeight), guideAreaY); + guideContainer.y = Phaser.Math.Clamp(guideContainer.y + 30, maxScrollUp, guideAreaY); }); this.input.keyboard.on('keydown-DOWN', () => { - guideContainer.y = Phaser.Math.Clamp(guideContainer.y - 30, guideAreaY - (y - guideAreaHeight), guideAreaY); + guideContainer.y = Phaser.Math.Clamp(guideContainer.y - 30, maxScrollUp, guideAreaY); }); - // Add back button - const backButton = this.add.rectangle(width / 2, height - 50, 200, 50, 0x444444); - const backText = this.add.text(width / 2, height - 50, getText('back'), { - font: '24px Arial', - fill: '#ffffff' - }); - backText.setOrigin(0.5, 0.5); - - // Make back button interactive - backButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => backButton.fillColor = 0x666666) - .on('pointerout', () => backButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, height - 50, getText('back'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('MainMenu'); }); + }); + + this.cameras.main.fadeIn(400, 0, 0, 0); } } diff --git a/src/game/scenes/LevelIntro.js b/src/game/scenes/LevelIntro.js new file mode 100644 index 0000000..87a3325 --- /dev/null +++ b/src/game/scenes/LevelIntro.js @@ -0,0 +1,61 @@ +import Phaser from 'phaser'; +import { getText } from '../localization'; +import { COLORS, FONTS, createButton } from '../theme'; +import { LEVELS } from '../levels/Levels'; + +export class LevelIntro extends Phaser.Scene { + constructor() { + super('LevelIntro'); + } + + init(data) { + this.level = data.level || 1; + this.lives = data.lives || 3; + } + + create() { + const width = this.cameras.main.width; + const height = this.cameras.main.height; + + this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark); + + const levelData = LEVELS[this.level - 1]; + const levelName = levelData ? levelData.name : `Level ${this.level}`; + const storyKey = levelData ? levelData.storyKey : null; + const storyText = storyKey ? getText(storyKey) : ''; + + // Level number + this.add.text(width / 2, height / 4, `${getText('level')}${this.level}`, { + font: FONTS.small, + fill: COLORS.textSecondary, + }).setOrigin(0.5, 0.5); + + // Level name + this.add.text(width / 2, height / 4 + 40, levelName, { + font: FONTS.title, + fill: COLORS.textTitle, + align: 'center', + }).setOrigin(0.5, 0.5); + + // Story text + if (storyText) { + this.add.text(width / 2, height / 2, storyText, { + font: FONTS.body, + fill: COLORS.textPrimary, + align: 'center', + wordWrap: { width: width * 0.7 }, + lineSpacing: 6, + }).setOrigin(0.5, 0.5); + } + + // Continue button + createButton(this, width / 2, height * 0.78, getText('continue'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('Game', { level: this.level, lives: this.lives }); + }); + }); + + this.cameras.main.fadeIn(400, 0, 0, 0); + } +} diff --git a/src/game/scenes/LevelSelect.js b/src/game/scenes/LevelSelect.js index b33945b..b4d6512 100644 --- a/src/game/scenes/LevelSelect.js +++ b/src/game/scenes/LevelSelect.js @@ -1,5 +1,7 @@ import Phaser from 'phaser'; import { getText } from '../localization'; +import { COLORS, FONTS, createButton } from '../theme'; +import { getProgress } from '../progress'; export class LevelSelect extends Phaser.Scene { constructor() { @@ -10,60 +12,73 @@ export class LevelSelect extends Phaser.Scene { create() { const width = this.cameras.main.width; const height = this.cameras.main.height; + const progress = getProgress(); - // Add title - const title = this.add.text(width / 2, 50, getText('levelSelectTitle'), { - font: 'bold 32px Arial', - fill: '#ffffff', - align: 'center' - }); - title.setOrigin(0.5, 0.5); + this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark); - // Create level buttons + this.add.text(width / 2, 50, getText('levelSelectTitle'), { + font: FONTS.title, + fill: COLORS.textTitle, + align: 'center', + }).setOrigin(0.5, 0.5); + + // Level grid const buttonSize = 70; const padding = 20; const buttonsPerRow = 4; const startX = width / 2 - ((buttonsPerRow - 1) * (buttonSize + padding)) / 2; - const startY = 150; + const startY = 140; for (let i = 0; i < this.totalLevels; i++) { const row = Math.floor(i / buttonsPerRow); const col = i % buttonsPerRow; - const x = startX + col * (buttonSize + padding); const y = startY + row * (buttonSize + padding); + const levelNum = i + 1; + const isUnlocked = levelNum <= progress.maxLevel; + const isCompleted = progress.completedLevels.includes(levelNum); - // Create button - const levelButton = this.add.rectangle(x, y, buttonSize, buttonSize, 0x444444); - const levelText = this.add.text(x, y, `${i + 1}`, { - font: '24px Arial', - fill: '#ffffff' - }); - levelText.setOrigin(0.5, 0.5); + // Border (green for completed, purple for unlocked, dim for locked) + const borderColor = isCompleted ? COLORS.gridGoal : isUnlocked ? COLORS.btnBorder : 0x333344; + this.add.rectangle(x, y, buttonSize + 4, buttonSize + 4, borderColor); - // Make button interactive - levelButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => levelButton.fillColor = 0x666666) - .on('pointerout', () => levelButton.fillColor = 0x444444) - .on('pointerdown', () => { - this.scene.start('Game', { level: i + 1, lives: 3 }); - }); + const bgColor = isUnlocked ? COLORS.btnDefault : 0x111122; + const btn = this.add.rectangle(x, y, buttonSize, buttonSize, bgColor); + + const textColor = isUnlocked ? COLORS.textPrimary : '#555566'; + const label = this.add.text(x, y, `${levelNum}`, { + font: FONTS.button, + fill: textColor, + }).setOrigin(0.5, 0.5); + + // Completed checkmark + if (isCompleted) { + this.add.text(x + 22, y - 22, '\u2713', { + font: '14px Arial', + fill: '#00c853', + }).setOrigin(0.5, 0.5); + } + + if (isUnlocked) { + btn.setInteractive({ useHandCursor: true }) + .on('pointerover', () => btn.fillColor = COLORS.btnHover) + .on('pointerout', () => btn.fillColor = COLORS.btnDefault) + .on('pointerdown', () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('LevelIntro', { level: levelNum, lives: 3 }); + }); + }); + } } - // Add back button - const backButton = this.add.rectangle(width / 2, height - 70, 200, 50, 0x444444); - const backText = this.add.text(width / 2, height - 70, getText('back'), { - font: '20px Arial', - fill: '#ffffff' - }); - backText.setOrigin(0.5, 0.5); - - // Make back button interactive - backButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => backButton.fillColor = 0x666666) - .on('pointerout', () => backButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, height - 70, getText('back'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('MainMenu'); }); + }); + + this.cameras.main.fadeIn(400, 0, 0, 0); } } diff --git a/src/game/scenes/MainMenu.js b/src/game/scenes/MainMenu.js index af0e01d..db456fb 100644 --- a/src/game/scenes/MainMenu.js +++ b/src/game/scenes/MainMenu.js @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { getText } from '../localization'; +import { COLORS, FONTS, createButton } from '../theme'; export class MainMenu extends Phaser.Scene { constructor() { @@ -10,78 +11,63 @@ export class MainMenu extends Phaser.Scene { const width = this.cameras.main.width; const height = this.cameras.main.height; - // Add game title - const title = this.add.text(width / 2, height / 4, getText('gameTitle'), { - font: 'bold 32px Arial', - fill: '#ffffff', - align: 'center' + // Dark background + this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark); + + // Subtle starfield decoration + for (let i = 0; i < 40; i++) { + const sx = Phaser.Math.Between(0, width); + const sy = Phaser.Math.Between(0, height); + const alpha = Phaser.Math.FloatBetween(0.1, 0.5); + const size = Phaser.Math.Between(1, 2); + this.add.circle(sx, sy, size, 0xffffff, alpha); + } + + // Title + const title = this.add.text(width / 2, height / 5, getText('gameTitle'), { + font: FONTS.title, + fill: COLORS.textTitle, + align: 'center', }); title.setOrigin(0.5, 0.5); - // Add ninja icon - const ninjaCircle = this.add.circle(width / 2, height / 2 - 50, 30, 0x000000); + // Ninja icon with glow + this.add.circle(width / 2, height / 5 + 70, 24, COLORS.btnBorder, 0.3); + this.add.circle(width / 2, height / 5 + 70, 18, COLORS.player); - // Add start button - const startButton = this.add.rectangle(width / 2, height / 2 + 20, 200, 50, 0x444444); - const startText = this.add.text(width / 2, height / 2 + 20, getText('startGame'), { - font: '24px Arial', - fill: '#ffffff' - }); - startText.setOrigin(0.5, 0.5); + // Menu buttons + const btnY = height / 2 + 20; + const gap = 62; - // Add level select button - const levelButton = this.add.rectangle(width / 2, height / 2 + 80, 200, 50, 0x444444); - const levelText = this.add.text(width / 2, height / 2 + 80, getText('levelSelect'), { - font: '24px Arial', - fill: '#ffffff' - }); - levelText.setOrigin(0.5, 0.5); - - // Add guide button - const guideButton = this.add.rectangle(width / 2, height / 2 + 140, 200, 50, 0x444444); - const guideText = this.add.text(width / 2, height / 2 + 140, getText('guide'), { - font: '24px Arial', - fill: '#ffffff' - }); - guideText.setOrigin(0.5, 0.5); - - // Add settings button - const settingsButton = this.add.rectangle(width / 2, height / 2 + 200, 200, 50, 0x444444); - const settingsText = this.add.text(width / 2, height / 2 + 200, getText('settings'), { - font: '24px Arial', - fill: '#ffffff' - }); - settingsText.setOrigin(0.5, 0.5); - - // Make buttons interactive - startButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => startButton.fillColor = 0x666666) - .on('pointerout', () => startButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, btnY, getText('startGame'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('StoryIntro'); }); + }); - levelButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => levelButton.fillColor = 0x666666) - .on('pointerout', () => levelButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, btnY + gap, getText('levelSelect'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('LevelSelect'); }); + }); - guideButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => guideButton.fillColor = 0x666666) - .on('pointerout', () => guideButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, btnY + gap * 2, getText('guide'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('Guide'); }); + }); - settingsButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => settingsButton.fillColor = 0x666666) - .on('pointerout', () => settingsButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, btnY + gap * 3, getText('settings'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('Settings'); }); + }); - + // Fade in + this.cameras.main.fadeIn(400, 0, 0, 0); } } diff --git a/src/game/scenes/Preloader.js b/src/game/scenes/Preloader.js index 9a77214..cd56b22 100644 --- a/src/game/scenes/Preloader.js +++ b/src/game/scenes/Preloader.js @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import { COLORS } from '../theme'; export class Preloader extends Phaser.Scene { constructor() { @@ -6,36 +7,33 @@ export class Preloader extends Phaser.Scene { } preload() { - // Create loading bar const width = this.cameras.main.width; const height = this.cameras.main.height; - - const progressBar = this.add.graphics(); + + this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark); + const progressBox = this.add.graphics(); - progressBox.fillStyle(0x222222, 0.8); + progressBox.fillStyle(COLORS.bgPanel, 0.8); progressBox.fillRect(width / 4, height / 2 - 30, width / 2, 50); - + + const progressBar = this.add.graphics(); + const loadingText = this.add.text(width / 2, height / 2 - 50, 'Loading...', { font: '20px Arial', - fill: '#ffffff' - }); - loadingText.setOrigin(0.5, 0.5); - - // Update progress bar as assets load + fill: COLORS.textPrimary, + }).setOrigin(0.5, 0.5); + this.load.on('progress', (value) => { progressBar.clear(); - progressBar.fillStyle(0xffffff, 1); + progressBar.fillStyle(COLORS.btnBorder, 1); progressBar.fillRect(width / 4 + 10, height / 2 - 20, (width / 2 - 20) * value, 30); }); - + this.load.on('complete', () => { progressBar.destroy(); progressBox.destroy(); loadingText.destroy(); }); - - // Load any game assets here - // For this game, we're using simple shapes, so no image assets needed } create() { diff --git a/src/game/scenes/Settings.js b/src/game/scenes/Settings.js index 0fe3069..821286b 100644 --- a/src/game/scenes/Settings.js +++ b/src/game/scenes/Settings.js @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { getText, setLanguage, getLanguage } from '../localization'; +import { COLORS, FONTS, createButton } from '../theme'; export class Settings extends Phaser.Scene { constructor() { @@ -10,110 +11,46 @@ export class Settings extends Phaser.Scene { const width = this.cameras.main.width; const height = this.cameras.main.height; - // Add settings title + this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark); + const title = this.add.text(width / 2, height / 4, getText('settings'), { - font: 'bold 32px Arial', - fill: '#ffffff', - align: 'center' + font: FONTS.title, + fill: COLORS.textTitle, + align: 'center', }); title.setOrigin(0.5, 0.5); - // Add language settings label - const languageLabel = this.add.text(width / 2, height / 2 - 50, getText('languageSettings'), { - font: '24px Arial', - fill: '#ffffff' - }); - languageLabel.setOrigin(0.5, 0.5); + this.add.text(width / 2, height / 2 - 50, getText('languageSettings'), { + font: FONTS.body, + fill: COLORS.textSecondary, + }).setOrigin(0.5, 0.5); - // Add language options const currentLanguage = getLanguage(); - // English button - const englishButton = this.add.rectangle(width / 2 - 80, height / 2, 150, 50, - currentLanguage === 'en' ? 0x666666 : 0x444444); - const englishText = this.add.text(width / 2 - 80, height / 2, getText('english'), { - font: '20px Arial', - fill: '#ffffff' - }); - englishText.setOrigin(0.5, 0.5); + const enBtn = createButton(this, width / 2 - 120, height / 2 + 10, getText('english'), () => { + this.changeLanguage('en'); + }, 200, 46); + if (currentLanguage === 'en') enBtn.bg.fillColor = COLORS.btnHover; - // Vietnamese button - const vietnameseButton = this.add.rectangle(width / 2 + 80, height / 2, 150, 50, - currentLanguage === 'vi' ? 0x666666 : 0x444444); - const vietnameseText = this.add.text(width / 2 + 80, height / 2, getText('vietnamese'), { - font: '20px Arial', - fill: '#ffffff' - }); - vietnameseText.setOrigin(0.5, 0.5); + const viBtn = createButton(this, width / 2 + 120, height / 2 + 10, getText('vietnamese'), () => { + this.changeLanguage('vi'); + }, 200, 46); + if (currentLanguage === 'vi') viBtn.bg.fillColor = COLORS.btnHover; - // Back button - const backButton = this.add.rectangle(width / 2, height / 2 + 100, 200, 50, 0x444444); - const backText = this.add.text(width / 2, height / 2 + 100, getText('back'), { - font: '24px Arial', - fill: '#ffffff' - }); - backText.setOrigin(0.5, 0.5); - - // Make buttons interactive - englishButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => { - if (currentLanguage !== 'en') englishButton.fillColor = 0x555555; - }) - .on('pointerout', () => { - if (currentLanguage !== 'en') englishButton.fillColor = 0x444444; - }) - .on('pointerdown', () => { - this.changeLanguage('en'); - }); - - vietnameseButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => { - if (currentLanguage !== 'vi') vietnameseButton.fillColor = 0x555555; - }) - .on('pointerout', () => { - if (currentLanguage !== 'vi') vietnameseButton.fillColor = 0x444444; - }) - .on('pointerdown', () => { - this.changeLanguage('vi'); - }); - - backButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => backButton.fillColor = 0x666666) - .on('pointerout', () => backButton.fillColor = 0x444444) - .on('pointerdown', () => { + createButton(this, width / 2, height / 2 + 100, getText('back'), () => { + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('MainMenu'); }); + }); - // Store UI elements that need to be updated when language changes - this.uiElements = { - title, - languageLabel, - englishText, - vietnameseText, - backText, - englishButton, - vietnameseButton - }; + this.cameras.main.fadeIn(400, 0, 0, 0); } changeLanguage(language) { - // Only update if the language actually changed if (getLanguage() !== language) { setLanguage(language); - this.updateUIText(); - - // Update button colors - this.uiElements.englishButton.fillColor = language === 'en' ? 0x666666 : 0x444444; - this.uiElements.vietnameseButton.fillColor = language === 'vi' ? 0x666666 : 0x444444; + this.scene.restart(); } } - - updateUIText() { - // Update all text elements with the new language - this.uiElements.title.setText(getText('settings')); - this.uiElements.languageLabel.setText(getText('languageSettings')); - this.uiElements.englishText.setText(getText('english')); - this.uiElements.vietnameseText.setText(getText('vietnamese')); - this.uiElements.backText.setText(getText('back')); - } } diff --git a/src/game/scenes/StoryIntro.js b/src/game/scenes/StoryIntro.js index b7d6b60..2aaee74 100644 --- a/src/game/scenes/StoryIntro.js +++ b/src/game/scenes/StoryIntro.js @@ -1,83 +1,58 @@ import Phaser from 'phaser'; import { getText } from '../localization'; +import { COLORS, FONTS, createButton } from '../theme'; export class StoryIntro extends Phaser.Scene { constructor() { super('StoryIntro'); - this.scrollSpeed = 1.5; // Pixels per frame - increased for smoother scrolling + this.scrollSpeed = 1.5; this.isScrollComplete = false; } create() { const width = this.cameras.main.width; const height = this.cameras.main.height; + this.isScrollComplete = false; + this.hasStartedGame = false; - // Create a dark background - this.add.rectangle(0, 0, width, height, 0x000000).setOrigin(0, 0); + this.add.rectangle(0, 0, width, height, COLORS.bgDark).setOrigin(0, 0); - // Add title at the top - const title = this.add.text(width / 2, 100, getText('storyTitle'), { - font: 'bold 32px Arial', - fill: '#ffffff', - align: 'center' - }); - title.setOrigin(0.5, 0.5); - - // Create a container for the scrolling text - this.storyContainer = this.add.container(0, 0); - - // Add the story text - const storyText = this.add.text(width / 2, 0, getText('storyText'), { - font: '20px Arial', - fill: '#ffffff', + this.add.text(width / 2, 100, getText('storyTitle'), { + font: FONTS.title, + fill: COLORS.textAccent, align: 'center', - wordWrap: { width: width * 0.8 } - }); - storyText.setOrigin(0.5, 0); + }).setOrigin(0.5, 0.5); - // Add the text to the container + this.storyContainer = this.add.container(0, 0); + const storyText = this.add.text(width / 2, 0, getText('storyText'), { + font: FONTS.body, + fill: COLORS.textPrimary, + align: 'center', + wordWrap: { width: width * 0.8 }, + }).setOrigin(0.5, 0); this.storyContainer.add(storyText); - - // Position the text to start at the bottom of the screen (but visible) - // This ensures it starts scrolling immediately this.storyContainer.y = height - 50; - - // Calculate the total height of the text this.totalTextHeight = storyText.height; - // Add skip button - const skipButton = this.add.rectangle(width - 100, height - 50, 150, 40, 0x444444); - const skipText = this.add.text(width - 100, height - 50, getText('skip'), { - font: '20px Arial', - fill: '#ffffff' - }); - skipText.setOrigin(0.5, 0.5); + // Skip button + createButton(this, width - 100, height - 50, getText('skip'), () => { + this.startGame(); + }, 150, 40); - // Make skip button interactive - skipButton.setInteractive({ useHandCursor: true }) - .on('pointerover', () => skipButton.fillColor = 0x666666) - .on('pointerout', () => skipButton.fillColor = 0x444444) - .on('pointerdown', () => { - this.startGame(); - }); - - // Set a timer to start the game after the text has scrolled completely - // Calculate how long it should take based on text height and scroll speed const scrollTime = (this.totalTextHeight + height) / this.scrollSpeed; - this.time.delayedCall(scrollTime * 16.67, () => { // Convert frames to ms (60fps = 16.67ms per frame) + this.time.delayedCall(scrollTime * 16.67, () => { if (!this.isScrollComplete) { this.isScrollComplete = true; this.startGame(); } }); + + this.cameras.main.fadeIn(400, 0, 0, 0); } update() { - // Scroll the text container upward if (!this.isScrollComplete) { this.storyContainer.y -= this.scrollSpeed; - - // Check if the text has scrolled completely off the screen if (this.storyContainer.y < -this.totalTextHeight) { this.isScrollComplete = true; this.startGame(); @@ -86,10 +61,12 @@ export class StoryIntro extends Phaser.Scene { } startGame() { - // Only start the game once if (!this.hasStartedGame) { this.hasStartedGame = true; - this.scene.start('Game', { level: 1, lives: 3 }); + this.cameras.main.fadeOut(300, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('LevelIntro', { level: 1, lives: 3 }); + }); } } } diff --git a/src/game/theme.js b/src/game/theme.js new file mode 100644 index 0000000..cd08ea5 --- /dev/null +++ b/src/game/theme.js @@ -0,0 +1,70 @@ +// Shared visual theme constants for Night Ninja: Twilight Voyage + +export const COLORS = { + // Backgrounds + bgDark: 0x0a0a1a, + bgPanel: 0x1a1a2e, + bgOverlay: 0x000000, + + // Buttons + btnDefault: 0x16213e, + btnHover: 0x0f3460, + btnBorder: 0x533483, + + // Grid + gridEmpty: 0x1a1a2e, + gridWall: 0x4a4a5e, + gridGoal: 0x00c853, + gridLit: 0xffea00, + gridBorder: 0x2a2a3e, + + // Text + textPrimary: '#e0e0ff', + textSecondary: '#9999bb', + textAccent: '#bb86fc', + textDanger: '#ff5555', + textTitle: '#ffffff', + + // Guards + guardStatic: 0xff4444, + guardRotating: 0x4488ff, + guardBlinking: 0xffdd44, + guardBlinkingOff: 0x887722, + guardPatrolling: 0xbb44ff, + + // Player + player: 0x111111, +}; + +export const FONTS = { + title: 'bold 36px Arial', + heading: 'bold 28px Arial', + button: '22px Arial', + buttonSmall: '18px Arial', + body: '20px Arial', + small: '16px Arial', + ui: '18px Arial', +}; + +// Create a styled button with consistent look +export function createButton(scene, x, y, text, onClick, width = 220, height = 50) { + const border = scene.add.rectangle(x, y, width + 4, height + 4, COLORS.btnBorder); + const bg = scene.add.rectangle(x, y, width, height, COLORS.btnDefault); + const label = scene.add.text(x, y, text, { + font: FONTS.button, + fill: COLORS.textPrimary, + }); + label.setOrigin(0.5, 0.5); + + bg.setInteractive({ useHandCursor: true }) + .on('pointerover', () => bg.fillColor = COLORS.btnHover) + .on('pointerout', () => bg.fillColor = COLORS.btnDefault) + .on('pointerdown', onClick); + + return { border, bg, label }; +} + +// Create a small UI button (for pause, menu, etc.) +export function createSmallButton(scene, x, y, text, onClick) { + return createButton(scene, x, y, text, onClick, 110, 34); +} diff --git a/src/main.js b/src/main.js index 9414bad..5d60d39 100644 --- a/src/main.js +++ b/src/main.js @@ -8,18 +8,19 @@ import { GameOver } from './game/scenes/GameOver'; import { Settings } from './game/scenes/Settings'; import { Guide } from './game/scenes/Guide'; import { StoryIntro } from './game/scenes/StoryIntro'; +import { LevelIntro } from './game/scenes/LevelIntro'; const config = { type: Phaser.AUTO, width: 1024, height: 768, parent: 'app', - backgroundColor: '#333333', + backgroundColor: '#0a0a1a', scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }, - scene: [Boot, Preloader, MainMenu, StoryIntro, LevelSelect, Game, GameOver, Settings, Guide], + scene: [Boot, Preloader, MainMenu, StoryIntro, LevelSelect, LevelIntro, Game, GameOver, Settings, Guide], physics: { default: 'arcade', arcade: {