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)
This commit is contained in:
2026-04-12 10:53:04 +07:00
parent 7eb454443c
commit 35d3f45d6f
24 changed files with 1526 additions and 937 deletions
+200
View File
@@ -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)
+282
View File
@@ -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
+94
View File
@@ -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.
+166
View File
@@ -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)
-9
View File
@@ -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)
}
+1 -5
View File
@@ -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 = [];
+188 -255
View File
@@ -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,
},
];
+20 -6
View File
@@ -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."
}
+20 -6
View File
@@ -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 lnh đỏ 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."
}
-41
View File
@@ -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;
+7 -13
View File
@@ -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);
}
}
+52 -29
View File
@@ -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;
+42
View File
@@ -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;
}
+94 -184
View File
@@ -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);
+25 -47
View File
@@ -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);
}
}
+43 -95
View File
@@ -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);
}
}
+61
View File
@@ -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);
}
}
+52 -37
View File
@@ -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);
}
}
+42 -56
View File
@@ -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);
}
}
+13 -15
View File
@@ -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() {
+24 -87
View File
@@ -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'));
}
}
+27 -50
View File
@@ -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 });
});
}
}
}
+70
View File
@@ -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);
}
+3 -2
View File
@@ -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: {