` with class based on state: `.wall`, `.goal`, `.lit`, `.empty`
+ - Click handler on cells for tap-to-move (emits `oncellclick` with row, col)
+ - Cell colors from CSS variables
+
+3. **PlayerSprite.svelte** — renders the player
+ - Props: `row`, `col`, `cellSize`, `gridOffset`
+ - Positioned absolutely over the grid
+ - CSS `transition: top 100ms, left 100ms` for smooth movement (replaces Phaser tweens)
+ - Black circle via `border-radius: 50%`
+
+4. **GuardSprite.svelte** — renders a guard
+ - Props: `guard` object `{ row, col, type, direction, isOn }`
+ - Props: `cellSize`, `gridOffset`
+ - Positioned absolutely over the grid
+ - Circle colored by type (CSS class: `.guard-static`, `.guard-rotating`, etc.)
+ - Direction indicator for rotating/patrolling: rotated CSS triangle or line
+ - Blinking guard dim state: reduced opacity via class toggle
+
+5. **GameHud.svelte** — top HUD bar
+ - Props: `lives`, `level`, `turns`
+ - Events: `onpause`, `onmenu`
+ - Fixed position, not part of game board
+ - Uses Button.svelte for pause/menu
+
+6. **DetectionPopup.svelte** — detection overlay
+ - Props: `visible`
+ - Events: `onplayagain`
+ - Dark overlay with centered message + button
+ - Uses `transition:fade`
+
+7. **PauseMenu.svelte** — pause overlay
+ - Props: `visible`
+ - Events: `onresume`, `onrestart`, `onmainmenu`
+ - Dark overlay with 3 buttons
+ - Uses `transition:fade`
+
+## Rendering Strategy
+
+```
+┌─ GameBoard.svelte (CSS Grid) ─────────┐
+│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
+│ │ │ │ │ │ │ │ │ │ │ │ │ ... │
+│ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │
+│ (each cell = div with bg color) │
+│ │
+│ PlayerSprite (absolute positioned) │
+│ GuardSprite × N (absolute positioned)│
+└────────────────────────────────────────┘
+```
+
+## CSS Class Map
+
+| Game State | CSS Class | Color Variable |
+|---|---|---|
+| Empty cell | `.cell` | `--grid-empty` |
+| Wall | `.cell.wall` | `--grid-wall` |
+| Goal | `.cell.goal` | `--grid-goal` |
+| Lit | `.cell.lit` | `--grid-lit` |
+| Static guard | `.guard.static` | `--guard-static` |
+| Rotating guard | `.guard.rotating` | `--guard-rotating` |
+| Blinking guard (on) | `.guard.blinking` | `--guard-blinking` |
+| Blinking guard (off) | `.guard.blinking.off` | `--guard-blinking-off` |
+| Patrolling guard | `.guard.patrolling` | `--guard-patrolling` |
+
+## Success Criteria
+- GameBoard renders correct grid for any level (6x6 through 10x10)
+- Player circle moves smoothly with CSS transitions
+- Guards display correct colors and direction indicators
+- All overlays (detection, pause) fade in/out
+- Components accept plain JS data — no Phaser objects
diff --git a/plans/260412-1113-svelte5-rewrite/phase-04-scene-components.md b/plans/260412-1113-svelte5-rewrite/phase-04-scene-components.md
new file mode 100644
index 0000000..ba527af
--- /dev/null
+++ b/plans/260412-1113-svelte5-rewrite/phase-04-scene-components.md
@@ -0,0 +1,85 @@
+# Phase 4: Build Scene Components
+
+**Priority:** High | **Status:** pending | **Effort:** Medium
+
+## Overview
+Replace all Phaser Scene classes with Svelte components. App.svelte acts as the scene router using reactive state + transitions.
+
+## Steps
+
+1. **App.svelte** — scene router
+ - Module-level `$state` for `currentScene` and `sceneData`
+ - `{#if currentScene === 'MainMenu'}` blocks for each scene
+ - Each block wrapped in `transition:fade={{ duration: 300 }}`
+ - Export `navigate(scene, data)` function for child components
+ - Initialize with `currentScene = 'MainMenu'`
+
+2. **MainMenu.svelte**
+ - Starfield background (random positioned dots via `{#each}`)
+ - Title, ninja icon, 4 buttons (Start, Level Select, Guide, Settings)
+ - Buttons call `navigate('StoryIntro')` etc.
+
+3. **StoryIntro.svelte**
+ - Dark background, scrolling story text
+ - CSS animation `@keyframes scroll` moves text upward
+ - Skip button → navigates to LevelIntro
+ - Auto-advance after scroll completes
+
+4. **LevelIntro.svelte**
+ - Props via sceneData: `{ level, lives }`
+ - Shows level number, name (from levels.js), story text (from localization)
+ - Continue button → navigates to Game
+
+5. **LevelSelect.svelte**
+ - Grid of 12 level buttons (CSS Grid, 4 columns)
+ - Reads progress from `getProgress()`
+ - Locked/unlocked/completed states via CSS classes
+ - Checkmark on completed levels
+ - Click → navigate to LevelIntro with level data
+
+6. **GameOver.svelte**
+ - Props via sceneData: `{ level, isLastLevel }`
+ - Different message for final level vs normal game over
+ - Try Again + Main Menu buttons
+
+7. **Settings.svelte**
+ - Language toggle (EN/VI)
+ - Calls `setLanguage()` from localization.js
+ - Re-renders text reactively via `$derived` on language
+
+8. **Guide.svelte**
+ - Scrollable content area (native CSS `overflow-y: auto`)
+ - Sections: objectives, controls, enemy types
+ - Back button
+
+## Scene Navigation Pattern
+
+```svelte
+
+
+
+{#if currentScene === 'MainMenu'}
+
+
+
+{:else if currentScene === 'Game'}
+
+
+
+{/if}
+```
+
+## Success Criteria
+- All 8 scenes render correctly
+- Scene transitions fade smoothly
+- Language switching updates all visible text
+- Level select shows correct progress state
+- Story intro scrolls and auto-advances
diff --git a/plans/260412-1113-svelte5-rewrite/phase-05-game-scene-input.md b/plans/260412-1113-svelte5-rewrite/phase-05-game-scene-input.md
new file mode 100644
index 0000000..de228a6
--- /dev/null
+++ b/plans/260412-1113-svelte5-rewrite/phase-05-game-scene-input.md
@@ -0,0 +1,96 @@
+# Phase 5: Wire Game Scene + Input
+
+**Priority:** Critical | **Status:** pending | **Effort:** Large
+
+## Overview
+The main Game.svelte scene — connects game logic (grid, guards, turns, player) to the visual components and handles keyboard/click input.
+
+## Steps
+
+1. **Game.svelte** — main game orchestrator
+ - `$state` for: grid, player, guards, lives, level, turns, isPaused, detected, isFinalLevel
+ - `onMount`: call `loadLevel()` to get initial state, set up keyboard listener
+ - `onDestroy`: clean up keyboard listener
+ - Render: GameBoard + PlayerSprite + GuardSprites + GameHud + overlays
+
+2. **Keyboard input**
+ ```js
+ function onKeyDown(e) {
+ if (isPaused || !inputEnabled) return;
+ const dirMap = {
+ ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
+ w: 'up', s: 'down', a: 'left', d: 'right',
+ };
+ const dir = dirMap[e.key];
+ if (dir) handleMove(dir);
+ }
+ ```
+ - Bind to `window` via `svelte:window` directive: `
`
+
+3. **Click-to-move input**
+ - GameBoard emits `oncellclick(row, col)`
+ - Game.svelte checks if cell is adjacent to player, calls `handleMove()`
+
+4. **Turn flow** (handleMove function)
+ ```
+ 1. player.move(direction)
+ 2. If moved successfully:
+ a. Check if player at goal → handleLevelComplete()
+ b. clearAllLight()
+ c. Each guard.onTurnChange()
+ d. grid.render state update (triggers Svelte reactivity)
+ e. Check if player in lit cell → showDetection()
+ f. turns++
+ ```
+
+5. **Level 12 special mechanic**
+ - On each move, check `isFinalLevel && Manhattan distance to goal <= 2`
+ - If triggered: set ALL non-wall cells to `isLight = true`
+ - Show princess detected message
+ - This looks like normal gameplay — player won't know it's impossible until they experience it
+
+6. **Detection flow**
+ - Set `detected = true` → shows DetectionPopup
+ - On "Play Again": `lives--`, if lives > 0 reload level, else navigate to GameOver
+
+7. **Level complete flow**
+ - Call `completeLevel()` from progress.js
+ - Brief CSS flash animation on board
+ - Navigate to LevelIntro for next level (or GameOver for final)
+
+8. **Pause flow**
+ - Toggle `isPaused` → shows PauseMenu
+ - Resume / Restart / Main Menu actions
+
+## Reactive State Shape
+
+```js
+// In Game.svelte
+let grid = $state(null); // GridSystem instance
+let player = $state(null); // Player instance
+let guards = $state([]); // Guard instances
+let lives = $state(3);
+let currentLevel = $state(1);
+let turns = $state(0);
+let isPaused = $state(false);
+let detected = $state(false);
+let isFinalLevel = $state(false);
+
+// Derived for rendering
+let cells = $derived(grid ? grid.getAllCells() : []);
+let playerPos = $derived(player ? { row: player.row, col: player.col } : null);
+let guardData = $derived(guards.map(g => ({
+ row: g.row, col: g.col, type: g.constructor.name,
+ direction: g.direction, isOn: g.isOn,
+})));
+```
+
+## Success Criteria
+- Arrow keys / WASD move player through grid
+- Click adjacent cell moves player
+- Guards update correctly each turn (static lit, rotating beam, blinking toggle, patrol movement)
+- Detection popup shows when stepping on lit cell
+- Level completes when reaching goal
+- Level 12 lights up entire map near goal
+- Pause menu works
+- Lives decrement and game over triggers at 0
diff --git a/plans/260412-1113-svelte5-rewrite/phase-06-polish-verify.md b/plans/260412-1113-svelte5-rewrite/phase-06-polish-verify.md
new file mode 100644
index 0000000..7d1ee6d
--- /dev/null
+++ b/plans/260412-1113-svelte5-rewrite/phase-06-polish-verify.md
@@ -0,0 +1,60 @@
+# Phase 6: Polish + Verify
+
+**Priority:** High | **Status:** pending | **Effort:** Small
+
+## Overview
+Final pass — visual polish, cleanup old Phaser code, update docs, verify all 12 levels.
+
+## Steps
+
+1. **Delete old Phaser code**
+ - Remove entire `src/game/` directory
+ - Remove `vite/` directory
+ - Remove `log.js`
+ - Remove `public/style.css` (styles now in Svelte components + theme.css)
+ - Remove `phaser` from package.json dependencies
+ - Remove `terser` from devDependencies (Vite handles minification)
+ - Run `npm install` to clean lockfile
+
+2. **Visual polish**
+ - Verify dark theme renders correctly on all scenes
+ - Test responsive scaling (Scale.FIT equivalent via CSS `max-width` + `aspect-ratio`)
+ - Ensure grid is centered on all level sizes (6x6 through 10x10)
+ - Test direction indicators on rotating/patrolling guards
+ - Verify blinking guard color toggle
+
+3. **Test all 12 levels**
+ - Level 1: move freely, reach goal
+ - Levels 2-5: static guard lit cells block correct positions
+ - Level 6: rotating guard beam sweeps and blocks cells
+ - Levels 7-8: mixed guard interactions
+ - Level 9: blinking guard toggles on/off
+ - Level 10: patrolling guard moves along path
+ - Level 11: all 4 guard types simultaneously
+ - Level 12: approach goal → entire map lights up (unbeatable)
+
+4. **Test game flows**
+ - Lives system: lose 3 lives → game over screen
+ - Level progression: complete level → intro → next level
+ - Level select: locked/unlocked/completed states correct
+ - Progress persistence: refresh browser, progress retained
+ - Language switch: EN ↔ VI updates all text
+ - Pause menu: resume, restart, main menu all work
+
+5. **Update docs**
+ - Update `docs/system-architecture.md` — Svelte 5 architecture
+ - Update `docs/codebase-summary.md` — new file listing
+ - Update `docs/code-standards.md` — Svelte conventions
+ - Update `README.md` — remove Phaser references, add Svelte info
+
+6. **Build verification**
+ - `npm run build` produces working dist/
+ - Serve dist/ locally, test full game
+ - Check bundle size (target: <50KB app code, down from ~1MB with Phaser)
+
+## Success Criteria
+- Zero Phaser code remains
+- All 12 levels playable with correct guard behavior
+- Level 12 secret mechanic works
+- Bundle size < 50KB (excluding assets)
+- Docs updated to reflect new stack
diff --git a/plans/260412-1113-svelte5-rewrite/plan.md b/plans/260412-1113-svelte5-rewrite/plan.md
new file mode 100644
index 0000000..4214959
--- /dev/null
+++ b/plans/260412-1113-svelte5-rewrite/plan.md
@@ -0,0 +1,124 @@
+---
+title: Rewrite NNTV from Phaser 3 to Svelte 5
+status: ready
+created: 2026-04-12
+branch: main
+blockedBy: []
+blocks: []
+---
+
+# Rewrite NNTV: Phaser 3 → Svelte 5 + Vite
+
+## Overview
+
+Replace Phaser 3 game engine (~1MB) with plain Svelte 5 + Vite for a turn-based grid puzzle game that only uses rectangles, circles, and text. All game logic (guards, turns, levels, lighting) ports as-is. Only the rendering/UI layer changes.
+
+## Architecture: Before → After
+
+```
+BEFORE (Phaser 3) AFTER (Svelte 5)
+───────────────── ────────────────
+Phaser.Game config → App.svelte (scene router)
+Phaser.Scene classes → Svelte components per scene
+scene.add.circle/rect/text → HTML/CSS elements
+scene.input.keyboard → window keydown events
+scene.tweens.add → CSS transitions
+this.scene.start() → reactive scene state
+camera.fadeIn/fadeOut → svelte/transition (fade)
+Phaser physics (unused) → DELETE
+```
+
+## File Migration Map
+
+| Current File | Action | New Location |
+|---|---|---|
+| `locales/en.json`, `vi.json` | PORT AS-IS | `src/lib/locales/` |
+| `levels/Levels.js` | PORT AS-IS | `src/lib/levels/levels.js` |
+| `progress.js` | PORT AS-IS | `src/lib/progress.js` |
+| `localization.js` | PORT AS-IS | `src/lib/localization.js` |
+| `TurnManager.js` | PORT (remove Phaser import) | `src/lib/game/turn-manager.js` |
+| `GridSystem.js` | SPLIT: data logic → pure JS, rendering → Svelte | `src/lib/game/grid-system.js` + `GameBoard.svelte` |
+| `Guard.js` | SPLIT: logic → pure JS, rendering → Svelte | `src/lib/game/guards.js` + `GuardSprite.svelte` |
+| `Player.js` | SPLIT: logic → pure JS, rendering → Svelte | `src/lib/game/player.js` + `PlayerSprite.svelte` |
+| `LightingSystem.js` | MERGE into grid-system.js | `src/lib/game/grid-system.js` |
+| `LevelManager.js` | REWRITE as pure function | `src/lib/game/level-manager.js` |
+| `theme.js` | REWRITE as CSS variables | `src/styles/theme.css` |
+| All scenes/*.js | REWRITE as Svelte components | `src/scenes/*.svelte` |
+| `main.js` | REWRITE | `src/main.js` + `src/App.svelte` |
+| `LevelTester.js` | DELETE | — |
+
+## New Project Structure
+
+```
+src/
+├── main.js # Mount Svelte app
+├── App.svelte # Scene router + transitions
+├── styles/
+│ └── theme.css # CSS variables (colors, fonts)
+├── lib/
+│ ├── localization.js # getText, setLanguage (ported)
+│ ├── progress.js # localStorage persistence (ported)
+│ ├── locales/
+│ │ ├── en.json # English (ported)
+│ │ └── vi.json # Vietnamese (ported)
+│ ├── levels/
+│ │ └── levels.js # Level data (ported)
+│ └── game/
+│ ├── grid-system.js # Grid data model + lighting (pure JS)
+│ ├── guards.js # Guard classes, logic only (pure JS)
+│ ├── player.js # Player position + movement (pure JS)
+│ ├── turn-manager.js # Turn logic (pure JS)
+│ └── level-manager.js # Level loading (pure JS)
+├── components/
+│ ├── GameBoard.svelte # CSS Grid rendering of cells
+│ ├── PlayerSprite.svelte # Player circle element
+│ ├── GuardSprite.svelte # Guard circle + direction indicator
+│ ├── GameHud.svelte # Lives, level, turns, pause/menu buttons
+│ ├── DetectionPopup.svelte # "You've been detected" overlay
+│ ├── PauseMenu.svelte # Pause overlay
+│ └── Button.svelte # Reusable styled button
+└── scenes/
+ ├── MainMenu.svelte
+ ├── StoryIntro.svelte
+ ├── LevelSelect.svelte
+ ├── LevelIntro.svelte
+ ├── Game.svelte # Main game scene
+ ├── GameOver.svelte
+ ├── Settings.svelte
+ └── Guide.svelte
+```
+
+## Key Design Decisions
+
+1. **Plain Svelte + Vite** (not SvelteKit) — no routing needed for SPA game
+2. **Svelte 5 runes** (`$state`, `$derived`, `$effect`) — reactive game state
+3. **CSS Grid** for game board — native, responsive, zero dependencies
+4. **CSS transitions** for player movement — replace Phaser tweens
+5. **`svelte/transition`** for scene switching — fade between screens
+6. **Pure JS game logic** — all guard/turn/level logic stays framework-agnostic
+7. **Game state as module-level `$state`** in `.svelte.js` files — shared across components
+
+## Phases
+
+| # | Phase | Status | File |
+|---|-------|--------|------|
+| 1 | Scaffold Svelte 5 project | pending | [phase-01](phase-01-scaffold-project.md) |
+| 2 | Port pure game logic | pending | [phase-02](phase-02-port-game-logic.md) |
+| 3 | Build game board + sprites | pending | [phase-03](phase-03-game-board-components.md) |
+| 4 | Build scene components | pending | [phase-04](phase-04-scene-components.md) |
+| 5 | Wire game scene + input | pending | [phase-05](phase-05-game-scene-input.md) |
+| 6 | Polish + verify | pending | [phase-06](phase-06-polish-verify.md) |
+
+## Risk Assessment
+
+| Risk | Impact | Mitigation |
+|---|---|---|
+| Grid reactivity perf on 10x10 | Low | Svelte fine-grained updates; only changed cells re-render |
+| CSS transition timing mismatch with game turns | Medium | Use fixed duration, test each guard type |
+| Level 12 special mechanic (lightUpEntireMap) | Low | Direct DOM class toggle on all cells |
+
+## Dependencies
+
+- Svelte 5 (latest)
+- Vite 6.x (already used)
+- No other runtime dependencies
diff --git a/plans/reports/researcher-260412-1127-svelte5-grid-puzzle-game.md b/plans/reports/researcher-260412-1127-svelte5-grid-puzzle-game.md
new file mode 100644
index 0000000..ae480b3
--- /dev/null
+++ b/plans/reports/researcher-260412-1127-svelte5-grid-puzzle-game.md
@@ -0,0 +1,293 @@
+# Svelte 5 Research: Grid Puzzle Game Rewrite
+
+**Status:** Complete | **Focus:** Runes, setup, transitions, grid patterns, state management
+**Date:** 2026-04-12 | **Scope:** Actionable patterns for turn-based grid game
+
+---
+
+## 1. Svelte 5 Runes Syntax — Reactive State
+
+### Core Pattern: $state for Grid
+Runes are compiler keywords (not functions), prefixed with `$`. Use `$state` to declare reactive cell data.
+
+**Grid state pattern (array of cells):**
+```javascript
+let grid = $state([
+ { id: 0, type: 'wall' },
+ { id: 1, type: 'floor' },
+ // ... 16+ cells
+]);
+```
+
+**For object-based grid:**
+```javascript
+let gridMap = $state(new Map([
+ ['0,0', { type: 'wall', solved: false }],
+ ['0,1', { type: 'puzzle', value: 5 }],
+]));
+```
+
+### $derived — Computed Grid State
+Auto-updates when dependencies change (memoized). Zero side-effects allowed.
+
+```javascript
+let solvedCount = $derived(grid.filter(c => c.solved).length);
+let isWon = $derived(solvedCount === grid.length);
+let gridDisplay = $derived(grid.map(c => c.type)); // safe memo
+```
+
+### $effect — Side Effects (Subscriptions, DOM, Analytics)
+Runs after DOM update. Return cleanup function for teardown.
+
+```javascript
+$effect(() => {
+ console.log('Game state changed:', grid);
+ localStorage.setItem('game', JSON.stringify(grid));
+});
+
+$effect(() => {
+ return () => console.log('Component unmounting'); // cleanup
+});
+```
+
+### Key Gotchas
+- Avoid mutations inside `$derived`: `$derived(count++)` **fails**.
+- `$state` works outside `.svelte` files (in `.svelte.js` / `.svelte.ts`).
+- Runes replace old `let x = 0; $: x = x + 1` reactive declarations.
+
+---
+
+## 2. Svelte 5 + Vite Setup
+
+### Recommended: Use `sv` CLI (SvelteKit)
+```bash
+npx sv create my-puzzle-game
+# Select: Svelte, TypeScript, Vitest, Prettier
+cd my-puzzle-game
+npm run dev
+```
+
+Scaffold includes: Vite (pre-configured), TypeScript, dev server at `localhost:5173`.
+
+### Alternative: Bare Svelte + Vite
+```bash
+npm create vite@latest my-game -- --template svelte-ts
+npm install
+npm run dev
+```
+
+Simpler but misses SvelteKit's routing/build optimizations. **For a static SPA game, either works.**
+
+### Vite Config (if bare Svelte)
+`vite.config.ts` auto-includes `vite-plugin-svelte`. No manual config needed for basic setup.
+
+---
+
+## 3. Scene Transitions (Fade/Fly/Slide)
+
+### Pattern: Conditional Scenes
+```svelte
+
+
+{#if currentScene === 'intro'}
+
+
Intro Scene
+
+
+{:else if currentScene === 'game'}
+
+
+
+{:else if currentScene === 'win'}
+
+
You Won!
+
+{/if}
+```
+
+### Available Transitions
+| Name | Use | Params |
+|------|-----|--------|
+| `fade` | Opacity 0→1 | `duration`, `delay`, `easing` |
+| `fly` | Position + opacity | `x`, `y`, `duration` |
+| `slide` | Axis-based slide | `axis` ('x'/'y'), `duration` |
+| `scale`, `blur` | Also available | config object |
+
+### Bidirectional
+`transition:` works both in and out. Reverses smoothly mid-animation if toggled.
+
+---
+
+## 4. CSS Grid for Game Board
+
+### Reactive Grid Layout
+```svelte
+
+
+
+ {#each grid as cell (cell.id)}
+
handleCellClick(cell.id)}>
+ {cell.id}
+
+ {/each}
+
+
+
+```
+
+### Performance Note
+Svelte's fine-grained reactivity means updating a single cell **doesn't re-render all others**. Safe for responsive grids (6x6, 8x8).
+
+---
+
+## 5. State Management: Stores vs. Runes
+
+### For Game Progress + Localization: Use Runes in `.svelte.js`
+
+**gameState.svelte.js:**
+```javascript
+export const game = $state({
+ level: 1,
+ moves: 0,
+ isWon: false,
+ grid: [],
+});
+
+export const i18n = $state({
+ lang: 'en',
+ strings: { title: 'Puzzle Game', play: 'Play' },
+});
+
+export function resetLevel() {
+ game.level = 1;
+ game.moves = 0;
+ game.isWon = false;
+}
+```
+
+**App.svelte:**
+```svelte
+
+
+
{i18n.strings.title}
+
Level {game.level} • Moves: {game.moves}
+
+```
+
+### When to Use Stores (Deprecated Pattern)
+Only for **async data streams** (polling, WebSocket). Most games don't need this.
+
+### Why Runes > Stores
+- No boilerplate (no `writable()`, `.subscribe()`).
+- Works in plain `.js` files (shared logic).
+- Memoization via `$derived`.
+
+---
+
+## 6. SvelteKit vs. Plain Svelte for Static Game
+
+### Recommendation: **Use Plain Svelte + Vite**
+
+| Criteria | Plain Svelte | SvelteKit |
+|----------|--------------|-----------|
+| **Setup time** | 2 min | 3 min |
+| **Routing** | Manual | File-based (unnecessary) |
+| **Build output** | Single HTML/JS | Server capable |
+| **Static deploy** | ✅ Simpler | ✅ Needs adapter-static |
+| **DevTools** | Vite only | Vite + SvelteKit CLI |
+| **Scaling** | Hits limits ~2-3 scenes | Extensible |
+
+### For a Static SPA Game
+```bash
+npm create vite@latest my-game -- --template svelte-ts
+```
+
+This is **KISS**. No file-based routing overhead, no server code, direct control.
+
+### Only Use SvelteKit If
+- Game needs **server-side scoring/leaderboards** (then use SvelteKit backend).
+- Game will expand to **10+ pages** (use file-based routing).
+- Team prefers **unified framework** across multiple projects.
+
+---
+
+## Implementation Checklist
+
+### Phase 1: Setup
+- [ ] `npm create vite@latest` (svelte-ts template)
+- [ ] Replace Vite's default App.svelte with game component
+- [ ] Verify `npm run dev` opens at localhost:5173
+
+### Phase 2: Grid & State
+- [ ] Create `gameState.svelte.js` with `$state(grid)`
+- [ ] Build `GameBoard.svelte` with CSS Grid + `{#each grid}`
+- [ ] Wire `$derived(isWon)` from grid state
+
+### Phase 3: Interactions
+- [ ] Add cell click handlers → update grid state
+- [ ] Bind to `$effect` for persistence (localStorage)
+- [ ] Test fine-grained reactivity (click cell, only that cell re-renders)
+
+### Phase 4: Transitions
+- [ ] Import `fade`, `fly`, `slide` from `svelte/transition`
+- [ ] Wrap scenes in conditional blocks with `transition:` directives
+- [ ] Test bidirectional transitions (scene switches)
+
+### Phase 5: Deploy
+- [ ] `npm run build` → outputs `dist/` (static)
+- [ ] Upload `dist/` to any static host (Vercel, GitHub Pages, Netlify)
+
+---
+
+## Trade-offs & Adoption Risk
+
+| Risk | Mitigation |
+|------|-----------|
+| Svelte 5 is new (2024+) | Use LTS minor versions; runes are stable now |
+| Team unfamiliar with runes | Runes are **simpler** than Vue/React hooks; quick ramp |
+| Grid performance at scale | Fine-grained reactivity handles 100+ cells easily |
+| Transitions can stutter | Use `duration: 200–400ms`; test on target devices |
+
+---
+
+## Sources
+
+- [Svelte Runes Documentation](https://svelte.dev/docs/svelte/what-are-runes)
+- [Svelte Transitions](https://svelte.dev/docs/svelte/svelte-transition)
+- [Svelte Stores](https://svelte.dev/docs/svelte/stores)
+- [SvelteKit Single-Page Apps](https://svelte.dev/docs/kit/single-page-apps)
+- [Getting Started](https://svelte.dev/docs/svelte/getting-started)
+- [Mainmatter: Runes and Global State](https://mainmatter.com/blog/2025/03/11/global-state-in-svelte-5/)
+- [Frontend Masters: Game Board Grid](https://frontendmasters.com/courses/svelte-v2/game-board-grid/)
+
+---
+
+## Unresolved Questions
+
+- **WebSocket multiplayer?** Not covered; requires SvelteKit backend or third-party service.
+- **Mobile touch events?** Standard DOM events work; no Svelte-specific guidance needed.
+- **Save file encryption?** Depends on backend; out of scope for static game.
diff --git a/public/style.css b/public/style.css
deleted file mode 100644
index efa9771..0000000
--- a/public/style.css
+++ /dev/null
@@ -1,28 +0,0 @@
-body {
- margin: 0;
- padding: 0;
- color: rgba(255, 255, 255, 0.87);
- background-color: #222;
- font-family: Arial, sans-serif;
- height: 100vh;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-#app {
- width: 100%;
- height: 100vh;
- overflow: hidden;
- display: flex;
- justify-content: center;
- align-items: center;
- position: relative;
- box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
-}
-
-/* Ensure Phaser canvas is centered */
-canvas {
- display: block;
- margin: 0 auto;
-}
diff --git a/src/App.svelte b/src/App.svelte
new file mode 100644
index 0000000..df5b9e7
--- /dev/null
+++ b/src/App.svelte
@@ -0,0 +1,61 @@
+
+
+
+ {#key currentScene}
+
+ {#if currentScene === 'MainMenu'}
+
+ {:else if currentScene === 'StoryIntro'}
+
+ {:else if currentScene === 'LevelIntro'}
+
+ {:else if currentScene === 'LevelSelect'}
+
+ {:else if currentScene === 'Game'}
+
+ {:else if currentScene === 'GameOver'}
+
+ {:else if currentScene === 'Settings'}
+
+ {:else if currentScene === 'Guide'}
+
+ {/if}
+
+ {/key}
+
+
+
diff --git a/src/components/Button.svelte b/src/components/Button.svelte
new file mode 100644
index 0000000..3461faa
--- /dev/null
+++ b/src/components/Button.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/src/components/DetectionPopup.svelte b/src/components/DetectionPopup.svelte
new file mode 100644
index 0000000..22c86bb
--- /dev/null
+++ b/src/components/DetectionPopup.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
diff --git a/src/components/GameBoard.svelte b/src/components/GameBoard.svelte
new file mode 100644
index 0000000..4541690
--- /dev/null
+++ b/src/components/GameBoard.svelte
@@ -0,0 +1,38 @@
+
+
+
+ {#each cells as cell (cell.row * cols + cell.col)}
+
+
oncellclick?.(cell.row, cell.col)}
+ >
+ {/each}
+
+
+
diff --git a/src/components/GameHud.svelte b/src/components/GameHud.svelte
new file mode 100644
index 0000000..748e0cd
--- /dev/null
+++ b/src/components/GameHud.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+ {getText('lives')}{lives}
+ Turns: {turns}
+
+
+ {getText('level')}{level}
+
+
+
+
+
+
diff --git a/src/components/GuardSprite.svelte b/src/components/GuardSprite.svelte
new file mode 100644
index 0000000..78fc0e0
--- /dev/null
+++ b/src/components/GuardSprite.svelte
@@ -0,0 +1,45 @@
+
+
+
+ {#if hasDirection}
+
+ {/if}
+
+
+
diff --git a/src/components/PauseMenu.svelte b/src/components/PauseMenu.svelte
new file mode 100644
index 0000000..9b8ebfd
--- /dev/null
+++ b/src/components/PauseMenu.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
diff --git a/src/components/PlayerSprite.svelte b/src/components/PlayerSprite.svelte
new file mode 100644
index 0000000..21ad5a4
--- /dev/null
+++ b/src/components/PlayerSprite.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/src/game/levels/LevelManager.js b/src/game/levels/LevelManager.js
deleted file mode 100644
index 94482aa..0000000
--- a/src/game/levels/LevelManager.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import { LEVELS } from './Levels';
-
-export class LevelManager {
- constructor(scene) {
- this.scene = scene;
- this.levels = LEVELS;
- }
-
- // Tải màn chơi theo ID
- loadLevel(levelId) {
- // Đảm bảo levelId hợp lệ
- if (levelId < 1 || levelId > this.levels.length) {
- console.error(`Invalid level ID: ${levelId}`);
- return false;
- }
-
- // Lấy dữ liệu màn chơi
- const levelData = this.levels[levelId - 1];
- console.log(`Loading level ${levelId}: ${levelData.name}`);
-
- // Cập nhật UI
- this.scene.levelText.setText(`Level: ${levelId}`);
-
- // Xóa các trạm gác hiện tại
- if (this.scene.guards) {
- this.scene.guards.forEach(guard => guard.destroy());
- }
- this.scene.guards = [];
-
- // Điều chỉnh kích thước lưới nếu cần
- const gridSize = levelData.grid.rows;
- if (gridSize !== this.scene.gridSize) {
- this.scene.gridSize = gridSize;
- this.scene.grid.resize(gridSize, gridSize);
-
- // Tính toán lại vị trí lưới để căn giữa
- const width = this.scene.cameras.main.width;
- const height = this.scene.cameras.main.height;
- this.scene.gridOffsetX = Math.floor((width - (gridSize * this.scene.cellSize)) / 2);
- this.scene.gridOffsetY = Math.floor((height - (gridSize * this.scene.cellSize)) / 2);
-
- // Cập nhật vị trí của graphics
- this.scene.grid.graphics.x = this.scene.gridOffsetX;
- this.scene.grid.graphics.y = this.scene.gridOffsetY;
-
- if (this.scene.lightSystem) {
- this.scene.lightSystem.graphics.x = this.scene.gridOffsetX;
- this.scene.lightSystem.graphics.y = this.scene.gridOffsetY;
- }
- }
-
- // Reset lưới
- for (let row = 0; row < this.scene.gridSize; row++) {
- for (let col = 0; col < this.scene.gridSize; col++) {
- this.scene.grid.setWall(row, col, false);
- this.scene.grid.setGoal(row, col, false);
- this.scene.grid.setLight(row, col, false);
- }
- }
-
- // Thiết lập tường
- if (levelData.walls) {
- levelData.walls.forEach(wall => {
- this.scene.grid.setWall(wall.row, wall.col, true);
- });
- }
-
- // Thiết lập đích
- if (levelData.goal) {
- this.scene.grid.setGoal(levelData.goal.row, levelData.goal.col, true);
- }
-
- // Thiết lập vị trí người chơi
- if (levelData.player) {
- // Sử dụng skipDetectionCheck = true để tránh kiểm tra phát hiện khi tải lại màn chơi
- this.scene.player.moveTo(levelData.player.row, levelData.player.col, true);
- }
-
- // Thiết lập các ô sáng cố định (nếu có)
- if (levelData.litCells) {
- levelData.litCells.forEach(cell => {
- this.scene.grid.setLight(cell.row, cell.col, true);
- });
- }
-
- // Tạo các trạm gác
- if (levelData.guards) {
- levelData.guards.forEach(guardData => {
- let guard = null;
-
- switch (guardData.type) {
- case 'static':
- guard = new this.scene.StaticGuard(
- this.scene,
- this.scene.grid,
- guardData.position.row,
- guardData.position.col,
- guardData.litCells
- );
- break;
- case 'rotating':
- guard = new this.scene.RotatingGuard(
- this.scene,
- this.scene.grid,
- guardData.position.row,
- guardData.position.col,
- guardData.startDirection
- );
- break;
- case 'blinking':
- guard = new this.scene.BlinkingGuard(
- this.scene,
- this.scene.grid,
- guardData.position.row,
- guardData.position.col,
- guardData.litCells,
- guardData.startState
- );
- break;
- case 'patrolling':
- guard = new this.scene.PatrollingGuard(
- this.scene,
- this.scene.grid,
- guardData.startPosition.row,
- guardData.startPosition.col,
- guardData.path
- );
- break;
- }
-
- if (guard) {
- this.scene.guards.push(guard);
- }
- });
- }
-
- // Lưu trữ thông tin màn cuối
- this.scene.isFinalLevel = levelData.isFinalLevel || false;
-
- // Cập nhật ánh sáng từ tất cả các trạm gác
- this.scene.guards.forEach(guard => {
- guard.updateLight();
- });
-
- // Reset turn manager
- this.scene.turnManager.reset();
-
- // Vẽ lại lưới
- this.scene.grid.render();
-
- // Setup camera to follow player after level is loaded
- // Add a small delay to ensure everything is properly initialized
- this.scene.time.delayedCall(100, () => {
- this.scene.setupCamera();
- });
-
- return true;
- }
-
- // Kiểm tra xem có phải màn cuối không
- isFinalLevel(levelId) {
- if (levelId < 1 || levelId > this.levels.length) {
- return false;
- }
- return this.levels[levelId - 1].isFinalLevel || false;
- }
-
- // Lấy tổng số màn chơi
- getTotalLevels() {
- return this.levels.length;
- }
-}
diff --git a/src/game/levels/LevelTester.js b/src/game/levels/LevelTester.js
deleted file mode 100644
index fb93c03..0000000
--- a/src/game/levels/LevelTester.js
+++ /dev/null
@@ -1,225 +0,0 @@
-import Phaser from 'phaser';
-import { LEVELS } from '../levels/Levels.js';
-
-// Level testing utility to verify level completion is possible
-export class LevelTester {
- constructor(scene) {
- this.scene = scene;
- this.levels = LEVELS;
- this.testResults = [];
- }
-
- // Test if a specific level is completable
- async testLevel(levelId) {
- console.log(`Testing level ${levelId}...`);
-
- // Skip testing level 12 as it's designed to be impossible
- if (levelId === 12) {
- console.log("Level 12 is designed to be impossible - skipping test");
- return {
- levelId: levelId,
- completable: false,
- reason: "Final level is designed to be impossible by game design"
- };
- }
-
- const levelData = this.levels[levelId - 1];
- if (!levelData) {
- return {
- levelId: levelId,
- completable: false,
- reason: "Level data not found"
- };
- }
-
- // Create a simplified grid for pathfinding
- const grid = this.createSimplifiedGrid(levelData);
-
- // Find path from player start to goal
- const path = this.findPath(
- grid,
- levelData.player.row,
- levelData.player.col,
- levelData.goal.row,
- levelData.goal.col
- );
-
- if (!path) {
- return {
- levelId: levelId,
- completable: false,
- reason: "No path found from start to goal"
- };
- }
-
- // Check if the path avoids all lit cells
- const result = this.validatePath(path, levelData);
-
- return {
- levelId: levelId,
- completable: result.valid,
- reason: result.valid ? "Path found" : result.reason,
- path: path
- };
- }
-
- // Test all levels and return results
- async testAllLevels() {
- this.testResults = [];
-
- for (let i = 1; i <= this.levels.length; i++) {
- const result = await this.testLevel(i);
- this.testResults.push(result);
- }
-
- return this.testResults;
- }
-
- // Create a simplified grid representation for pathfinding
- createSimplifiedGrid(levelData) {
- const rows = levelData.grid.rows;
- const cols = levelData.grid.cols;
-
- // Initialize grid with all cells walkable
- const grid = Array(rows).fill().map(() => Array(cols).fill(0));
-
- // Mark walls as unwalkable
- if (levelData.walls) {
- levelData.walls.forEach(wall => {
- grid[wall.row][wall.col] = 1;
- });
- }
-
- return grid;
- }
-
- // Simple breadth-first search pathfinding
- findPath(grid, startRow, startCol, goalRow, goalCol) {
- const rows = grid.length;
- const cols = grid[0].length;
-
- // Queue for BFS
- const queue = [{row: startRow, col: startCol, path: []}];
-
- // Visited cells
- const visited = Array(rows).fill().map(() => Array(cols).fill(false));
- visited[startRow][startCol] = true;
-
- // Directions: up, right, down, left
- const directions = [
- {row: -1, col: 0},
- {row: 0, col: 1},
- {row: 1, col: 0},
- {row: 0, col: -1}
- ];
-
- while (queue.length > 0) {
- const current = queue.shift();
-
- // Check if reached goal
- if (current.row === goalRow && current.col === goalCol) {
- return [...current.path, {row: current.row, col: current.col}];
- }
-
- // Try all four directions
- for (const dir of directions) {
- const newRow = current.row + dir.row;
- const newCol = current.col + dir.col;
-
- // Check if valid position
- if (
- newRow >= 0 && newRow < rows &&
- newCol >= 0 && newCol < cols &&
- grid[newRow][newCol] === 0 && // Not a wall
- !visited[newRow][newCol]
- ) {
- visited[newRow][newCol] = true;
- queue.push({
- row: newRow,
- col: newCol,
- path: [...current.path, {row: current.row, col: current.col}]
- });
- }
- }
- }
-
- // No path found
- return null;
- }
-
- // Validate if a path avoids all lit cells
- validatePath(path, levelData) {
- // For each step in the path, check if it would be lit by any guard
- for (let i = 0; i < path.length; i++) {
- const step = path[i];
-
- // Skip the first step (starting position)
- if (i === 0) continue;
-
- // Check static guards
- if (levelData.guards) {
- for (const guard of levelData.guards) {
- if (guard.type === "static") {
- // Check if step is in any of the lit cells
- for (const litCell of guard.litCells) {
- if (litCell.row === step.row && litCell.col === step.col) {
- return {
- valid: false,
- reason: `Step at (${step.row},${step.col}) would be lit by static guard`
- };
- }
- }
- }
- else if (guard.type === "rotating") {
- // This is a simplification - in reality, we'd need to simulate turns
- // For testing purposes, we'll assume rotating guards can be timed correctly
- continue;
- }
- else if (guard.type === "blinking") {
- // For blinking guards, we can assume we can time our movement when they're off
- continue;
- }
- else if (guard.type === "patrolling") {
- // For patrolling guards, we'd need to simulate their movement
- // For testing purposes, we'll assume patrolling guards can be avoided with timing
- continue;
- }
- }
- }
-
- // Check predefined lit cells (for level 2)
- if (levelData.litCells) {
- for (const litCell of levelData.litCells) {
- if (litCell.row === step.row && litCell.col === step.col) {
- return {
- valid: false,
- reason: `Step at (${step.row},${step.col}) would be in a predefined lit cell`
- };
- }
- }
- }
- }
-
- return { valid: true };
- }
-
- // Generate a report of test results
- generateReport() {
- let report = "Level Completion Test Results:\n";
- report += "==============================\n\n";
-
- this.testResults.forEach(result => {
- report += `Level ${result.levelId}: ${result.completable ? "COMPLETABLE" : "NOT COMPLETABLE"}\n`;
- report += `Reason: ${result.reason}\n`;
- if (result.path) {
- report += `Path length: ${result.path.length} steps\n`;
- }
- report += "\n";
- });
-
- const completableCount = this.testResults.filter(r => r.completable).length;
- report += `Summary: ${completableCount} out of ${this.testResults.length} levels are completable.\n`;
-
- return report;
- }
-}
diff --git a/src/game/objects/GridSystem.js b/src/game/objects/GridSystem.js
deleted file mode 100644
index e4ed120..0000000
--- a/src/game/objects/GridSystem.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import Phaser from 'phaser';
-import { COLORS } from '../theme';
-
-export class GridSystem {
- constructor(scene, rows, cols, cellSize) {
- this.scene = scene;
- this.rows = rows;
- this.cols = cols;
- this.cellSize = cellSize;
-
- // Create grid data
- this.grid = Array(rows).fill().map(() => Array(cols).fill().map(() => ({
- isWall: false,
- isGoal: false,
- isLight: false
- })));
-
- // Create graphics object for rendering
- this.graphics = this.scene.add.graphics();
- }
-
- // Resize grid
- resize(rows, cols) {
- this.rows = rows;
- this.cols = cols;
-
- // Create new grid data with new size
- this.grid = Array(rows).fill().map(() => Array(cols).fill().map(() => ({
- isWall: false,
- isGoal: false,
- isLight: false
- })));
- }
-
- // Check if position is valid
- isValidPosition(row, col) {
- return row >= 0 && row < this.rows && col >= 0 && col < this.cols;
- }
-
- // Set wall at position
- setWall(row, col, value) {
- if (this.isValidPosition(row, col)) {
- this.grid[row][col].isWall = value;
- }
- }
-
- // Check if position is a wall
- isWall(row, col) {
- if (this.isValidPosition(row, col)) {
- return this.grid[row][col].isWall;
- }
- return false;
- }
-
- // Set goal at position
- setGoal(row, col, value) {
- if (this.isValidPosition(row, col)) {
- this.grid[row][col].isGoal = value;
- }
- }
-
- // Check if position is a goal
- isGoal(row, col) {
- if (this.isValidPosition(row, col)) {
- return this.grid[row][col].isGoal;
- }
- return false;
- }
-
- // Set light at position
- setLight(row, col, value) {
- if (this.isValidPosition(row, col)) {
- this.grid[row][col].isLight = value;
- }
- }
-
- // Check if position is lit
- isLight(row, col) {
- if (this.isValidPosition(row, col)) {
- return this.grid[row][col].isLight;
- }
- return false;
- }
-
- // Convert grid coordinates to pixel coordinates
- gridToPixel(row, col) {
- return {
- x: col * this.cellSize + this.cellSize / 2,
- y: row * this.cellSize + this.cellSize / 2
- };
- }
-
- // Convert pixel coordinates to grid coordinates
- pixelToGrid(x, y) {
- return {
- row: Math.floor(y / this.cellSize),
- col: Math.floor(x / this.cellSize)
- };
- }
-
- // Render the grid
- render() {
- this.graphics.clear();
-
- // Draw grid cells
- for (let row = 0; row < this.rows; row++) {
- for (let col = 0; col < this.cols; col++) {
- const cell = this.grid[row][col];
- const x = col * this.cellSize;
- const y = row * this.cellSize;
-
- // Draw cell background
- if (cell.isWall) {
- this.graphics.fillStyle(COLORS.gridWall);
- } else if (cell.isGoal) {
- this.graphics.fillStyle(COLORS.gridGoal);
- } else if (cell.isLight) {
- this.graphics.fillStyle(COLORS.gridLit);
- } else {
- this.graphics.fillStyle(COLORS.gridEmpty);
- }
- this.graphics.fillRect(x, y, this.cellSize, this.cellSize);
-
- // Draw cell border
- this.graphics.lineStyle(1, COLORS.gridBorder);
- this.graphics.strokeRect(x, y, this.cellSize, this.cellSize);
- }
- }
- }
-}
diff --git a/src/game/objects/Guard.js b/src/game/objects/Guard.js
deleted file mode 100644
index fb8cb94..0000000
--- a/src/game/objects/Guard.js
+++ /dev/null
@@ -1,392 +0,0 @@
-import Phaser from 'phaser';
-import { COLORS } from '../theme';
-
-// Base class cho trạm gác
-export class Guard {
- constructor(scene, grid, row, col, color) {
- this.scene = scene;
- this.grid = grid;
- this.row = row;
- this.col = col;
- this.color = color || 0xFF0000;
- this.sprite = null;
- this.createSprite();
- }
-
- createSprite() {
- // Lấy vị trí pixel từ tọa độ lưới
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
-
- // Tạo sprite trạm gác (hình tròn)
- // Sử dụng kích thước phù hợp để đảm bảo hình tròn vừa vặn trong ô
- const radius = this.grid.cellSize * 0.25; // Kích thước phù hợp để hình tròn vừa vặn trong ô
-
- // Tạo hình tròn với màu tương ứng
- // Sử dụng Phaser.GameObjects.Circle
- // Circle tự động căn chỉnh tâm ở vị trí (x,y)
- this.sprite = this.scene.add.circle(
- x, // Vị trí x
- y, // Vị trí y
- radius, // Bán kính
- this.color // Màu sắc
- );
-
- // Add guard sprite to world container if it exists
- if (this.scene.worldContainer) {
- this.scene.worldContainer.add(this.sprite);
- }
-
- // Set depth to ensure guard is visible above grid but below player
- this.sprite.setDepth(5);
-
- // Đảm bảo hình tròn nằm chính giữa ô
- // Circle đã tự động đặt origin ở (0.5, 0.5)
- }
-
- // Cập nhật ánh sáng
- updateLight() {
- // Được override bởi subclass
- }
-
- // Xử lý khi chuyển lượt
- onTurnChange() {
- // Đượ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);
- this.sprite.x = x;
- this.sprite.y = y;
- }
-}
-
-// Trạm gác cố định
-export class StaticGuard extends Guard {
- constructor(scene, grid, row, col, litCells) {
- super(scene, grid, row, col, COLORS.guardStatic);
- this.litCells = litCells || []; // Giữ lại để tương thích với các level cũ
- }
-
- updateLight() {
- // 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() {
- this.updateLight();
- }
-}
-
-// 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, 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
- { row: 1, col: 0 }, // down
- { row: 0, col: -1 } // left
- ];
-
- // Tạo chỉ báo hướng (một đường thẳng từ tâm đến rìa hình tròn)
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
- const radius = this.grid.cellSize * 0.25;
- this.directionIndicator = this.scene.add.line(
- x, y, // Vị trí trung tâm
- 0, 0, // Điểm bắt đầu (tâm)
- 0, -radius, // Điểm kết thúc (hướng lên trên)
- 0xFFFFFF // Màu trắng
- );
-
- // Add direction indicator to world container if it exists
- if (this.scene.worldContainer) {
- this.scene.worldContainer.add(this.directionIndicator);
- }
-
- // Set depth to ensure direction indicator is visible above guard
- this.directionIndicator.setDepth(6);
-
- this.updateSpriteRotation();
- }
-
- updateLight() {
- // 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() {
- // Rotate to next direction
- this.direction = (this.direction + 1) % 4;
- this.updateSpriteRotation();
- this.updateLight();
- }
-
- updateSpriteRotation() {
- // Xoay chỉ báo hướng theo hướng hiện tại
- // Mỗi hướng xoay 90 độ (PI/2 radian)
- this.directionIndicator.rotation = this.direction * Math.PI / 2;
- }
-
- destroy() {
- super.destroy();
- if (this.directionIndicator) {
- this.directionIndicator.destroy();
- this.directionIndicator = null;
- }
- }
-
- update() {
- super.update();
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
- this.directionIndicator.x = x;
- this.directionIndicator.y = y;
- }
-}
-
-// 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, 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 = COLORS.guardBlinkingOff;
- }
- }
-
- updateLight() {
- if (this.isOn) {
- this.litCells.forEach(cell => {
- if (this.grid.isValidPosition(cell.row, cell.col)) {
- this.grid.setLight(cell.row, cell.col, true);
- }
- });
- }
- }
-
- onTurnChange() {
- this.isOn = !this.isOn;
- this.sprite.fillColor = this.isOn ? COLORS.guardBlinking : COLORS.guardBlinkingOff;
-
- this.updateLight();
- }
-}
-
-// Trạm gác tuần tra
-export class PatrollingGuard extends Guard {
- constructor(scene, grid, startRow, startCol, path) {
- 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
- this.direction = 0; // 0: up, 1: right, 2: down, 3: left
- this.isCircularPath = this.checkIfCircularPath();
- this.isReversing = false;
-
- // Tạo chỉ báo hướng (một đường thẳng từ tâm đến rìa hình tròn)
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
- const radius = this.grid.cellSize * 0.25;
- this.directionIndicator = this.scene.add.line(
- x, y, // Vị trí trung tâm
- 0, 0, // Điểm bắt đầu (tâm)
- 0, -radius, // Điểm kết thúc (hướng lên trên)
- 0xFFFFFF // Màu trắng
- );
-
- // Add direction indicator to world container if it exists
- if (this.scene.worldContainer) {
- this.scene.worldContainer.add(this.directionIndicator);
- }
-
- // Set depth to ensure direction indicator is visible above guard
- this.directionIndicator.setDepth(6);
-
- // Xác định hướng ban đầu dựa trên đường đi
- this.updateInitialDirection();
- }
-
- // Kiểm tra xem đường đi có phải là vòng tròn không
- checkIfCircularPath() {
- if (this.path.length <= 1) return false;
-
- // Kiểm tra nếu điểm đầu và điểm cuối giống nhau
- const firstPos = this.path[0];
- const lastPos = this.path[this.path.length - 1];
-
- return (firstPos.row === lastPos.row && firstPos.col === lastPos.col);
- }
-
- // Xác định hướng ban đầu dựa trên vị trí hiện tại và vị trí tiếp theo
- updateInitialDirection() {
- if (this.path.length <= 1) return;
-
- const currentPos = this.path[this.currentPathIndex];
- const nextIndex = (this.currentPathIndex + 1) % this.path.length;
- const nextPos = this.path[nextIndex];
-
- // Xác định hướng dựa trên sự khác biệt giữa vị trí hiện tại và vị trí tiếp theo
- if (nextPos.row < currentPos.row) this.direction = 0; // up
- else if (nextPos.col > currentPos.col) this.direction = 1; // right
- else if (nextPos.row > currentPos.row) this.direction = 2; // down
- else if (nextPos.col < currentPos.col) this.direction = 3; // left
-
- this.updateSpriteRotation();
- }
-
- updateSpriteRotation() {
- // Xoay chỉ báo hướng theo hướng hiện tại
- this.directionIndicator.rotation = this.direction * Math.PI / 2;
- }
-
- updateLight() {
- // Chiếu sáng ô phía trước và bên phải
- this.litCells = [];
-
- // Xác định ô phía trước
- const frontDir = this.getDirectionOffset(this.direction);
- const frontRow = this.row + frontDir.row;
- const frontCol = this.col + frontDir.col;
-
- // Xác định ô bên phải (tương đối với hướng hiện tại)
- const rightDir = this.getDirectionOffset((this.direction + 1) % 4);
- const rightRow = this.row + rightDir.row;
- const rightCol = this.col + rightDir.col;
-
- // Chiếu sáng ô phía trước
- if (this.grid.isValidPosition(frontRow, frontCol)) {
- this.grid.setLight(frontRow, frontCol, true);
- this.litCells.push({ row: frontRow, col: frontCol });
- }
-
- // Chiếu sáng ô bên phải
- if (this.grid.isValidPosition(rightRow, rightCol)) {
- this.grid.setLight(rightRow, rightCol, true);
- this.litCells.push({ row: rightRow, col: rightCol });
- }
- }
-
- // Lấy offset dựa trên hướng
- getDirectionOffset(dir) {
- const directions = [
- { row: -1, col: 0 }, // up
- { row: 0, col: 1 }, // right
- { row: 1, col: 0 }, // down
- { row: 0, col: -1 } // left
- ];
-
- return directions[dir];
- }
-
- onTurnChange() {
- if (this.path.length <= 1) return;
-
- let nextIndex;
-
- if (this.isCircularPath) {
- // Nếu là đường tròn, luôn di chuyển tiếp
- nextIndex = (this.currentPathIndex + 1) % this.path.length;
- } else {
- // Nếu không phải đường tròn, xử lý đảo chiều khi đến cuối
- if (this.isReversing) {
- // Đang đi ngược, giảm index
- nextIndex = this.currentPathIndex - 1;
-
- // Nếu đã đến đầu đường đi, chuyển sang đi xuôi
- if (nextIndex < 0) {
- this.isReversing = false;
- nextIndex = 1; // Bắt đầu từ vị trí thứ 2 (index 1)
-
- // Xoay ngược chiều kim đồng hồ (quay trái)
- this.direction = (this.direction + 3) % 4;
- this.updateSpriteRotation();
- }
- } else {
- // Đang đi xuôi, tăng index
- nextIndex = this.currentPathIndex + 1;
-
- // Nếu đã đến cuối đường đi, chuyển sang đi ngược
- if (nextIndex >= this.path.length) {
- this.isReversing = true;
- nextIndex = this.path.length - 2; // Bắt đầu từ vị trí áp cuối
-
- // Xoay ngược chiều kim đồng hồ (quay trái)
- this.direction = (this.direction + 3) % 4;
- this.updateSpriteRotation();
- }
- }
- }
-
- // Cập nhật vị trí hiện tại
- this.currentPathIndex = nextIndex;
- const nextPos = this.path[this.currentPathIndex];
-
- // Cập nhật hướng dựa trên sự thay đổi vị trí
- this.updateDirection(this.row, this.col, nextPos.row, nextPos.col);
-
- // Di chuyển đến vị trí mới
- this.row = nextPos.row;
- this.col = nextPos.col;
-
- // Cập nhật vị trí sprite
- this.update();
-
- // Cập nhật ánh sáng
- this.updateLight();
- }
-
- // Cập nhật hướng dựa trên sự thay đổi vị trí
- updateDirection(oldRow, oldCol, newRow, newCol) {
- if (newRow < oldRow) this.direction = 0; // up
- else if (newCol > oldCol) this.direction = 1; // right
- else if (newRow > oldRow) this.direction = 2; // down
- else if (newCol < oldCol) this.direction = 3; // left
-
- this.updateSpriteRotation();
- }
-
- destroy() {
- super.destroy();
- if (this.directionIndicator) {
- this.directionIndicator.destroy();
- this.directionIndicator = null;
- }
- }
-
- update() {
- super.update();
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
- this.directionIndicator.x = x;
- this.directionIndicator.y = y;
- }
-}
diff --git a/src/game/objects/LightingSystem.js b/src/game/objects/LightingSystem.js
deleted file mode 100644
index 5b6f070..0000000
--- a/src/game/objects/LightingSystem.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import Phaser from 'phaser';
-
-export class LightingSystem {
- constructor(scene, grid) {
- this.scene = scene;
- this.grid = grid;
- // Create graphics object for rendering light effects
- this.graphics = this.scene.add.graphics();
-
- // Add lighting graphics to world container if it exists
- if (this.scene.worldContainer) {
- this.scene.worldContainer.add(this.graphics);
- }
-
- // Position the lighting graphics at the same position as the grid
- if (this.scene.gridOffsetX !== undefined && this.scene.gridOffsetY !== undefined) {
- this.graphics.x = this.scene.gridOffsetX;
- this.graphics.y = this.scene.gridOffsetY;
- }
- }
-
- // Xóa tất cả ánh sáng trên lưới
- clearAllLight() {
- for (let row = 0; row < this.grid.rows; row++) {
- for (let col = 0; col < this.grid.cols; col++) {
- this.grid.setLight(row, col, false);
- }
- }
- }
-
- // Cập nhật ánh sáng từ tất cả các trạm gác
- updateLightFromGuards(guards) {
- this.clearAllLight();
-
- if (guards && Array.isArray(guards)) {
- guards.forEach(guard => {
- guard.updateLight();
- });
- }
- }
-}
diff --git a/src/game/objects/Player.js b/src/game/objects/Player.js
deleted file mode 100644
index e7c668a..0000000
--- a/src/game/objects/Player.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import Phaser from 'phaser';
-
-export class Player {
- constructor(scene, grid, row, col) {
- this.scene = scene;
- this.grid = grid;
- this.row = row;
- this.col = col;
- this.sprite = null;
- this.createSprite();
- }
-
- createSprite() {
- // Lấy vị trí pixel từ tọa độ lưới
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
-
- // Tạo sprite người chơi (hình tròn đen)
- this.sprite = this.scene.add.circle(
- x,
- y,
- this.grid.cellSize / 3,
- 0x000000
- );
-
- // Add player sprite to world container if it exists
- if (this.scene.worldContainer) {
- this.scene.worldContainer.add(this.sprite);
- }
-
- // Set depth to ensure player is visible above other elements
- this.sprite.setDepth(10);
- }
-
- // Di chuyển đến vị trí mới
- moveTo(row, col, skipDetectionCheck = false) {
- // Kiểm tra vị trí hợp lệ và không phải là tường
- if (!this.grid.isValidPosition(row, col) || this.grid.isWall(row, col)) {
- return false;
- }
-
- this.row = row;
- this.col = col;
-
- // Lấy vị trí pixel mới
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
-
- // Tạo hiệu ứng di chuyển
- this.scene.tweens.add({
- targets: this.sprite,
- x: x,
- y: y,
- duration: 100,
- ease: 'Linear',
- onComplete: () => {
- // Kiểm tra xem người chơi có đang ở ô sáng không sau khi di chuyển
- // Bỏ qua kiểm tra nếu skipDetectionCheck = true (khi đang tải lại màn chơi)
- if (!skipDetectionCheck && this.isInLitCell()) {
- // Người chơi bị phát hiện
- this.scene.showDetectionPopup();
- }
- }
- });
-
- return true;
- }
-
- // Di chuyển theo hướng
- move(direction) {
- let newRow = this.row;
- let newCol = this.col;
-
- switch (direction) {
- case 'up':
- newRow--;
- break;
- case 'down':
- newRow++;
- break;
- case 'left':
- newCol--;
- break;
- case 'right':
- newCol++;
- break;
- }
-
- return this.moveTo(newRow, newCol);
- }
-
- // Kiểm tra xem người chơi có đang ở ô sáng không
- isInLitCell() {
- return this.grid.isLight(this.row, this.col);
- }
-
- // Kiểm tra xem người chơi có đến đích không
- isAtGoal() {
- return this.grid.isGoal(this.row, this.col);
- }
-
- // Cập nhật vị trí sprite
- update() {
- const { x, y } = this.scene.gridToScreen(this.row, this.col);
-
- // Update position
- this.sprite.x = x;
- this.sprite.y = y;
- }
-}
diff --git a/src/game/objects/TurnManager.js b/src/game/objects/TurnManager.js
deleted file mode 100644
index b841c72..0000000
--- a/src/game/objects/TurnManager.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import Phaser from 'phaser';
-
-export class TurnManager {
- constructor(scene) {
- this.scene = scene;
- this.isPlayerTurn = true;
- this.turnCount = 0;
- }
-
- // Chuyển sang lượt tiếp theo
- nextTurn() {
- this.turnCount++;
-
- // Kiểm tra xem người chơi có đang ở ô đích không
- const playerRow = this.scene.player.row;
- const playerCol = this.scene.player.col;
-
- // Kiểm tra nếu grid đã được khởi tạo và có phương thức isGoal
- if (this.scene.grid && typeof this.scene.grid.isGoal === 'function') {
- if (this.scene.grid.isGoal(playerRow, playerCol)) {
- // Người chơi đã đến đích
- this.scene.handleLevelComplete();
- return;
- }
- }
-
- // Xóa ánh sáng hiện tại
- if (this.scene.lightSystem && typeof this.scene.lightSystem.clearAllLight === 'function') {
- this.scene.lightSystem.clearAllLight();
- }
-
- // Cập nhật trạm gác
- if (this.scene.guards && Array.isArray(this.scene.guards)) {
- this.scene.guards.forEach(guard => {
- if (guard && typeof guard.onTurnChange === 'function') {
- guard.onTurnChange();
- }
- });
- }
-
- // Kiểm tra xem người chơi có đang ở ô sáng không
- if (this.scene.grid && typeof this.scene.grid.isLight === 'function') {
- if (this.scene.grid.isLight(playerRow, playerCol)) {
- // Người chơi bị bắt - hiển thị popup trước
- this.scene.showDetectionPopup();
- return;
- }
- }
-
- // Chuyển lượt về người chơi
- this.isPlayerTurn = true;
- }
-
- // Reset turn manager
- reset() {
- this.isPlayerTurn = true;
- this.turnCount = 0;
- }
-}
diff --git a/src/game/scenes/Boot.js b/src/game/scenes/Boot.js
deleted file mode 100644
index 443e1ce..0000000
--- a/src/game/scenes/Boot.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Phaser from 'phaser';
-import { initLanguage } from '../localization';
-
-export class Boot extends Phaser.Scene {
- constructor() {
- super('Boot');
- }
-
- preload() {
- // Load any assets needed for the preloader scene
- // For this game, we don't need any fancy preloader assets
-
- // Initialize language settings
- initLanguage();
- }
-
- create() {
- this.scene.start('Preloader');
- }
-}
diff --git a/src/game/scenes/Game.js b/src/game/scenes/Game.js
deleted file mode 100644
index df8195b..0000000
--- a/src/game/scenes/Game.js
+++ /dev/null
@@ -1,513 +0,0 @@
-import Phaser from 'phaser';
-import { GridSystem } from '../objects/GridSystem';
-import { Player } from '../objects/Player';
-import { TurnManager } from '../objects/TurnManager';
-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() {
- super('Game');
- this.player = null;
- this.grid = null;
- this.lightSystem = null;
- this.turnManager = null;
- this.guards = [];
- this.levelManager = null;
- this.currentLevel = 1;
- this.livesRemaining = 3;
- this.gridSize = 8; // Default grid size
- this.cellSize = 50; // Default cell size
- this.gridOffsetX = 0;
- this.gridOffsetY = 0;
- this.inputEnabled = true;
- this.inputCooldown = 0;
- this.isPaused = false;
- this.isFinalLevel = false;
- this.cameraFollowsPlayer = false; // Disable camera following player
- }
-
- init(data) {
- this.currentLevel = data.level || 1;
- this.livesRemaining = data.lives || 3;
- // Reset state flags (constructor only runs once in Phaser)
- this.inputEnabled = true;
- this.isPaused = false;
- this.isFinalLevel = false;
- this.inputCooldown = 0;
- this.guards = [];
- this.detectionPopup = null;
- this.finalLevelMessage = null;
- }
-
- create() {
- // Get screen dimensions
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- // Create a container for the game world
- this.worldContainer = this.add.container(0, 0);
-
- // Create a camera for the game world
- this.gameCamera = this.cameras.main;
-
- // Calculate grid position to center it
- this.gridOffsetX = Math.floor((width - (this.gridSize * this.cellSize)) / 2);
- this.gridOffsetY = Math.floor((height - (this.gridSize * this.cellSize)) / 2);
-
- // Create grid system
- this.createGridSystem();
-
- // Create lighting system
- this.lightSystem = new LightingSystem(this, this.grid);
-
- // Make guard classes available to LevelManager
- this.StaticGuard = StaticGuard;
- this.RotatingGuard = RotatingGuard;
- this.BlinkingGuard = BlinkingGuard;
- this.PatrollingGuard = PatrollingGuard;
-
- // Create level manager
- this.levelManager = new LevelManager(this);
-
- // Create turn manager
- this.turnManager = new TurnManager(this);
-
- // Create player
- this.createPlayer(0, 0); // Default position, will be updated by level data
-
- // Setup input
- this.setupInput();
-
- // Create UI (fixed to camera, not in world container)
- this.createUI();
-
- // Load level
- this.levelManager.loadLevel(this.currentLevel);
-
- // Setup camera to follow player after level is loaded
- this.setupCamera();
-
- // Fade in
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-
- setupCamera() {
- // Make sure player exists
- if (!this.player || !this.player.sprite) {
- console.log("Cannot setup camera: player or player sprite is null");
- return;
- }
-
- // Get the game dimensions
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- // Reset the main camera
- this.cameras.main.resetFX();
- this.cameras.main.stopFollow();
-
- // Set the camera bounds to the entire world
- const worldWidth = Math.max(width, this.gridSize * this.cellSize + this.gridOffsetX * 2);
- const worldHeight = Math.max(height, this.gridSize * this.cellSize + this.gridOffsetY * 2);
-
- // Set world bounds
- this.physics.world.setBounds(0, 0, worldWidth, worldHeight);
-
- // Set camera bounds
- this.cameras.main.setBounds(0, 0, worldWidth, worldHeight);
-
- // Ensure the world container is centered
- this.worldContainer.x = 0;
- this.worldContainer.y = 0;
-
- // Center the camera on the grid instead of following the player
- const gridCenterX = this.gridOffsetX + (this.gridSize * this.cellSize) / 2;
- const gridCenterY = this.gridOffsetY + (this.gridSize * this.cellSize) / 2;
- this.cameras.main.centerOn(gridCenterX, gridCenterY);
-
- console.log("Camera setup complete - centered on grid");
- }
-
- update() {
- // Only process input if game is not paused
- if (!this.isPaused) {
- // Check for keyboard input
- this.handleKeyboardInput();
-
- // Check for final level special condition
- if (this.isFinalLevel) {
- this.checkFinalLevelCondition();
- }
-
- // Make sure player exists
- if (this.player && this.player.sprite) {
- // Update player position
- this.player.update();
-
- // No need to check camera following anymore as we're using a static camera
- }
- }
- }
-
- createGridSystem() {
- this.grid = new GridSystem(this, this.gridSize, this.gridSize, this.cellSize);
-
- // Position the grid in the center of the screen
- this.grid.graphics.x = this.gridOffsetX;
- this.grid.graphics.y = this.gridOffsetY;
-
- // Add grid graphics to world container
- this.worldContainer.add(this.grid.graphics);
-
- // Render the grid
- this.grid.render();
-
- // Debug: Visualize cell centers (uncomment for debugging)
- // this.visualizeCellCenters();
- }
-
- // Helper method to visualize the center of each cell (for debugging)
- visualizeCellCenters() {
- for (let row = 0; row < this.gridSize; row++) {
- for (let col = 0; col < this.gridSize; col++) {
- const { x, y } = this.gridToScreen(row, col);
- // Draw a small dot at the center of each cell
- const centerMarker = this.add.circle(x, y, 2, 0xFF0000);
- centerMarker.setDepth(100); // Ensure it's visible above other elements
- }
- }
- }
-
- createPlayer(row, col) {
- this.player = new Player(this, this.grid, row, col);
- }
-
- setupInput() {
- // Setup keyboard input
- this.cursors = this.input.keyboard.createCursorKeys();
-
- // WASD keys
- this.wasd = {
- up: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
- left: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
- down: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
- right: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
- };
-
- // Add click/tap input for grid cells
- this.input.on('pointerdown', (pointer) => {
- if (this.isPaused || !this.inputEnabled || !this.turnManager.isPlayerTurn) return;
-
- // Convert screen coordinates to grid coordinates
- const gridPos = this.screenToGrid(pointer.x, pointer.y);
-
- // Check if the clicked position is adjacent to the player
- const rowDiff = Math.abs(gridPos.row - this.player.row);
- const colDiff = Math.abs(gridPos.col - this.player.col);
-
- // Only allow moving to adjacent cells (not diagonally)
- if ((rowDiff === 1 && colDiff === 0) || (rowDiff === 0 && colDiff === 1)) {
- if (this.player.moveTo(gridPos.row, gridPos.col)) {
- this.turnManager.nextTurn();
- this.updateTurnDisplay();
- }
- }
- });
- }
-
- handleKeyboardInput() {
- // Only process input during player's turn and when input is enabled
- if (!this.inputEnabled || !this.turnManager.isPlayerTurn) return;
-
- // Use a cooldown to prevent multiple inputs in a single frame
- if (this.inputCooldown > 0) {
- this.inputCooldown--;
- return;
- }
-
- let direction = null;
-
- // Check arrow keys
- if (this.cursors.up.isDown || this.wasd.up.isDown) {
- direction = 'up';
- } else if (this.cursors.down.isDown || this.wasd.down.isDown) {
- direction = 'down';
- } else if (this.cursors.left.isDown || this.wasd.left.isDown) {
- direction = 'left';
- } else if (this.cursors.right.isDown || this.wasd.right.isDown) {
- direction = 'right';
- }
-
- if (direction) {
- if (this.player.move(direction)) {
- this.turnManager.nextTurn();
- this.updateTurnDisplay();
- this.inputCooldown = 10;
- }
- }
- }
-
- createUI() {
- const width = this.cameras.main.width;
-
- this.uiContainer = this.add.container(0, 0);
- this.uiContainer.setScrollFactor(0);
-
- // Lives display
- this.livesText = this.add.text(20, 20, `${getText('lives')}${this.livesRemaining}`, {
- font: FONTS.ui, fill: COLORS.textPrimary,
- }).setScrollFactor(0);
-
- // Level display
- this.levelText = this.add.text(width - 20, 20, `${getText('level')}${this.currentLevel}`, {
- 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
- const pauseBtn = createSmallButton(this, width - 65, 60, getText('pause'), () => {
- this.togglePause();
- });
- [pauseBtn.border, pauseBtn.bg, pauseBtn.label].forEach(o => o.setScrollFactor(0));
-
- // Menu button
- const menuBtn = createSmallButton(this, width - 65, 100, getText('menu'), () => {
- this.scene.start('MainMenu');
- });
- [menuBtn.border, menuBtn.bg, menuBtn.label].forEach(o => o.setScrollFactor(0));
-
- this.uiContainer.add([
- this.livesText, this.levelText, this.turnText,
- pauseBtn.border, pauseBtn.bg, pauseBtn.label,
- menuBtn.border, menuBtn.bg, menuBtn.label,
- ]);
-
- this.createPauseMenu();
- }
-
- createPauseMenu() {
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- this.pauseMenu = this.add.container(width / 2, height / 2);
- this.pauseMenu.setScrollFactor(0);
-
- 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);
-
- const title = this.add.text(0, -100, getText('paused'), {
- 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);
- });
- const menuEls = makeMenuBtn(80, getText('mainMenu'), () => this.scene.start('MainMenu'));
-
- this.pauseMenu.add([bg, border, title, ...resumeEls, ...restartEls, ...menuEls]);
- this.pauseMenu.setVisible(false);
- }
-
- togglePause() {
- this.isPaused = !this.isPaused;
- this.pauseMenu.setVisible(this.isPaused);
- this.inputEnabled = !this.isPaused;
- }
-
- updateTurnDisplay() {
- if (this.turnText) {
- this.turnText.setText(`Turns: ${this.turnManager.turnCount}`);
- }
- }
-
-
-
- // Helper method to convert grid coordinates to screen coordinates
- gridToScreen(row, col) {
- const gridPos = this.grid.gridToPixel(row, col);
- return {
- x: gridPos.x + this.gridOffsetX,
- y: gridPos.y + this.gridOffsetY
- };
- }
-
- // Helper method to convert screen coordinates to grid coordinates
- screenToGrid(x, y) {
- return this.grid.pixelToGrid(x - this.gridOffsetX, y - this.gridOffsetY);
- }
-
- showDetectionPopup() {
- if (this.detectionPopup) return;
-
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- this.isPaused = true;
- this.inputEnabled = false;
-
- this.detectionPopup = this.add.container(width / 2, height / 2);
- this.detectionPopup.setDepth(100);
- this.detectionPopup.setScrollFactor(0);
-
- const bg = this.add.rectangle(0, 0, 300, 200, COLORS.bgOverlay, 0.85);
- const title = this.add.text(0, -60, getText('detected'), {
- font: FONTS.heading, fill: COLORS.textDanger, wordWrap: { width: 280 },
- }).setOrigin(0.5, 0.5);
-
- 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);
-
- this.detectionPopup.add([bg, title, btnBg, btnLabel]);
-
- btnBg.setInteractive({ useHandCursor: true })
- .on('pointerover', () => btnBg.fillColor = COLORS.btnHover)
- .on('pointerout', () => btnBg.fillColor = COLORS.btnDefault)
- .on('pointerdown', () => {
- this.closeDetectionPopup();
- this.handlePlayerCaught();
- });
- }
-
- closeDetectionPopup() {
- if (this.detectionPopup) {
- this.detectionPopup.removeAll(true);
- this.detectionPopup.destroy();
- this.detectionPopup = null;
- this.isPaused = false;
- this.inputEnabled = true;
- }
- }
-
- // Xử lý khi người chơi bị bắt
- handlePlayerCaught() {
- this.livesRemaining--;
-
- // Cập nhật hiển thị số mạng
- if (this.livesText) {
- this.livesText.setText(`${getText('lives')}${this.livesRemaining}`);
- }
-
- // Đảm bảo tất cả các popup đã được đóng
- if (this.detectionPopup) {
- this.closeDetectionPopup();
- }
-
- if (this.livesRemaining <= 0) {
- // Hết mạng, game over
- this.scene.start('GameOver', {
- level: this.currentLevel,
- isLastLevel: false
- });
- } else {
- // Còn mạng, reset màn chơi hiện tại
- this.levelManager.loadLevel(this.currentLevel);
- }
- }
-
- handleLevelComplete() {
- const totalLevels = this.levelManager.getTotalLevels();
- completeLevel(this.currentLevel, totalLevels);
-
- this.isPaused = true;
- this.inputEnabled = false;
-
- const nextLevel = this.currentLevel + 1;
-
- // 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 });
- }
- });
- });
- }
-
- // Kiểm tra điều kiện đặc biệt cho màn cuối
- checkFinalLevelCondition() {
- if (!this.isFinalLevel) return false;
-
- // Kiểm tra xem người chơi có đang ở gần công chúa cà rốt bị mất tích không
- const playerRow = this.player.row;
- const playerCol = this.player.col;
- const goalRow = 9; // Vị trí công chúa cà rốt ở màn cuối (row 9, col 9)
- const goalCol = 9;
-
- // Tính khoảng cách Manhattan từ người chơi đến công chúa cà rốt
- const distance = Math.abs(playerRow - goalRow) + Math.abs(playerCol - goalCol);
-
- // Nếu ninja thỏ đến gần công chúa cà rốt (khoảng cách <= 2), kích hoạt cơ chế đặc biệt
- if (distance <= 2) {
- // Bật sáng toàn bộ bản đồ
- this.lightUpEntireMap();
- return true;
- }
-
- return false;
- }
-
- // Bật sáng toàn bộ bản đồ cho màn cuối
- lightUpEntireMap() {
- // Hiển thị thông báo
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- if (!this.finalLevelMessage) {
- this.finalLevelMessage = this.add.text(width / 2, height / 4,
- getText('princessDetected'), {
- font: FONTS.body,
- fill: COLORS.textDanger,
- backgroundColor: '#000000',
- padding: { x: 10, y: 5 },
- wordWrap: { width: width * 0.8 },
- });
- this.finalLevelMessage.setOrigin(0.5, 0.5);
- this.finalLevelMessage.setDepth(100);
- this.finalLevelMessage.setScrollFactor(0); // Fix to camera (don't move with world)
- }
-
- // Bật sáng toàn bộ các ô
- for (let row = 0; row < this.grid.rows; row++) {
- for (let col = 0; col < this.grid.cols; col++) {
- if (!this.grid.isWall(row, col)) {
- this.grid.setLight(row, col, true);
- }
- }
- }
-
- // Vẽ lại lưới
- this.grid.render();
- }
-}
diff --git a/src/game/scenes/GameOver.js b/src/game/scenes/GameOver.js
deleted file mode 100644
index 4f82c94..0000000
--- a/src/game/scenes/GameOver.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import Phaser from 'phaser';
-import { getText } from '../localization';
-import { COLORS, FONTS, createButton } from '../theme';
-
-export class GameOver extends Phaser.Scene {
- constructor() {
- super('GameOver');
- }
-
- init(data) {
- this.finalLevel = data.level || 1;
- this.isLastLevel = data.isLastLevel || false;
- }
-
- 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 isEnd = this.isLastLevel;
- const titleText = isEnd ? getText('theEnd') : getText('gameOver');
- const descText = isEnd ? getText('ninjaFailed') : getText('caughtInLight');
-
- this.add.text(width / 2, height / 3, titleText, {
- font: 'bold 48px Arial',
- fill: isEnd ? COLORS.textAccent : COLORS.textDanger,
- align: 'center',
- wordWrap: { width: width * 0.8 },
- }).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);
-
- 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) {
- this.scene.start('MainMenu');
- } else {
- this.scene.start('Game', { level: this.finalLevel, lives: 3 });
- }
- });
- });
-
- createButton(this, width / 2, height * 0.8, getText('mainMenu'), () => {
- this.cameras.main.fadeOut(300, 0, 0, 0);
- this.cameras.main.once('camerafadeoutcomplete', () => {
- this.scene.start('MainMenu');
- });
- });
-
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-}
diff --git a/src/game/scenes/Guide.js b/src/game/scenes/Guide.js
deleted file mode 100644
index beb9001..0000000
--- a/src/game/scenes/Guide.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import Phaser from 'phaser';
-import { getText } from '../localization';
-import { COLORS, FONTS, createButton } from '../theme';
-
-export class Guide extends Phaser.Scene {
- constructor() {
- super('Guide');
- }
-
- create() {
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark);
-
- // Scrollable guide area
- const guideAreaY = 20;
- const guideAreaHeight = height - 120;
-
- const guideContainer = this.add.container(0, 0);
-
- let y = guideAreaY;
- const spacing = 20;
-
- const title = this.add.text(width / 2, y + 30, getText('guideTitle'), {
- font: FONTS.title,
- fill: COLORS.textTitle,
- align: 'center',
- }).setOrigin(0.5, 0.5);
- guideContainer.add(title);
- y += 60;
-
- // 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 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;
- };
-
- addSection('levelObjectives', 'levelObjectivesContent');
- addSection('movementControls', 'movementControlsContent');
- addSection('enemyTypes', 'enemyTypesContent');
-
- // Mask for scroll area
- const maskShape = this.make.graphics({ x: 0, y: 0, add: false });
- maskShape.fillStyle(0xffffff);
- maskShape.fillRect(0, guideAreaY, width, guideAreaHeight);
- guideContainer.setMask(maskShape.createGeometryMask());
-
- // 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 - deltaY * 0.5, maxScrollUp, guideAreaY
- );
- });
- this.input.keyboard.on('keydown-UP', () => {
- guideContainer.y = Phaser.Math.Clamp(guideContainer.y + 30, maxScrollUp, guideAreaY);
- });
- this.input.keyboard.on('keydown-DOWN', () => {
- guideContainer.y = Phaser.Math.Clamp(guideContainer.y - 30, maxScrollUp, guideAreaY);
- });
-
- createButton(this, width / 2, height - 50, getText('back'), () => {
- this.cameras.main.fadeOut(300, 0, 0, 0);
- this.cameras.main.once('camerafadeoutcomplete', () => {
- this.scene.start('MainMenu');
- });
- });
-
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-}
diff --git a/src/game/scenes/LevelIntro.js b/src/game/scenes/LevelIntro.js
deleted file mode 100644
index 87a3325..0000000
--- a/src/game/scenes/LevelIntro.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import Phaser from 'phaser';
-import { getText } from '../localization';
-import { COLORS, FONTS, createButton } from '../theme';
-import { LEVELS } from '../levels/Levels';
-
-export class LevelIntro extends Phaser.Scene {
- constructor() {
- super('LevelIntro');
- }
-
- init(data) {
- this.level = data.level || 1;
- this.lives = data.lives || 3;
- }
-
- create() {
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark);
-
- const levelData = LEVELS[this.level - 1];
- const levelName = levelData ? levelData.name : `Level ${this.level}`;
- const storyKey = levelData ? levelData.storyKey : null;
- const storyText = storyKey ? getText(storyKey) : '';
-
- // Level number
- this.add.text(width / 2, height / 4, `${getText('level')}${this.level}`, {
- font: FONTS.small,
- fill: COLORS.textSecondary,
- }).setOrigin(0.5, 0.5);
-
- // Level name
- this.add.text(width / 2, height / 4 + 40, levelName, {
- font: FONTS.title,
- fill: COLORS.textTitle,
- align: 'center',
- }).setOrigin(0.5, 0.5);
-
- // Story text
- if (storyText) {
- this.add.text(width / 2, height / 2, storyText, {
- font: FONTS.body,
- fill: COLORS.textPrimary,
- align: 'center',
- wordWrap: { width: width * 0.7 },
- lineSpacing: 6,
- }).setOrigin(0.5, 0.5);
- }
-
- // Continue button
- createButton(this, width / 2, height * 0.78, getText('continue'), () => {
- this.cameras.main.fadeOut(300, 0, 0, 0);
- this.cameras.main.once('camerafadeoutcomplete', () => {
- this.scene.start('Game', { level: this.level, lives: this.lives });
- });
- });
-
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-}
diff --git a/src/game/scenes/LevelSelect.js b/src/game/scenes/LevelSelect.js
deleted file mode 100644
index b4d6512..0000000
--- a/src/game/scenes/LevelSelect.js
+++ /dev/null
@@ -1,84 +0,0 @@
-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() {
- super('LevelSelect');
- this.totalLevels = 12;
- }
-
- create() {
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
- const progress = getProgress();
-
- this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark);
-
- 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 = 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);
-
- // 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);
-
- 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 });
- });
- });
- }
- }
-
- createButton(this, width / 2, height - 70, getText('back'), () => {
- this.cameras.main.fadeOut(300, 0, 0, 0);
- this.cameras.main.once('camerafadeoutcomplete', () => {
- this.scene.start('MainMenu');
- });
- });
-
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-}
diff --git a/src/game/scenes/MainMenu.js b/src/game/scenes/MainMenu.js
deleted file mode 100644
index db456fb..0000000
--- a/src/game/scenes/MainMenu.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import Phaser from 'phaser';
-import { getText } from '../localization';
-import { COLORS, FONTS, createButton } from '../theme';
-
-export class MainMenu extends Phaser.Scene {
- constructor() {
- super('MainMenu');
- }
-
- create() {
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- // 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);
-
- // 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);
-
- // Menu buttons
- const btnY = height / 2 + 20;
- const gap = 62;
-
- createButton(this, width / 2, btnY, getText('startGame'), () => {
- this.cameras.main.fadeOut(300, 0, 0, 0);
- this.cameras.main.once('camerafadeoutcomplete', () => {
- this.scene.start('StoryIntro');
- });
- });
-
- 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');
- });
- });
-
- 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');
- });
- });
-
- createButton(this, width / 2, btnY + gap * 3, getText('settings'), () => {
- this.cameras.main.fadeOut(300, 0, 0, 0);
- this.cameras.main.once('camerafadeoutcomplete', () => {
- this.scene.start('Settings');
- });
- });
-
- // Fade in
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-}
diff --git a/src/game/scenes/Preloader.js b/src/game/scenes/Preloader.js
deleted file mode 100644
index cd56b22..0000000
--- a/src/game/scenes/Preloader.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import Phaser from 'phaser';
-import { COLORS } from '../theme';
-
-export class Preloader extends Phaser.Scene {
- constructor() {
- super('Preloader');
- }
-
- preload() {
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
-
- this.add.rectangle(width / 2, height / 2, width, height, COLORS.bgDark);
-
- const progressBox = this.add.graphics();
- 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: COLORS.textPrimary,
- }).setOrigin(0.5, 0.5);
-
- this.load.on('progress', (value) => {
- progressBar.clear();
- 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();
- });
- }
-
- create() {
- this.scene.start('MainMenu');
- }
-}
diff --git a/src/game/scenes/Settings.js b/src/game/scenes/Settings.js
deleted file mode 100644
index 821286b..0000000
--- a/src/game/scenes/Settings.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import Phaser from 'phaser';
-import { getText, setLanguage, getLanguage } from '../localization';
-import { COLORS, FONTS, createButton } from '../theme';
-
-export class Settings extends Phaser.Scene {
- constructor() {
- super('Settings');
- }
-
- 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 title = this.add.text(width / 2, height / 4, getText('settings'), {
- font: FONTS.title,
- fill: COLORS.textTitle,
- align: 'center',
- });
- title.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);
-
- const currentLanguage = getLanguage();
-
- 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;
-
- 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;
-
- 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');
- });
- });
-
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-
- changeLanguage(language) {
- if (getLanguage() !== language) {
- setLanguage(language);
- this.scene.restart();
- }
- }
-}
diff --git a/src/game/scenes/StoryIntro.js b/src/game/scenes/StoryIntro.js
deleted file mode 100644
index 2aaee74..0000000
--- a/src/game/scenes/StoryIntro.js
+++ /dev/null
@@ -1,72 +0,0 @@
-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;
- this.isScrollComplete = false;
- }
-
- create() {
- const width = this.cameras.main.width;
- const height = this.cameras.main.height;
- this.isScrollComplete = false;
- this.hasStartedGame = false;
-
- this.add.rectangle(0, 0, width, height, COLORS.bgDark).setOrigin(0, 0);
-
- this.add.text(width / 2, 100, getText('storyTitle'), {
- font: FONTS.title,
- fill: COLORS.textAccent,
- align: 'center',
- }).setOrigin(0.5, 0.5);
-
- 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);
- this.storyContainer.y = height - 50;
- this.totalTextHeight = storyText.height;
-
- // Skip button
- createButton(this, width - 100, height - 50, getText('skip'), () => {
- this.startGame();
- }, 150, 40);
-
- const scrollTime = (this.totalTextHeight + height) / this.scrollSpeed;
- this.time.delayedCall(scrollTime * 16.67, () => {
- if (!this.isScrollComplete) {
- this.isScrollComplete = true;
- this.startGame();
- }
- });
-
- this.cameras.main.fadeIn(400, 0, 0, 0);
- }
-
- update() {
- if (!this.isScrollComplete) {
- this.storyContainer.y -= this.scrollSpeed;
- if (this.storyContainer.y < -this.totalTextHeight) {
- this.isScrollComplete = true;
- this.startGame();
- }
- }
- }
-
- startGame() {
- if (!this.hasStartedGame) {
- this.hasStartedGame = true;
- this.cameras.main.fadeOut(300, 0, 0, 0);
- this.cameras.main.once('camerafadeoutcomplete', () => {
- this.scene.start('LevelIntro', { level: 1, lives: 3 });
- });
- }
- }
-}
diff --git a/src/game/theme.js b/src/game/theme.js
deleted file mode 100644
index cd08ea5..0000000
--- a/src/game/theme.js
+++ /dev/null
@@ -1,70 +0,0 @@
-// Shared visual theme constants for Night Ninja: Twilight Voyage
-
-export const COLORS = {
- // Backgrounds
- bgDark: 0x0a0a1a,
- bgPanel: 0x1a1a2e,
- bgOverlay: 0x000000,
-
- // Buttons
- btnDefault: 0x16213e,
- btnHover: 0x0f3460,
- btnBorder: 0x533483,
-
- // Grid
- gridEmpty: 0x1a1a2e,
- gridWall: 0x4a4a5e,
- gridGoal: 0x00c853,
- gridLit: 0xffea00,
- gridBorder: 0x2a2a3e,
-
- // Text
- textPrimary: '#e0e0ff',
- textSecondary: '#9999bb',
- textAccent: '#bb86fc',
- textDanger: '#ff5555',
- textTitle: '#ffffff',
-
- // Guards
- guardStatic: 0xff4444,
- guardRotating: 0x4488ff,
- guardBlinking: 0xffdd44,
- guardBlinkingOff: 0x887722,
- guardPatrolling: 0xbb44ff,
-
- // Player
- player: 0x111111,
-};
-
-export const FONTS = {
- title: 'bold 36px Arial',
- heading: 'bold 28px Arial',
- button: '22px Arial',
- buttonSmall: '18px Arial',
- body: '20px Arial',
- small: '16px Arial',
- ui: '18px Arial',
-};
-
-// Create a styled button with consistent look
-export function createButton(scene, x, y, text, onClick, width = 220, height = 50) {
- const border = scene.add.rectangle(x, y, width + 4, height + 4, COLORS.btnBorder);
- const bg = scene.add.rectangle(x, y, width, height, COLORS.btnDefault);
- const label = scene.add.text(x, y, text, {
- font: FONTS.button,
- fill: COLORS.textPrimary,
- });
- label.setOrigin(0.5, 0.5);
-
- bg.setInteractive({ useHandCursor: true })
- .on('pointerover', () => bg.fillColor = COLORS.btnHover)
- .on('pointerout', () => bg.fillColor = COLORS.btnDefault)
- .on('pointerdown', onClick);
-
- return { border, bg, label };
-}
-
-// Create a small UI button (for pause, menu, etc.)
-export function createSmallButton(scene, x, y, text, onClick) {
- return createButton(scene, x, y, text, onClick, 110, 34);
-}
diff --git a/src/lib/game/grid-system.js b/src/lib/game/grid-system.js
new file mode 100644
index 0000000..1de5665
--- /dev/null
+++ b/src/lib/game/grid-system.js
@@ -0,0 +1,75 @@
+// Grid data model — pure JS, no framework dependency
+
+export class GridSystem {
+ constructor(rows, cols, cellSize) {
+ this.rows = rows;
+ this.cols = cols;
+ this.cellSize = cellSize;
+ this.grid = this.createEmptyGrid(rows, cols);
+ }
+
+ createEmptyGrid(rows, cols) {
+ return Array.from({ length: rows }, () =>
+ Array.from({ length: cols }, () => ({
+ isWall: false,
+ isGoal: false,
+ isLight: false,
+ }))
+ );
+ }
+
+ resize(rows, cols) {
+ this.rows = rows;
+ this.cols = cols;
+ this.grid = this.createEmptyGrid(rows, cols);
+ }
+
+ isValidPosition(row, col) {
+ return row >= 0 && row < this.rows && col >= 0 && col < this.cols;
+ }
+
+ setWall(row, col, value) {
+ if (this.isValidPosition(row, col)) this.grid[row][col].isWall = value;
+ }
+
+ isWall(row, col) {
+ return this.isValidPosition(row, col) && this.grid[row][col].isWall;
+ }
+
+ setGoal(row, col, value) {
+ if (this.isValidPosition(row, col)) this.grid[row][col].isGoal = value;
+ }
+
+ isGoal(row, col) {
+ return this.isValidPosition(row, col) && this.grid[row][col].isGoal;
+ }
+
+ setLight(row, col, value) {
+ if (this.isValidPosition(row, col)) this.grid[row][col].isLight = value;
+ }
+
+ isLight(row, col) {
+ return this.isValidPosition(row, col) && this.grid[row][col].isLight;
+ }
+
+ // Clear all light from the grid
+ clearAllLight() {
+ for (let row = 0; row < this.rows; row++) {
+ for (let col = 0; col < this.cols; col++) {
+ this.grid[row][col].isLight = false;
+ }
+ }
+ }
+
+ // Get flat array of all cells with positions (for rendering)
+ getAllCells() {
+ const cells = [];
+ for (let row = 0; row < this.rows; row++) {
+ for (let col = 0; col < this.cols; col++) {
+ const cell = this.grid[row][col];
+ cells.push({ row, col, ...cell });
+ }
+ }
+ return cells;
+ }
+}
diff --git a/src/lib/game/guards.js b/src/lib/game/guards.js
new file mode 100644
index 0000000..1b94d15
--- /dev/null
+++ b/src/lib/game/guards.js
@@ -0,0 +1,184 @@
+// Guard logic — pure JS, no framework dependency
+// Each guard exposes { row, col, type, direction, isOn } for rendering
+
+class Guard {
+ constructor(grid, row, col, type) {
+ this.grid = grid;
+ this.row = row;
+ this.col = col;
+ this.type = type;
+ this.direction = 0;
+ this.isOn = true;
+ }
+
+ updateLight() {}
+ onTurnChange() {}
+}
+
+export class StaticGuard extends Guard {
+ constructor(grid, row, col, litCells) {
+ super(grid, row, col, 'static');
+ this.litCells = litCells || [];
+ }
+
+ updateLight() {
+ if (this.grid.isValidPosition(this.row, this.col)) {
+ this.grid.setLight(this.row, this.col, true);
+ }
+ this.litCells.forEach(cell => {
+ if (this.grid.isValidPosition(cell.row, cell.col)) {
+ this.grid.setLight(cell.row, cell.col, true);
+ }
+ });
+ }
+
+ onTurnChange() {
+ this.updateLight();
+ }
+}
+
+export class RotatingGuard extends Guard {
+ constructor(grid, row, col, startDirection) {
+ super(grid, row, col, 'rotating');
+ this.direction = startDirection || 0;
+ this.lightRange = 2;
+ this.directions = [
+ { row: -1, col: 0 }, // up
+ { row: 0, col: 1 }, // right
+ { row: 1, col: 0 }, // down
+ { row: 0, col: -1 }, // left
+ ];
+ }
+
+ updateLight() {
+ if (this.grid.isValidPosition(this.row, this.col)) {
+ this.grid.setLight(this.row, this.col, true);
+ }
+ 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() {
+ this.direction = (this.direction + 1) % 4;
+ this.updateLight();
+ }
+}
+
+export class BlinkingGuard extends Guard {
+ constructor(grid, row, col, litCells, startState) {
+ super(grid, row, col, 'blinking');
+ this.litCells = litCells || [];
+ this.isOn = startState !== undefined ? startState : true;
+ }
+
+ updateLight() {
+ if (this.isOn) {
+ this.litCells.forEach(cell => {
+ if (this.grid.isValidPosition(cell.row, cell.col)) {
+ this.grid.setLight(cell.row, cell.col, true);
+ }
+ });
+ }
+ }
+
+ onTurnChange() {
+ this.isOn = !this.isOn;
+ this.updateLight();
+ }
+}
+
+export class PatrollingGuard extends Guard {
+ constructor(grid, startRow, startCol, path) {
+ super(grid, startRow, startCol, 'patrolling');
+ this.path = path || [];
+ this.currentPathIndex = 0;
+ this.isCircularPath = this.checkIfCircularPath();
+ this.isReversing = false;
+ this.updateInitialDirection();
+ }
+
+ checkIfCircularPath() {
+ if (this.path.length <= 1) return false;
+ const first = this.path[0];
+ const last = this.path[this.path.length - 1];
+ return first.row === last.row && first.col === last.col;
+ }
+
+ updateInitialDirection() {
+ if (this.path.length <= 1) return;
+ const curr = this.path[this.currentPathIndex];
+ const next = this.path[(this.currentPathIndex + 1) % this.path.length];
+ if (next.row < curr.row) this.direction = 0;
+ else if (next.col > curr.col) this.direction = 1;
+ else if (next.row > curr.row) this.direction = 2;
+ else if (next.col < curr.col) this.direction = 3;
+ }
+
+ getDirectionOffset(dir) {
+ const directions = [
+ { row: -1, col: 0 },
+ { row: 0, col: 1 },
+ { row: 1, col: 0 },
+ { row: 0, col: -1 },
+ ];
+ return directions[dir];
+ }
+
+ updateLight() {
+ const frontDir = this.getDirectionOffset(this.direction);
+ const frontRow = this.row + frontDir.row;
+ const frontCol = this.col + frontDir.col;
+ const rightDir = this.getDirectionOffset((this.direction + 1) % 4);
+ const rightRow = this.row + rightDir.row;
+ const rightCol = this.col + rightDir.col;
+
+ if (this.grid.isValidPosition(frontRow, frontCol)) {
+ this.grid.setLight(frontRow, frontCol, true);
+ }
+ if (this.grid.isValidPosition(rightRow, rightCol)) {
+ this.grid.setLight(rightRow, rightCol, true);
+ }
+ }
+
+ updateDirection(oldRow, oldCol, newRow, newCol) {
+ if (newRow < oldRow) this.direction = 0;
+ else if (newCol > oldCol) this.direction = 1;
+ else if (newRow > oldRow) this.direction = 2;
+ else if (newCol < oldCol) this.direction = 3;
+ }
+
+ onTurnChange() {
+ if (this.path.length <= 1) return;
+
+ let nextIndex;
+ if (this.isCircularPath) {
+ nextIndex = (this.currentPathIndex + 1) % this.path.length;
+ } else if (this.isReversing) {
+ nextIndex = this.currentPathIndex - 1;
+ if (nextIndex < 0) {
+ this.isReversing = false;
+ nextIndex = 1;
+ this.direction = (this.direction + 3) % 4;
+ }
+ } else {
+ nextIndex = this.currentPathIndex + 1;
+ if (nextIndex >= this.path.length) {
+ this.isReversing = true;
+ nextIndex = this.path.length - 2;
+ this.direction = (this.direction + 3) % 4;
+ }
+ }
+
+ this.currentPathIndex = nextIndex;
+ const nextPos = this.path[this.currentPathIndex];
+ this.updateDirection(this.row, this.col, nextPos.row, nextPos.col);
+ this.row = nextPos.row;
+ this.col = nextPos.col;
+ this.updateLight();
+ }
+}
diff --git a/src/lib/game/level-manager.js b/src/lib/game/level-manager.js
new file mode 100644
index 0000000..f729355
--- /dev/null
+++ b/src/lib/game/level-manager.js
@@ -0,0 +1,71 @@
+// Level loader — pure function, no framework dependency
+
+import { LEVELS } from '../levels/levels.js';
+import { GridSystem } from './grid-system.js';
+import { Player } from './player.js';
+import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard } from './guards.js';
+
+// Load a level by ID, returns complete game state
+export function loadLevel(levelId) {
+ if (levelId < 1 || levelId > LEVELS.length) return null;
+
+ const data = LEVELS[levelId - 1];
+ const size = data.grid.rows;
+ const cellSize = 50;
+
+ const grid = new GridSystem(size, size, cellSize);
+
+ // Set walls
+ if (data.walls) {
+ data.walls.forEach(w => grid.setWall(w.row, w.col, true));
+ }
+
+ // Set goal
+ if (data.goal) {
+ grid.setGoal(data.goal.row, data.goal.col, true);
+ }
+
+ // Create player
+ const player = new Player(grid, data.player.row, data.player.col);
+
+ // Create guards
+ const guards = [];
+ if (data.guards) {
+ data.guards.forEach(g => {
+ let guard = null;
+ switch (g.type) {
+ case 'static':
+ guard = new StaticGuard(grid, g.position.row, g.position.col, g.litCells);
+ break;
+ case 'rotating':
+ guard = new RotatingGuard(grid, g.position.row, g.position.col, g.startDirection);
+ break;
+ case 'blinking':
+ guard = new BlinkingGuard(grid, g.position.row, g.position.col, g.litCells, g.startState);
+ break;
+ case 'patrolling':
+ guard = new PatrollingGuard(grid, g.startPosition.row, g.startPosition.col, g.path);
+ break;
+ }
+ if (guard) guards.push(guard);
+ });
+ }
+
+ // Initialize guard lights
+ guards.forEach(g => g.updateLight());
+
+ return {
+ grid,
+ player,
+ guards,
+ levelName: data.name,
+ storyKey: data.storyKey,
+ isFinalLevel: data.isFinalLevel || false,
+ goalRow: data.goal.row,
+ goalCol: data.goal.col,
+ };
+}
+
+export function getTotalLevels() {
+ return LEVELS.length;
+}
diff --git a/src/lib/game/player.js b/src/lib/game/player.js
new file mode 100644
index 0000000..245ee84
--- /dev/null
+++ b/src/lib/game/player.js
@@ -0,0 +1,40 @@
+// Player data model — pure JS, no framework dependency
+
+export class Player {
+ constructor(grid, row, col) {
+ this.grid = grid;
+ this.row = row;
+ this.col = col;
+ }
+
+ moveTo(row, col) {
+ if (!this.grid.isValidPosition(row, col) || this.grid.isWall(row, col)) {
+ return false;
+ }
+ this.row = row;
+ this.col = col;
+ return true;
+ }
+
+ move(direction) {
+ let newRow = this.row;
+ let newCol = this.col;
+
+ switch (direction) {
+ case 'up': newRow--; break;
+ case 'down': newRow++; break;
+ case 'left': newCol--; break;
+ case 'right': newCol++; break;
+ }
+
+ return this.moveTo(newRow, newCol);
+ }
+
+ isInLitCell() {
+ return this.grid.isLight(this.row, this.col);
+ }
+
+ isAtGoal() {
+ return this.grid.isGoal(this.row, this.col);
+ }
+}
diff --git a/src/lib/game/turn-manager.js b/src/lib/game/turn-manager.js
new file mode 100644
index 0000000..f9bbc72
--- /dev/null
+++ b/src/lib/game/turn-manager.js
@@ -0,0 +1,33 @@
+// Turn manager — pure JS, no framework dependency
+
+export class TurnManager {
+ constructor() {
+ this.turnCount = 0;
+ }
+
+ // Process a turn: update guards, check detection
+ // Returns { detected, levelComplete }
+ nextTurn(grid, player, guards) {
+ this.turnCount++;
+
+ // Check if player reached goal
+ if (grid.isGoal(player.row, player.col)) {
+ return { detected: false, levelComplete: true };
+ }
+
+ // Clear current lights and update guards
+ grid.clearAllLight();
+ guards.forEach(guard => guard.onTurnChange());
+
+ // Check if player is now in a lit cell
+ if (grid.isLight(player.row, player.col)) {
+ return { detected: true, levelComplete: false };
+ }
+
+ return { detected: false, levelComplete: false };
+ }
+
+ reset() {
+ this.turnCount = 0;
+ }
+}
diff --git a/src/game/levels/Levels.js b/src/lib/levels/levels.js
similarity index 100%
rename from src/game/levels/Levels.js
rename to src/lib/levels/levels.js
diff --git a/src/game/locales/en.json b/src/lib/locales/en.json
similarity index 100%
rename from src/game/locales/en.json
rename to src/lib/locales/en.json
diff --git a/src/game/locales/vi.json b/src/lib/locales/vi.json
similarity index 100%
rename from src/game/locales/vi.json
rename to src/lib/locales/vi.json
diff --git a/src/game/localization.js b/src/lib/localization.js
similarity index 100%
rename from src/game/localization.js
rename to src/lib/localization.js
diff --git a/src/game/progress.js b/src/lib/progress.js
similarity index 100%
rename from src/game/progress.js
rename to src/lib/progress.js
diff --git a/src/main.js b/src/main.js
index 5d60d39..39e0179 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,35 +1,7 @@
-import Phaser from 'phaser';
-import { Boot } from './game/scenes/Boot';
-import { Preloader } from './game/scenes/Preloader';
-import { MainMenu } from './game/scenes/MainMenu';
-import { LevelSelect } from './game/scenes/LevelSelect';
-import { Game } from './game/scenes/Game';
-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';
+import './styles/theme.css';
+import { mount } from 'svelte';
+import App from './App.svelte';
-const config = {
- type: Phaser.AUTO,
- width: 1024,
- height: 768,
- parent: 'app',
- backgroundColor: '#0a0a1a',
- scale: {
- mode: Phaser.Scale.FIT,
- autoCenter: Phaser.Scale.CENTER_BOTH
- },
- scene: [Boot, Preloader, MainMenu, StoryIntro, LevelSelect, LevelIntro, Game, GameOver, Settings, Guide],
- physics: {
- default: 'arcade',
- arcade: {
- gravity: { y: 0 },
- debug: false
- }
- }
-};
+const app = mount(App, { target: document.getElementById('app') });
-window.addEventListener('load', () => {
- const game = new Phaser.Game(config);
-});
+export default app;
diff --git a/src/scenes/Game.svelte b/src/scenes/Game.svelte
new file mode 100644
index 0000000..066527c
--- /dev/null
+++ b/src/scenes/Game.svelte
@@ -0,0 +1,237 @@
+
+
+
+
+
+
isPaused = true}
+ onmenu={() => navigate('MainMenu')}
+ />
+
+ {#if grid && player}
+
+
+
+
+ {#each guards as guard}
+
+ {/each}
+
+
+ {/if}
+
+ {#if finalMessage}
+
+
{getText('princessDetected')}
+
+ {/if}
+
+ {#if detected}
+
+ {/if}
+
+ {#if isPaused}
+ isPaused = false}
+ onrestart={initLevel}
+ onmainmenu={() => navigate('MainMenu')}
+ />
+ {/if}
+
+
+
diff --git a/src/scenes/GameOver.svelte b/src/scenes/GameOver.svelte
new file mode 100644
index 0000000..2ba2e2a
--- /dev/null
+++ b/src/scenes/GameOver.svelte
@@ -0,0 +1,41 @@
+
+
+
+
{title}
+
{desc}
+
+
+
+
+
diff --git a/src/scenes/Guide.svelte b/src/scenes/Guide.svelte
new file mode 100644
index 0000000..e2d5c84
--- /dev/null
+++ b/src/scenes/Guide.svelte
@@ -0,0 +1,54 @@
+
+
+
+
{getText('guideTitle')}
+
+
+
{getText('levelObjectives')}
+
{getText('levelObjectivesContent')}
+
+
{getText('movementControls')}
+
{getText('movementControlsContent')}
+
+
{getText('enemyTypes')}
+
{getText('enemyTypesContent')}
+
+
+
navigate('MainMenu')} />
+
+
+
diff --git a/src/scenes/LevelIntro.svelte b/src/scenes/LevelIntro.svelte
new file mode 100644
index 0000000..40b77a3
--- /dev/null
+++ b/src/scenes/LevelIntro.svelte
@@ -0,0 +1,45 @@
+
+
+
+
{getText('level')}{level}
+
{levelName}
+ {#if storyText}
+
{storyText}
+ {/if}
+
navigate('Game', { level, lives })} />
+
+
+
diff --git a/src/scenes/LevelSelect.svelte b/src/scenes/LevelSelect.svelte
new file mode 100644
index 0000000..76e93b2
--- /dev/null
+++ b/src/scenes/LevelSelect.svelte
@@ -0,0 +1,83 @@
+
+
+
+
{getText('levelSelectTitle')}
+
+
+ {#each Array(totalLevels) as _, i}
+ {@const num = i + 1}
+ {@const unlocked = num <= progress.maxLevel}
+ {@const completed = progress.completedLevels.includes(num)}
+ selectLevel(num)}
+ disabled={!unlocked}
+ >
+ {num}
+ {#if completed}✓{/if}
+
+ {/each}
+
+
+
navigate('MainMenu')} />
+
+
+
diff --git a/src/scenes/MainMenu.svelte b/src/scenes/MainMenu.svelte
new file mode 100644
index 0000000..ffd6ebc
--- /dev/null
+++ b/src/scenes/MainMenu.svelte
@@ -0,0 +1,66 @@
+
+
+
+
+
diff --git a/src/scenes/Settings.svelte b/src/scenes/Settings.svelte
new file mode 100644
index 0000000..a033559
--- /dev/null
+++ b/src/scenes/Settings.svelte
@@ -0,0 +1,57 @@
+
+
+{#key tick}
+
+
{getText('settings')}
+
{getText('languageSettings')}
+
+ changeLang('en')}>
+ {getText('english')}
+
+ changeLang('vi')}>
+ {getText('vietnamese')}
+
+
+
navigate('MainMenu')} />
+
+{/key}
+
+
diff --git a/src/scenes/StoryIntro.svelte b/src/scenes/StoryIntro.svelte
new file mode 100644
index 0000000..b1e9f92
--- /dev/null
+++ b/src/scenes/StoryIntro.svelte
@@ -0,0 +1,73 @@
+
+
+
+
{getText('storyTitle')}
+
+
+
+
+
+
+
diff --git a/src/styles/theme.css b/src/styles/theme.css
new file mode 100644
index 0000000..c906a79
--- /dev/null
+++ b/src/styles/theme.css
@@ -0,0 +1,71 @@
+:root {
+ /* Backgrounds */
+ --bg-dark: #0a0a1a;
+ --bg-panel: #1a1a2e;
+ --bg-overlay: rgba(0, 0, 0, 0.85);
+
+ /* Buttons */
+ --btn-default: #16213e;
+ --btn-hover: #0f3460;
+ --btn-border: #533483;
+
+ /* Grid */
+ --grid-empty: #1a1a2e;
+ --grid-wall: #4a4a5e;
+ --grid-goal: #00c853;
+ --grid-lit: #ffea00;
+ --grid-border: #2a2a3e;
+
+ /* Text */
+ --text-primary: #e0e0ff;
+ --text-secondary: #9999bb;
+ --text-accent: #bb86fc;
+ --text-danger: #ff5555;
+ --text-title: #ffffff;
+
+ /* Guards */
+ --guard-static: #ff4444;
+ --guard-rotating: #4488ff;
+ --guard-blinking: #ffdd44;
+ --guard-blinking-off: #887722;
+ --guard-patrolling: #bb44ff;
+
+ /* Player */
+ --player-color: #111111;
+
+ /* Fonts */
+ --font-title: bold 36px Arial, sans-serif;
+ --font-heading: bold 28px Arial, sans-serif;
+ --font-button: 22px Arial, sans-serif;
+ --font-button-small: 18px Arial, sans-serif;
+ --font-body: 20px Arial, sans-serif;
+ --font-small: 16px Arial, sans-serif;
+ --font-ui: 18px Arial, sans-serif;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ background-color: var(--bg-dark);
+ color: var(--text-primary);
+ font-family: Arial, sans-serif;
+ height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
+}
+
+#app {
+ width: 1024px;
+ height: 768px;
+ max-width: 100vw;
+ max-height: 100vh;
+ aspect-ratio: 1024 / 768;
+ position: relative;
+ overflow: hidden;
+}
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..c9a9871
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+export default {
+ preprocess: vitePreprocess(),
+};
diff --git a/test-levels.js b/test-levels.js
deleted file mode 100644
index 6efb158..0000000
--- a/test-levels.js
+++ /dev/null
@@ -1,224 +0,0 @@
-// Level testing script
-import { LEVELS } from './src/game/levels/Levels.js';
-
-// Simple testing function to check if levels are completable
-function testLevels() {
- console.log("Testing level completion possibility...");
- console.log("=======================================");
-
- const results = [];
-
- for (let i = 0; i < LEVELS.length; i++) {
- const levelId = i + 1;
- const level = LEVELS[i];
-
- console.log(`\nTesting Level ${levelId}: ${level.name}`);
-
- // Skip testing level 12 as it's designed to be impossible
- if (levelId === 12) {
- console.log("Level 12 is designed to be impossible - skipping detailed test");
- results.push({
- levelId,
- completable: false,
- reason: "Final level is designed to be impossible by game design"
- });
- continue;
- }
-
- // Create a simplified grid for pathfinding
- const grid = createSimplifiedGrid(level);
-
- // Find path from player start to goal
- const path = findPath(
- grid,
- level.player.row,
- level.player.col,
- level.goal.row,
- level.goal.col
- );
-
- if (!path) {
- console.log("❌ NO PATH FOUND: Level appears to be impossible to complete");
- results.push({
- levelId,
- completable: false,
- reason: "No path found from start to goal"
- });
- continue;
- }
-
- // Check if the path avoids all lit cells
- const result = validatePath(path, level);
-
- if (result.valid) {
- console.log(`✅ PATH FOUND: Level is completable (${path.length} steps)`);
- results.push({
- levelId,
- completable: true,
- reason: "Path found",
- pathLength: path.length
- });
- } else {
- console.log(`❌ INVALID PATH: ${result.reason}`);
- results.push({
- levelId,
- completable: false,
- reason: result.reason
- });
- }
- }
-
- // Generate summary
- console.log("\n\nSUMMARY OF RESULTS");
- console.log("=================");
-
- const nonFinalLevels = results.filter(r => r.levelId !== 12);
- const completableCount = nonFinalLevels.filter(r => r.completable).length;
-
- console.log(`${completableCount} out of ${nonFinalLevels.length} non-final levels are completable.`);
-
- if (completableCount === nonFinalLevels.length) {
- console.log("\n✅ SUCCESS: All non-final levels are completable!");
- } else {
- console.log("\n❌ FAILURE: Some non-final levels are not completable!");
-
- // List problematic levels
- const problematicLevels = nonFinalLevels.filter(r => !r.completable);
- problematicLevels.forEach(level => {
- console.log(`- Level ${level.levelId}: ${level.reason}`);
- });
- }
-
- return results;
-}
-
-// Create a simplified grid representation for pathfinding
-function createSimplifiedGrid(levelData) {
- const rows = levelData.grid.rows;
- const cols = levelData.grid.cols;
-
- // Initialize grid with all cells walkable
- const grid = Array(rows).fill().map(() => Array(cols).fill(0));
-
- // Mark walls as unwalkable
- if (levelData.walls) {
- levelData.walls.forEach(wall => {
- grid[wall.row][wall.col] = 1;
- });
- }
-
- return grid;
-}
-
-// Simple breadth-first search pathfinding
-function findPath(grid, startRow, startCol, goalRow, goalCol) {
- const rows = grid.length;
- const cols = grid[0].length;
-
- // Queue for BFS
- const queue = [{row: startRow, col: startCol, path: []}];
-
- // Visited cells
- const visited = Array(rows).fill().map(() => Array(cols).fill(false));
- visited[startRow][startCol] = true;
-
- // Directions: up, right, down, left
- const directions = [
- {row: -1, col: 0},
- {row: 0, col: 1},
- {row: 1, col: 0},
- {row: 0, col: -1}
- ];
-
- while (queue.length > 0) {
- const current = queue.shift();
-
- // Check if reached goal
- if (current.row === goalRow && current.col === goalCol) {
- return [...current.path, {row: current.row, col: current.col}];
- }
-
- // Try all four directions
- for (const dir of directions) {
- const newRow = current.row + dir.row;
- const newCol = current.col + dir.col;
-
- // Check if valid position
- if (
- newRow >= 0 && newRow < rows &&
- newCol >= 0 && newCol < cols &&
- grid[newRow][newCol] === 0 && // Not a wall
- !visited[newRow][newCol]
- ) {
- visited[newRow][newCol] = true;
- queue.push({
- row: newRow,
- col: newCol,
- path: [...current.path, {row: current.row, col: current.col}]
- });
- }
- }
- }
-
- // No path found
- return null;
-}
-
-// Validate if a path avoids all lit cells
-function validatePath(path, levelData) {
- // For each step in the path, check if it would be lit by any guard
- for (let i = 0; i < path.length; i++) {
- const step = path[i];
-
- // Skip the first step (starting position)
- if (i === 0) continue;
-
- // Check static guards
- if (levelData.guards) {
- for (const guard of levelData.guards) {
- if (guard.type === "static") {
- // Check if step is in any of the lit cells
- for (const litCell of guard.litCells) {
- if (litCell.row === step.row && litCell.col === step.col) {
- return {
- valid: false,
- reason: `Step at (${step.row},${step.col}) would be lit by static guard`
- };
- }
- }
- }
- else if (guard.type === "rotating") {
- // This is a simplification - in reality, we'd need to simulate turns
- // For testing purposes, we'll assume rotating guards can be timed correctly
- continue;
- }
- else if (guard.type === "blinking") {
- // For blinking guards, we can assume we can time our movement when they're off
- continue;
- }
- else if (guard.type === "patrolling") {
- // For patrolling guards, we'd need to simulate their movement
- // For testing purposes, we'll assume patrolling guards can be avoided with timing
- continue;
- }
- }
- }
-
- // Check predefined lit cells (for level 2)
- if (levelData.litCells) {
- for (const litCell of levelData.litCells) {
- if (litCell.row === step.row && litCell.col === step.col) {
- return {
- valid: false,
- reason: `Step at (${step.row},${step.col}) would be in a predefined lit cell`
- };
- }
- }
- }
- }
-
- return { valid: true };
-}
-
-// Run the tests
-testLevels();
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..4520fce
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+
+export default defineConfig({
+ plugins: [svelte()],
+ server: {
+ port: 8080,
+ },
+});
diff --git a/vite/config.dev.mjs b/vite/config.dev.mjs
deleted file mode 100644
index 71e0cc8..0000000
--- a/vite/config.dev.mjs
+++ /dev/null
@@ -1,17 +0,0 @@
-import { defineConfig } from 'vite';
-
-export default defineConfig({
- base: './',
- build: {
- rollupOptions: {
- output: {
- manualChunks: {
- phaser: ['phaser']
- }
- }
- },
- },
- server: {
- port: 8080
- }
-});
diff --git a/vite/config.prod.mjs b/vite/config.prod.mjs
deleted file mode 100644
index 5592cb4..0000000
--- a/vite/config.prod.mjs
+++ /dev/null
@@ -1,47 +0,0 @@
-import { defineConfig } from 'vite';
-
-const phasermsg = () => {
- return {
- name: 'phasermsg',
- buildStart() {
- process.stdout.write(`Building for production...\n`);
- },
- buildEnd() {
- const line = "---------------------------------------------------------";
- const msg = `❤️❤️❤️ Tell us about your game! - games@phaser.io ❤️❤️❤️`;
- process.stdout.write(`${line}\n${msg}\n${line}\n`);
-
- process.stdout.write(`✨ Done ✨\n`);
- }
- }
-}
-
-export default defineConfig({
- base: './',
- logLevel: 'warn',
- build: {
- rollupOptions: {
- output: {
- manualChunks: {
- phaser: ['phaser']
- }
- }
- },
- minify: 'terser',
- terserOptions: {
- compress: {
- passes: 2
- },
- mangle: true,
- format: {
- comments: false
- }
- }
- },
- server: {
- port: 8080
- },
- plugins: [
- phasermsg()
- ]
-});