diff --git a/package-lock.json b/package-lock.json index c7dfef2..2a6fb90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "react-dom": "^19.2.6" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.3", "@eslint/js": "^10.0.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -23,6 +24,90 @@ "vite": "^8.0.12" } }, + "node_modules/@andrewbranch/untar.js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@andrewbranch/untar.js/-/untar.js-1.0.3.tgz", + "integrity": "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==", + "dev": true + }, + "node_modules/@arethetypeswrong/cli": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/cli/-/cli-0.18.3.tgz", + "integrity": "sha512-GeAlc+lUD4gKHD/LDQNvQY30FfQ+xAXg2inbQKUjFZgTOdI5ygEweaOnGHGBPSKXSLGQC7VLhpXu9zMnYk/4sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@arethetypeswrong/core": "0.18.3", + "chalk": "^4.1.2", + "cli-table3": "^0.6.3", + "commander": "^10.0.1", + "marked": "^9.1.2", + "marked-terminal": "^7.1.0", + "semver": "^7.5.4" + }, + "bin": { + "attw": "dist/index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@arethetypeswrong/core": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/core/-/core-0.18.3.tgz", + "integrity": "sha512-sWBB/tdIktaT5xMq0Dz6CJyqcf6oMNdmiKiuPU1lWoJLTL6gjRSsksBuSgqot21hylkklBQY1wiSu+PkZhW7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@andrewbranch/untar.js": "^1.0.3", + "@loaderkit/resolve": "^1.0.2", + "cjs-module-lexer": "^1.2.3", + "fflate": "^0.8.3", + "lru-cache": "^11.0.1", + "semver": "^7.5.4", + "typescript": "5.6.1-rc", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -263,6 +348,24 @@ "node": ">=6.9.0" } }, + "node_modules/@braidai/lang": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@braidai/lang/-/lang-1.1.2.tgz", + "integrity": "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -541,6 +644,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaderkit/resolve": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@loaderkit/resolve/-/resolve-1.0.6.tgz", + "integrity": "sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@braidai/lang": "^1.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -834,6 +947,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -952,6 +1078,58 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1043,6 +1221,120 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1114,6 +1406,33 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1358,6 +1677,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1434,6 +1760,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1460,6 +1796,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -1477,6 +1823,16 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1507,6 +1863,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1892,6 +2258,54 @@ "yallist": "^3.0.2" } }, + "node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/marked-terminal/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -1915,6 +2329,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1941,6 +2367,22 @@ "dev": true, "license": "MIT" }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -1951,6 +2393,16 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2001,6 +2453,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2111,6 +2587,16 @@ "react": "^19.2.6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -2184,6 +2670,19 @@ "node": ">=8" } }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2194,6 +2693,97 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2232,6 +2822,30 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "5.6.1-rc", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.1-rc.tgz", + "integrity": "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2273,6 +2887,16 @@ "punycode": "^2.1.0" } }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", @@ -2377,6 +3001,34 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2384,6 +3036,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d266638..6f75ab9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "react-dom": "^19.2.6" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.3", "@eslint/js": "^10.0.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/App.css b/src/App.css index e3a7c7b..7f0f3cb 100644 --- a/src/App.css +++ b/src/App.css @@ -554,6 +554,75 @@ body { color: #9ca3af; } +.action-queue { + position: absolute; + left: 0; + bottom: 0; + width: 240px; + border: 1px solid #2a2a3e; + border-radius: 4px; + background: #12121e; + padding: 8px; + max-height: 180px; + overflow-y: auto; +} + +.queue-title { + font-size: 12px; + font-weight: bold; + color: #9ca3af; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.queue-empty { + font-size: 12px; + color: #4a4a5e; + font-style: italic; +} + +.queue-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 4px; + font-size: 13px; + border-radius: 2px; +} + +.queue-item.queue-current { + background: #1a1a2e; + color: #22c55e; +} + +.queue-idx { + width: 16px; + text-align: right; + color: #6b7280; + font-size: 11px; +} + +.queue-item.queue-current .queue-idx { + color: #22c55e; +} + +.queue-label { + color: #e0e0e0; +} + +.queue-cancel { + margin-left: auto; + cursor: pointer; + color: #6b7280; + font-size: 12px; + line-height: 1; + padding: 0 2px; +} +.queue-cancel:hover { + color: #ef4444; +} + .panel-hint { color: #6b7280; font-size: 14px; @@ -1820,6 +1889,23 @@ body { font-size: 11px; } +.equip-unequip-btn, +.equip-btn { + padding: 2px 8px; + font-size: 11px; + background: #2e303a; + color: #e0e0e0; + border: 1px solid #4b5563; + border-radius: 3px; + cursor: pointer; +} + +.equip-unequip-btn:hover, +.equip-btn:hover { + background: #4b5563; + color: #fff; +} + .inv-divider { height: 1px; background: #2e303a; diff --git a/src/App.jsx b/src/App.jsx index 0565788..d28fe15 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -21,33 +21,32 @@ const connections = [ ] const characters = [ - { id: 'player', name: 'You', location: 'A' }, - { id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['herbs', 'old_map'] }, - { id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['lockpicks'] }, - { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['silver_brooch', 'fine_wine'] }, - { id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['health_potion'] }, - { id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['torch', 'old_map'] }, - { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['lockpicks'] }, - { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['health_potion', 'silver_brooch'] }, - { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['herbs', 'rope'] }, + { id: 'player', name: 'You', location: 'A', size: 1.3 }, + { id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['pistol_ammo'], size: 1 }, + { id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['pistol_ammo'], size: 1.2 }, + { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['pistol_ammo'], size: 0.9 }, + { id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['pistol_ammo'], size: 1 }, + { id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['pistol_ammo'], size: 1.1 }, + { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['pistol_ammo'], size: 0.8 }, + { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['pistol_ammo'], size: 1 }, + { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['pistol_ammo'], size: 1 }, ] const shops = [ { id: 'shop_e', locationId: 'E', name: 'Epsilon Market', items: [ - { name: 'Health Potion', price: 15, itemId: 'health_potion' }, { name: 'Steel Sword', price: 80, weaponId: 'steel_sword' }, { name: 'Iron Shield', price: 50, weaponId: 'iron_shield' }, { name: 'Dagger', price: 30, weaponId: 'dagger' }, - { name: 'Torch', price: 5, itemId: 'torch' }, - { name: 'Rope', price: 8, itemId: 'rope' }, + { name: 'Pistol Ammo', price: 5, itemId: 'pistol_ammo' }, ], }, ] const weapons = [ { id: 'fists', name: 'Fists', damage: 35, condition: 100, maxCondition: 100, passives: ['Brawler'], weight: 0, speed: 14, skills: [{ name: 'Jab', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Hook', mult: 1.3, damageType: 'blunt', range: 12 }, { name: 'Kick', mult: 1.5, damageType: 'blunt', range: 14 }, { name: 'Shove', mult: 0, damageType: 'shove', range: 10 }] }, + { id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Reload', mult: 0, damageType: 'reload', range: 0 }] }, { id: 'rusty_sword', name: 'Rusty Sword', damage: 60, condition: 65, maxCondition: 100, passives: ['Bleed'], weight: 4, speed: 10, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }] }, { id: 'steel_sword', name: 'Steel Sword', damage: 85, condition: 100, maxCondition: 100, passives: ['Bleed', 'Pierce'], weight: 5, speed: 9, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Overhead', mult: 1.4, damageType: 'slash', range: 14 }] }, { id: 'iron_shield', name: 'Iron Shield', damage: 40, condition: 100, maxCondition: 100, passives: ['Block', 'Sturdy'], weight: 6, speed: 7, skills: [{ name: 'Bash', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Shield Slam', mult: 1.3, damageType: 'blunt', range: 13 }] }, @@ -56,15 +55,7 @@ const weapons = [ ] const items = [ - { id: 'health_potion', name: 'Health Potion', description: 'Restores 15 HP when consumed.', weight: 1 }, - { id: 'torch', name: 'Torch', description: 'A wooden torch that burns for hours.', weight: 2 }, - { id: 'rope', name: 'Rope', description: '20 feet of sturdy rope.', weight: 3 }, - { id: 'leather_bag', name: 'Leather Bag', description: 'A worn leather bag with plenty of storage space.', weight: 1 }, - { id: 'herbs', name: 'Herbs', description: 'A bundle of medicinal herbs.', weight: 1 }, - { id: 'old_map', name: 'Old Map', description: 'A faded map of the surrounding region.', weight: 1 }, - { id: 'lockpicks', name: 'Lockpicks', description: 'A set of fine metal tools for picking locks.', weight: 1 }, - { id: 'silver_brooch', name: 'Silver Brooch', description: 'A polished silver brooch inlaid with a small gem.', weight: 1 }, - { id: 'fine_wine', name: 'Fine Wine', description: 'A bottle of aged red wine worth a pretty penny.', weight: 2 }, + { id: 'pistol_ammo', name: 'Pistol Ammo', description: 'Standard pistol ammunition.', weight: 0.5 }, ] const itemMap = {} @@ -88,13 +79,12 @@ const MAX_WEIGHT = 50 const MAP_BLOCKS = [ { x: 30, y: 20, w: 20, h: 40 }, { x: 100, y: 10, w: 25, h: 35 }, - { x: 70, y: 60, w: 40, h: 15 }, { x: 15, y: 90, w: 35, h: 20 }, - { x: 105, y: 100, w: 30, h: 30 }, ] const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } const TURN_SPEED = 6 +const PISTOL_MAG = 2 const bodyPartLabels = { head: 'Head', torso: 'Torso', @@ -122,6 +112,33 @@ function freshInjuries(){ return { head: [], torso: [], leftArm: [], rightArm: [ function totalHp(body) { return Object.values(body).reduce((a, b) => a + b, 0) } function isDead(integrity) { return !integrity || integrity.head <= 0 || integrity.torso <= 0 } +function createCharacter(data) { + const weaponId = data.weapon ?? 'fists' + const wpn = weaponMap[weaponId] + const integrity = data.integrity ?? freshBody() + const injuries = data.injuries ?? freshInjuries() + return { + id: data.id ?? 'unknown', + name: data.name ?? 'Unknown', + age: data.age ?? Math.floor(Math.random() * 30) + 18, + location: data.location ?? 'Unknown', + money: data.money ?? 0, + inventory: [...(data.inventory ?? [])], + equipped: data.equipped ? { left: data.equipped.left, right: data.equipped.right } : { left: 'fists', right: 'fists' }, + weaponConditions: data.weaponConditions ? { ...data.weaponConditions } : {}, + integrity: { ...integrity }, + injuries: Object.keys(injuries).reduce((acc, k) => { acc[k] = [...injuries[k]]; return acc }, {}), + size: data.size ?? 1, + fillColor: data.fillColor ?? `hsl(${Math.random() * 360}, 70%, 55%)`, + weapon: weaponId, + pos: data.pos ? { x: data.pos.x, y: data.pos.y } : undefined, + speed: data.speed ?? wpn?.speed ?? 10, + attack: data.attack ?? wpn?.damage ?? 30, + weaponMagazines: data.weaponMagazines ? { ...data.weaponMagazines } : {}, + itemCounts: data.itemCounts ? { ...data.itemCounts } : {}, + } +} + function randomPart() { const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg'] return parts[Math.floor(Math.random() * parts.length)] @@ -255,17 +272,19 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp : (base?.skills ?? []).find(s => s.name === skillName) ?? { name: 'Attack', mult: 1.0, damageType: 'blunt' } if (skill.damageType === 'shove') { - // Shove is resolved immediately in the handler, not here return { type: 'shove_pending', actor: 'player', weaponId, skillName, enemyIndex } } const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0 const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult }) - const enemy = combat.enemies[enemyIndex] + const enemyId = combat.enemyIds[enemyIndex] + const enemy = enemyId ? combat.characters[enemyId] : null return { type: 'attack', actor: 'player', + charId: 'player', + enemyCharId: enemyId, weaponId, skillName, targetPart, enemyIndex, dmg, severity, injType, injLabel, attackerParts: { ...bodyParts }, @@ -279,16 +298,18 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp * Run enemy AI. Returns an array of raw action objects. * Uses the same computeMove / computeHit as the player. */ -function runEnemyAI(combat, playerParts, playerInjuries, enemyDirs) { +function runEnemyAI(combat, playerParts, playerInjuries) { const actions = [] - const moveTargets = {} + const playerChar = combat.characters[combat.playerEntityId] + const playerPos = playerChar.pos - for (let i = 0; i < combat.enemies.length; i++) { - const e = combat.enemies[i] + for (const cid of Object.keys(combat.characters)) { + if (cid === combat.playerEntityId) continue + const e = combat.characters[cid] if (isDead(e.integrity)) continue const dist = Math.sqrt( - (combat.playerPos.x - e.pos.x) ** 2 + (combat.playerPos.y - e.pos.y) ** 2 + (playerPos.x - e.pos.x) ** 2 + (playerPos.y - e.pos.y) ** 2 ) // ── in range → attack @@ -303,7 +324,7 @@ function runEnemyAI(combat, playerParts, playerInjuries, enemyDirs) { actions.push({ type: 'attack', actor: 'enemy', - enemyIndex: i, + charId: cid, targetPart, weaponId, skillName: skill.name, @@ -315,21 +336,14 @@ function runEnemyAI(combat, playerParts, playerInjuries, enemyDirs) { }) } else { // ── out of range → move toward player - const speed = e.speed > 12 ? 3 : 1.5 - const newPos = computeMove(e.pos, combat.playerPos, speed) + const speed = e.speed > 12 ? 10 : 5 + const newPos = computeMove(e.pos, playerPos, speed) if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) { - moveTargets[i] = { fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y } - if (enemyDirs) { - const dx = newPos.x - e.pos.x - const dy = newPos.y - e.pos.y - const d = Math.sqrt(dx * dx + dy * dy) - if (d > 0) enemyDirs[i] = { dx: dx / d, dy: dy / d } - } + actions.push({ type: 'move', charId: cid, fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y, speed }) } } } - if (Object.keys(moveTargets).length > 0) actions.push({ type: 'enemyMove', targets: moveTargets }) return actions } @@ -364,11 +378,15 @@ function BodyImage({ body }) { export default function App() { // ── world state - const [playerLoc, setPlayerLoc] = useState('A') - const [playerMoney, setPlayerMoney] = useState(50) - const [playerInventory, setPlayerInventory] = useState(['leather_bag']) - const [equippedWeapons, setEquippedWeapons] = useState({ left: 'fists', right: 'rusty_sword' }) - const [weaponConditions,setWeaponConditions]= useState({ rusty_sword: 65 }) + const [player, setPlayer] = useState(createCharacter({ + id: 'player', name: 'You', age: 24, location: 'A', money: 50, + inventory: ['pistol_ammo'], + equipped: { left: 'pistol', right: 'rusty_sword' }, + weaponConditions: { rusty_sword: 65, pistol: 100 }, + size: 1.3, + weaponMagazines: { pistol: 2 }, + itemCounts: { pistol_ammo: 20 }, + })) const [gameTime, setGameTime] = useState({ day: 1, hour: 8 }) const [weather, setWeather] = useState('clear') const [defeatedNpcs, setDefeatedNpcs] = useState([]) @@ -387,6 +405,7 @@ export default function App() { const [panelTarget, setPanelTarget] = useState('player') const [contextMenu, setContextMenu] = useState(null) const [buyArmChoice, setBuyArmChoice] = useState(null) + const [equipArmChoice, setEquipArmChoice] = useState(null) const [passTimeOpen, setPassTimeOpen] = useState(false) const [passTimeHours, setPassTimeHours] = useState(1) const [skipAnim, setSkipAnim] = useState(null) @@ -397,8 +416,6 @@ export default function App() { // ── combat state const [combat, setCombat] = useState(null) - const [bodyParts, setBodyParts] = useState(freshBody()) - const [bodyInjuries, setBodyInjuries] = useState(freshInjuries()) const [combatTime, setCombatTime] = useState(0) const [displayTime, setDisplayTime] = useState(0) // timerActive: drives the timer — kept as a ref only (no re-render needed) @@ -426,6 +443,15 @@ export default function App() { // ── ui busy (mirrors ref-based action state for re-renders) const [busy, setBusy] = useState(false) + // ── aiming mode (pistol skill selected, waiting for minimap click) + const [aimMode, setAimMode] = useState(false) + + // ── projectiles (modular projectile framework) + const [projectiles, setProjectiles] = useState([]) + + // ── hovered character for arrow glow + const [hoveredChar, setHoveredChar] = useState(null) + // ── replay const [fightRecord, setFightRecord] = useState(null) const [replayActive, setReplayActive] = useState(false) @@ -434,17 +460,21 @@ export default function App() { const combatRef = useRef(null); combatRef.current = combat const combatTimeRef = useRef(0); combatTimeRef.current = combatTime const displayTimeRef = useRef(0); displayTimeRef.current = displayTime - // timerActiveRef is a standalone ref — written synchronously in executeAttack/startMove - // so the rAF loop sees it on the very next frame, not after a React re-render. const timerActiveRef = useRef(false) - // timerRunning is the React state mirror used only for the UI play icon. const [timerRunning, setTimerRunning] = useState(false) - const bodyPartsRef = useRef(null); bodyPartsRef.current = bodyParts - const bodyInjuriesRef = useRef(null); bodyInjuriesRef.current = bodyInjuries - const playerActionRef = useRef(null) - const enemyActionsRef = useRef([]) - const replayFrameRef = useRef(null) - const replayIdxRef = useRef(0) + const speedRef = useRef(1) + const [speedMult, setSpeedMult] = useState(1) + const playerRef = useRef(null); playerRef.current = player + const magazineRef = useRef({ pistol: 2 }); magazineRef.current = player.weaponMagazines + + // ── Universal character action/direction/position refs ── + // Every character (player, enemies) uses the same ref structure. + // The player entity ID is combat.playerEntityId. + // Character IDs: 'player', 'enemy_0', 'enemy_1', ... + const characterActionsRef = useRef({}) // { [charId]: action } + const characterDirsRef = useRef({}) // { [charId]: {dx,dy} } + const characterPositionsRef = useRef({}) // { [charId]: {x,y} } + const recordingRef = useRef(null) const viewRef = useRef(null); viewRef.current = view const svgRef = useRef(null) @@ -453,12 +483,7 @@ export default function App() { const minimapPanRef = useRef(false) const minimapPanStart = useRef({ x: 0, y: 0 }) const minimapMovedRef = useRef(false) - const equippedWeaponsRef = useRef(null); equippedWeaponsRef.current = equippedWeapons - // Tracks the latest enemy positions so AI reads fresh positions (not stale combatRef) - const positionsRef = useRef({}) - const playerDirRef = useRef({ dx: 0, dy: 1 }) - const playerTargetDirRef = useRef({ dx: 0, dy: 1 }) - const enemyDirsRef = useRef({}) + const replayElapsedRef = useRef(0) const wasBusyRef = useRef(false) // ───────────────────────────────────────────────────────────────────────── @@ -467,9 +492,15 @@ export default function App() { const locMap = {}; locations.forEach(l => { locMap[l.id] = l }) const weatherLabels = { clear: 'Clear', cloudy: 'Cloudy', rain: 'Rain', storm: 'Storm' } - const playerHp = totalHp(bodyParts) - const currentWeight = playerInventory.reduce((sum, id) => sum + (itemMap[id]?.weight ?? 0), 0) - const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === playerLoc) + const getDateString = (day) => { + const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + const DAYS = [31,28,31,30,31,30,31,31,30,31,30,31] + let d = day, m = 5 // June + while (d > DAYS[m]) { d -= DAYS[m]; m++; if (m > 11) m = 0 } + return `${MONTHS[m]} ${d}` + } + const playerHp = totalHp(player.integrity) + const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === player.location) const liveCharacters= characters.filter(c => !defeatedNpcs.includes(c.id)) const connectedTo = (locId) => { @@ -495,11 +526,104 @@ export default function App() { const ANIM_TYPES = { SLASH: 'slash', PIERCE_STRIKE: 'pierce_strike', + FIST_STRIKE: 'fist_strike', + FIST_HOOK: 'fist_hook', + SHOOT: 'shoot', } const ANIM_CFG = { [ANIM_TYPES.SLASH]: { duration: 0.3, hitAt: 0.15 }, [ANIM_TYPES.PIERCE_STRIKE]: { duration: 0.3, hitAt: 0.15 }, + [ANIM_TYPES.FIST_STRIKE]: { duration: 0.25, hitAt: 0.1 }, + [ANIM_TYPES.FIST_HOOK]: { duration: 0.3, hitAt: 0.15 }, + [ANIM_TYPES.SHOOT]: { duration: 0.5, hitAt: 0.5 }, + } + + // ───────────────────────────────────────────────────────────────────────── + // MODULAR PROJECTILES FRAMEWORK + // Types, config, factory, and step function. + // ───────────────────────────────────────────────────────────────────────── + + const PROJ_TYPES = { + BULLET: 'bullet', + } + + const PROJ_CFG = { + [PROJ_TYPES.BULLET]: { speed: 800, hitRadius: 10 }, + } + + function createProjectile(startPos, endPos, actor, weaponId, skillName) { + const dx = endPos.x - startPos.x + const dy = endPos.y - startPos.y + const clickDist = Math.sqrt(dx * dx + dy * dy) || 1 + const range = weaponMap[weaponId]?.skills?.find(s => s.name === skillName)?.range ?? 60 + return { + id: `proj-${Date.now()}`, + type: PROJ_TYPES.BULLET, + actor, + startPos: { ...startPos }, + endPos: { ...endPos }, + pos: { ...startPos }, + distTraveled: 0, + dist: Math.max(clickDist, range), + dir: { x: dx / clickDist, y: dy / clickDist }, + weaponId, + skillName, + hitFired: false, + } + } + + /** + * Step all active projectiles. Returns the filtered array of still-active projectiles. + * Applies hit damage / logging as side effects. + */ + function stepProjectiles(projs, dt, cc) { + if (!cc) return [] + const out = [] + for (const p of projs) { + if (p.hitFired) continue + + const cfg = PROJ_CFG[p.type] + const stepDist = cfg.speed * dt + const remaining = p.dist - p.distTraveled + const moveDist = Math.min(stepDist, remaining) + + const subSteps = Math.max(1, Math.ceil(moveDist)) + let hit = false + + for (let s = 1; s <= subSteps; s++) { + const t = s / subSteps + const cx = p.pos.x + p.dir.x * moveDist * t + const cy = p.pos.y + p.dir.y * moveDist * t + + if (MAP_BLOCKS.some(b => rectCollides(cx, cy, b))) { + applyProjectileHit(p, null) + hit = true; break + } + + for (const cid of Object.keys(cc.characters)) { + if (cid === cc.playerEntityId) continue + const e = cc.characters[cid] + if (!e || isDead(e.integrity)) continue + const ePos = characterPositionsRef.current[cid] ?? e.pos + const r = cfg.hitRadius + 4 * (e.size ?? 1) + if (Math.sqrt((cx - ePos.x) ** 2 + (cy - ePos.y) ** 2) < r) { + applyProjectileHit(p, cid) + hit = true; break + } + } + if (hit) break + } + if (hit) continue + + if (moveDist < stepDist) continue + + p.distTraveled += moveDist + p.pos.x += p.dir.x * moveDist + p.pos.y += p.dir.y * moveDist + out.push(p) + } + return out } /** @@ -507,24 +631,29 @@ export default function App() { * slash → arcing motion, pierce → straight thrust, blunt → straight strike. */ function createWeaponStrikeAnim(action, cc) { + if (!cc) return null const isPlayer = action.actor === 'player' - const startPos = isPlayer - ? { ...(cc?.playerPos ?? { x: 20, y: 75 }) } - : { ...(cc?.enemies[action.enemyIndex]?.pos ?? { x: 20, y: 75 }) } - const endPos = isPlayer - ? { ...(cc?.enemies[action.enemyIndex]?.pos ?? startPos) } - : { ...(cc?.playerPos ?? startPos) } + const attackerId = isPlayer ? cc.playerEntityId : action.charId + const targetId = isPlayer ? action.enemyCharId : cc.playerEntityId + const attacker = cc.characters[attackerId] + const target = cc.characters[targetId] + const startPos = attacker ? { ...attacker.pos } : { x: 20, y: 75 } + const endPos = target ? { ...target.pos } : { x: 20, y: 75 } const weapon = weaponMap[action.weaponId] const skill = weapon?.skills?.find(s => s.name === action.skillName) const damageType = skill?.damageType ?? 'blunt' - const type = damageType === 'slash' ? ANIM_TYPES.SLASH : ANIM_TYPES.PIERCE_STRIKE + const isFist = action.weaponId === 'fists' || action.weaponId === 'left_arm' || action.weaponId === 'right_arm' + const type = isFist + ? (action.weaponId === 'right_arm' ? ANIM_TYPES.FIST_HOOK : ANIM_TYPES.FIST_STRIKE) + : damageType === 'slash' ? ANIM_TYPES.SLASH + : ANIM_TYPES.PIERCE_STRIKE - return { - id: `${action.actor}-${action.enemyIndex}-${Date.now()}`, + return new AnimAction({ + id: `${action.actor}-${targetId}-${Date.now()}`, type, actor: action.actor, - enemyIndex: action.enemyIndex, + charId: action.charId, startPos, endPos, elapsed: 0, @@ -540,7 +669,7 @@ export default function App() { attackerInjuries: action.attackerInjuries, targetParts: action.targetParts, targetInjuries: action.targetInjuries, - } + }) } /** @@ -581,6 +710,121 @@ export default function App() { } } + /** + * Fist jab: wind back slightly, snap forward to target, return. + */ + function getFistStrikePos(anim) { + const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.FIST_STRIKE] + const t = Math.min(anim.elapsed / duration, 1) + + let progress + if (anim.elapsed < hitAt) { + const p = anim.elapsed / hitAt + progress = -0.15 + 1.15 * p + } else { + const p = (anim.elapsed - hitAt) / (duration - hitAt) + progress = 1 - p * p + } + + const dx = anim.endPos.x - anim.startPos.x + const dy = anim.endPos.y - anim.startPos.y + + return { + x: anim.startPos.x + dx * progress, + y: anim.startPos.y + dy * progress, + } + } + + /** + * Fist hook: wide curved arc — sweeps out to the side then into the target. + */ + function getFistHookPos(anim) { + const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.FIST_HOOK] + const t = anim.elapsed < hitAt + ? anim.elapsed / hitAt + : anim.elapsed < duration + ? 1 - (anim.elapsed - hitAt) / (duration - hitAt) + : 0 + + const mid = { + x: (anim.startPos.x + anim.endPos.x) / 2, + y: (anim.startPos.y + anim.endPos.y) / 2, + } + const dx = anim.endPos.x - anim.startPos.x + const dy = anim.endPos.y - anim.startPos.y + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const nx = -dy / dist + const ny = dx / dist + const arcWidth = Math.sin(t * Math.PI) * 12 + + return { + x: anim.startPos.x + dx * t + nx * arcWidth, + y: anim.startPos.y + dy * t + ny * arcWidth, + } + } + + /** + * Apply damage / log when a projectile hits something. + */ + function applyProjectileHit(proj, charId) { + proj.hitFired = true + if (!charId) { + setCombat(c => ({ ...c, log: [...c.log, 'The bullet hits a wall!'] })) + return + } + + const cc = combatRef.current + const enemy = cc?.characters[charId] + if (!enemy || isDead(enemy.integrity)) return + + const skill = weaponMap.pistol?.skills?.[0] + if (!skill) return + + const { dmg, severity, injType, injLabel } = computeHit({ + weaponId: 'pistol', + skill, + weaponConditions: playerRef.current.weaponConditions, + bothMult: 1.0, + }) + + const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg'] + const part = parts[Math.floor(Math.random() * parts.length)] + + setCombat(c => { + const characters = { ...c.characters } + const e = { ...characters[charId] } + const integrity = { ...e.integrity } + const injuries = { ...(e.injuries ?? { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] }) } + + integrity[part] = Math.max(0, (integrity[part] ?? 100) - dmg) + if (integrity[part] > 0 && severity > 0) { + injuries[part] = [...(injuries[part] ?? []), { type: injType, severity }] + } + e.integrity = integrity + e.injuries = injuries + characters[charId] = e + + const log = dmg > 0 + ? [`You shoot ${e.name} in the ${bodyPartLabels[part] ?? part} for ${dmg} damage! ${injLabel} (severity ${severity}).`] + : ['Your shot glances off harmlessly.'] + if (isDead(integrity)) { + if (part === 'head') log.push(`You blow ${e.name}'s head off!`) + log.push(`${e.name} is defeated!`) + } + return { ...c, characters, log: [...c.log, ...log] } + }) + setHitShake({ actor: 'enemy', charId, start: performance.now() }) + } + + /** Queue a shot toward a map coordinate — queues a shoot action. */ + const queueShot = (tx, ty) => { + if (!combat) return + if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } + const startPos = characterPositionsRef.current[combat.playerEntityId] ?? combat.characters[combat.playerEntityId].pos + characterActionsRef.current[combat.playerEntityId] = new AnimAction({ type: 'shoot', startPos: { x: startPos.x, y: startPos.y }, endPos: { x: tx, y: ty }, elapsed: 0 }) + setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null) + } + /** * Dispatch: get the current position of any animation type. * Returns { x, y } or null if the type is unknown. @@ -589,62 +833,109 @@ export default function App() { switch (anim?.type) { case ANIM_TYPES.SLASH: return getSlashPos(anim) case ANIM_TYPES.PIERCE_STRIKE: return getPierceStrikePos(anim) + case ANIM_TYPES.FIST_STRIKE: return getFistStrikePos(anim) + case ANIM_TYPES.FIST_HOOK: return getFistHookPos(anim) default: return null } } - /** - * Tick a single animation by dt seconds. Returns true when the animation is done. - */ - function tickAnimation(anim, dt) { - const cfg = ANIM_CFG[anim?.type] +class AnimAction { + constructor(data) { Object.assign(this, data); this.elapsed = this.elapsed ?? 0; this.hitFired = this.hitFired ?? false } + step(dt) { + const cfg = ANIM_CFG[this.type] if (!cfg) return true - anim.elapsed += dt - return anim.elapsed >= cfg.duration + this.elapsed += dt + return this.elapsed >= cfg.duration } +} + +class TurnAction { + constructor(data) { Object.assign(this, data) } + step(dt) { + const dir = this.getDir() + const tgt = this.targetDir + if (dir.dx === tgt.dx && dir.dy === tgt.dy) return true + const curAngle = Math.atan2(dir.dy, dir.dx) + const tgtAngle = Math.atan2(tgt.dy, tgt.dx) + let delta = tgtAngle - curAngle + while (delta > Math.PI) delta -= 2 * Math.PI + while (delta < -Math.PI) delta += 2 * Math.PI + const maxStep = TURN_SPEED * dt + if (Math.abs(delta) <= maxStep) { + this.setDir({ dx: tgt.dx, dy: tgt.dy }) + return true + } + const newAngle = curAngle + Math.sign(delta) * maxStep + this.setDir({ dx: Math.cos(newAngle), dy: Math.sin(newAngle) }) + return false + } +} + +class SeqAction { + constructor(data) { Object.assign(this, data); this.index = 0 } + step(dt) { + while (this.index < this.actions.length) { + const done = this.actions[this.index].step(dt) + if (!done) return false + this.index++ + } + return true + } +} + +class MoveAction { + constructor(data) { Object.assign(this, data); this.cx = this.fromX; this.cy = this.fromY } + step(dt) { + const steps = Math.max(1, Math.round(dt * 60)) + for (let i = 0; i < steps; i++) { + const next = computeMove({ x: this.cx, y: this.cy }, { x: this.toX, y: this.toY }, this.speed * dt * 60 / steps) + if (next.x === this.cx && next.y === this.cy) return true + this.cx = next.x; this.cy = next.y + this.setPos(next) + if (next.x === this.toX && next.y === this.toY) return true + } + return false + } +} - /** - * Apply the hit/damage logic when an animation reaches its hitAt time. - */ function applyAnimHit(anim) { const liveCc = combatRef.current let inRange = false if (liveCc) { - if (anim.actor === 'player') { - const e = liveCc.enemies[anim.enemyIndex] - if (e) inRange = canReach(liveCc.playerPos, e.pos, anim.weaponId, anim.skillName) - } else { - const e = liveCc.enemies[anim.enemyIndex] - if (e) { - const ddx = liveCc.playerPos.x - e.pos.x - const ddy = liveCc.playerPos.y - e.pos.y - inRange = Math.sqrt(ddx * ddx + ddy * ddy) <= 15 - } + const attackerId = anim.charId + const targetId = anim.actor === 'player' ? anim.enemyCharId : liveCc.playerEntityId + const attacker = liveCc.characters[attackerId] + const target = liveCc.characters[targetId] + if (attacker && target) { + const range = anim.actor === 'player' + ? getSkillRange(anim.weaponId, anim.skillName) + : 15 + inRange = canReach(attacker.pos, target.pos, anim.weaponId, anim.skillName) } } if (!inRange) { const msg = anim.actor === 'player' ? 'Your attack misses!' - : `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!` + : `${liveCc?.characters[anim.charId]?.name ?? 'Enemy'}'s attack misses!` setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) return } - setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() }) + setHitShake({ actor: anim.actor, charId: anim.charId, start: performance.now() }) if (anim.actor === 'player') { const wid = anim.weaponId if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { const base = weaponMap[wid] - setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) })) + setPlayer(p => ({ ...p, weaponConditions: { ...p.weaponConditions, [wid]: Math.max(0, (p.weaponConditions[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) } })) } - const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt) + const decayedPlayer = calcIntegrity(playerRef.current.integrity, playerRef.current.injuries, ANIM_CFG[anim.type].hitAt) - if (liveCc) { - const enemy = liveCc.enemies[anim.enemyIndex] + if (liveCc && anim.enemyCharId) { + const enemy = liveCc.characters[anim.enemyCharId] if (enemy) { const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, ANIM_CFG[anim.type].hitAt) const prevHp = decayedEnemy[anim.targetPart] @@ -663,131 +954,80 @@ export default function App() { setCombat(prev => ({ ...prev, - enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: hitEnemy, injuries: newInjuries } : e), + characters: { ...prev.characters, [anim.enemyCharId]: { ...enemy, integrity: hitEnemy, injuries: newInjuries } }, log: [...prev.log, ...log], })) } } - setBodyParts(decayedPlayer) - setBodyInjuries(bodyInjuriesRef.current) + setPlayer(p => ({ ...p, integrity: decayedPlayer })) + setPlayer(p => ({ ...p, injuries: playerRef.current.injuries })) } else { - const currentEnemy = liveCc?.enemies[anim.enemyIndex] + const currentEnemy = liveCc?.characters[anim.charId] const decayedEnemy = currentEnemy ? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, ANIM_CFG[anim.type].hitAt) : calcIntegrity(anim.attackerParts, anim.attackerInjuries, ANIM_CFG[anim.type].hitAt) - const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt) + const decayedPlayer = calcIntegrity(playerRef.current.integrity, playerRef.current.injuries, ANIM_CFG[anim.type].hitAt) const prevHp = decayedPlayer[anim.targetPart] const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } const newInjuries = prevHp > 0 && anim.severity > 0 - ? applyInjury(bodyInjuriesRef.current, anim.targetPart, anim.injType, anim.severity) - : bodyInjuriesRef.current + ? applyInjury(playerRef.current.injuries, anim.targetPart, anim.injType, anim.severity) + : playerRef.current.injuries if (liveCc) { - const enemy = liveCc.enemies[anim.enemyIndex] + const enemy = liveCc.characters[anim.charId] const enemyName = enemy?.name ?? 'Enemy' setCombat(prev => ({ ...prev, - enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: decayedEnemy } : e), + characters: { ...prev.characters, [anim.charId]: { ...enemy, integrity: decayedEnemy } }, log: anim.dmg > 0 ? [...prev.log, `${enemyName} lands a ${anim.injLabel} on your ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`] : prev.log, })) } - setBodyParts(hitPlayer) - setBodyInjuries(newInjuries) + setPlayer(p => ({ ...p, integrity: hitPlayer, injuries: newInjuries })) } } - /** - * Advance a player move by dt seconds. Returns true when done/blocked. - */ - /** - * Advance a player turn by dt. Returns true when facing the target. - */ - function tickTurn() { - const dir = playerDirRef.current - const tgt = playerTargetDirRef.current - return dir.dx === tgt.dx && dir.dy === tgt.dy - } - - function tickPlayerMove(anim, dt) { - const mdx = anim.toX - anim.fromX - const mdy = anim.toY - anim.fromY - const mdist = Math.sqrt(mdx * mdx + mdy * mdy) - const moveDir = mdist > 0 ? { dx: mdx / mdist, dy: mdy / mdist } : { dx: 0, dy: 1 } - const facing = playerDirRef.current - const dot = Math.max(-1, Math.min(1, facing.dx * moveDir.dx + facing.dy * moveDir.dy)) - const speedMult = 0.7 + 0.3 * dot - const speed = anim.speed * speedMult - - const steps = Math.max(1, Math.round(dt * 60)) - let cf = { x: anim.fromX, y: anim.fromY } - for (let i = 0; i < steps; i++) { - const p = computeMove(cf, { x: anim.toX, y: anim.toY }, speed) - if (p.x === anim.toX && p.y === anim.toY) { - setCombat(c => ({ ...c, playerPos: p })); positionsRef.current.player = p; return true - } - if (p.x === cf.x && p.y === cf.y) return true - cf = p - setCombat(c => ({ ...c, playerPos: p })) - } - anim.fromX = cf.x; anim.fromY = cf.y - positionsRef.current.player = cf - return false - } - - /** - * Advance an enemy move by dt seconds. Returns true when done. - */ - function tickEnemyMove(anim, dt) { - const DURATION = 0.2 - anim.elapsed += dt - const t = Math.min(anim.elapsed / DURATION, 1) - const ea = anim.targets[anim.enemyIndex] - const pos = { x: ea.fromX + (ea.toX - ea.fromX) * t, y: ea.fromY + (ea.toY - ea.fromY) * t } - setCombat(c => { - const enemies = [...c.enemies] - enemies[anim.enemyIndex] = { ...enemies[anim.enemyIndex], pos } - return { ...c, enemies } - }) - positionsRef.current[anim.enemyIndex] = pos - return t >= 1 - } - // ───────────────────────────────────────────────────────────────────────── // TIMER — ticks while timerActive is true, updates all character actions // ───────────────────────────────────────────────────────────────────────── useEffect(() => { - if (!combat || replayActive) { setDisplayTime(0); return } + if (!combat) { setDisplayTime(0); return } let lastTime = performance.now() let rafId const tick = () => { rafId = requestAnimationFrame(tick) const now = performance.now() - const dt = (now - lastTime) / 1000 - lastTime = now + const rawDt = (now - lastTime) / 1000 + const dt = rawDt * speedRef.current + lastTime = now - // ── Smooth turn player toward target direction ── - const curDir = playerDirRef.current - const tgtDir = playerTargetDirRef.current - if (curDir.dx !== tgtDir.dx || curDir.dy !== tgtDir.dy) { - const curAngle = Math.atan2(curDir.dy, curDir.dx) - const tgtAngle = Math.atan2(tgtDir.dy, tgtDir.dx) - let delta = tgtAngle - curAngle - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI - const maxStep = TURN_SPEED * dt - if (Math.abs(delta) <= maxStep) { - playerDirRef.current = { dx: tgtDir.dx, dy: tgtDir.dy } - } else { - const newAngle = curAngle + Math.sign(delta) * maxStep - playerDirRef.current = { dx: Math.cos(newAngle), dy: Math.sin(newAngle) } + // ── Replay mode ── + if (replayActive) { + const rec = fightRecord + if (rec) { + replayElapsedRef.current += dt + const lastFrame = rec.frames[rec.frames.length - 1] + const idx = rec.frames.findLastIndex(f => f.time <= replayElapsedRef.current) + if (idx >= 0) { + const f = rec.frames[idx] + setCombat(f.combat) + setPlayer(p => ({ ...p, integrity: f.playerIntegrity, injuries: f.playerInjuries, equipped: f.playerEquipped ?? p.equipped, weaponConditions: f.playerWeaponConditions ?? p.weaponConditions })) + if (f.playerDir) characterDirsRef.current['player'] = f.playerDir + setAnimations(Array.isArray(f.animations) ? f.animations : []) + setProjectiles(Array.isArray(f.projectiles) ? f.projectiles : []) + setHitShake(f.hitShake ?? null) + } + setCombatTime(replayElapsedRef.current) + setDisplayTime(replayElapsedRef.current) + if (replayElapsedRef.current >= lastFrame.time) setReplayActive(false) } + return } // ── Effects (run every frame, independent of timer) ── @@ -804,59 +1044,78 @@ export default function App() { setCombatTime(t => t + dt) setDisplayTime(t => t + dt) - // ── Tick player action ── + // ── Build fresh combat snapshot with live positions ── const activeAnims = [] - if (playerActionRef.current) { - const rawAnim = playerActionRef.current - const done = rawAnim.type === 'move' - ? tickPlayerMove(rawAnim, dt) - : rawAnim.type === 'turn' - ? tickTurn() - : tickAnimation(rawAnim, dt) - if (!done && ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { - rawAnim.hitFired = true - applyAnimHit(rawAnim) - } - if (done) { - playerActionRef.current = null - setHitShake(null) - } else if (ANIM_CFG[rawAnim.type]) { - activeAnims.push(rawAnim) - } - } - - // ── Tick enemy actions ── const cc = combatRef.current + const playerCharId = cc?.playerEntityId const fresh = cc ? (() => { - const pp = positionsRef.current.player - return { - ...cc, - playerPos: pp ?? cc.playerPos, - enemies: cc.enemies.map((e, i) => { - const p = positionsRef.current[i] - return p ? { ...e, pos: p } : e - }) + const chars = {} + for (const cid of Object.keys(cc.characters)) { + const c = cc.characters[cid] + const pos = characterPositionsRef.current[cid] ?? c.pos + chars[cid] = { ...c, pos: { ...pos } } } + return { ...cc, characters: chars } })() : null + + // ── Tick ALL character actions uniformly ── if (cc) { - for (let i = 0; i < cc.enemies.length; i++) { - const rawAnim = enemyActionsRef.current[i] + for (const cid of Object.keys(cc.characters)) { + const rawAnim = characterActionsRef.current[cid] if (!rawAnim) continue - const done = rawAnim.type === 'enemyMove' - ? tickEnemyMove(rawAnim, dt) - : tickAnimation(rawAnim, dt) - if (!done && ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { + + // Sync move action start position from live ref + if (rawAnim.type === 'move') { + const cp = characterPositionsRef.current[cid] + if (cp) { rawAnim.cx = cp.x; rawAnim.cy = cp.y } + } + + // Track elapsed for attack anims + if (ANIM_CFG[rawAnim.type]) { + if (rawAnim.elapsed !== undefined) rawAnim.elapsed += dt + } + + const done = rawAnim.step(dt) + if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { rawAnim.hitFired = true - applyAnimHit(rawAnim) + if (rawAnim.type === 'shoot') { + const pp = characterPositionsRef.current[cid] ?? cc.characters[cid]?.pos + const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } + const offset = 5 + const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset } + if (cid === 'player' && magazineRef.current.pistol > 0) { + const newMag = { pistol: magazineRef.current.pistol - 1 } + magazineRef.current = newMag + setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag } })) + const ddx = rawAnim.endPos.x - spawnPos.x + const ddy = rawAnim.endPos.y - spawnPos.y + const d = Math.sqrt(ddx * ddx + ddy * ddy) || 1 + const dir = { x: ddx / d, y: ddy / d } + const facing = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } + const dot = Math.max(-1, Math.min(1, facing.dx * dir.x + facing.dy * dir.y)) + const maxDeviation = (1 - dot) / 2 * (Math.PI / 4) + const deviation = (Math.random() * 2 - 1) * maxDeviation + const cos = Math.cos(deviation), sin = Math.sin(deviation) + const spreadDir = { x: dir.x * cos - dir.y * sin, y: dir.x * sin + dir.y * cos } + const finalDir = { x: isNaN(spreadDir.x) ? dir.x : spreadDir.x, y: isNaN(spreadDir.y) ? dir.y : spreadDir.y } + setProjectiles(prev => [...prev, createProjectile(spawnPos, { x: spawnPos.x + finalDir.x * d, y: spawnPos.y + finalDir.y * d }, 'player', 'pistol', 'Shoot')]) + } else if (cid === 'player') { + setCombat(c => ({ ...c, log: [...c.log, 'Click! The pistol is empty.'] })) + } + } else { + applyAnimHit(rawAnim) + } } if (done) { - if (rawAnim.type === 'enemyMove') { - const e = fresh?.enemies[i] ?? cc.enemies[i] - const finalPos = { x: rawAnim.targets[i].toX, y: rawAnim.targets[i].toY } + // ── Enemy move → attack transition ── + if (rawAnim.type === 'move' && cid !== playerCharId) { + const e = fresh?.characters[cid] ?? cc.characters[cid] + const finalPos = { x: rawAnim.toX, y: rawAnim.toY } if (e && !isDead(e.integrity)) { - const dist = Math.sqrt( - ((fresh ?? cc).playerPos.x - finalPos.x) ** 2 + ((fresh ?? cc).playerPos.y - finalPos.y) ** 2 - ) + const playerChar = (fresh ?? cc).characters[playerCharId] + const dist = playerChar ? Math.sqrt( + (playerChar.pos.x - finalPos.x) ** 2 + (playerChar.pos.y - finalPos.y) ** 2 + ) : 999 if (dist <= 15) { const wpn = e.weapon ?? 'fists' const base = weaponMap[wpn] @@ -866,57 +1125,64 @@ export default function App() { const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) const targetPart = randomPart() const action = { - type: 'attack', actor: 'enemy', enemyIndex: i, targetPart, + type: 'attack', actor: 'enemy', charId: cid, targetPart, weaponId: wpn, skillName: skill.name, dmg, severity, injType, injLabel, attackerParts: { ...e.integrity }, attackerInjuries: { ...e.injuries }, - targetParts: { ...bodyPartsRef.current }, - targetInjuries: { ...bodyInjuriesRef.current }, + targetParts: { ...playerRef.current.integrity }, + targetInjuries: { ...playerRef.current.injuries }, } - enemyActionsRef.current[i] = createWeaponStrikeAnim(action, fresh) + characterActionsRef.current[cid] = createWeaponStrikeAnim(action, fresh ?? cc) continue } } } - enemyActionsRef.current[i] = null + characterActionsRef.current[cid] = null } else { - activeAnims.push(rawAnim) + if (ANIM_CFG[rawAnim.type]) { + activeAnims.push(rawAnim) + } } } } // ── Run enemy AI every frame for idle enemies ── if (fresh) { - const actions = runEnemyAI(fresh, bodyPartsRef.current, bodyInjuriesRef.current, enemyDirsRef.current) + const actions = runEnemyAI(fresh, playerRef.current.integrity, playerRef.current.injuries) for (const act of actions) { - if (act.type === 'attack' && !enemyActionsRef.current[act.enemyIndex]) { - enemyActionsRef.current[act.enemyIndex] = createWeaponStrikeAnim(act, fresh) - } else if (act.type === 'enemyMove') { - for (const idxStr of Object.keys(act.targets)) { - const idx = parseInt(idxStr) - if (!enemyActionsRef.current[idx]) { - enemyActionsRef.current[idx] = { - type: 'enemyMove', enemyIndex: idx, - targets: act.targets, elapsed: 0 - } + if (act.type === 'attack' && !characterActionsRef.current[act.charId]) { + characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh) + } else if (act.type === 'move' && !characterActionsRef.current[act.charId]) { + characterActionsRef.current[act.charId] = new MoveAction({ + ...act, + setPos: p => { + setCombat(c => ({ + ...c, + characters: { ...c.characters, [act.charId]: { ...c.characters[act.charId], pos: { ...p } } } + })) + characterPositionsRef.current[act.charId] = p } - } + }) } } } + // ── Step projectiles ── + setProjectiles(prev => stepProjectiles(prev, dt, combatRef.current)) + // ── Update rendering state for active animations ── setAnimations(activeAnims) - // ── Auto-stop timer when an action just completed ── - const playerBusy = playerActionRef.current !== null - if (wasBusyRef.current && !playerBusy) { - timerActiveRef.current = false - setTimerRunning(false) + // ── Auto-stop timer when ALL actions complete ── + const anyBusy = Object.values(characterActionsRef.current).some(a => a !== null) + if (wasBusyRef.current && !anyBusy) { + timerActiveRef.current = false; setTimerRunning(false) } - wasBusyRef.current = playerBusy - setBusy(playerBusy) + wasBusyRef.current = anyBusy + setBusy(anyBusy) + + } rafId = requestAnimationFrame(tick) @@ -933,7 +1199,7 @@ export default function App() { const frame = requestAnimationFrame(() => { const progress = Math.min(1, (performance.now() - travelAnim.startTs) / 1000) setTravelAnim(a => ({ ...a, progress })) - if (progress >= 1) { setPlayerLoc(travelAnim.toId); setTravelAnim(null) } + if (progress >= 1) { setPlayer(p => ({ ...p, location: travelAnim.toId })); setTravelAnim(null) } }) return () => cancelAnimationFrame(frame) }, [travelAnim]) @@ -944,8 +1210,8 @@ export default function App() { useEffect(() => { if (!combat || replayActive) return - if (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) endFight() - }, [combat, bodyParts, playerHp]) + if (isDead(player.integrity) || !(combat.enemyIds ?? []).some(eid => !isDead(combat.characters[eid].integrity))) endFight() + }, [combat, player.integrity, playerHp]) // ───────────────────────────────────────────────────────────────────────── // KEYBINDS @@ -971,9 +1237,13 @@ export default function App() { recordingRef.current.frames.push({ time: combatTime, combat: JSON.parse(JSON.stringify(combat)), - bodyParts: JSON.parse(JSON.stringify(bodyParts)), - bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)), + playerIntegrity: JSON.parse(JSON.stringify(player.integrity)), + playerInjuries: JSON.parse(JSON.stringify(player.injuries)), + playerEquipped: { ...player.equipped }, + playerWeaponConditions: { ...player.weaponConditions }, + playerDir: { ...(characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }) }, animations: JSON.parse(JSON.stringify(animations)), + projectiles: JSON.parse(JSON.stringify(projectiles)), hitShake: hitShake ? { ...hitShake } : null, }) } @@ -981,10 +1251,10 @@ export default function App() { useEffect(() => { if (!recordingRef.current || !combat || replayActive || !timerActiveRef.current) return const prev = recordingRef.current.frames[recordingRef.current.frames.length - 1] - if (JSON.stringify(prev.combat) !== JSON.stringify(combat) || JSON.stringify(prev.bodyParts) !== JSON.stringify(bodyParts)) { + if (JSON.stringify(prev.combat) !== JSON.stringify(combat) || JSON.stringify(prev.playerIntegrity) !== JSON.stringify(player.integrity)) { pushSnapshot() } - }, [combat, bodyParts, bodyInjuries]) + }, [combat, player.integrity, player.injuries]) // ───────────────────────────────────────────────────────────────────────── // WORLD CLOCK (outside combat) @@ -1011,11 +1281,14 @@ export default function App() { useEffect(() => { if (!skipAnim) return - const duration = (skipAnim.target - skipAnim.current) * 2000 + const { current: start, target, start: startTime } = skipAnim + const duration = (target - start) * 2000 const frame = requestAnimationFrame(function tick() { - const t = Math.min((performance.now() - skipAnim.start) / duration, 1) + const elapsed = performance.now() - startTime + const t = Math.min(elapsed / duration, 1) + setSkipAnim(s => ({ ...s, current: start + (target - start) * t })) if (t >= 1) setSkipAnim(null) - else { setSkipAnim(s => ({ ...s, current: s.current + (s.target - s.current) * t })); requestAnimationFrame(tick) } + else requestAnimationFrame(tick) }) return () => cancelAnimationFrame(frame) }, [skipAnim]) @@ -1044,34 +1317,14 @@ export default function App() { // REPLAY // ───────────────────────────────────────────────────────────────────────── - useEffect(() => { - if (!replayActive && replayFrameRef.current) { cancelAnimationFrame(replayFrameRef.current); replayFrameRef.current = null } - }, [replayActive]) - const startReplay = () => { setReplayActive(true) const rec = fightRecord const f0 = rec.frames[0] - setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries) - setAnimations(Array.isArray(f0.animations) ? f0.animations : []); setHitShake(f0.hitShake ?? null) - setCombatTime(0); setDisplayTime(0) - replayIdxRef.current = 0 - - const startTs = performance.now() - const tick = () => { - const elapsed = performance.now() - startTs - const idx = rec.frames.findLastIndex(f => f.time <= elapsed / 1000) - if (idx > replayIdxRef.current) { - replayIdxRef.current = idx - const f = rec.frames[idx] - setCombat(f.combat); setBodyParts(f.bodyParts); setBodyInjuries(f.bodyInjuries) - setAnimations(Array.isArray(f.animations) ? f.animations : []); setHitShake(f.hitShake ?? null) - setCombatTime(f.time) - } - if (idx >= rec.frames.length - 1) return - replayFrameRef.current = requestAnimationFrame(tick) - } - replayFrameRef.current = requestAnimationFrame(tick) + setCombat(f0.combat); setPlayer(p => ({ ...p, integrity: f0.playerIntegrity, injuries: f0.playerInjuries, equipped: f0.playerEquipped ?? p.equipped, weaponConditions: f0.playerWeaponConditions ?? p.weaponConditions })) + characterDirsRef.current['player'] = f0.playerDir ?? { dx: 0, dy: 1 } + setAnimations(Array.isArray(f0.animations) ? f0.animations : []); setProjectiles(Array.isArray(f0.projectiles) ? f0.projectiles : []); setHitShake(f0.hitShake ?? null) + setCombatTime(0); setDisplayTime(0); characterActionsRef.current = {} } // ───────────────────────────────────────────────────────────────────────── @@ -1079,19 +1332,11 @@ export default function App() { // ───────────────────────────────────────────────────────────────────────── const makeEnemy = (npc, index) => { - const npcWeaponId = npc.weapon ?? 'fists' - return { - id: npc.id, name: npc.name, - age: Math.floor(Math.random() * 30) + 18, - location: npc.location ?? 'Unknown', + return createCharacter({ + ...npc, + id: npc.id ?? `enemy_${index}`, pos: { x: 120 + index * 20, y: 60 + index * 20 }, - integrity: freshBody(), injuries: freshInjuries(), - attack: weaponMap[npcWeaponId]?.damage ?? 30, - speed: weaponMap[npcWeaponId]?.speed ?? 10, - weapon: npcWeaponId, - inventory: npc.inventory ?? [], - money: npc.money ?? 0, - } + }) } const startFight = (npcList) => { @@ -1100,19 +1345,48 @@ export default function App() { return npc ? [npc] : [] })() if (list.length === 0) return - const enemies = list.map((npc, i) => makeEnemy(npc, i)) - const initCombat = { playerPos: { x: 20, y: 75 }, log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], enemies } + const enemies = list.map((npc, i) => makeEnemy(npc, i)) + const characters = {} + characters['player'] = createCharacter({ + id: 'player', name: player.name, + pos: { x: 20, y: 75 }, + integrity: { ...player.integrity }, + injuries: JSON.parse(JSON.stringify(player.injuries)), + equipped: { ...player.equipped }, + weaponConditions: { ...player.weaponConditions }, + weaponMagazines: { ...player.weaponMagazines }, + itemCounts: { ...player.itemCounts }, + }) + for (const e of enemies) { + const cid = e.id + characters[cid] = { ...e, pos: { ...e.pos } } + } + const enemyIds = enemies.map(e => e.id) + const initCombat = { + playerEntityId: 'player', + characters, + enemyIds, + log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], + } setCombat(initCombat) timerActiveRef.current = false; setTimerRunning(false); setBusy(false) setCombatTime(0); setDisplayTime(0) - playerActionRef.current = null - enemyActionsRef.current = initCombat.enemies.map(() => null) - positionsRef.current = {} + characterActionsRef.current = {} + characterPositionsRef.current = {} + characterDirsRef.current = {} + for (const cid of Object.keys(characters)) { + characterActionsRef.current[cid] = null + characterPositionsRef.current[cid] = { ...characters[cid].pos } + characterDirsRef.current[cid] = { dx: 0, dy: 1 } + } setSelectedEnemy(0); setReplayActive(false) setExpandedWeapon('ROOT'); setAnimations([]); setHitShake(null); setMoveMode(null) recordingRef.current = { initialCombat: JSON.parse(JSON.stringify(initCombat)), - frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), animations: [], hitShake: null }], + frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), + playerIntegrity: freshBody(), playerInjuries: freshInjuries(), + playerEquipped: player.equipped, playerWeaponConditions: player.weaponConditions, + playerDir: { dx: 0, dy: 1 }, animations: [], projectiles: [], hitShake: null }], } } @@ -1120,55 +1394,57 @@ export default function App() { if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } timerActiveRef.current = false; setTimerRunning(false) setCombatTime(0); setDisplayTime(0) - playerActionRef.current = null - enemyActionsRef.current = [] + characterActionsRef.current = {} + setProjectiles([]) if (combat) { - const deadIds = combat.enemies.filter(e => isDead(e.integrity)).map(e => e.id) + const deadIds = combat.enemyIds.map(id => combat.characters[id]).filter(e => isDead(e.integrity)).map(e => e.id) if (deadIds.length > 0) setDefeatedNpcs(prev => [...new Set([...prev, ...deadIds])]) } } - /** Execute a player attack; timer starts immediately. */ + /** Execute a player attack; queue it for execution. */ const executeAttack = () => { if (!limbSelect || !selectedTarget) return const cc = combatRef.current if (!cc) return - if (playerActionRef.current) return - if (isDead(bodyPartsRef.current)) return + if (isDead(playerRef.current.integrity)) return const ei = attackEnemy ?? selectedEnemy - const enemy = cc.enemies[ei] + const enemyId = cc.enemyIds[ei] + const enemy = cc.characters[enemyId] if (!enemy || isDead(enemy.integrity)) return const weaponId = limbSelect - if (!canReach(cc.playerPos, enemy.pos, weaponId, selectedSkill)) return + if (!canReach(cc.characters['player'].pos, enemy.pos, weaponId, selectedSkill)) return const action = buildPlayerAttack({ weaponId, skillName: selectedSkill, targetPart: selectedTarget, - enemyIndex: ei, equippedWeapons, weaponConditions, - bodyParts: bodyPartsRef.current, bodyInjuries: bodyInjuriesRef.current, combat: cc, + enemyIndex: ei, equippedWeapons: player.equipped, weaponConditions: player.weaponConditions, + bodyParts: playerRef.current.integrity, bodyInjuries: playerRef.current.injuries, combat: cc, }) if (action.type === 'shove_pending') { - const enemy2 = combat.enemies[action.enemyIndex] + const enemy2 = cc.characters[action.enemyCharId] if (!enemy2 || isDead(enemy2.integrity)) return - const { pos, actualPush } = computeShove(combat.playerPos, enemy2.pos) + const { pos, actualPush } = computeShove(cc.characters['player'].pos, enemy2.pos) + if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } setCombat(c => ({ ...c, - enemies: c.enemies.map((e, i) => i === action.enemyIndex ? { ...e, pos } : e), + characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, log: [...c.log, `You shove ${enemy2.name} back ${Math.round(actualPush)} units!`], })) - timerActiveRef.current = true; setTimerRunning(true) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) return } - playerActionRef.current = createWeaponStrikeAnim(action, combatRef.current) - timerActiveRef.current = true; setTimerRunning(true) + if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } + const anim = createWeaponStrikeAnim(action, combatRef.current) + characterActionsRef.current['player'] = anim setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } const cancelAttack = () => { setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + setAimMode(false) } const toggleTimer = () => { @@ -1176,19 +1452,71 @@ export default function App() { setTimerRunning(timerActiveRef.current) } - /** Execute a player move; timer starts immediately. */ + // Escape cancels aim mode + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape' && aimMode) { setAimMode(false); setSelectedSkill(null) } } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [aimMode]) + + /** Execute a player move; queue it for execution. */ const startMove = (targetX, targetY) => { - if (!combat || playerActionRef.current) return - const fromX = combat.playerPos.x, fromY = combat.playerPos.y - const dist = Math.sqrt((targetX - fromX) ** 2 + (targetY - fromY) ** 2) + if (!combat) return + const curPos = characterPositionsRef.current['player'] ?? combat.characters['player'].pos + const dist = Math.sqrt((targetX - curPos.x) ** 2 + (targetY - curPos.y) ** 2) if (dist < 1) return - const speed = moveMode === 'walk' ? 1 : 3 - playerActionRef.current = { type: 'move', fromX, fromY, toX: targetX, toY: targetY, speed } - playerTargetDirRef.current = { dx: (targetX - fromX) / dist, dy: (targetY - fromY) / dist } - timerActiveRef.current = true; setTimerRunning(true) + const isRun = moveMode === 'run' + const speed = isRun ? 1.5 : 1 + if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } + const moveAction = new MoveAction({ type: 'move', fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, + setPos: p => { + setCombat(c => ({ ...c, characters: { ...c.characters, player: { ...c.characters.player, pos: { ...p } } } })) + characterPositionsRef.current['player'] = p + } + }) + if (isRun) { + const dx = targetX - curPos.x, dy = targetY - curPos.y + const d = Math.sqrt(dx * dx + dy * dy) + const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } + characterActionsRef.current['player'] = new SeqAction({ + actions: [ + new TurnAction({ targetDir: dir, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } }), + moveAction + ] + }) + } else { + characterActionsRef.current['player'] = moveAction + } setMoveMode(null) } + const reloadPistol = () => { + if (!combat) return + const cur = magazineRef.current.pistol + if (cur >= PISTOL_MAG) { + setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already fully loaded.'] })) + return + } + const reserve = playerRef.current.itemCounts?.pistol_ammo ?? 0 + if (reserve <= 0) { + setCombat(c => ({ ...c, log: [...c.log, 'No pistol ammo left!'] })) + return + } + const newMag = { pistol: cur + 1 } + magazineRef.current = newMag + setPlayer(p => { + const newCount = p.itemCounts.pistol_ammo - 1 + const counts = { ...p.itemCounts, pistol_ammo: newCount } + if (newCount <= 0) delete counts.pistol_ammo + return { + ...p, + weaponMagazines: { ...p.weaponMagazines, ...newMag }, + itemCounts: counts, + } + }) + setCombat(c => ({ ...c, log: [...c.log, 'Reloaded 1 round into the pistol.'] })) + } + // ───────────────────────────────────────────────────────────────────────── // MAP / ZOOM HELPERS // ───────────────────────────────────────────────────────────────────────── @@ -1235,26 +1563,59 @@ export default function App() { const travel = (targetId) => { if (travelAnim) return - const from = locations.find(l => l.id === playerLoc) + const from = locations.find(l => l.id === player.location) const to = locations.find(l => l.id === targetId) if (!from || !to) return - setTravelAnim({ from, to, fromId: playerLoc, toId: targetId, startTs: null, progress: 0 }) + setTravelAnim({ from, to, fromId: player.location, toId: targetId, startTs: null, progress: 0 }) } const buyItem = (item) => { - if (playerMoney < item.price) return + if (player.money < item.price) return if (item.weaponId) { setBuyArmChoice({ item }) } - else { setPlayerMoney(m => m - item.price); setPlayerInventory(prev => [...prev, item.itemId]) } + else if (item.itemId === 'pistol_ammo') { + setPlayer(p => ({ + ...p, + money: p.money - item.price, + inventory: p.inventory.includes('pistol_ammo') ? p.inventory : [...p.inventory, 'pistol_ammo'], + itemCounts: { ...p.itemCounts, pistol_ammo: (p.itemCounts?.pistol_ammo ?? 0) + 1 }, + })) + } + else { setPlayer(p => ({ ...p, money: p.money - item.price, inventory: [...p.inventory, item.itemId] })) } } const confirmEquipArm = (arm) => { const { item } = buyArmChoice - setPlayerMoney(m => m - item.price) - setWeaponConditions(prev => ({ ...prev, [item.weaponId]: weaponMap[item.weaponId].maxCondition })) - setEquippedWeapons(prev => arm === 'both' ? { left: item.weaponId, right: item.weaponId } : { ...prev, [arm]: item.weaponId }) + setPlayer(p => ({ + ...p, + money: p.money - item.price, + weaponConditions: { ...p.weaponConditions, [item.weaponId]: weaponMap[item.weaponId].maxCondition }, + equipped: arm === 'both' ? { left: item.weaponId, right: item.weaponId } : { ...p.equipped, [arm]: item.weaponId }, + })) setBuyArmChoice(null) } + const unequipWeapon = (arm) => { + const wid = player.equipped[arm] + if (wid === 'fists' || wid === 'left_arm' || wid === 'right_arm') return + setPlayer(p => ({ ...p, inventory: [...p.inventory, wid], equipped: { ...p.equipped, [arm]: 'fists' } })) + } + + const unequipBoth = () => { + const wid = player.equipped.left + if (wid === 'fists' || wid === 'left_arm' || wid === 'right_arm') return + setPlayer(p => ({ ...p, inventory: [...p.inventory, wid], equipped: { left: 'fists', right: 'fists' } })) + } + + const equipFromInventory = (weaponId, arm) => { + setPlayer(p => ({ + ...p, + inventory: p.inventory.filter(id => id !== weaponId), + equipped: arm === 'both' ? { left: weaponId, right: weaponId } : { ...p.equipped, [arm]: weaponId }, + weaponConditions: p.weaponConditions[weaponId] !== undefined ? p.weaponConditions : { ...p.weaponConditions, [weaponId]: weaponMap[weaponId].maxCondition }, + })) + setEquipArmChoice(null) + } + // ───────────────────────────────────────────────────────────────────────── // DISPLAY HELPERS // ───────────────────────────────────────────────────────────────────────── @@ -1265,6 +1626,7 @@ export default function App() { const skipTotalHours = skipAnim !== null ? skipAnim.current : null const displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour + const dateString = getDateString(displayDay) // busy is React state, updated in the timer loop to reflect refs @@ -1292,14 +1654,22 @@ export default function App() { )} Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')} +