mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-15 22:14:57 +00:00
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:
1 parent
4461e45502
commit
f52dfde719
64 files changed
+2521
-2773
No files matched your search
@@ -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>
|
||||
@@ -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();
|
||||
Generated
+203
-104
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
-224
@@ -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();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 8080,
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
phaser: ['phaser']
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 8080
|
||||
}
|
||||
});
|
||||
@@ -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()
|
||||
]
|
||||
});
|
||||
Reference in new issue
Block a user