feat: rewrite from Phaser 3 to Svelte 5

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.
This commit is contained in:
tiennm99 committed 2026-04-12 18:58:19 +07:00
1 parent 4461e45502
commit f52dfde719
64 files changed
+2521 -2773

No files matched your search

-4
View File
@@ -1,17 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/style.css">
<title>Night Ninja: Twilight Voyage</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
-36
View File
@@ -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();
+203 -104
View File
@@ -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"
}
}
}
+6 -17
View File
@@ -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 <support@phaser.io> (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"
}
}
@@ -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
@@ -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
@@ -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 `<div>` 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
@@ -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
<!-- App.svelte -->
<script>
let currentScene = $state('MainMenu');
let sceneData = $state({});
function navigate(scene, data = {}) {
sceneData = data;
currentScene = scene;
}
</script>
{#if currentScene === 'MainMenu'}
<div transition:fade>
<MainMenu {navigate} />
</div>
{:else if currentScene === 'Game'}
<div transition:fade>
<Game {navigate} level={sceneData.level} lives={sceneData.lives} />
</div>
{/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
@@ -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: `<svelte:window onkeydown={onKeyDown} />`
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
@@ -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
+124
View File
@@ -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
@@ -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
<script>
import { fade, fly, slide } from 'svelte/transition';
let currentScene = $state('intro');
</script>
{#if currentScene === 'intro'}
<div transition:fade={{ duration: 300 }}>
<h1>Intro Scene</h1>
<button onclick={() => currentScene = 'game'}>Play</button>
</div>
{:else if currentScene === 'game'}
<div transition:fly={{ x: 100, duration: 400 }}>
<!-- Game board here -->
</div>
{:else if currentScene === 'win'}
<div transition:slide={{ axis: 'y', duration: 600 }}>
<h1>You Won!</h1>
</div>
{/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
<script>
let grid = $state(Array(16).fill(null).map((_, i) => ({
id: i,
row: Math.floor(i / 4),
col: i % 4,
})));
</script>
<div class="board">
{#each grid as cell (cell.id)}
<div class="cell" onclick={() => handleCellClick(cell.id)}>
{cell.id}
</div>
{/each}
</div>
<style>
.board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
width: 320px;
}
.cell {
aspect-ratio: 1;
background: #e0e0e0;
border-radius: 4px;
cursor: pointer;
}
</style>
```
### 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
<script>
import { game, i18n } from './gameState.svelte.js';
</script>
<h1>{i18n.strings.title}</h1>
<p>Level {game.level} • Moves: {game.moves}</p>
<button onclick={() => i18n.lang = 'es'}>Español</button>
```
### 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.
-28
View File
@@ -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;
}
+61
View File
@@ -0,0 +1,61 @@
<script>
import { fade } from 'svelte/transition';
import { initLanguage } from './lib/localization.js';
import MainMenu from './scenes/MainMenu.svelte';
import StoryIntro from './scenes/StoryIntro.svelte';
import LevelIntro from './scenes/LevelIntro.svelte';
import LevelSelect from './scenes/LevelSelect.svelte';
import Game from './scenes/Game.svelte';
import GameOver from './scenes/GameOver.svelte';
import Settings from './scenes/Settings.svelte';
import Guide from './scenes/Guide.svelte';
// Initialize language from localStorage
initLanguage();
let currentScene = $state('MainMenu');
let sceneData = $state({});
function navigate(scene, data = {}) {
sceneData = data;
currentScene = scene;
}
</script>
<div class="game-container">
{#key currentScene}
<div class="scene" transition:fade={{ duration: 250 }}>
{#if currentScene === 'MainMenu'}
<MainMenu {navigate} />
{:else if currentScene === 'StoryIntro'}
<StoryIntro {navigate} />
{:else if currentScene === 'LevelIntro'}
<LevelIntro {navigate} level={sceneData.level} lives={sceneData.lives} />
{:else if currentScene === 'LevelSelect'}
<LevelSelect {navigate} />
{:else if currentScene === 'Game'}
<Game {navigate} level={sceneData.level} lives={sceneData.lives} />
{:else if currentScene === 'GameOver'}
<GameOver {navigate} level={sceneData.level} isLastLevel={sceneData.isLastLevel} />
{:else if currentScene === 'Settings'}
<Settings {navigate} />
{:else if currentScene === 'Guide'}
<Guide {navigate} />
{/if}
</div>
{/key}
</div>
<style>
.game-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: var(--bg-dark);
}
.scene {
position: absolute;
inset: 0;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<script>
let { text, onclick, small = false } = $props();
</script>
<button class="btn" class:small onclick={onclick}>
{text}
</button>
<style>
.btn {
font: var(--font-button);
color: var(--text-primary);
background: var(--btn-default);
border: 2px solid var(--btn-border);
border-radius: 4px;
padding: 10px 32px;
min-width: 220px;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover { background: var(--btn-hover); }
.btn.small {
font: var(--font-button-small);
padding: 6px 16px;
min-width: 110px;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script>
import { fade } from 'svelte/transition';
import { getText } from '../lib/localization.js';
import Button from './Button.svelte';
let { onplayagain } = $props();
</script>
<div class="overlay" transition:fade={{ duration: 200 }}>
<div class="popup">
<h2>{getText('detected')}</h2>
<Button text={getText('playAgain')} onclick={onplayagain} />
</div>
</div>
<style>
.overlay {
position: absolute;
inset: 0;
background: var(--bg-overlay);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.popup {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
h2 {
font: var(--font-heading);
color: var(--text-danger);
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script>
let { cells = [], rows = 6, cols = 6, cellSize = 50, oncellclick } = $props();
</script>
<div
class="board"
style="grid-template-columns: repeat({cols}, {cellSize}px); grid-template-rows: repeat({rows}, {cellSize}px);"
>
{#each cells as cell (cell.row * cols + cell.col)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="cell"
class:wall={cell.isWall}
class:goal={cell.isGoal}
class:lit={cell.isLight}
role="gridcell"
onclick={() => oncellclick?.(cell.row, cell.col)}
></div>
{/each}
</div>
<style>
.board {
display: grid;
gap: 0;
position: relative;
}
.cell {
width: 100%;
height: 100%;
background: var(--grid-empty);
border: 1px solid var(--grid-border);
cursor: pointer;
}
.cell.wall { background: var(--grid-wall); cursor: default; }
.cell.goal { background: var(--grid-goal); }
.cell.lit { background: var(--grid-lit); }
</style>
+34
View File
@@ -0,0 +1,34 @@
<script>
import { getText } from '../lib/localization.js';
import Button from './Button.svelte';
let { lives = 3, level = 1, turns = 0, onpause, onmenu } = $props();
</script>
<div class="hud">
<div class="hud-left">
<span>{getText('lives')}{lives}</span>
<span class="turns">Turns: {turns}</span>
</div>
<div class="hud-right">
<span>{getText('level')}{level}</span>
<Button text={getText('pause')} onclick={onpause} small />
<Button text={getText('menu')} onclick={onmenu} small />
</div>
</div>
<style>
.hud {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
font: var(--font-ui);
color: var(--text-primary);
}
.hud-left, .hud-right {
display: flex;
align-items: center;
gap: 12px;
}
.turns { color: var(--text-secondary); font: var(--font-small); }
</style>
+45
View File
@@ -0,0 +1,45 @@
<script>
let { guard, cellSize = 50 } = $props();
let top = $derived(guard.row * cellSize + cellSize / 2);
let left = $derived(guard.col * cellSize + cellSize / 2);
let size = $derived(Math.floor(cellSize / 2));
let rotation = $derived(guard.direction * 90);
let hasDirection = $derived(guard.type === 'rotating' || guard.type === 'patrolling');
</script>
<div
class="guard {guard.type}"
class:off={guard.type === 'blinking' && !guard.isOn}
style="top: {top}px; left: {left}px; width: {size}px; height: {size}px;"
>
{#if hasDirection}
<div class="direction-indicator" style="transform: rotate({rotation}deg);"></div>
{/if}
</div>
<style>
.guard {
position: absolute;
border-radius: 50%;
transform: translate(-50%, -50%);
transition: top 100ms linear, left 100ms linear;
z-index: 5;
pointer-events: none;
}
.guard.static { background: var(--guard-static); }
.guard.rotating { background: var(--guard-rotating); }
.guard.blinking { background: var(--guard-blinking); }
.guard.blinking.off { background: var(--guard-blinking-off); }
.guard.patrolling { background: var(--guard-patrolling); }
.direction-indicator {
position: absolute;
top: 0;
left: 50%;
width: 2px;
height: 50%;
background: white;
transform-origin: bottom center;
translate: -50% 0;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script>
import { fade } from 'svelte/transition';
import { getText } from '../lib/localization.js';
import Button from './Button.svelte';
let { onresume, onrestart, onmainmenu } = $props();
</script>
<div class="overlay" transition:fade={{ duration: 200 }}>
<div class="popup">
<h2>{getText('paused')}</h2>
<Button text={getText('resume')} onclick={onresume} />
<Button text={getText('restartLevel')} onclick={onrestart} />
<Button text={getText('mainMenu')} onclick={onmainmenu} />
</div>
</div>
<style>
.overlay {
position: absolute;
inset: 0;
background: var(--bg-overlay);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.popup {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
border: 2px solid var(--btn-border);
background: var(--bg-panel);
padding: 32px 40px;
border-radius: 8px;
}
h2 {
font: var(--font-heading);
color: var(--text-title);
margin-bottom: 8px;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script>
let { row = 0, col = 0, cellSize = 50 } = $props();
let top = $derived(row * cellSize + cellSize / 2);
let left = $derived(col * cellSize + cellSize / 2);
let size = $derived(Math.floor(cellSize / 1.5));
</script>
<div
class="player"
style="top: {top}px; left: {left}px; width: {size}px; height: {size}px;"
></div>
<style>
.player {
position: absolute;
background: var(--player-color);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: top 100ms linear, left 100ms linear;
z-index: 10;
pointer-events: none;
}
</style>
-172
View File
@@ -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;
}
}
-225
View File
@@ -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;
}
}
-130
View File
@@ -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);
}
}
}
}
-392
View File
@@ -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;
}
}
-41
View File
@@ -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();
});
}
}
}
-108
View File
@@ -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;
}
}
-59
View File
@@ -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;
}
}
-20
View File
@@ -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');
}
}
-513
View File
@@ -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();
}
}
-59
View File
@@ -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);
}
}
-86
View File
@@ -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);
}
}
-61
View File
@@ -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);
}
}
-84
View File
@@ -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);
}
}
-73
View File
@@ -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);
}
}
-42
View File
@@ -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');
}
}
-56
View File
@@ -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();
}
}
}
-72
View File
@@ -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 });
});
}
}
}
-70
View File
@@ -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);
}
+75
View File
@@ -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;
}
}
+184
View File
@@ -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();
}
}
+71
View File
@@ -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;
}
+40
View File
@@ -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);
}
}
+33
View File
@@ -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;
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+5 -33
View File
@@ -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;
+237
View File
@@ -0,0 +1,237 @@
<script>
import { onMount } from 'svelte';
import { getText } from '../lib/localization.js';
import { loadLevel, getTotalLevels } from '../lib/game/level-manager.js';
import { TurnManager } from '../lib/game/turn-manager.js';
import { completeLevel } from '../lib/progress.js';
import GameBoard from '../components/GameBoard.svelte';
import PlayerSprite from '../components/PlayerSprite.svelte';
import GuardSprite from '../components/GuardSprite.svelte';
import GameHud from '../components/GameHud.svelte';
import DetectionPopup from '../components/DetectionPopup.svelte';
import PauseMenu from '../components/PauseMenu.svelte';
let { navigate, level = 1, lives = 3 } = $props();
// Game state
let grid = $state(null);
let player = $state(null);
let guards = $state([]);
let turnManager = $state(new TurnManager());
let currentLevel = $state(level); // svelte-ignore state_referenced_locally
let livesRemaining = $state(lives); // svelte-ignore state_referenced_locally
let isFinalLevel = $state(false);
let goalRow = $state(0);
let goalCol = $state(0);
// UI state
let isPaused = $state(false);
let detected = $state(false);
let showFlash = $state(false);
let finalMessage = $state(false);
// Derived rendering data
let cells = $derived(grid ? grid.getAllCells() : []);
let turns = $derived(turnManager.turnCount);
let cellSize = $derived(grid ? Math.min(50, Math.floor(500 / grid.rows)) : 50);
// Initialize level
function initLevel() {
const state = loadLevel(currentLevel);
if (!state) return;
grid = state.grid;
player = state.player;
guards = state.guards;
isFinalLevel = state.isFinalLevel;
goalRow = state.goalRow;
goalCol = state.goalCol;
turnManager = new TurnManager();
detected = false;
isPaused = false;
finalMessage = false;
showFlash = false;
}
onMount(() => { initLevel(); });
// Input handling
function onKeyDown(e) {
if (isPaused || detected) 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) { e.preventDefault(); handleMove(dir); }
}
function onCellClick(row, col) {
if (isPaused || detected || !player) return;
const rowDiff = Math.abs(row - player.row);
const colDiff = Math.abs(col - player.col);
if ((rowDiff === 1 && colDiff === 0) || (rowDiff === 0 && colDiff === 1)) {
let dir;
if (row < player.row) dir = 'up';
else if (row > player.row) dir = 'down';
else if (col < player.col) dir = 'left';
else dir = 'right';
handleMove(dir);
}
}
function handleMove(direction) {
if (!player || !grid) return;
if (!player.move(direction)) return;
// Check final level proximity trigger BEFORE turn processing
if (isFinalLevel && checkFinalLevel()) return;
const result = turnManager.nextTurn(grid, player, guards);
// Force reactivity by reassigning
grid = grid;
guards = [...guards];
player = player;
if (result.levelComplete) {
handleLevelComplete();
} else if (result.detected) {
detected = true;
}
}
function checkFinalLevel() {
const distance = Math.abs(player.row - goalRow) + Math.abs(player.col - goalCol);
if (distance <= 2) {
lightUpEntireMap();
return true;
}
return false;
}
function lightUpEntireMap() {
finalMessage = true;
for (let r = 0; r < grid.rows; r++) {
for (let c = 0; c < grid.cols; c++) {
if (!grid.isWall(r, c)) grid.setLight(r, c, true);
}
}
grid = grid; // trigger reactivity
// Auto-detect after brief pause
setTimeout(() => { detected = true; }, 1500);
}
function handleLevelComplete() {
const total = getTotalLevels();
completeLevel(currentLevel, total);
showFlash = true;
setTimeout(() => {
showFlash = false;
const next = currentLevel + 1;
if (isFinalLevel) {
navigate('GameOver', { level: currentLevel, isLastLevel: true });
} else if (next > total) {
navigate('GameOver', { level: currentLevel, isLastLevel: false });
} else {
navigate('LevelIntro', { level: next, lives: livesRemaining });
}
}, 600);
}
function handleDetectionDismiss() {
detected = false;
livesRemaining--;
if (livesRemaining <= 0) {
navigate('GameOver', { level: currentLevel, isLastLevel: false });
} else {
initLevel();
}
}
</script>
<svelte:window onkeydown={onKeyDown} />
<div class="game-scene" class:flash={showFlash}>
<GameHud
lives={livesRemaining}
level={currentLevel}
{turns}
onpause={() => isPaused = true}
onmenu={() => navigate('MainMenu')}
/>
{#if grid && player}
<div class="board-wrapper">
<div class="board-container" style="position: relative;">
<GameBoard
{cells}
rows={grid.rows}
cols={grid.cols}
{cellSize}
oncellclick={onCellClick}
/>
<PlayerSprite row={player.row} col={player.col} {cellSize} />
{#each guards as guard}
<GuardSprite {guard} {cellSize} />
{/each}
</div>
</div>
{/if}
{#if finalMessage}
<div class="final-message">
<p>{getText('princessDetected')}</p>
</div>
{/if}
{#if detected}
<DetectionPopup onplayagain={handleDetectionDismiss} />
{/if}
{#if isPaused}
<PauseMenu
onresume={() => isPaused = false}
onrestart={initLevel}
onmainmenu={() => navigate('MainMenu')}
/>
{/if}
</div>
<style>
.game-scene {
width: 100%; height: 100%;
display: flex; flex-direction: column;
background: var(--bg-dark);
position: relative;
}
.game-scene.flash {
animation: level-flash 0.4s ease-out;
}
@keyframes level-flash {
0% { background: var(--bg-dark); }
50% { background: rgba(0, 200, 100, 0.2); }
100% { background: var(--bg-dark); }
}
.board-wrapper {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.board-container { position: relative; }
.final-message {
position: absolute;
top: 60px; left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
padding: 8px 16px;
border-radius: 4px;
z-index: 50;
}
.final-message p {
font: var(--font-body);
color: var(--text-danger);
text-align: center;
white-space: pre-line;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<script>
import { getText } from '../lib/localization.js';
import Button from '../components/Button.svelte';
let { navigate, level = 1, isLastLevel = false } = $props();
let title = $derived(isLastLevel ? getText('theEnd') : getText('gameOver'));
let desc = $derived(isLastLevel ? getText('ninjaFailed') : getText('caughtInLight'));
</script>
<div class="gameover">
<h1 class:accent={isLastLevel} class:danger={!isLastLevel}>{title}</h1>
<p>{desc}</p>
<div class="buttons">
<Button text={getText('tryAgain')} onclick={() => {
if (isLastLevel) navigate('MainMenu');
else navigate('Game', { level, lives: 3 });
}} />
<Button text={getText('mainMenu')} onclick={() => navigate('MainMenu')} />
</div>
</div>
<style>
.gameover {
width: 100%; height: 100%;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
gap: 24px;
background: var(--bg-dark);
}
h1 { font: bold 48px Arial, sans-serif; }
h1.accent { color: var(--text-accent); }
h1.danger { color: var(--text-danger); }
p {
font: var(--font-body);
color: var(--text-secondary);
text-align: center;
max-width: 70%;
white-space: pre-line;
}
.buttons { display: flex; flex-direction: column; gap: 12px; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<script>
import { getText } from '../lib/localization.js';
import Button from '../components/Button.svelte';
let { navigate } = $props();
</script>
<div class="guide">
<h1>{getText('guideTitle')}</h1>
<div class="content">
<h2>{getText('levelObjectives')}</h2>
<p>{getText('levelObjectivesContent')}</p>
<h2>{getText('movementControls')}</h2>
<p>{getText('movementControlsContent')}</p>
<h2>{getText('enemyTypes')}</h2>
<p>{getText('enemyTypesContent')}</p>
</div>
<Button text={getText('back')} onclick={() => navigate('MainMenu')} />
</div>
<style>
.guide {
width: 100%; height: 100%;
display: flex; flex-direction: column;
align-items: center;
background: var(--bg-dark);
padding: 24px;
}
h1 {
font: var(--font-title);
color: var(--text-title);
margin-bottom: 16px;
}
.content {
flex: 1;
overflow-y: auto;
width: 80%;
padding-right: 8px;
}
h2 {
font: var(--font-heading);
color: var(--text-accent);
margin: 16px 0 8px;
}
p {
font: var(--font-small);
color: var(--text-secondary);
white-space: pre-line;
line-height: 1.6;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<script>
import { getText } from '../lib/localization.js';
import { LEVELS } from '../lib/levels/levels.js';
import Button from '../components/Button.svelte';
let { navigate, level = 1, lives = 3 } = $props();
let levelData = $derived(LEVELS[level - 1]);
let levelName = $derived(levelData?.name || `Level ${level}`);
let storyText = $derived(levelData?.storyKey ? getText(levelData.storyKey) : '');
</script>
<div class="intro">
<span class="level-num">{getText('level')}{level}</span>
<h1>{levelName}</h1>
{#if storyText}
<p class="story">{storyText}</p>
{/if}
<Button text={getText('continue')} onclick={() => navigate('Game', { level, lives })} />
</div>
<style>
.intro {
width: 100%; height: 100%;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
gap: 20px;
background: var(--bg-dark);
}
.level-num {
font: var(--font-small);
color: var(--text-secondary);
}
h1 {
font: var(--font-title);
color: var(--text-title);
}
.story {
font: var(--font-body);
color: var(--text-primary);
text-align: center;
max-width: 70%;
line-height: 1.6;
white-space: pre-line;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<script>
import { getText } from '../lib/localization.js';
import { getProgress } from '../lib/progress.js';
import Button from '../components/Button.svelte';
let { navigate } = $props();
const totalLevels = 12;
let progress = $state(getProgress());
function selectLevel(num) {
if (num <= progress.maxLevel) {
navigate('LevelIntro', { level: num, lives: 3 });
}
}
</script>
<div class="select">
<h1>{getText('levelSelectTitle')}</h1>
<div class="grid">
{#each Array(totalLevels) as _, i}
{@const num = i + 1}
{@const unlocked = num <= progress.maxLevel}
{@const completed = progress.completedLevels.includes(num)}
<button
class="level-btn"
class:unlocked
class:completed
class:locked={!unlocked}
onclick={() => selectLevel(num)}
disabled={!unlocked}
>
{num}
{#if completed}<span class="check">✓</span>{/if}
</button>
{/each}
</div>
<Button text={getText('back')} onclick={() => navigate('MainMenu')} />
</div>
<style>
.select {
width: 100%; height: 100%;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
gap: 24px;
background: var(--bg-dark);
}
h1 { font: var(--font-title); color: var(--text-title); }
.grid {
display: grid;
grid-template-columns: repeat(4, 70px);
gap: 20px;
}
.level-btn {
width: 70px; height: 70px;
font: var(--font-button);
border-radius: 4px;
border: 2px solid var(--btn-border);
cursor: pointer;
position: relative;
transition: background 0.15s;
}
.level-btn.unlocked {
background: var(--btn-default);
color: var(--text-primary);
}
.level-btn.unlocked:hover { background: var(--btn-hover); }
.level-btn.completed { border-color: var(--grid-goal); }
.level-btn.locked {
background: #111122;
color: #555566;
border-color: #333344;
cursor: default;
}
.check {
position: absolute;
top: 2px; right: 4px;
font-size: 12px;
color: var(--grid-goal);
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script>
import { getText } from '../lib/localization.js';
import Button from '../components/Button.svelte';
let { navigate } = $props();
// Generate starfield dots
const stars = Array.from({ length: 40 }, () => ({
x: Math.random() * 100,
y: Math.random() * 100,
opacity: 0.1 + Math.random() * 0.4,
size: 1 + Math.floor(Math.random() * 2),
}));
</script>
<div class="menu">
{#each stars as star}
<div
class="star"
style="left: {star.x}%; top: {star.y}%; opacity: {star.opacity}; width: {star.size}px; height: {star.size}px;"
></div>
{/each}
<h1>{getText('gameTitle')}</h1>
<div class="ninja-icon"></div>
<div class="buttons">
<Button text={getText('startGame')} onclick={() => navigate('StoryIntro')} />
<Button text={getText('levelSelect')} onclick={() => navigate('LevelSelect')} />
<Button text={getText('guide')} onclick={() => navigate('Guide')} />
<Button text={getText('settings')} onclick={() => navigate('Settings')} />
</div>
</div>
<style>
.menu {
width: 100%; height: 100%;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
position: relative;
background: var(--bg-dark);
}
.star {
position: absolute;
background: white;
border-radius: 50%;
pointer-events: none;
}
h1 {
font: var(--font-title);
color: var(--text-title);
margin-bottom: 16px;
z-index: 1;
}
.ninja-icon {
width: 36px; height: 36px;
background: var(--player-color);
border-radius: 50%;
margin-bottom: 32px;
box-shadow: 0 0 20px rgba(83, 52, 131, 0.3);
z-index: 1;
}
.buttons {
display: flex; flex-direction: column;
gap: 12px; z-index: 1;
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<script>
import { getText, setLanguage, getLanguage } from '../lib/localization.js';
import Button from '../components/Button.svelte';
let { navigate } = $props();
let lang = $state(getLanguage());
// Force re-render on language change
let tick = $state(0);
function changeLang(l) {
if (lang !== l) {
setLanguage(l);
lang = l;
tick++;
}
}
</script>
{#key tick}
<div class="settings">
<h1>{getText('settings')}</h1>
<p class="label">{getText('languageSettings')}</p>
<div class="lang-buttons">
<button class="lang-btn" class:active={lang === 'en'} onclick={() => changeLang('en')}>
{getText('english')}
</button>
<button class="lang-btn" class:active={lang === 'vi'} onclick={() => changeLang('vi')}>
{getText('vietnamese')}
</button>
</div>
<Button text={getText('back')} onclick={() => navigate('MainMenu')} />
</div>
{/key}
<style>
.settings {
width: 100%; height: 100%;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
gap: 20px;
background: var(--bg-dark);
}
h1 { font: var(--font-title); color: var(--text-title); }
.label { font: var(--font-body); color: var(--text-secondary); }
.lang-buttons { display: flex; gap: 16px; }
.lang-btn {
font: var(--font-button);
color: var(--text-primary);
background: var(--btn-default);
border: 2px solid var(--btn-border);
border-radius: 4px;
padding: 10px 24px;
cursor: pointer;
transition: background 0.15s;
}
.lang-btn:hover { background: var(--btn-hover); }
.lang-btn.active { background: var(--btn-hover); }
</style>
+73
View File
@@ -0,0 +1,73 @@
<script>
import { onMount } from 'svelte';
import { getText } from '../lib/localization.js';
import Button from '../components/Button.svelte';
let { navigate } = $props();
let scrollDone = $state(false);
onMount(() => {
const el = document.querySelector('.story-scroll');
if (el) {
el.addEventListener('animationend', () => {
if (!scrollDone) { scrollDone = true; navigate('LevelIntro', { level: 1, lives: 3 }); }
});
}
});
function skip() {
if (!scrollDone) { scrollDone = true; navigate('LevelIntro', { level: 1, lives: 3 }); }
}
</script>
<div class="intro">
<h2>{getText('storyTitle')}</h2>
<div class="scroll-area">
<p class="story-scroll">{getText('storyText')}</p>
</div>
<div class="skip-btn">
<Button text={getText('skip')} onclick={skip} small />
</div>
</div>
<style>
.intro {
width: 100%; height: 100%;
display: flex; flex-direction: column;
align-items: center;
background: var(--bg-dark);
position: relative;
overflow: hidden;
}
h2 {
font: var(--font-title);
color: var(--text-accent);
margin-top: 60px;
z-index: 1;
}
.scroll-area {
flex: 1;
width: 80%;
overflow: hidden;
position: relative;
margin-top: 20px;
}
.story-scroll {
font: var(--font-body);
color: var(--text-primary);
text-align: center;
white-space: pre-line;
line-height: 1.6;
position: absolute;
top: 100%;
animation: scroll-up 30s linear forwards;
}
@keyframes scroll-up {
from { top: 100%; }
to { top: -200%; }
}
.skip-btn {
position: absolute;
bottom: 24px; right: 24px;
z-index: 2;
}
</style>
+71
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
-224
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
port: 8080,
},
});
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
build: {
rollupOptions: {
output: {
manualChunks: {
phaser: ['phaser']
}
}
},
},
server: {
port: 8080
}
});
-47
View File
@@ -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()
]
});