commit 04277726dbf9f87ddce5a831324d45f234af79fc Author: Peter Stockings Date: Sun Jan 4 09:22:55 2026 +1100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c8ddae8 --- /dev/null +++ b/bun.lock @@ -0,0 +1,201 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "rogue", + "dependencies": { + "phaser": "^3.90.0", + "rot-js": "^2.2.1", + }, + "devDependencies": { + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vitest": "^4.0.16", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="], + + "@vitest/runner": ["@vitest/runner@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" } }, "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="], + + "@vitest/spy": ["@vitest/spy@4.0.16", "", {}, "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="], + + "@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "phaser": ["phaser@3.90.0", "", { "dependencies": { "eventemitter3": "^5.0.1" } }, "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], + + "rot-js": ["rot-js@2.2.1", "", {}, "sha512-lItXH31vj4ebdypayCx9dh98qPr57E7jGW2lVMKxtBHooU3xpGRtLS8kdJIni232tvPJ8Sl0+aqXZj8c6W0MGw=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="], + + "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..a5692ce --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + rogue + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..3a2e5c3 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "rogue", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest" + }, + "devDependencies": { + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vitest": "^4.0.16" + }, + "dependencies": { + "phaser": "^3.90.0", + "rot-js": "^2.2.1" + } +} diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..1ac9c1f Binary files /dev/null and b/public/favicon.png differ diff --git a/public/splash_bg.png b/public/splash_bg.png new file mode 100644 index 0000000..b7248e5 Binary files /dev/null and b/public/splash_bg.png differ diff --git a/src/game/generator.ts b/src/game/generator.ts new file mode 100644 index 0000000..b381dc9 --- /dev/null +++ b/src/game/generator.ts @@ -0,0 +1,62 @@ +import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "./types"; +import { idx } from "./world"; + +export function makeTestWorld(level: number, runState: RunState): { world: World; playerId: EntityId } { + const width = 30; + const height = 18; + const tiles: Tile[] = new Array(width * height).fill(0); + + const fakeWorldForIdx: World = { width, height, tiles, actors: new Map(), exit: { x: 0, y: 0 } }; + + // Border walls + for (let x = 0; x < width; x++) { + tiles[idx(fakeWorldForIdx, x, 0)] = 1; + tiles[idx(fakeWorldForIdx, x, height - 1)] = 1; + } + for (let y = 0; y < height; y++) { + tiles[idx(fakeWorldForIdx, 0, y)] = 1; + tiles[idx(fakeWorldForIdx, width - 1, y)] = 1; + } + + // Internal walls (vary slightly with level so it feels different) + const shift = level % 4; + for (let x = 6; x < 22; x++) tiles[idx(fakeWorldForIdx, x, 7 + (shift % 2))] = 1; + for (let y = 4; y < 14; y++) tiles[idx(fakeWorldForIdx, 14 + ((shift + 1) % 2), y)] = 1; + + // Exit (stairs) + const exit: Vec2 = { x: width - 3, y: height - 3 }; + tiles[idx(fakeWorldForIdx, exit.x, exit.y)] = 0; + + const actors = new Map(); + + const playerId = 1; + actors.set(playerId, { + id: playerId, + isPlayer: true, + pos: { x: 3, y: 3 }, + speed: 100, + energy: 0, + stats: { ...runState.stats }, + inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] } + }); + + // Enemies + actors.set(2, { + id: 2, + isPlayer: false, + pos: { x: 24, y: 13 }, + speed: 90, + energy: 0, + stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 } + }); + actors.set(3, { + id: 3, + isPlayer: false, + pos: { x: 20, y: 4 }, + speed: 130, + energy: 0, + stats: { maxHp: 8, hp: 8, attack: 4, defense: 0 } + }); + + return { world: { width, height, tiles, actors, exit }, playerId }; +} diff --git a/src/game/pathfinding.ts b/src/game/pathfinding.ts new file mode 100644 index 0000000..5566541 --- /dev/null +++ b/src/game/pathfinding.ts @@ -0,0 +1,102 @@ +import type { World, Vec2 } from "./types"; +import { key, manhattan } from "./utils"; +import { inBounds, isWall, isBlocked, idx } from "./world"; + +/** + * Simple 4-dir A* pathfinding. + * Returns an array of positions INCLUDING start and end. If no path, returns []. + * + * Exploration rule: + * - You cannot path THROUGH unseen tiles. + * - You cannot path TO an unseen target tile. + */ +export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean } = {}): Vec2[] { + if (!inBounds(w, end.x, end.y)) return []; + if (isWall(w, end.x, end.y)) return []; + + // If not ignoring target block, fail if blocked + if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y)) return []; + + if (seen[idx(w, end.x, end.y)] !== 1) return []; + + const open: Vec2[] = [start]; + const cameFrom = new Map(); + + const gScore = new Map(); + const fScore = new Map(); + + const startK = key(start.x, start.y); + gScore.set(startK, 0); + fScore.set(startK, manhattan(start, end)); + + const inOpen = new Set([startK]); + + const dirs = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } + ]; + + while (open.length > 0) { + // Pick node with lowest fScore + let bestIdx = 0; + let bestF = Infinity; + for (let i = 0; i < open.length; i++) { + const k = key(open[i].x, open[i].y); + const f = fScore.get(k) ?? Infinity; + if (f < bestF) { + bestF = f; + bestIdx = i; + } + } + + const current = open.splice(bestIdx, 1)[0]; + const currentK = key(current.x, current.y); + inOpen.delete(currentK); + + if (current.x === end.x && current.y === end.y) { + // Reconstruct path + const path: Vec2[] = [end]; + let k = currentK; + while (cameFrom.has(k)) { + const prevK = cameFrom.get(k)!; + const [px, py] = prevK.split(",").map(Number); + path.push({ x: px, y: py }); + k = prevK; + } + path.reverse(); + return path; + } + + for (const d of dirs) { + const nx = current.x + d.x; + const ny = current.y + d.y; + if (!inBounds(w, nx, ny)) continue; + if (isWall(w, nx, ny)) continue; + + // Exploration rule: cannot path through unseen (except start) + if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue; + + // Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block) + const isTarget = nx === end.x && ny === end.y; + if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny)) continue; + + const nK = key(nx, ny); + const tentativeG = (gScore.get(currentK) ?? Infinity) + 1; + + if (tentativeG < (gScore.get(nK) ?? Infinity)) { + cameFrom.set(nK, currentK); + gScore.set(nK, tentativeG); + fScore.set(nK, tentativeG + manhattan({ x: nx, y: ny }, end)); + + if (!inOpen.has(nK)) { + open.push({ x: nx, y: ny }); + inOpen.add(nK); + } + } + } + } + + return []; +} diff --git a/src/game/simulation.ts b/src/game/simulation.ts new file mode 100644 index 0000000..45ad357 --- /dev/null +++ b/src/game/simulation.ts @@ -0,0 +1,120 @@ +import { ACTION_COST, ENERGY_THRESHOLD } from "./types"; +import type { World, EntityId, Action, SimEvent, Actor } from "./types"; +import { isBlocked } from "./world"; + +export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] { + const actor = w.actors.get(actorId); + if (!actor) return []; + + const events: SimEvent[] = []; + + if (action.type === "move") { + const from = { ...actor.pos }; + const nx = actor.pos.x + action.dx; + const ny = actor.pos.y + action.dy; + + if (!isBlocked(w, nx, ny)) { + actor.pos.x = nx; + actor.pos.y = ny; + const to = { ...actor.pos }; + events.push({ type: "moved", actorId, from, to }); + } else { + events.push({ type: "waited", actorId }); + } + } else if (action.type === "attack") { + console.log("Sim: Processing Attack on", action.targetId); + const target = w.actors.get(action.targetId); + if (target && target.stats && actor.stats) { + events.push({ type: "attacked", attackerId: actorId, targetId: action.targetId }); + + const dmg = Math.max(1, actor.stats.attack - target.stats.defense); + console.log("Sim: calculated damage:", dmg); + target.stats.hp -= dmg; + + events.push({ + type: "damaged", + targetId: action.targetId, + amount: dmg, + hp: target.stats.hp, + x: target.pos.x, + y: target.pos.y + }); + + if (target.stats.hp <= 0) { + w.actors.delete(target.id); + events.push({ type: "killed", targetId: target.id, killerId: actorId }); + } + } else { + events.push({ type: "waited", actorId }); // Missed or invalid target + } + } else { + events.push({ type: "waited", actorId }); + } + + // Spend energy for any action (move/wait/attack) + actor.energy -= ACTION_COST; + + return events; +} + +/** + * Very basic enemy AI: + * - if adjacent to player, "wait" (placeholder for attack) + * - else step toward player using greedy Manhattan + */ +export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action { + const dx = player.pos.x - enemy.pos.x; + const dy = player.pos.y - enemy.pos.y; + const dist = Math.abs(dx) + Math.abs(dy); + + if (dist === 1) { + return { type: "attack", targetId: player.id }; + } + + const options: { dx: number; dy: number }[] = []; + if (Math.abs(dx) >= Math.abs(dy)) { + options.push({ dx: Math.sign(dx), dy: 0 }); + options.push({ dx: 0, dy: Math.sign(dy) }); + } else { + options.push({ dx: 0, dy: Math.sign(dy) }); + options.push({ dx: Math.sign(dx), dy: 0 }); + } + + options.push({ dx: -options[0].dx, dy: -options[0].dy }); + + for (const o of options) { + if (o.dx === 0 && o.dy === 0) continue; + const nx = enemy.pos.x + o.dx; + const ny = enemy.pos.y + o.dy; + if (!isBlocked(w, nx, ny)) return { type: "move", dx: o.dx, dy: o.dy }; + } + return { type: "wait" }; +} + +/** + * Energy/speed scheduler: runs until it's the player's turn and the game needs input. + * Returns enemy events accumulated along the way. + */ +export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } { + const player = w.actors.get(playerId); + if (!player) throw new Error("Player missing"); + + const events: SimEvent[] = []; + + while (true) { + while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) { + for (const a of w.actors.values()) a.energy += a.speed; + } + + const ready = [...w.actors.values()].filter(a => a.energy >= ENERGY_THRESHOLD); + ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id)); + const actor = ready[0]; + + if (actor.isPlayer) { + return { awaitingPlayerId: actor.id, events }; + } + + const action = decideEnemyAction(w, actor, player); + events.push(...applyAction(w, actor.id, action)); + } +} diff --git a/src/game/types.ts b/src/game/types.ts new file mode 100644 index 0000000..baad038 --- /dev/null +++ b/src/game/types.ts @@ -0,0 +1,58 @@ +export type EntityId = number; + +export type Vec2 = { x: number; y: number }; + +export type Tile = 0 | 1; // 0 = floor, 1 = wall + +export type Action = + | { type: "move"; dx: number; dy: number } + | { type: "attack"; targetId: EntityId } + | { type: "wait" }; + +export type SimEvent = + | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } + | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } + | { type: "attacked"; attackerId: EntityId; targetId: EntityId } + | { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number } + | { type: "killed"; targetId: EntityId; killerId: EntityId } + | { type: "waited"; actorId: EntityId }; + +export type Stats = { + maxHp: number; + hp: number; + attack: number; + defense: number; +}; + +export type Inventory = { + gold: number; + items: string[]; +}; + +export type RunState = { + stats: Stats; + inventory: Inventory; +}; + +export type Actor = { + id: EntityId; + isPlayer: boolean; + pos: Vec2; + speed: number; + energy: number; + + stats?: Stats; + inventory?: Inventory; +}; + +export type World = { + width: number; + height: number; + tiles: Tile[]; + actors: Map; + exit: Vec2; +}; + +export const TILE_SIZE = 24; +export const ENERGY_THRESHOLD = 100; +export const ACTION_COST = 100; diff --git a/src/game/utils.ts b/src/game/utils.ts new file mode 100644 index 0000000..af1764d --- /dev/null +++ b/src/game/utils.ts @@ -0,0 +1,9 @@ +import type { Vec2 } from "./types"; + +export function key(x: number, y: number) { + return `${x},${y}`; +} + +export function manhattan(a: Vec2, b: Vec2) { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} diff --git a/src/game/world.ts b/src/game/world.ts new file mode 100644 index 0000000..5f20d0c --- /dev/null +++ b/src/game/world.ts @@ -0,0 +1,29 @@ +import type { World, EntityId } from "./types"; + +export function inBounds(w: World, x: number, y: number) { + return x >= 0 && y >= 0 && x < w.width && y < w.height; +} + +export function idx(w: World, x: number, y: number) { + return y * w.width + x; +} + +export function isWall(w: World, x: number, y: number) { + return w.tiles[idx(w, x, y)] === 1; +} + +export function isBlocked(w: World, x: number, y: number) { + if (!inBounds(w, x, y)) return true; + if (isWall(w, x, y)) return true; + + for (const a of w.actors.values()) { + if (a.pos.x === x && a.pos.y === y) return true; + } + return false; +} + +export function isPlayerOnExit(w: World, playerId: EntityId) { + const p = w.actors.get(playerId); + if (!p) return false; + return p.pos.x === w.exit.x && p.pos.y === w.exit.y; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..a9b8ffb --- /dev/null +++ b/src/main.ts @@ -0,0 +1,15 @@ +import GameUI from "./scenes/GameUI"; +import { GameScene } from "./scenes/GameScene"; +import { SplashScene } from "./scenes/SplashScene"; +import { StartScene } from "./scenes/StartScene"; + +new Phaser.Game({ + type: Phaser.AUTO, + parent: "app", + width: 960, + height: 540, + backgroundColor: "#111", + pixelArt: true, + roundPixels: true, + scene: [SplashScene, StartScene, GameScene, GameUI] +}); diff --git a/src/scenes/DungeonRenderer.ts b/src/scenes/DungeonRenderer.ts new file mode 100644 index 0000000..92dd203 --- /dev/null +++ b/src/scenes/DungeonRenderer.ts @@ -0,0 +1,161 @@ +import Phaser from "phaser"; +import { FOV } from "rot-js"; +import { type World, type EntityId, type Vec2, TILE_SIZE } from "../game/types"; +import { idx, inBounds, isWall } from "../game/world"; + +export class DungeonRenderer { + private scene: Phaser.Scene; + private gfx: Phaser.GameObjects.Graphics; + + // FOV + private fov!: any; + private seen!: Uint8Array; + private visible!: Uint8Array; + private visibleStrength!: Float32Array; + private viewRadius = 8; + + // State refs + private world!: World; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + this.gfx = this.scene.add.graphics(); + } + + initializeLevel(world: World) { + this.world = world; + this.seen = new Uint8Array(this.world.width * this.world.height); + this.visible = new Uint8Array(this.world.width * this.world.height); + this.visibleStrength = new Float32Array(this.world.width * this.world.height); + + this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => { + if (!inBounds(this.world, x, y)) return false; + return !isWall(this.world, x, y); + }); + } + + computeFov(playerId: EntityId) { + this.visible.fill(0); + this.visibleStrength.fill(0); + + const player = this.world.actors.get(playerId)!; + const ox = player.pos.x; + const oy = player.pos.y; + + this.fov.compute(ox, oy, this.viewRadius, (x: number, y: number, r: number, v: number) => { + if (!inBounds(this.world, x, y)) return; + + const i = idx(this.world, x, y); + this.visible[i] = 1; + this.seen[i] = 1; + + // falloff: 1 at center, ~0.4 at radius edge + const radiusT = Phaser.Math.Clamp(r / this.viewRadius, 0, 1); + const falloff = 1 - radiusT * 0.6; + const strength = Phaser.Math.Clamp(v * falloff, 0, 1); + + if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength; + }); + } + + isSeen(x: number, y: number): boolean { + if (!this.world || !inBounds(this.world, x, y)) return false; + return this.seen[idx(this.world, x, y)] === 1; + } + + get seenArray() { + return this.seen; + } + + render(playerPath: Vec2[]) { + this.gfx.clear(); + + if (!this.world) return; + + // Tiles w/ fog + falloff + silhouettes + for (let y = 0; y < this.world.height; y++) { + for (let x = 0; x < this.world.width; x++) { + const i = idx(this.world, x, y); + + const isSeen = this.seen[i] === 1; + const isVis = this.visible[i] === 1; + + if (!isSeen) { + this.gfx.fillStyle(0x000000, 1); + this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); + continue; + } + + const wall = isWall(this.world, x, y); + const base = wall ? 0x2b2b2b : 0x161616; + + let alpha: number; + if (isVis) { + const s = this.visibleStrength[i]; + alpha = Phaser.Math.Clamp(0.35 + s * 0.65, 0.35, 1.0); + } else { + alpha = wall ? 0.35 : 0.15; + } + + this.gfx.fillStyle(base, alpha); + this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); + } + } + + // Exit (stairs) if seen + { + const ex = this.world.exit.x; + const ey = this.world.exit.y; + const i = idx(this.world, ex, ey); + if (this.seen[i] === 1) { + const alpha = this.visible[i] === 1 ? 1.0 : 0.35; + this.gfx.fillStyle(0xffd166, alpha); + this.gfx.fillRect(ex * TILE_SIZE + 7, ey * TILE_SIZE + 7, TILE_SIZE - 14, TILE_SIZE - 14); + } + } + + // Path preview (seen only) + if (playerPath.length >= 2) { + this.gfx.fillStyle(0x3355ff, 0.35); + for (const p of playerPath) { + // We can check isSeen via internal helper or just local array since we're inside + const i = idx(this.world, p.x, p.y); + if (this.seen[i] !== 1) continue; + this.gfx.fillRect(p.x * TILE_SIZE + 6, p.y * TILE_SIZE + 6, TILE_SIZE - 12, TILE_SIZE - 12); + } + } + + // Actors (enemies only if visible) + for (const a of this.world.actors.values()) { + const i = idx(this.world, a.pos.x, a.pos.y); + const isVis = this.visible[i] === 1; + if (!a.isPlayer && !isVis) continue; + + const color = a.isPlayer ? 0x66ff66 : 0xff6666; + this.gfx.fillStyle(color, 1); + this.gfx.fillRect(a.pos.x * TILE_SIZE + 4, a.pos.y * TILE_SIZE + 4, TILE_SIZE - 8, TILE_SIZE - 8); + } + } + + showDamage(x: number, y: number, amount: number) { + const screenX = x * TILE_SIZE + TILE_SIZE / 2; + const screenY = y * TILE_SIZE; + + const text = this.scene.add.text(screenX, screenY, amount.toString(), { + fontSize: "16px", + color: "#ff3333", + stroke: "#000", + strokeThickness: 2, + fontStyle: "bold" + }).setOrigin(0.5, 1).setDepth(200); + + this.scene.tweens.add({ + targets: text, + y: screenY - 24, + alpha: 0, + duration: 800, + ease: "Power1", + onComplete: () => text.destroy() + }); + } +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts new file mode 100644 index 0000000..e8b9bd1 --- /dev/null +++ b/src/scenes/GameScene.ts @@ -0,0 +1,257 @@ +import Phaser from "phaser"; +import { + type EntityId, + type Vec2, + type Action, + type RunState, + type World, + TILE_SIZE +} from "../game/types"; +import { inBounds, isBlocked, isPlayerOnExit } from "../game/world"; +import { findPathAStar } from "../game/pathfinding"; +import { applyAction, stepUntilPlayerTurn } from "../game/simulation"; +import { makeTestWorld } from "../game/generator"; +import { DungeonRenderer } from "./DungeonRenderer"; + +export class GameScene extends Phaser.Scene { + private world!: World; + private playerId!: EntityId; + + private levelIndex = 1; + + private runState: RunState = { + stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, + inventory: { gold: 0, items: [] } + }; + + private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; + + private playerPath: Vec2[] = []; + private awaitingPlayer = false; + + // Sub-systems + private dungeonRenderer!: DungeonRenderer; + private isMenuOpen = false; + + constructor() { + super("GameScene"); + } + + create() { + this.cursors = this.input.keyboard!.createCursorKeys(); + + // Camera + this.cameras.main.setZoom(2); + + // Initialize Sub-systems + this.dungeonRenderer = new DungeonRenderer(this); + + // Launch UI Scene + this.scene.launch("GameUI"); + + // Listen for Menu State + this.events.on("menu-toggled", (isOpen: boolean) => { + this.isMenuOpen = isOpen; + }); + + // Load initial level + this.loadLevel(1); + + // Menu Inputs + this.input.keyboard?.on("keydown-I", () => { + this.events.emit("toggle-menu"); + // Force update UI in case it opened + this.emitUIUpdate(); + }); + this.input.keyboard?.on("keydown-ESC", () => { + this.events.emit("close-menu"); + }); + + // Mouse click -> compute path (only during player turn, and not while menu is open) + this.input.on("pointerdown", (p: Phaser.Input.Pointer) => { + if (!this.awaitingPlayer) return; + if (this.isMenuOpen) return; + + const tx = Math.floor(p.worldX / TILE_SIZE); + const ty = Math.floor(p.worldY / TILE_SIZE); + + if (!inBounds(this.world, tx, ty)) return; + + // Exploration rule: cannot click-to-move into unseen tiles + if (!this.dungeonRenderer.isSeen(tx, ty)) return; + + // Check if clicking on an enemy + const isEnemy = [...this.world.actors.values()].some(a => a.pos.x === tx && a.pos.y === ty && !a.isPlayer); + + const player = this.world.actors.get(this.playerId)!; + const path = findPathAStar( + this.world, + this.dungeonRenderer.seenArray, + { ...player.pos }, + { x: tx, y: ty }, + { ignoreBlockedTarget: isEnemy } + ); + + if (path.length >= 2) this.playerPath = path; + this.dungeonRenderer.render(this.playerPath); + }); + } + + update() { + if (!this.awaitingPlayer) return; + if (this.isMenuOpen) return; + + // Auto-walk one step per turn + if (this.playerPath.length >= 2) { + const player = this.world.actors.get(this.playerId)!; + const next = this.playerPath[1]; + const dx = next.x - player.pos.x; + const dy = next.y - player.pos.y; + + if (Math.abs(dx) + Math.abs(dy) !== 1) { + this.playerPath = []; + return; + } + + if (isBlocked(this.world, next.x, next.y)) { + // Check if it's an enemy at 'next' + const targetId = [...this.world.actors.values()].find( + a => a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer + )?.id; + + if (targetId !== undefined) { + this.commitPlayerAction({ type: "attack", targetId }); + this.playerPath = []; // Stop after attack + return; + } else { + // Blocked by something else (friendly?) + this.playerPath = []; + return; + } + } + + this.commitPlayerAction({ type: "move", dx, dy }); + this.playerPath.shift(); + return; + } + + // Arrow keys + let action: Action | null = null; + let dx = 0; + let dy = 0; + + if (Phaser.Input.Keyboard.JustDown(this.cursors.left!)) dx = -1; + else if (Phaser.Input.Keyboard.JustDown(this.cursors.right!)) dx = 1; + else if (Phaser.Input.Keyboard.JustDown(this.cursors.up!)) dy = -1; + else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1; + + if (dx !== 0 || dy !== 0) { + console.log("Input: ", dx, dy); + const player = this.world.actors.get(this.playerId)!; + const targetX = player.pos.x + dx; + const targetY = player.pos.y + dy; + console.log("Target: ", targetX, targetY); + + // Check for enemy at target position + const targetId = [...this.world.actors.values()].find( + a => a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer + )?.id; + console.log("Found Target ID:", targetId); + + if (targetId !== undefined) { + action = { type: "attack", targetId }; + } else { + action = { type: "move", dx, dy }; + } + } + + if (action) { + this.playerPath = []; + this.commitPlayerAction(action); + } + } + + private emitUIUpdate() { + this.events.emit("update-ui", { + world: this.world, + playerId: this.playerId, + levelIndex: this.levelIndex + }); + } + + private commitPlayerAction(action: Action) { + this.awaitingPlayer = false; + + const playerEvents = applyAction(this.world, this.playerId, action); + const enemyStep = stepUntilPlayerTurn(this.world, this.playerId); + this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; + + // Process events for visual fx + const allEvents = [...playerEvents, ...enemyStep.events]; + if (allEvents.length > 0) console.log("Events:", allEvents); + for (const ev of allEvents) { + if (ev.type === "damaged") { + console.log("Showing damage:", ev.amount, "at", ev.x, ev.y); + this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount); + } + } + + // Level transition + if (isPlayerOnExit(this.world, this.playerId)) { + this.syncRunStateFromPlayer(); + this.loadLevel(this.levelIndex + 1); + return; + } + + this.dungeonRenderer.computeFov(this.playerId); + this.centerCameraOnPlayer(); + this.dungeonRenderer.render(this.playerPath); + this.emitUIUpdate(); + } + + private loadLevel(level: number) { + this.levelIndex = level; + + const { world, playerId } = makeTestWorld(level, this.runState); + this.world = world; + this.playerId = playerId; + + // Reset transient state + this.playerPath = []; + this.awaitingPlayer = false; + + // Camera bounds for this level + this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE); + + // Initialize Renderer for new level + this.dungeonRenderer.initializeLevel(this.world); + + // Step until player turn + const enemyStep = stepUntilPlayerTurn(this.world, this.playerId); + this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; + + this.dungeonRenderer.computeFov(this.playerId); + this.centerCameraOnPlayer(); + this.dungeonRenderer.render(this.playerPath); + this.emitUIUpdate(); + + } + + private syncRunStateFromPlayer() { + const p = this.world.actors.get(this.playerId); + if (!p?.stats || !p.inventory) return; + + this.runState = { + stats: { ...p.stats }, + inventory: { gold: p.inventory.gold, items: [...p.inventory.items] } + }; + } + + private centerCameraOnPlayer() { + const player = this.world.actors.get(this.playerId)!; + this.cameras.main.centerOn( + player.pos.x * TILE_SIZE + TILE_SIZE / 2, + player.pos.y * TILE_SIZE + TILE_SIZE / 2 + ); + } +} diff --git a/src/scenes/GameUI.ts b/src/scenes/GameUI.ts new file mode 100644 index 0000000..70e749f --- /dev/null +++ b/src/scenes/GameUI.ts @@ -0,0 +1,173 @@ +import Phaser from "phaser"; +import { type World, type EntityId } from "../game/types"; + +export default class GameUI extends Phaser.Scene { + // HUD + private levelText!: Phaser.GameObjects.Text; + private healthBar!: Phaser.GameObjects.Graphics; + + // Menu + private menuOpen = false; + private menuContainer!: Phaser.GameObjects.Container; + private menuText!: Phaser.GameObjects.Text; + private menuBg!: Phaser.GameObjects.Rectangle; + private menuButton!: Phaser.GameObjects.Container; + + constructor() { + super({ key: "GameUI" }); + } + + create() { + this.createHud(); + this.createMenu(); + + // Listen for updates from GameScene + const gameScene = this.scene.get("GameScene"); + gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; levelIndex: number }) => { + this.updateUI(data.world, data.playerId, data.levelIndex); + }); + + // Also listen for toggle request if needed, or stick to inputs in GameScene? + // GameScene handles Input 'I' -> calls events.emit('toggle-menu')? + // Or GameUI handles input? + // Let's keep input in GameScene for now to avoid conflicts, or move 'I' here. + // If 'I' is pressed, GameScene might need to know if menu is open (to pause). + gameScene.events.on("toggle-menu", () => this.toggleMenu()); + gameScene.events.on("close-menu", () => this.setMenuOpen(false)); + } + + private createHud() { + this.levelText = this.add.text(10, 10, "Level 1", { + fontSize: "20px", + color: "#ffffff", + fontStyle: "bold" + }).setDepth(100); + + this.healthBar = this.add.graphics().setDepth(100); + } + + private createMenu() { + const cam = this.cameras.main; + + // Button (top-right) + const btnW = 90; + const btnH = 28; + + const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8); + const btnLabel = this.add.text(0, 0, "Menu", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5); + + this.menuButton = this.add.container(0, 0, [btnBg, btnLabel]); + this.menuButton.setDepth(1000); + + const placeButton = () => { + this.menuButton.setPosition(cam.width - btnW / 2 - 10, btnH / 2 + 10); + }; + placeButton(); + this.scale.on("resize", placeButton); // Use scale manager resize + + btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMenu()); + + // Panel (center) + const panelW = 340; + const panelH = 220; + + this.menuBg = this.add + .rectangle(0, 0, panelW, panelH, 0x000000, 0.8) + .setStrokeStyle(1, 0xffffff, 0.9) + .setInteractive(); // capture clicks + + this.menuText = this.add + .text(-panelW / 2 + 14, -panelH / 2 + 12, "", { + fontSize: "14px", + color: "#ffffff", + wordWrap: { width: panelW - 28 } + }) + .setOrigin(0, 0); + + this.menuContainer = this.add.container(0, 0, [this.menuBg, this.menuText]); + this.menuContainer.setDepth(1001); + + const placePanel = () => { + this.menuContainer.setPosition(cam.width / 2, cam.height / 2); + }; + placePanel(); + this.scale.on("resize", placePanel); + + this.setMenuOpen(false); + } + + private toggleMenu() { + this.setMenuOpen(!this.menuOpen); + } + + private setMenuOpen(open: boolean) { + this.menuOpen = open; + this.menuContainer.setVisible(open); + + // Notify GameScene back? + const gameScene = this.scene.get("GameScene"); + gameScene.events.emit("menu-toggled", open); + } + + private updateUI(world: World, playerId: EntityId, levelIndex: number) { + this.updateHud(world, playerId, levelIndex); + if (this.menuOpen) { + this.updateMenuText(world, playerId, levelIndex); + } + } + + private updateHud(world: World, playerId: EntityId, levelIndex: number) { + this.levelText.setText(`Level ${levelIndex}`); + + const p = world.actors.get(playerId); + if (!p || !p.stats) return; + + const barX = 10; + const barY = 40; + const barW = 200; + const barH = 16; + + this.healthBar.clear(); + this.healthBar.fillStyle(0x444444, 1); + this.healthBar.fillRect(barX, barY, barW, barH); + + const hp = Math.max(0, p.stats.hp); + const maxHp = Math.max(1, p.stats.maxHp); + const pct = Phaser.Math.Clamp(hp / maxHp, 0, 1); + const fillW = Math.floor(barW * pct); + + this.healthBar.fillStyle(0xff0000, 1); + this.healthBar.fillRect(barX, barY, fillW, barH); + + this.healthBar.lineStyle(2, 0xffffff, 1); + this.healthBar.strokeRect(barX, barY, barW, barH); + } + + private updateMenuText(world: World, playerId: EntityId, levelIndex: number) { + const p = world.actors.get(playerId); + const stats = p?.stats; + const inv = p?.inventory; + + const lines: string[] = []; + lines.push(`Level ${levelIndex}`); + lines.push(""); + lines.push("Stats"); + lines.push(` HP: ${stats?.hp ?? 0}/${stats?.maxHp ?? 0}`); + lines.push(` Attack: ${stats?.attack ?? 0}`); + lines.push(` Defense: ${stats?.defense ?? 0}`); + lines.push(` Speed: ${p?.speed ?? 0}`); + lines.push(""); + lines.push("Inventory"); + lines.push(` Gold: ${inv?.gold ?? 0}`); + lines.push(` Items: ${(inv?.items?.length ?? 0) === 0 ? "(none)" : ""}`); + + if (inv?.items?.length) { + for (const it of inv.items) lines.push(` - ${it}`); + } + + lines.push(""); + lines.push("Hotkeys: I to toggle, Esc to close"); + + this.menuText.setText(lines.join("\n")); + } +} diff --git a/src/scenes/SplashScene.ts b/src/scenes/SplashScene.ts new file mode 100644 index 0000000..c5fb150 --- /dev/null +++ b/src/scenes/SplashScene.ts @@ -0,0 +1,41 @@ +import Phaser from "phaser"; +import { Scene } from 'phaser'; + +export class SplashScene extends Scene { + constructor() { + super("SplashScene"); + } + + preload() { + this.load.image('splash', 'splash_bg.png'); + } + + create() { + const { width, height } = this.scale; + + // Background (Placeholder for Image) + // If we successfully load the image 'splash', we use it. + if (this.textures.exists('splash')) { + this.add.image(width / 2, height / 2, 'splash').setDisplaySize(width, height); + } else { + this.add.rectangle(0, 0, width, height, 0x110022).setOrigin(0); + this.add.text(width/2, height/2, "ROGUE LEGACY", { + fontSize: "48px", + color: "#ffffff", + fontStyle: "bold" + }).setOrigin(0.5); + } + + // Fade In + this.cameras.main.fadeIn(1000, 0, 0, 0); + + // Fade Out after delay + this.time.delayedCall(2500, () => { + this.cameras.main.fadeOut(1000, 0, 0, 0); + }); + + this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => { + this.scene.start("StartScene"); + }); + } +} diff --git a/src/scenes/StartScene.ts b/src/scenes/StartScene.ts new file mode 100644 index 0000000..8fb0f53 --- /dev/null +++ b/src/scenes/StartScene.ts @@ -0,0 +1,52 @@ +import Phaser from "phaser"; + +export class StartScene extends Phaser.Scene { + constructor() { + super("StartScene"); + } + + create() { + const { width, height } = this.scale; + + this.cameras.main.fadeIn(500, 0, 0, 0); + + // Title + this.add.text(width / 2, height * 0.3, "ROGUE", { + fontSize: "64px", + color: "#ff0044", + fontStyle: "bold", + stroke: "#ffffff", + strokeThickness: 4 + }).setOrigin(0.5); + + // Buttons + const startBtn = this.createButton(width / 2, height * 0.55, "Start Game"); + const optBtn = this.createButton(width / 2, height * 0.65, "Options"); + + startBtn.on("pointerdown", () => { + this.scene.start("GameScene"); + }); + + optBtn.on("pointerdown", () => { + console.log("Options clicked"); + }); + } + + private createButton(x: number, y: number, text: string) { + const bg = this.add.rectangle(0, 0, 200, 50, 0x333333).setStrokeStyle(2, 0xffffff); + const txt = this.add.text(0, 0, text, { fontSize: "24px", color: "#ffffff" }).setOrigin(0.5); + + const container = this.add.container(x, y, [bg, txt]); + container.setSize(200, 50); + container.setInteractive({ useHandCursor: true }); + + container.on("pointerover", () => { + bg.setFillStyle(0x555555); + }); + container.on("pointerout", () => { + bg.setFillStyle(0x333333); + }); + + return container; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4ba8dd9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +}