From f52dfde719ad6c17fed52d34c965fffdbf6a2463 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 12 Apr 2026 18:58:19 +0700 Subject: [PATCH] feat: rewrite from Phaser 3 to Svelte 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Phaser 3 game engine (~1MB) with Svelte 5 + Vite. Bundle size: 77KB JS (28KB gzip) vs ~1MB with Phaser. Architecture: - src/lib/game/ — Pure JS game logic (guards, grid, player, turns) - src/components/ — Svelte UI components (GameBoard, sprites, HUD) - src/scenes/ — Scene components with fade transitions - src/App.svelte — Scene router - CSS Grid for game board, CSS transitions for movement - svelte/transition for scene switching All game logic preserved: 12 levels, 4 guard types, turn system, detection, lives, progress persistence, bilingual localization, level 12 special mechanic. Removed: Phaser, terser, canvas rendering, physics engine, custom vite configs, log.js telemetry. --- index.html | 4 - log.js | 36 -- package-lock.json | 307 +++++++---- package.json | 23 +- .../phase-01-scaffold-project.md | 50 ++ .../phase-02-port-game-logic.md | 63 +++ .../phase-03-game-board-components.md | 87 +++ .../phase-04-scene-components.md | 85 +++ .../phase-05-game-scene-input.md | 96 ++++ .../phase-06-polish-verify.md | 60 ++ plans/260412-1113-svelte5-rewrite/plan.md | 124 +++++ ...er-260412-1127-svelte5-grid-puzzle-game.md | 293 ++++++++++ public/style.css | 28 - src/App.svelte | 61 +++ src/components/Button.svelte | 27 + src/components/DetectionPopup.svelte | 35 ++ src/components/GameBoard.svelte | 38 ++ src/components/GameHud.svelte | 34 ++ src/components/GuardSprite.svelte | 45 ++ src/components/PauseMenu.svelte | 42 ++ src/components/PlayerSprite.svelte | 23 + src/game/levels/LevelManager.js | 172 ------ src/game/levels/LevelTester.js | 225 -------- src/game/objects/GridSystem.js | 130 ----- src/game/objects/Guard.js | 392 ------------- src/game/objects/LightingSystem.js | 41 -- src/game/objects/Player.js | 108 ---- src/game/objects/TurnManager.js | 59 -- src/game/scenes/Boot.js | 20 - src/game/scenes/Game.js | 513 ------------------ src/game/scenes/GameOver.js | 59 -- src/game/scenes/Guide.js | 86 --- src/game/scenes/LevelIntro.js | 61 --- src/game/scenes/LevelSelect.js | 84 --- src/game/scenes/MainMenu.js | 73 --- src/game/scenes/Preloader.js | 42 -- src/game/scenes/Settings.js | 56 -- src/game/scenes/StoryIntro.js | 72 --- src/game/theme.js | 70 --- src/lib/game/grid-system.js | 75 +++ src/lib/game/guards.js | 184 +++++++ src/lib/game/level-manager.js | 71 +++ src/lib/game/player.js | 40 ++ src/lib/game/turn-manager.js | 33 ++ .../levels/Levels.js => lib/levels/levels.js} | 0 src/{game => lib}/locales/en.json | 0 src/{game => lib}/locales/vi.json | 0 src/{game => lib}/localization.js | 0 src/{game => lib}/progress.js | 0 src/main.js | 38 +- src/scenes/Game.svelte | 237 ++++++++ src/scenes/GameOver.svelte | 41 ++ src/scenes/Guide.svelte | 54 ++ src/scenes/LevelIntro.svelte | 45 ++ src/scenes/LevelSelect.svelte | 83 +++ src/scenes/MainMenu.svelte | 66 +++ src/scenes/Settings.svelte | 57 ++ src/scenes/StoryIntro.svelte | 73 +++ src/styles/theme.css | 71 +++ svelte.config.js | 5 + test-levels.js | 224 -------- vite.config.js | 9 + vite/config.dev.mjs | 17 - vite/config.prod.mjs | 47 -- 64 files changed, 2521 insertions(+), 2773 deletions(-) delete mode 100644 log.js create mode 100644 plans/260412-1113-svelte5-rewrite/phase-01-scaffold-project.md create mode 100644 plans/260412-1113-svelte5-rewrite/phase-02-port-game-logic.md create mode 100644 plans/260412-1113-svelte5-rewrite/phase-03-game-board-components.md create mode 100644 plans/260412-1113-svelte5-rewrite/phase-04-scene-components.md create mode 100644 plans/260412-1113-svelte5-rewrite/phase-05-game-scene-input.md create mode 100644 plans/260412-1113-svelte5-rewrite/phase-06-polish-verify.md create mode 100644 plans/260412-1113-svelte5-rewrite/plan.md create mode 100644 plans/reports/researcher-260412-1127-svelte5-grid-puzzle-game.md delete mode 100644 public/style.css create mode 100644 src/App.svelte create mode 100644 src/components/Button.svelte create mode 100644 src/components/DetectionPopup.svelte create mode 100644 src/components/GameBoard.svelte create mode 100644 src/components/GameHud.svelte create mode 100644 src/components/GuardSprite.svelte create mode 100644 src/components/PauseMenu.svelte create mode 100644 src/components/PlayerSprite.svelte delete mode 100644 src/game/levels/LevelManager.js delete mode 100644 src/game/levels/LevelTester.js delete mode 100644 src/game/objects/GridSystem.js delete mode 100644 src/game/objects/Guard.js delete mode 100644 src/game/objects/LightingSystem.js delete mode 100644 src/game/objects/Player.js delete mode 100644 src/game/objects/TurnManager.js delete mode 100644 src/game/scenes/Boot.js delete mode 100644 src/game/scenes/Game.js delete mode 100644 src/game/scenes/GameOver.js delete mode 100644 src/game/scenes/Guide.js delete mode 100644 src/game/scenes/LevelIntro.js delete mode 100644 src/game/scenes/LevelSelect.js delete mode 100644 src/game/scenes/MainMenu.js delete mode 100644 src/game/scenes/Preloader.js delete mode 100644 src/game/scenes/Settings.js delete mode 100644 src/game/scenes/StoryIntro.js delete mode 100644 src/game/theme.js create mode 100644 src/lib/game/grid-system.js create mode 100644 src/lib/game/guards.js create mode 100644 src/lib/game/level-manager.js create mode 100644 src/lib/game/player.js create mode 100644 src/lib/game/turn-manager.js rename src/{game/levels/Levels.js => lib/levels/levels.js} (100%) rename src/{game => lib}/locales/en.json (100%) rename src/{game => lib}/locales/vi.json (100%) rename src/{game => lib}/localization.js (100%) rename src/{game => lib}/progress.js (100%) create mode 100644 src/scenes/Game.svelte create mode 100644 src/scenes/GameOver.svelte create mode 100644 src/scenes/Guide.svelte create mode 100644 src/scenes/LevelIntro.svelte create mode 100644 src/scenes/LevelSelect.svelte create mode 100644 src/scenes/MainMenu.svelte create mode 100644 src/scenes/Settings.svelte create mode 100644 src/scenes/StoryIntro.svelte create mode 100644 src/styles/theme.css create mode 100644 svelte.config.js delete mode 100644 test-levels.js create mode 100644 vite.config.js delete mode 100644 vite/config.dev.mjs delete mode 100644 vite/config.prod.mjs diff --git a/index.html b/index.html index 0a170de..6e50012 100644 --- a/index.html +++ b/index.html @@ -1,17 +1,13 @@ - - Night Ninja: Twilight Voyage -
- diff --git a/log.js b/log.js deleted file mode 100644 index 5ed46a7..0000000 --- a/log.js +++ /dev/null @@ -1,36 +0,0 @@ -import * as fs from 'fs'; -import * as https from 'https'; - -const main = async () => { - const args = process.argv.slice(2); - const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8')); - const event = args[0] || 'unknown'; - const phaserVersion = packageData.dependencies.phaser; - - const options = { - hostname: 'gryzor.co', - port: 443, - path: `/v/${event}/${phaserVersion}/${packageData.name}`, - method: 'GET' - }; - - try { - const req = https.request(options, (res) => { - res.on('data', () => {}); - res.on('end', () => { - process.exit(0); - }); - }); - - req.on('error', (error) => { - process.exit(1); - }); - - req.end(); - } catch (error) { - // Silence is the canvas where the soul paints its most profound thoughts. - process.exit(1); - } -} - -main(); diff --git a/package-lock.json b/package-lock.json index 01b07b7..3777ac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "nntv", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nntv", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { - "phaser": "^3.88.2" + "svelte": "^5.55.3" }, "devDependencies": { - "terser": "^5.39.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "vite": "^6.3.6" } }, @@ -442,63 +442,44 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -785,18 +766,51 @@ "win32" ] }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -805,18 +819,47 @@ "node": ">=0.4.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", "license": "MIT" }, "node_modules/esbuild": { @@ -860,12 +903,29 @@ "@esbuild/win32-x64": "0.25.2" } }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "license": "MIT" }, + "node_modules/esrap": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", + "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/fdir": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", @@ -896,6 +956,30 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -915,14 +999,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/phaser": { - "version": "3.88.2", - "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.88.2.tgz", - "integrity": "sha512-UBgd2sAFuRJbF2xKaQ5jpMWB8oETncChLnymLGHcrnT53vaqiGrQWbUKUDBawKLm24sghjKo4Bf+/xfv8espZQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1" - } + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -1013,16 +1099,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1033,34 +1109,31 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "node_modules/svelte": { + "version": "5.55.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz", + "integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==", "license": "MIT", "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" }, "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/tinyglobby": { @@ -1154,6 +1227,32 @@ "optional": true } } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 6d0e337..0e371a6 100644 --- a/package.json +++ b/package.json @@ -2,30 +2,19 @@ "name": "nntv", "description": "Night Ninja: Twilight Voyage", "private": true, - "version": "0.0.1", + "version": "0.1.0", "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/phaserjs/template-vite.git" - }, - "author": "Phaser Studio (https://phaser.io/)", "license": "MIT", - "licenseUrl": "http://www.opensource.org/licenses/mit-license.php", - "bugs": { - "url": "https://github.com/phaserjs/template-vite/issues" - }, - "homepage": "https://github.com/phaserjs/template-vite#readme", "scripts": { - "dev": "node log.js dev & vite --config vite/config.dev.mjs", - "build": "node log.js build & vite build --config vite/config.prod.mjs", - "dev-nolog": "vite --config vite/config.dev.mjs", - "build-nolog": "vite build --config vite/config.prod.mjs" + "dev": "vite", + "build": "vite build", + "preview": "vite preview" }, "devDependencies": { - "terser": "^5.39.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "vite": "^6.3.6" }, "dependencies": { - "phaser": "^3.88.2" + "svelte": "^5.55.3" } } diff --git a/plans/260412-1113-svelte5-rewrite/phase-01-scaffold-project.md b/plans/260412-1113-svelte5-rewrite/phase-01-scaffold-project.md new file mode 100644 index 0000000..d20a15a --- /dev/null +++ b/plans/260412-1113-svelte5-rewrite/phase-01-scaffold-project.md @@ -0,0 +1,50 @@ +# Phase 1: Scaffold Svelte 5 Project + +**Priority:** Critical | **Status:** pending | **Effort:** Small + +## Overview +Set up a fresh Svelte 5 + Vite project structure alongside existing code. Replace Phaser dependency with Svelte. + +## Steps + +1. Remove Phaser and related deps from `package.json` +2. Install Svelte 5 + vite-plugin-svelte + ```bash + npm install svelte && npm install -D @sveltejs/vite-plugin-svelte + ``` +3. Replace Vite config files (`vite/config.dev.mjs`, `vite/config.prod.mjs`) with single `vite.config.js` using svelte plugin +4. Create directory structure: + ``` + src/lib/ # Pure JS game logic + src/lib/locales/ # Locale JSON files + src/lib/levels/ # Level data + src/lib/game/ # Game logic classes + src/components/ # Reusable Svelte components + src/scenes/ # Scene components + src/styles/ # CSS + ``` +5. Create `src/main.js` — mount Svelte app to `#app` +6. Create minimal `src/App.svelte` — renders "Hello NNTV" placeholder +7. Create `src/styles/theme.css` — port color/font constants from `theme.js` as CSS variables +8. Update `index.html` — remove Phaser-specific meta, keep basic structure +9. Update `package.json` scripts to use new vite config +10. Verify `npm run dev` serves the placeholder app + +## Files to Create +- `vite.config.js` +- `src/main.js` (replace existing) +- `src/App.svelte` +- `src/styles/theme.css` + +## Files to Delete +- `vite/config.dev.mjs` +- `vite/config.prod.mjs` +- `vite/` directory +- `log.js` +- `src/game/theme.js` + +## Success Criteria +- `npm run dev` opens browser with "Hello NNTV" +- `npm run build` produces working dist/ +- Zero Phaser imports remain +- CSS variables available globally diff --git a/plans/260412-1113-svelte5-rewrite/phase-02-port-game-logic.md b/plans/260412-1113-svelte5-rewrite/phase-02-port-game-logic.md new file mode 100644 index 0000000..9f4d446 --- /dev/null +++ b/plans/260412-1113-svelte5-rewrite/phase-02-port-game-logic.md @@ -0,0 +1,63 @@ +# Phase 2: Port Pure Game Logic + +**Priority:** Critical | **Status:** pending | **Effort:** Medium + +## Overview +Move all framework-agnostic game logic into `src/lib/`. Strip Phaser imports. Separate data/logic from rendering. + +## Steps + +1. **Copy as-is** (just move files): + - `locales/en.json` → `src/lib/locales/en.json` + - `locales/vi.json` → `src/lib/locales/vi.json` + - `levels/Levels.js` → `src/lib/levels/levels.js` + - `progress.js` → `src/lib/progress.js` + - `localization.js` → `src/lib/localization.js` + +2. **Port TurnManager** → `src/lib/game/turn-manager.js` + - Remove `import Phaser` (line 1) + - Remove `this.scene` references + - Make it accept callbacks instead of calling scene methods directly + - Constructor takes `{ onLevelComplete, onDetected, getPlayerPos, getGrid, getGuards, getLightSystem }` + +3. **Port GridSystem** → `src/lib/game/grid-system.js` + - Keep: grid data model, isValidPosition, setWall/setGoal/setLight, gridToPixel, pixelToGrid + - Remove: `scene.add.graphics()`, `render()` method, all Phaser graphics calls + - Merge LightingSystem's `clearAllLight()` into GridSystem + - Add `getCellState(row, col)` returning `{ isWall, isGoal, isLight }` + +4. **Port Guards** → `src/lib/game/guards.js` + - Keep: all guard logic (updateLight, onTurnChange, patrol path, rotation, blinking) + - Remove: `createSprite()`, `update()` (sprite positioning), `scene.add.circle/line` + - Remove: `destroy()` sprite cleanup (no sprites to destroy) + - Each guard exposes: `{ row, col, color, type, direction, isOn }` for rendering + +5. **Port Player** → `src/lib/game/player.js` + - Keep: `moveTo()`, `move()`, `isInLitCell()`, `isAtGoal()` + - Remove: `createSprite()`, `update()` (sprite), tweens + - Pure data: `{ row, col }` position tracking + +6. **Port LevelManager** → `src/lib/game/level-manager.js` + - Rewrite as a pure function: `loadLevel(levelId, grid)` → returns `{ player, guards, isFinalLevel }` + - No scene references + - Creates guard instances, sets up grid walls/goals + +## Files to Create +- `src/lib/game/grid-system.js` +- `src/lib/game/guards.js` +- `src/lib/game/player.js` +- `src/lib/game/turn-manager.js` +- `src/lib/game/level-manager.js` +- `src/lib/localization.js` +- `src/lib/progress.js` +- `src/lib/locales/en.json` +- `src/lib/locales/vi.json` +- `src/lib/levels/levels.js` + +## Files to Delete (after all phases complete) +- Entire `src/game/` directory + +## Success Criteria +- All game logic files import zero Phaser code +- Guard logic can be tested independently (no DOM/canvas needed) +- `loadLevel()` returns a complete game state object diff --git a/plans/260412-1113-svelte5-rewrite/phase-03-game-board-components.md b/plans/260412-1113-svelte5-rewrite/phase-03-game-board-components.md new file mode 100644 index 0000000..9d664ac --- /dev/null +++ b/plans/260412-1113-svelte5-rewrite/phase-03-game-board-components.md @@ -0,0 +1,87 @@ +# Phase 3: Build Game Board + Sprite Components + +**Priority:** Critical | **Status:** pending | **Effort:** Medium + +## Overview +Create Svelte components that render the game grid, player, and guards using HTML/CSS instead of Phaser canvas. + +## Steps + +1. **Button.svelte** — reusable styled button + - Props: `text`, `onclick`, `small` (boolean for compact variant) + - Styled with CSS variables from theme.css + - Hover/active states via CSS + +2. **GameBoard.svelte** — renders the grid + - Props: `grid` (2D array of cell states), `rows`, `cols`, `cellSize` + - CSS Grid layout: `grid-template-columns: repeat(cols, cellSize)` + - Each cell is a `
` 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')}

+
+ +
+ + 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} +
+ + 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)} + + {/each} +
+ +
+ + 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')}

+
+ + +
+
+{/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')}

+
+

{getText('storyText')}

+
+
+
+
+ + 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() - ] -});