diff --git a/.vscode/settings.json b/.vscode/settings.json
index cdb6388..8ea98a7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,10 +4,7 @@
"eslint.validate": [
"javascript",
"javascriptreact",
- {
- "language": "typescript",
- "autoFix": true
- },
+ "typescript"
],
"[javascript]": {
"editor.formatOnSave": true
@@ -19,6 +16,6 @@
"editor.formatOnSave": true
},
"editor.codeActionsOnSave": {
- "source.fixAll.eslint": true
+ "source.fixAll.eslint": "always"
},
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 42d7a88..ef0386f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"json-loader": "^0.5.7",
"phaser": "^3.60.0",
"phaser-raycaster": "^0.10.6",
+ "sharp": "^0.34.5",
"ts-loader": "^9.4.2",
"typescript": "^5.0.3",
"webpack": "^5.77.0",
@@ -1627,6 +1628,507 @@
"node": ">=10.0.0"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+ "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
@@ -2874,6 +3376,16 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -5334,6 +5846,64 @@
"node": ">=8"
}
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/package.json b/package.json
index 01b211e..e185372 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"json-loader": "^0.5.7",
"phaser": "^3.60.0",
"phaser-raycaster": "^0.10.6",
+ "sharp": "^0.34.5",
"ts-loader": "^9.4.2",
"typescript": "^5.0.3",
"webpack": "^5.77.0",
diff --git a/scripts/create-zombie-atlas.js b/scripts/create-zombie-atlas.js
new file mode 100644
index 0000000..b46588a
--- /dev/null
+++ b/scripts/create-zombie-atlas.js
@@ -0,0 +1,135 @@
+const fs = require("fs");
+const path = require("path");
+const sharp = require("sharp");
+
+async function main() {
+ const zombieImagesDir = path.join(__dirname, "../spine/zombie/images");
+ const assetsDir = path.join(__dirname, "../src/assets/spine");
+
+ // Images for the zombie atlas (upper body only - legs are separate)
+ const imageFiles = [
+ { name: "head", file: "head.png" },
+ { name: "torso", file: "torso.png" },
+ { name: "arm", file: "arm.png" },
+ { name: "forearm", file: "forearm.png" },
+ { name: "hand", file: "hand.png" },
+ ];
+
+ console.log("Creating zombie atlas from zombified textures...\n");
+
+ // Load all images and get their metadata
+ const images = [];
+ for (const imgInfo of imageFiles) {
+ const imgPath = path.join(zombieImagesDir, imgInfo.file);
+
+ if (!fs.existsSync(imgPath)) {
+ console.log(`Warning: ${imgInfo.file} not found, skipping`);
+ continue;
+ }
+
+ try {
+ const meta = await sharp(imgPath).metadata();
+ images.push({
+ name: imgInfo.name,
+ path: imgPath,
+ width: meta.width,
+ height: meta.height,
+ });
+ console.log(`Loaded: ${imgInfo.name} (${meta.width}x${meta.height})`);
+ } catch (err) {
+ console.log(`Error loading ${imgInfo.file}: ${err.message}`);
+ }
+ }
+
+ if (images.length === 0) {
+ console.error("No images loaded! Run zombify-textures.js first.");
+ process.exit(1);
+ }
+
+ // Sort by height descending for better packing
+ images.sort((a, b) => b.height - a.height);
+
+ // Simple shelf packing
+ const padding = 2;
+ let atlasWidth = 256;
+ let currentX = padding;
+ let currentY = padding;
+ let shelfHeight = 0;
+
+ for (const img of images) {
+ if (currentX + img.width + padding > atlasWidth) {
+ // Start new shelf
+ currentX = padding;
+ currentY += shelfHeight + padding;
+ shelfHeight = 0;
+ }
+
+ img.x = currentX;
+ img.y = currentY;
+
+ currentX += img.width + padding;
+ shelfHeight = Math.max(shelfHeight, img.height);
+ }
+
+ // Calculate final atlas height (power of 2)
+ const neededHeight = currentY + shelfHeight + padding;
+ let atlasHeight = 128;
+ while (atlasHeight < neededHeight) {
+ atlasHeight *= 2;
+ }
+
+ console.log(`\nAtlas size: ${atlasWidth}x${atlasHeight}`);
+
+ // Create composite operations for sharp
+ const composites = [];
+ for (const img of images) {
+ composites.push({
+ input: img.path,
+ left: img.x,
+ top: img.y,
+ });
+ console.log(`Packed: ${img.name} at (${img.x}, ${img.y})`);
+ }
+
+ // Create the atlas image
+ await sharp({
+ create: {
+ width: atlasWidth,
+ height: atlasHeight,
+ channels: 4,
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
+ },
+ })
+ .composite(composites)
+ .png()
+ .toFile(path.join(assetsDir, "zombie.png"));
+
+ console.log("\nCreated: zombie.png");
+
+ // Create the atlas file (must start with empty line before filename)
+ const atlasContent = `
+zombie.png
+size: ${atlasWidth}, ${atlasHeight}
+format: RGBA8888
+filter: Linear, Linear
+repeat: none
+${images
+ .map(
+ (img) => `${img.name}
+ rotate: false
+ xy: ${img.x}, ${img.y}
+ size: ${img.width}, ${img.height}
+ orig: ${img.width}, ${img.height}
+ offset: 0, 0
+ index: -1`
+ )
+ .join("\n")}
+`;
+
+ fs.writeFileSync(path.join(assetsDir, "zombie.atlas"), atlasContent);
+ console.log("Created: zombie.atlas");
+
+ console.log("\nZombie atlas created successfully!");
+}
+
+main().catch(console.error);
diff --git a/scripts/create-zombie-images.js b/scripts/create-zombie-images.js
new file mode 100644
index 0000000..b33d759
--- /dev/null
+++ b/scripts/create-zombie-images.js
@@ -0,0 +1,129 @@
+const fs = require("fs");
+const path = require("path");
+
+// Simple PNG creator using raw bytes (no dependencies)
+function createPNG(width, height, r, g, b, a = 255) {
+ const png = [];
+
+ // PNG Signature
+ png.push(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
+
+ // IHDR chunk
+ const ihdr = [
+ (width >> 24) & 0xff,
+ (width >> 16) & 0xff,
+ (width >> 8) & 0xff,
+ width & 0xff,
+ (height >> 24) & 0xff,
+ (height >> 16) & 0xff,
+ (height >> 8) & 0xff,
+ height & 0xff,
+ 8, // bit depth
+ 6, // color type (RGBA)
+ 0, // compression
+ 0, // filter
+ 0, // interlace
+ ];
+ writeChunk(png, "IHDR", ihdr);
+
+ // IDAT chunk (image data)
+ const rawData = [];
+ for (let y = 0; y < height; y++) {
+ rawData.push(0); // filter byte
+ for (let x = 0; x < width; x++) {
+ rawData.push(r, g, b, a);
+ }
+ }
+
+ // Use zlib to compress (Node.js built-in)
+ const zlib = require("zlib");
+ const compressed = zlib.deflateSync(Buffer.from(rawData));
+ writeChunk(png, "IDAT", Array.from(compressed));
+
+ // IEND chunk
+ writeChunk(png, "IEND", []);
+
+ return Buffer.from(png);
+}
+
+function writeChunk(png, type, data) {
+ const length = data.length;
+ png.push(
+ (length >> 24) & 0xff,
+ (length >> 16) & 0xff,
+ (length >> 8) & 0xff,
+ length & 0xff
+ );
+
+ const typeBytes = type.split("").map((c) => c.charCodeAt(0));
+ png.push(...typeBytes);
+ png.push(...data);
+
+ // CRC
+ const crcData = [...typeBytes, ...data];
+ const crc = crc32(Buffer.from(crcData));
+ png.push(
+ (crc >> 24) & 0xff,
+ (crc >> 16) & 0xff,
+ (crc >> 8) & 0xff,
+ crc & 0xff
+ );
+}
+
+function crc32(buf) {
+ let crc = 0xffffffff;
+ const table = makeCRCTable();
+ for (let i = 0; i < buf.length; i++) {
+ crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 0xff];
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function makeCRCTable() {
+ const table = [];
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ table[n] = c >>> 0;
+ }
+ return table;
+}
+
+// Create zombie body parts with zombie-green colors
+const imagesDir = path.join(__dirname, "../spine/zombie/images");
+const assetsDir = path.join(__dirname, "../src/assets/spine");
+
+// Zombie green/gray color palette
+const zombieGreen = { r: 0x5a, g: 0x7a, b: 0x5a }; // muted green
+const zombieDark = { r: 0x3a, g: 0x4a, b: 0x3a }; // darker green
+const zombieRed = { r: 0x8a, g: 0x3a, b: 0x3a }; // blood/wounds
+
+const images = [
+ { name: "head", width: 64, height: 64, color: zombieGreen },
+ { name: "torso", width: 64, height: 96, color: zombieGreen },
+ { name: "arm", width: 48, height: 32, color: zombieGreen },
+ { name: "forearm", width: 40, height: 28, color: zombieGreen },
+ { name: "hand", width: 24, height: 24, color: zombieDark },
+];
+
+// Create images
+images.forEach((img) => {
+ const png = createPNG(
+ img.width,
+ img.height,
+ img.color.r,
+ img.color.g,
+ img.color.b
+ );
+ const filePath = path.join(imagesDir, `${img.name}.png`);
+ fs.writeFileSync(filePath, png);
+ console.log(`Created: ${filePath}`);
+});
+
+// Also copy to assets/spine for combined atlas (we'll need it there)
+console.log("\nZombie images created successfully!");
+console.log(
+ "You can now open spine/zombie/zombie.spine in Spine editor to refine the model."
+);
diff --git a/scripts/zombify-textures.js b/scripts/zombify-textures.js
new file mode 100644
index 0000000..1d5e942
--- /dev/null
+++ b/scripts/zombify-textures.js
@@ -0,0 +1,125 @@
+const fs = require("fs");
+const path = require("path");
+const sharp = require("sharp");
+
+// Seeded random for reproducibility
+let seed = 12345;
+function random() {
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+ return seed / 0x7fffffff;
+}
+
+async function zombifyImage(inputPath, outputPath) {
+ // Read the image
+ const image = sharp(inputPath);
+ const { width, height } = await image.metadata();
+
+ // Get raw pixel data (ensure RGBA)
+ const rawBuffer = await image.ensureAlpha().raw().toBuffer();
+ const pixels = new Uint8Array(rawBuffer);
+
+ // Process each pixel
+ for (let i = 0; i < pixels.length; i += 4) {
+ let r = pixels[i];
+ let g = pixels[i + 1];
+ let b = pixels[i + 2];
+ let a = pixels[i + 3];
+
+ if (a === 0) continue; // Skip transparent pixels
+
+ // 1. Apply zombie green/gray tint
+ const gray = r * 0.299 + g * 0.587 + b * 0.114;
+ r = Math.floor(r * 0.5 + gray * 0.3 + 20);
+ g = Math.floor(g * 0.6 + gray * 0.4 + 40); // More green
+ b = Math.floor(b * 0.4 + gray * 0.3);
+
+ // 2. Darken overall
+ r = Math.floor(r * 0.75);
+ g = Math.floor(g * 0.8);
+ b = Math.floor(b * 0.7);
+
+ // 3. Add random dirt/grime spots
+ if (random() < 0.12) {
+ const dirtAmount = random() * 35 + 15;
+ r = Math.max(0, r - dirtAmount);
+ g = Math.max(0, g - dirtAmount * 0.8);
+ b = Math.max(0, b - dirtAmount * 0.6);
+ }
+
+ // 4. Add occasional blood/wound spots (dark red)
+ if (random() < 0.02) {
+ r = Math.min(255, r + 50);
+ g = Math.max(0, g - 25);
+ b = Math.max(0, b - 25);
+ }
+
+ // 5. Add decay texture variation
+ const noiseR = (random() - 0.5) * 15;
+ const noiseG = (random() - 0.5) * 12;
+ const noiseB = (random() - 0.5) * 18;
+
+ r = Math.max(0, Math.min(255, r + noiseR));
+ g = Math.max(0, Math.min(255, g + noiseG));
+ b = Math.max(0, Math.min(255, b + noiseB));
+
+ pixels[i] = Math.floor(r);
+ pixels[i + 1] = Math.floor(g);
+ pixels[i + 2] = Math.floor(b);
+ // Keep original alpha
+ }
+
+ // Write the zombified image
+ await sharp(Buffer.from(pixels), {
+ raw: { width, height, channels: 4 },
+ })
+ .png()
+ .toFile(outputPath);
+}
+
+async function main() {
+ const playerImagesDir = path.join(__dirname, "../spine/player/images");
+ const zombieImagesDir = path.join(__dirname, "../spine/zombie/images");
+
+ // Ensure zombie images directory exists
+ if (!fs.existsSync(zombieImagesDir)) {
+ fs.mkdirSync(zombieImagesDir, { recursive: true });
+ }
+
+ // Images to zombify (body parts only - upper body, legs are separate)
+ const bodyParts = [
+ { src: "head.png", dst: "head.png" },
+ { src: "torso.png", dst: "torso.png" },
+ { src: "arm.png", dst: "arm.png" },
+ { src: "forearm.png", dst: "forearm.png" },
+ { src: "hand_fist.png", dst: "hand.png" },
+ ];
+
+ console.log("Zombifying player textures...\n");
+
+ for (const part of bodyParts) {
+ const srcPath = path.join(playerImagesDir, part.src);
+ const dstPath = path.join(zombieImagesDir, part.dst);
+
+ if (!fs.existsSync(srcPath)) {
+ console.log(`Skipping ${part.src} (not found)`);
+ continue;
+ }
+
+ // Reset seed for each image for consistent results
+ seed = 12345 + part.src.charCodeAt(0) * 1000;
+
+ try {
+ await zombifyImage(srcPath, dstPath);
+ const meta = await sharp(dstPath).metadata();
+ console.log(
+ `✓ ${part.src} -> ${part.dst} (${meta.width}x${meta.height})`
+ );
+ } catch (err) {
+ console.log(`✗ ${part.src}: ${err.message}`);
+ }
+ }
+
+ console.log("\nZombie textures created in spine/zombie/images/");
+}
+
+main().catch(console.error);
diff --git a/spine/zombie/images/arm.png b/spine/zombie/images/arm.png
new file mode 100644
index 0000000..1be6f32
Binary files /dev/null and b/spine/zombie/images/arm.png differ
diff --git a/spine/zombie/images/foot.png b/spine/zombie/images/foot.png
new file mode 100644
index 0000000..ae84878
Binary files /dev/null and b/spine/zombie/images/foot.png differ
diff --git a/spine/zombie/images/forearm.png b/spine/zombie/images/forearm.png
new file mode 100644
index 0000000..9b94cd0
Binary files /dev/null and b/spine/zombie/images/forearm.png differ
diff --git a/spine/zombie/images/hand.png b/spine/zombie/images/hand.png
new file mode 100644
index 0000000..c96c0c0
Binary files /dev/null and b/spine/zombie/images/hand.png differ
diff --git a/spine/zombie/images/head.png b/spine/zombie/images/head.png
new file mode 100644
index 0000000..6e8668e
Binary files /dev/null and b/spine/zombie/images/head.png differ
diff --git a/spine/zombie/images/leg.png b/spine/zombie/images/leg.png
new file mode 100644
index 0000000..0b0a151
Binary files /dev/null and b/spine/zombie/images/leg.png differ
diff --git a/spine/zombie/images/torso.png b/spine/zombie/images/torso.png
new file mode 100644
index 0000000..f5ab1f4
Binary files /dev/null and b/spine/zombie/images/torso.png differ
diff --git a/src/assets/spine/zombie.atlas b/src/assets/spine/zombie.atlas
new file mode 100644
index 0000000..9b58bca
--- /dev/null
+++ b/src/assets/spine/zombie.atlas
@@ -0,0 +1,41 @@
+
+zombie.png
+size: 256, 512
+format: RGBA8888
+filter: Linear, Linear
+repeat: none
+torso
+ rotate: false
+ xy: 2, 2
+ size: 96, 192
+ orig: 96, 192
+ offset: 0, 0
+ index: -1
+head
+ rotate: false
+ xy: 100, 2
+ size: 96, 96
+ orig: 96, 96
+ offset: 0, 0
+ index: -1
+arm
+ rotate: false
+ xy: 2, 196
+ size: 96, 64
+ orig: 96, 64
+ offset: 0, 0
+ index: -1
+forearm
+ rotate: false
+ xy: 100, 196
+ size: 64, 64
+ orig: 64, 64
+ offset: 0, 0
+ index: -1
+hand
+ rotate: false
+ xy: 166, 196
+ size: 64, 64
+ orig: 64, 64
+ offset: 0, 0
+ index: -1
diff --git a/src/assets/spine/zombie.json b/src/assets/spine/zombie.json
new file mode 100644
index 0000000..2b3d3fd
--- /dev/null
+++ b/src/assets/spine/zombie.json
@@ -0,0 +1,473 @@
+{
+"skeleton": {
+ "hash": "zombie-v1",
+ "spine": "3.8-from-4.0-from-4.1.21",
+ "x": -99.41,
+ "y": -102.85,
+ "width": 289.37,
+ "height": 202.85,
+ "images": "./images/",
+ "audio": ""
+},
+"bones": [
+ { "name": "root" },
+ { "name": "head", "parent": "root", "length": 38.85 },
+ { "name": "torso", "parent": "root", "length": 87.86, "rotation": 180 },
+ { "name": "l_arm", "parent": "torso", "length": 49.43, "rotation": -164, "x": -7, "y": -58 },
+ { "name": "r_arm", "parent": "torso", "length": 48.41, "rotation": 164, "x": -7, "y": 58 },
+ { "name": "l_forearm", "parent": "l_arm", "length": 48.94, "rotation": 334, "x": 50, "y": 0.22 },
+ { "name": "r_forearm", "parent": "r_arm", "length": 53.35, "rotation": -334, "x": 50, "y": -0.22 },
+ { "name": "l_hand", "parent": "l_forearm", "length": 26.66, "x": 47.68, "y": 0.29 },
+ { "name": "r_hand", "parent": "r_forearm", "length": 22.59, "x": 47.68, "y": -0.29 }
+],
+"slots": [
+ { "name": "torso", "bone": "torso", "attachment": "torso" },
+ { "name": "l_arm", "bone": "l_arm", "attachment": "arm" },
+ { "name": "l_hand", "bone": "l_hand", "attachment": "hand" },
+ { "name": "l_forearm", "bone": "l_forearm", "attachment": "forearm" },
+ { "name": "r_hand", "bone": "r_hand", "attachment": "hand" },
+ { "name": "r_arm", "bone": "r_arm", "attachment": "arm" },
+ { "name": "r_forearm", "bone": "r_forearm", "attachment": "forearm" },
+ { "name": "head", "bone": "head", "attachment": "head" }
+],
+"skins": [
+ {
+ "name": "default",
+ "attachments": {
+ "head": {
+ "head": { "width": 96, "height": 96 }
+ },
+ "torso": {
+ "torso": { "rotation": -180, "width": 96, "height": 192 }
+ },
+ "l_arm": {
+ "arm": { "x": 27.75, "y": 2.44, "rotation": -16, "width": 96, "height": 64 }
+ },
+ "r_arm": {
+ "arm": { "x": 28.54, "y": -5.19, "scaleY": -1, "rotation": 16, "width": 96, "height": 64 }
+ },
+ "l_forearm": {
+ "forearm": { "x": 25.31, "y": 0.41, "rotation": 10, "width": 64, "height": 64 }
+ },
+ "r_forearm": {
+ "forearm": { "x": 24.82, "y": -3.22, "scaleY": -1, "rotation": -10, "width": 64, "height": 64 }
+ },
+ "l_hand": {
+ "hand": { "x": 16.69, "y": 1.77, "rotation": 10, "width": 64, "height": 64 }
+ },
+ "r_hand": {
+ "hand": { "x": 15.67, "y": -4.06, "scaleY": -1, "rotation": -10, "width": 64, "height": 64 }
+ }
+ }
+ }
+],
+"animations": {
+ "idle": {
+ "bones": {
+ "torso": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 1.0, "angle": -2 },
+ { "time": 2.0, "angle": 0 }
+ ],
+ "translate": [
+ { "x": 0, "y": 0 },
+ { "time": 1.0, "x": -1, "y": 0 },
+ { "time": 2.0, "x": 0, "y": 0 }
+ ]
+ },
+ "head": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 1.0, "angle": -3 },
+ { "time": 2.0, "angle": 0 }
+ ],
+ "translate": [
+ { "x": 0, "y": 0 },
+ { "time": 1.0, "x": -1, "y": -1 },
+ { "time": 2.0, "x": 0, "y": 0 }
+ ]
+ },
+ "l_arm": {
+ "rotate": [
+ { "angle": -16 },
+ { "time": 1.0, "angle": -14 },
+ { "time": 2.0, "angle": -16 }
+ ]
+ },
+ "r_arm": {
+ "rotate": [
+ { "angle": 16 },
+ { "time": 1.0, "angle": 14 },
+ { "time": 2.0, "angle": 16 }
+ ]
+ },
+ "l_forearm": {
+ "rotate": [
+ { "angle": -334 },
+ { "time": 1.0, "angle": -332 },
+ { "time": 2.0, "angle": -334 }
+ ]
+ },
+ "r_forearm": {
+ "rotate": [
+ { "angle": 334 },
+ { "time": 1.0, "angle": 332 },
+ { "time": 2.0, "angle": 334 }
+ ]
+ }
+ }
+ },
+ "walk": {
+ "bones": {
+ "torso": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 1.0, "angle": -2 },
+ { "time": 2.0, "angle": 0 }
+ ],
+ "translate": [
+ {},
+ { "time": 1.0, "x": 1, "y": -1 },
+ { "time": 2.0 }
+ ]
+ },
+ "head": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.7, "angle": 10 },
+ { "time": 1.4, "angle": -10 },
+ { "time": 2.0, "angle": 0 }
+ ],
+ "translate": [
+ {},
+ { "time": 1.0, "x": -1, "y": 1 },
+ { "time": 2.0 }
+ ]
+ },
+ "l_arm": {
+ "rotate": [
+ { "angle": -16 },
+ { "time": 1.0, "angle": -14 },
+ { "time": 2.0, "angle": -16 }
+ ]
+ },
+ "r_arm": {
+ "rotate": [
+ { "angle": 16 },
+ { "time": 1.0, "angle": 14 },
+ { "time": 2.0, "angle": 16 }
+ ]
+ },
+ "l_forearm": {
+ "rotate": [
+ { "angle": -334 },
+ { "time": 1.0, "angle": -332 },
+ { "time": 2.0, "angle": -334 }
+ ]
+ },
+ "r_forearm": {
+ "rotate": [
+ { "angle": 334 },
+ { "time": 1.0, "angle": 332 },
+ { "time": 2.0, "angle": 334 }
+ ]
+ }
+ }
+ },
+ "run": {
+ "bones": {
+ "torso": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.3, "angle": -5 },
+ { "time": 0.5, "angle": 2 },
+ { "time": 0.8, "angle": -3 },
+ { "time": 1.1, "angle": 4 },
+ { "time": 1.4, "angle": -2 },
+ { "time": 1.7, "angle": -6 },
+ { "time": 2.0, "angle": 0 }
+ ],
+ "translate": [
+ {},
+ { "time": 0.3, "x": 3, "y": -1 },
+ { "time": 0.5, "x": -1, "y": -3 },
+ { "time": 0.8, "x": 2, "y": 1 },
+ { "time": 1.1, "x": -2, "y": -2 },
+ { "time": 1.4, "x": 1, "y": -4 },
+ { "time": 1.7, "x": 4, "y": 0 },
+ { "time": 2.0 }
+ ]
+ },
+ "head": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.25, "angle": 25 },
+ { "time": 0.5, "angle": -20 },
+ { "time": 0.75, "angle": 15 },
+ { "time": 1.0, "angle": -30 },
+ { "time": 1.3, "angle": 20 },
+ { "time": 1.6, "angle": -15 },
+ { "time": 2.0, "angle": 0 }
+ ],
+ "translate": [
+ {},
+ { "time": 0.5, "x": -1, "y": 2 },
+ { "time": 1.0, "x": 1, "y": -2 },
+ { "time": 1.5, "x": -1, "y": 1 },
+ { "time": 2.0 }
+ ]
+ },
+ "l_arm": {
+ "rotate": [
+ { "angle": -16 },
+ { "time": 0.35, "angle": -10 },
+ { "time": 0.6, "angle": -20 },
+ { "time": 0.9, "angle": -12 },
+ { "time": 1.2, "angle": -22 },
+ { "time": 1.5, "angle": -14 },
+ { "time": 1.8, "angle": -18 },
+ { "time": 2.0, "angle": -16 }
+ ]
+ },
+ "r_arm": {
+ "rotate": [
+ { "angle": 16 },
+ { "time": 0.3, "angle": 22 },
+ { "time": 0.55, "angle": 12 },
+ { "time": 0.85, "angle": 20 },
+ { "time": 1.15, "angle": 10 },
+ { "time": 1.45, "angle": 18 },
+ { "time": 1.75, "angle": 14 },
+ { "time": 2.0, "angle": 16 }
+ ]
+ },
+ "l_forearm": {
+ "rotate": [
+ { "angle": -334 },
+ { "time": 0.4, "angle": -328 },
+ { "time": 0.7, "angle": -338 },
+ { "time": 1.0, "angle": -330 },
+ { "time": 1.35, "angle": -340 },
+ { "time": 1.7, "angle": -332 },
+ { "time": 2.0, "angle": -334 }
+ ]
+ },
+ "r_forearm": {
+ "rotate": [
+ { "angle": 334 },
+ { "time": 0.35, "angle": 340 },
+ { "time": 0.65, "angle": 328 },
+ { "time": 0.95, "angle": 338 },
+ { "time": 1.25, "angle": 330 },
+ { "time": 1.6, "angle": 336 },
+ { "time": 2.0, "angle": 334 }
+ ]
+ },
+ "l_hand": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.45, "angle": 8 },
+ { "time": 0.9, "angle": -6 },
+ { "time": 1.3, "angle": 10 },
+ { "time": 1.7, "angle": -4 },
+ { "time": 2.0, "angle": 0 }
+ ]
+ },
+ "r_hand": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.4, "angle": -10 },
+ { "time": 0.8, "angle": 6 },
+ { "time": 1.2, "angle": -8 },
+ { "time": 1.6, "angle": 5 },
+ { "time": 2.0, "angle": 0 }
+ ]
+ }
+ }
+ },
+ "attack": {
+ "bones": {
+ "torso": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.05, "angle": 35 },
+ { "time": 0.12, "angle": -25 },
+ { "time": 0.18, "angle": 40 },
+ { "time": 0.25, "angle": -30 },
+ { "time": 0.35, "angle": 45 },
+ { "time": 0.45, "angle": -35 },
+ { "time": 0.55, "angle": 30 },
+ { "time": 0.65, "angle": -20 },
+ { "time": 0.8, "angle": 0 }
+ ],
+ "translate": [
+ { "x": 0, "y": 0 },
+ { "time": 0.05, "x": 8, "y": -5 },
+ { "time": 0.12, "x": -6, "y": 8 },
+ { "time": 0.18, "x": 10, "y": -8 },
+ { "time": 0.25, "x": -8, "y": 6 },
+ { "time": 0.35, "x": 12, "y": -4 },
+ { "time": 0.45, "x": -10, "y": 10 },
+ { "time": 0.55, "x": 6, "y": -6 },
+ { "time": 0.8, "x": 0, "y": 0 }
+ ]
+ },
+ "head": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.04, "angle": -40 },
+ { "time": 0.1, "angle": 50 },
+ { "time": 0.16, "angle": -45 },
+ { "time": 0.22, "angle": 55 },
+ { "time": 0.3, "angle": -50 },
+ { "time": 0.4, "angle": 45 },
+ { "time": 0.5, "angle": -40 },
+ { "time": 0.6, "angle": 30 },
+ { "time": 0.8, "angle": 0 }
+ ],
+ "translate": [
+ { "x": 0, "y": 0 },
+ { "time": 0.08, "x": -4, "y": 6 },
+ { "time": 0.16, "x": 5, "y": -8 },
+ { "time": 0.24, "x": -6, "y": 4 },
+ { "time": 0.35, "x": 4, "y": -5 },
+ { "time": 0.8, "x": 0, "y": 0 }
+ ]
+ },
+ "l_arm": {
+ "rotate": [
+ { "angle": -16 },
+ { "time": 0.06, "angle": 80 },
+ { "time": 0.14, "angle": -60 },
+ { "time": 0.22, "angle": 90 },
+ { "time": 0.32, "angle": -70 },
+ { "time": 0.42, "angle": 85 },
+ { "time": 0.52, "angle": -50 },
+ { "time": 0.65, "angle": 40 },
+ { "time": 0.8, "angle": -16 }
+ ]
+ },
+ "r_arm": {
+ "rotate": [
+ { "angle": 16 },
+ { "time": 0.05, "angle": -85 },
+ { "time": 0.12, "angle": 70 },
+ { "time": 0.2, "angle": -90 },
+ { "time": 0.28, "angle": 75 },
+ { "time": 0.38, "angle": -80 },
+ { "time": 0.48, "angle": 60 },
+ { "time": 0.6, "angle": -40 },
+ { "time": 0.8, "angle": 16 }
+ ]
+ },
+ "l_forearm": {
+ "rotate": [
+ { "angle": -334 },
+ { "time": 0.07, "angle": -280 },
+ { "time": 0.15, "angle": -380 },
+ { "time": 0.24, "angle": -270 },
+ { "time": 0.34, "angle": -390 },
+ { "time": 0.44, "angle": -290 },
+ { "time": 0.55, "angle": -360 },
+ { "time": 0.8, "angle": -334 }
+ ]
+ },
+ "r_forearm": {
+ "rotate": [
+ { "angle": 334 },
+ { "time": 0.06, "angle": 280 },
+ { "time": 0.14, "angle": 385 },
+ { "time": 0.23, "angle": 275 },
+ { "time": 0.33, "angle": 395 },
+ { "time": 0.43, "angle": 285 },
+ { "time": 0.54, "angle": 365 },
+ { "time": 0.8, "angle": 334 }
+ ]
+ },
+ "l_hand": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.05, "angle": 45 },
+ { "time": 0.12, "angle": -50 },
+ { "time": 0.2, "angle": 55 },
+ { "time": 0.28, "angle": -45 },
+ { "time": 0.38, "angle": 40 },
+ { "time": 0.5, "angle": -35 },
+ { "time": 0.8, "angle": 0 }
+ ]
+ },
+ "r_hand": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.04, "angle": -50 },
+ { "time": 0.11, "angle": 55 },
+ { "time": 0.19, "angle": -60 },
+ { "time": 0.27, "angle": 50 },
+ { "time": 0.36, "angle": -45 },
+ { "time": 0.48, "angle": 35 },
+ { "time": 0.8, "angle": 0 }
+ ]
+ }
+ }
+ },
+ "die": {
+ "bones": {
+ "root": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.3, "angle": 45 },
+ { "time": 0.6, "angle": 90 }
+ ],
+ "translate": [
+ {},
+ { "time": 0.3, "x": 5, "y": -10 },
+ { "time": 0.6, "x": 10, "y": -30 }
+ ]
+ },
+ "torso": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.3, "angle": -20 },
+ { "time": 0.6, "angle": -40 }
+ ]
+ },
+ "head": {
+ "rotate": [
+ { "angle": 0 },
+ { "time": 0.3, "angle": -20 },
+ { "time": 0.6, "angle": -45 }
+ ]
+ },
+ "l_arm": {
+ "rotate": [
+ { "angle": 15 },
+ { "time": 0.3, "angle": 60 },
+ { "time": 0.6, "angle": 90 }
+ ]
+ },
+ "r_arm": {
+ "rotate": [
+ { "angle": -15 },
+ { "time": 0.3, "angle": -60 },
+ { "time": 0.6, "angle": -90 }
+ ]
+ },
+ "l_forearm": {
+ "rotate": [
+ { "angle": -20 },
+ { "time": 0.3, "angle": -40 },
+ { "time": 0.6, "angle": -60 }
+ ]
+ },
+ "r_forearm": {
+ "rotate": [
+ { "angle": 20 },
+ { "time": 0.3, "angle": 40 },
+ { "time": 0.6, "angle": 60 }
+ ]
+ }
+ }
+ }
+}
+}
diff --git a/src/assets/spine/zombie.png b/src/assets/spine/zombie.png
new file mode 100644
index 0000000..97cfbcf
Binary files /dev/null and b/src/assets/spine/zombie.png differ
diff --git a/src/editor/EditorState.ts b/src/editor/EditorState.ts
new file mode 100644
index 0000000..381bc4e
--- /dev/null
+++ b/src/editor/EditorState.ts
@@ -0,0 +1,85 @@
+const STORAGE_KEY = "spine-editor-state";
+
+export interface EditorStateData {
+ selectedModel: string | null;
+ selectedAnimation: string | null;
+ loop: boolean;
+ loopDelay: number;
+}
+
+const defaultState: EditorStateData = {
+ selectedModel: null,
+ selectedAnimation: null,
+ loop: true,
+ loopDelay: 500,
+};
+
+/**
+ * Manages editor state persistence using localStorage.
+ */
+export class EditorState {
+ private state: EditorStateData;
+
+ constructor() {
+ this.state = this.load();
+ }
+
+ private load(): EditorStateData {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ return { ...defaultState, ...JSON.parse(stored) };
+ }
+ } catch (e) {
+ console.warn("Failed to load editor state:", e);
+ }
+ return { ...defaultState };
+ }
+
+ private save(): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
+ } catch (e) {
+ console.warn("Failed to save editor state:", e);
+ }
+ }
+
+ get selectedModel(): string | null {
+ return this.state.selectedModel;
+ }
+
+ set selectedModel(value: string | null) {
+ this.state.selectedModel = value;
+ this.save();
+ }
+
+ get selectedAnimation(): string | null {
+ return this.state.selectedAnimation;
+ }
+
+ set selectedAnimation(value: string | null) {
+ this.state.selectedAnimation = value;
+ this.save();
+ }
+
+ get loop(): boolean {
+ return this.state.loop;
+ }
+
+ set loop(value: boolean) {
+ this.state.loop = value;
+ this.save();
+ }
+
+ get loopDelay(): number {
+ return this.state.loopDelay;
+ }
+
+ set loopDelay(value: number) {
+ this.state.loopDelay = value;
+ this.save();
+ }
+}
+
+// Export a singleton instance
+export const editorState = new EditorState();
diff --git a/src/editor/SpineModelRegistry.ts b/src/editor/SpineModelRegistry.ts
new file mode 100644
index 0000000..ff68d40
--- /dev/null
+++ b/src/editor/SpineModelRegistry.ts
@@ -0,0 +1,45 @@
+export interface SpineModelInfo {
+ key: string;
+ name: string;
+ jsonFile: string;
+ atlasFile: string;
+}
+
+/**
+ * Registry of available Spine models for the editor.
+ * Add new models here when they are created.
+ */
+export class SpineModelRegistry {
+ private static models: SpineModelInfo[] = [
+ {
+ key: "player",
+ name: "Player (Upper Body)",
+ jsonFile: "player.json",
+ atlasFile: "player.atlas",
+ },
+ {
+ key: "player-legs",
+ name: "Player Legs",
+ jsonFile: "player-legs.json",
+ atlasFile: "player-legs.atlas",
+ },
+ {
+ key: "zombie",
+ name: "Zombie",
+ jsonFile: "zombie.json",
+ atlasFile: "zombie.atlas",
+ },
+ ];
+
+ static getModels(): SpineModelInfo[] {
+ return this.models;
+ }
+
+ static getModel(key: string): SpineModelInfo | undefined {
+ return this.models.find((m) => m.key === key);
+ }
+
+ static getModelKeys(): string[] {
+ return this.models.map((m) => m.key);
+ }
+}
diff --git a/src/editor/index.html b/src/editor/index.html
new file mode 100644
index 0000000..226b2cf
--- /dev/null
+++ b/src/editor/index.html
@@ -0,0 +1,333 @@
+
+
+
+
+
+
+ Spine Editor
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/editor/index.ts b/src/editor/index.ts
new file mode 100644
index 0000000..b94e735
--- /dev/null
+++ b/src/editor/index.ts
@@ -0,0 +1,30 @@
+import "phaser";
+import "phaser/plugins/spine/dist/SpinePlugin";
+import EditorPreloadScene from "./scenes/EditorPreloadScene";
+import EditorScene from "./scenes/EditorScene";
+
+const config: Phaser.Types.Core.GameConfig = {
+ type: Phaser.WEBGL,
+ parent: "preview-canvas",
+ transparent: true,
+ scale: {
+ mode: Phaser.Scale.NONE,
+ width: 600,
+ height: 600,
+ },
+ render: {
+ pixelArt: false,
+ antialias: true,
+ },
+ scene: [EditorPreloadScene, EditorScene],
+ plugins: {
+ scene: [
+ { key: "SpinePlugin", plugin: window.SpinePlugin, mapping: "spine" },
+ ],
+ },
+};
+
+const game = new Phaser.Game(config);
+
+// Export game instance for external access
+(window as any).game = game;
diff --git a/src/editor/scenes/EditorPreloadScene.ts b/src/editor/scenes/EditorPreloadScene.ts
new file mode 100644
index 0000000..5a8ecba
--- /dev/null
+++ b/src/editor/scenes/EditorPreloadScene.ts
@@ -0,0 +1,21 @@
+import { SpineModelRegistry } from "../SpineModelRegistry";
+
+export default class EditorPreloadScene extends Phaser.Scene {
+ constructor() {
+ super("EditorPreloadScene");
+ }
+
+ preload() {
+ // Load all spine models from the registry
+ this.load.setPath("assets/spine/");
+
+ const models = SpineModelRegistry.getModels();
+ for (const model of models) {
+ this.load.spine(model.key, model.jsonFile, model.atlasFile);
+ }
+ }
+
+ create() {
+ this.scene.start("EditorScene");
+ }
+}
diff --git a/src/editor/scenes/EditorScene.ts b/src/editor/scenes/EditorScene.ts
new file mode 100644
index 0000000..d5d6aea
--- /dev/null
+++ b/src/editor/scenes/EditorScene.ts
@@ -0,0 +1,307 @@
+import { SpineModelRegistry } from "../SpineModelRegistry";
+import { editorState } from "../EditorState";
+
+export default class EditorScene extends Phaser.Scene {
+ private currentSpine: SpineGameObject | null = null;
+ private modelSelector!: HTMLSelectElement;
+ private animationList!: HTMLUListElement;
+ private loopToggle!: HTMLInputElement;
+ private loopDelayInput!: HTMLInputElement;
+ private loadingOverlay!: HTMLElement;
+ private loopDelayTimer: number | null = null;
+ private currentAnimationName: string | null = null;
+
+ constructor() {
+ super("EditorScene");
+ }
+
+ create() {
+ // Get DOM elements
+ this.modelSelector = document.getElementById(
+ "model-selector"
+ ) as HTMLSelectElement;
+ this.animationList = document.getElementById(
+ "animation-list"
+ ) as HTMLUListElement;
+ this.loopToggle = document.getElementById(
+ "loop-toggle"
+ ) as HTMLInputElement;
+ this.loopDelayInput = document.getElementById(
+ "loop-delay"
+ ) as HTMLInputElement;
+ this.loadingOverlay = document.getElementById(
+ "loading-overlay"
+ ) as HTMLElement;
+
+ // Set up the scene background
+ this.cameras.main.setBackgroundColor(0x2d2d2d);
+
+ // Initialize UI
+ this.setupModelSelector();
+ this.setupLoopToggle();
+ this.setupLoopDelay();
+
+ // Load persisted state
+ this.restoreState();
+
+ // Hide loading overlay
+ this.loadingOverlay.classList.add("hidden");
+ }
+
+ private setupModelSelector() {
+ const models = SpineModelRegistry.getModels();
+
+ // Clear and populate the selector
+ this.modelSelector.innerHTML = "";
+
+ // Add placeholder option
+ const placeholder = document.createElement("option");
+ placeholder.value = "";
+ placeholder.textContent = "Select a model...";
+ this.modelSelector.appendChild(placeholder);
+
+ // Add model options
+ for (const model of models) {
+ const option = document.createElement("option");
+ option.value = model.key;
+ option.textContent = model.name;
+ this.modelSelector.appendChild(option);
+ }
+
+ // Handle selection change
+ this.modelSelector.addEventListener("change", () => {
+ const selectedKey = this.modelSelector.value;
+ if (selectedKey) {
+ this.loadModel(selectedKey);
+ editorState.selectedModel = selectedKey;
+ // Clear animation selection when model changes
+ editorState.selectedAnimation = null;
+ } else {
+ this.clearPreview();
+ }
+ });
+ }
+
+ private setupLoopToggle() {
+ // Restore loop state
+ this.loopToggle.checked = editorState.loop;
+
+ this.loopToggle.addEventListener("change", () => {
+ editorState.loop = this.loopToggle.checked;
+ // Replay current animation with new loop setting
+ if (this.currentSpine && editorState.selectedAnimation) {
+ this.playAnimation(editorState.selectedAnimation);
+ }
+ });
+ }
+
+ private setupLoopDelay() {
+ // Restore loop delay state
+ this.loopDelayInput.value = String(editorState.loopDelay);
+
+ this.loopDelayInput.addEventListener("change", () => {
+ const delay = parseInt(this.loopDelayInput.value, 10) || 0;
+ editorState.loopDelay = Math.max(0, Math.min(5000, delay));
+ this.loopDelayInput.value = String(editorState.loopDelay);
+ // Replay current animation with new delay setting
+ if (this.currentSpine && editorState.selectedAnimation) {
+ this.playAnimation(editorState.selectedAnimation);
+ }
+ });
+ }
+
+ private restoreState() {
+ const savedModel = editorState.selectedModel;
+ const savedAnimation = editorState.selectedAnimation;
+
+ if (savedModel) {
+ this.modelSelector.value = savedModel;
+ this.loadModel(savedModel, savedAnimation);
+ }
+ }
+
+ private loadModel(key: string, autoPlayAnimation?: string | null) {
+ // Clear existing spine object
+ this.clearPreview();
+
+ try {
+ // Create the spine game object
+ const centerX = this.cameras.main.width / 2;
+ const centerY = this.cameras.main.height / 2;
+
+ this.currentSpine = this.add.spine(centerX, centerY, key);
+
+ // Scale to fit nicely in the preview
+ this.autoScale();
+
+ // Get available animations
+ const animations = this.getAnimations();
+
+ // Populate animation list
+ this.populateAnimationList(animations);
+
+ // Auto-play animation if specified or first available
+ if (autoPlayAnimation && animations.includes(autoPlayAnimation)) {
+ this.playAnimation(autoPlayAnimation);
+ this.highlightAnimation(autoPlayAnimation);
+ } else if (animations.length > 0) {
+ // Play first animation by default
+ this.playAnimation(animations[0]);
+ this.highlightAnimation(animations[0]);
+ editorState.selectedAnimation = animations[0];
+ }
+ } catch (error) {
+ console.error("Failed to load model:", error);
+ this.animationList.innerHTML =
+ 'Error loading model';
+ }
+ }
+
+ private autoScale() {
+ if (!this.currentSpine) return;
+
+ const bounds = this.currentSpine.getBounds();
+ const canvasWidth = this.cameras.main.width;
+ const canvasHeight = this.cameras.main.height;
+ const padding = 100;
+
+ const maxWidth = canvasWidth - padding;
+ const maxHeight = canvasHeight - padding;
+
+ const scaleX = maxWidth / bounds.size.x;
+ const scaleY = maxHeight / bounds.size.y;
+ const scale = Math.min(scaleX, scaleY, 1.5); // Cap at 1.5x
+
+ this.currentSpine.setScale(scale);
+ }
+
+ private getAnimations(): string[] {
+ if (!this.currentSpine) return [];
+
+ try {
+ const skeletonData = this.currentSpine.skeleton.data;
+ const animations: string[] = [];
+
+ for (let i = 0; i < skeletonData.animations.length; i++) {
+ animations.push(skeletonData.animations[i].name);
+ }
+
+ return animations.sort();
+ } catch (error) {
+ console.error("Failed to get animations:", error);
+ return [];
+ }
+ }
+
+ private populateAnimationList(animations: string[]) {
+ this.animationList.innerHTML = "";
+
+ if (animations.length === 0) {
+ const item = document.createElement("li");
+ item.className = "animation-item";
+ item.textContent = "No animations found";
+ this.animationList.appendChild(item);
+ return;
+ }
+
+ for (const anim of animations) {
+ const item = document.createElement("li");
+ item.className = "animation-item";
+ item.textContent = anim;
+ item.dataset.animation = anim;
+
+ item.addEventListener("click", () => {
+ this.playAnimation(anim);
+ this.highlightAnimation(anim);
+ editorState.selectedAnimation = anim;
+ });
+
+ this.animationList.appendChild(item);
+ }
+ }
+
+ private highlightAnimation(animationName: string) {
+ // Remove active class from all items
+ const items = this.animationList.querySelectorAll(".animation-item");
+ items.forEach((item) => item.classList.remove("active"));
+
+ // Add active class to selected item
+ const selected = this.animationList.querySelector(
+ `[data-animation="${animationName}"]`
+ );
+ if (selected) {
+ selected.classList.add("active");
+ }
+ }
+
+ private playAnimation(animationName: string) {
+ if (!this.currentSpine) return;
+
+ // Clear any pending loop timer
+ this.clearLoopTimer();
+ this.currentAnimationName = animationName;
+
+ try {
+ const loop = this.loopToggle.checked;
+ // Idle and move animations should loop without delay
+ const isLoopingAnimation = animationName.startsWith("idle_") || animationName.startsWith("move_");
+ const delay = isLoopingAnimation ? 0 : editorState.loopDelay;
+
+ // If looping with delay, we handle the loop manually
+ if (loop && delay > 0) {
+ this.currentSpine.play(animationName, false);
+ this.setupDelayedLoop(animationName, delay);
+ } else {
+ this.currentSpine.play(animationName, loop);
+ }
+ } catch (error) {
+ console.error("Failed to play animation:", error);
+ }
+ }
+
+ private setupDelayedLoop(animationName: string, delay: number) {
+ if (!this.currentSpine) return;
+
+ // Listen for animation complete
+ this.currentSpine.state.addListener({
+ complete: () => {
+ // Only restart if this is still the current animation and looping is enabled
+ if (
+ this.currentAnimationName === animationName &&
+ this.loopToggle.checked &&
+ this.currentSpine
+ ) {
+ this.loopDelayTimer = window.setTimeout(() => {
+ if (
+ this.currentSpine &&
+ this.currentAnimationName === animationName &&
+ this.loopToggle.checked
+ ) {
+ this.currentSpine.play(animationName, false);
+ }
+ }, delay);
+ }
+ },
+ });
+ }
+
+ private clearLoopTimer() {
+ if (this.loopDelayTimer !== null) {
+ window.clearTimeout(this.loopDelayTimer);
+ this.loopDelayTimer = null;
+ }
+ // Clear any existing listeners
+ if (this.currentSpine) {
+ this.currentSpine.state.clearListeners();
+ }
+ }
+
+ private clearPreview() {
+ this.clearLoopTimer();
+ this.currentAnimationName = null;
+ if (this.currentSpine) {
+ this.currentSpine.destroy();
+ this.currentSpine = null;
+ }
+ }
+}
diff --git a/src/objects/Zombie.ts b/src/objects/Zombie.ts
new file mode 100644
index 0000000..71c238c
--- /dev/null
+++ b/src/objects/Zombie.ts
@@ -0,0 +1,87 @@
+import SpineContainer from "../types/SpineContainer";
+
+export enum ZombieState {
+ IDLE = "idle",
+ WALK = "walk",
+ ATTACK = "attack",
+ DIE = "die",
+}
+
+export default class Zombie extends SpineContainer {
+ private currentState: ZombieState = ZombieState.IDLE;
+ private isDead: boolean = false;
+ private moveSpeed: number = 50;
+
+ constructor(scene: Phaser.Scene, x: number, y: number) {
+ super(scene, x, y, "zombie", ZombieState.IDLE, true);
+
+ this.name = "zombie";
+ this.setScale(0.6, 0.6);
+ }
+
+ /**
+ * Get the current state of the zombie
+ */
+ getState(): ZombieState {
+ return this.currentState;
+ }
+
+ /**
+ * Check if the zombie is dead
+ */
+ getIsDead(): boolean {
+ return this.isDead;
+ }
+
+ /**
+ * Set zombie state and play corresponding animation
+ */
+ setZombieState(state: ZombieState, loop: boolean = true): void {
+ if (this.isDead && state !== ZombieState.DIE) {
+ return;
+ }
+
+ if (this.currentState === state) {
+ return;
+ }
+
+ this.currentState = state;
+ this.spine.play(state, loop, true);
+ }
+
+ /**
+ * Make the zombie attack
+ */
+ attack(): void {
+ if (this.isDead) return;
+ this.setZombieState(ZombieState.ATTACK, false);
+ }
+
+ /**
+ * Kill the zombie
+ */
+ die(): void {
+ if (this.isDead) return;
+ this.isDead = true;
+ this.setZombieState(ZombieState.DIE, false);
+ }
+
+ /**
+ * Face towards a point
+ */
+ facePoint(x: number, y: number): void {
+ const angle = Phaser.Math.Angle.Between(this.x, this.y, x, y);
+ this.spine.rotation = angle + Math.PI / 2;
+ }
+
+ /**
+ * Set the movement speed
+ */
+ setMoveSpeed(speed: number): void {
+ this.moveSpeed = speed;
+ }
+
+ getMoveSpeed(): number {
+ return this.moveSpeed;
+ }
+}
diff --git a/src/scenes/PreloadScene.ts b/src/scenes/PreloadScene.ts
index a88e131..9f981f0 100644
--- a/src/scenes/PreloadScene.ts
+++ b/src/scenes/PreloadScene.ts
@@ -24,6 +24,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.setPath("assets/spine/");
this.load.spine("player", "player.json", "player.atlas");
this.load.spine("player-legs", "player-legs.json", "player-legs.atlas");
+ this.load.spine("zombie", "zombie.json", "zombie.atlas");
}
create() {
diff --git a/tsconfig.json b/tsconfig.json
index 6843311..f2d4812 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,11 @@
{
"compilerOptions": {
- "target": "es5",
+ "target": "ES2017",
+ "lib": ["ES2017", "dom"],
+ "module": "commonjs",
"esModuleInterop": true,
- }
+ "moduleResolution": "node",
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*"]
}
diff --git a/webpack.config.js b/webpack.config.js
index a8b7922..31f067e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -4,7 +4,10 @@ const CopyWebpackPlugin = require("copy-webpack-plugin");
const package = require("./package.json");
module.exports = {
- entry: path.resolve(__dirname, "./src/index.ts"),
+ entry: {
+ main: path.resolve(__dirname, "./src/index.ts"),
+ editor: path.resolve(__dirname, "./src/editor/index.ts"),
+ },
module: {
rules: [
{
@@ -54,6 +57,9 @@ module.exports = {
},
devServer: {
static: path.resolve(__dirname, "./dist"),
+ historyApiFallback: {
+ rewrites: [{ from: /^\/editor/, to: "/editor.html" }],
+ },
},
plugins: [
new CopyWebpackPlugin({
@@ -70,6 +76,15 @@ module.exports = {
title: package.description,
inject: "body",
hot: true,
+ chunks: ["main"],
+ }),
+ new HtmlWebpackPlugin({
+ template: path.resolve(__dirname, "./src/editor/index.html"),
+ filename: "editor.html",
+ title: "Spine Editor",
+ inject: "body",
+ hot: true,
+ chunks: ["editor"],
}),
],
};