From 19f01559eb6c1cfcbbbdbeec1e989dfc9a3fe660 Mon Sep 17 00:00:00 2001 From: johnruina Date: Wed, 10 Jun 2026 02:59:58 -0400 Subject: [PATCH] i dont even know --- package-lock.json | 681 +++++++++++++++++++ package.json | 1 + src/App.css | 86 +++ src/App.jsx | 1593 ++++++++++++++++++++++++++++----------------- 4 files changed, 1777 insertions(+), 584 deletions(-) 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')} +
+ {[1, 0.5, 0.25, 0.1].map(s => ( + + ))} +
{/* ── left: action tree */}
- {!isDead(bodyParts) && combat.enemies.some(e => !isDead(e.integrity)) && ( -
+ {!isDead(player.integrity) && (combat.enemyIds ?? []).some(eid => !isDead(combat.characters[eid].integrity)) && ( +
setExpandedWeapon(expandedWeapon === 'ROOT' ? null : 'ROOT')}> {expandedWeapon === 'ROOT' ? '▼' : '▶'} @@ -1316,41 +1686,46 @@ export default function App() { {weaponsOpen && (
{(() => { - const lw = equippedWeapons.left, rw = equippedWeapons.right + const lw = player.equipped.left, rw = player.equipped.right const entries = [] if (lw === 'fists') { entries.push(
setHoverInfo({ type: 'weapon', id: 'left_arm' })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + onClick={() => { setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Left Arm
) - } else { + } else if (lw !== rw) { entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + onClick={() => { setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Left: {weaponMap[lw].name}
) } if (rw === 'fists') { entries.push(
setHoverInfo({ type: 'weapon', id: 'right_arm' })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + onClick={() => { setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Right Arm
) } else if (lw !== rw) { entries.push(
setHoverInfo({ type: 'weapon', id: rw })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + onClick={() => { setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Right: {weaponMap[rw].name}
) } + if (lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm') { + entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> + Both: {weaponMap[lw].name}
) + } if (lw === 'fists' && rw === 'fists') { entries.push(
setHoverInfo({ type: 'weapon', id: 'fists' })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null) }}> + onClick={() => { setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> Fists
) } return entries })()}
)} - {/* Items */}
setItemsOpen(!itemsOpen)}> {itemsOpen ? '▼' : '▶'} @@ -1358,9 +1733,9 @@ export default function App() {
{itemsOpen && (
- {playerInventory.length === 0 + {player.inventory.length === 0 ?
No items
- : playerInventory.map((itemId, i) => ( + : player.inventory.map((itemId, i) => (
{ setShowInventory(true); setPanelTarget('player') }}> {itemMap[itemId]?.name ?? itemId}
@@ -1376,15 +1751,15 @@ export default function App() {
{movementOpen && (
-
{ if (!busy) setMoveMode(m => m === 'walk' ? null : 'walk') }}> - Walk -
-
{ if (!busy) setMoveMode(m => m === 'run' ? null : 'run') }}> - Run -
-
{ if (!busy) setMoveMode(m => m === 'turn' ? null : 'turn') }}> - Turn -
+
{ if (!busy) { setMoveMode(m => m === 'walk' ? null : 'walk'); setAimMode(false) } }}> + Walk +
+
{ if (!busy) { setMoveMode(m => m === 'run' ? null : 'run'); setAimMode(false) } }}> + Run +
+
{ if (!busy) { setMoveMode(m => m === 'turn' ? null : 'turn'); setAimMode(false) } }}> + Turn +
{moveMode && moveMode !== 'turn' &&
Click on the mini-map to move
} {moveMode === 'turn' &&
Click on the mini-map to face a direction
}
@@ -1398,7 +1773,7 @@ export default function App() { {/* ── center: minimap + log */}
-
+
@@ -1432,24 +1807,37 @@ export default function App() { }} onClick={e => { if (minimapMovedRef.current) { minimapMovedRef.current = false; return } - if (limbSelect || !moveMode) return + if (limbSelect || (!moveMode && !aimMode)) return const r = e.currentTarget.getBoundingClientRect() const tx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x const ty = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y - if (moveMode === 'turn') { - const dx = tx - combat.playerPos.x - const dy = ty - combat.playerPos.y - const d = Math.sqrt(dx * dx + dy * dy) - if (d > 0) { - playerTargetDirRef.current = { dx: dx / d, dy: dy / d } - playerActionRef.current = { type: 'turn' } - timerActiveRef.current = true; setTimerRunning(true) - setMoveMode(null) - } + if (aimMode) { + queueShot(tx, ty) return } + if (moveMode === 'turn') { + const pp = combat.characters['player'].pos + const dx = tx - pp.x + const dy = ty - pp.y + const d = Math.sqrt(dx * dx + dy * dy) + if (d > 0) { + if (!timerActiveRef.current) { timerActiveRef.current = true; setTimerRunning(true) } + characterActionsRef.current['player'] = new TurnAction({ targetDir: { dx: dx / d, dy: dy / d }, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } }) + setMoveMode(null) + } + return + } startMove(tx, ty) }}> + + + + + + + + + {MAP_BLOCKS.map((block, i) => )} - {(combat?.enemies || []).map((enemy, i) => { + {(combat?.enemyIds ?? []).map(eid => { + const enemy = combat.characters[eid] const dead = isDead(enemy.integrity) - const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy' + const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' + const eSize = enemy.size ?? 1 + const ex = isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x + const ey = isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y return ( - { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }) }} - onMouseLeave={() => setHoverInfo(null)} - onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> + + { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }); setHoveredChar(`enemy-${eid}`) }} + onMouseLeave={() => { setHoverInfo(null); setHoveredChar(null) }} + onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(eid) }} /> + ) })} {combat && selectedSkill && limbSelect && ( - + )} {combat && ( + cx={hitShake && hitShake.actor === 'player' ? combat.characters['player'].pos.x + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.x} + cy={hitShake && hitShake.actor === 'player' ? combat.characters['player'].pos.y + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.y} + r={4 * 1.3} fill={isDead(player.integrity) ? '#6b7280' : player.fillColor} stroke={isDead(player.integrity) ? '#6b7280' : '#3498db'} strokeWidth="1" opacity={isDead(player.integrity) ? 0.5 : 1} + onMouseEnter={() => setHoveredChar('player')} onMouseLeave={() => setHoveredChar(null)} + onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget('player') }} /> )} - {/* Weapon circles — player */} + {/* Weapon circles */} {combat && (() => { - const anim = animations.find(a => a.actor === 'player' && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE)) - const pos = anim ? getAnimPos(anim) : null - const px = pos ? pos.x : (hitShake && hitShake.actor === 'player' ? combat.playerPos.x + (Math.random() - 0.5) * 1.5 : combat.playerPos.x) - const py = pos ? pos.y : (hitShake && hitShake.actor === 'player' ? combat.playerPos.y + (Math.random() - 0.5) * 1.5 : combat.playerPos.y) - const dir = playerDirRef.current - const lw = equippedWeapons.left - const rw = equippedWeapons.right + const wpnOff = (dir, side, off) => { + if (side === 'front') return { x: dir.dx * off, y: dir.dy * off } + if (side === 'left') return { x: dir.dy * off, y: -dir.dx * off } + return { x: -dir.dy * off, y: dir.dx * off } + } + const wpnDots = (slots, bx, by) => slots.map(s => + + ) + const dir = characterDirsRef.current['player'] ?? { dx: 0, dy: 1 } const off = 7 - const circles = [] - const both = lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm' - if (both) { - circles.push( - - ) + const shake = hitShake?.actor === 'player' + const bx = shake ? combat.characters['player'].pos.x + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.x + const by = shake ? combat.characters['player'].pos.y + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.y + const lw = player.equipped.left, rw = player.equipped.right + const isFist = w => w === 'fists' || w === 'left_arm' || w === 'right_arm' + const bothWpn = lw === rw && !isFist(lw) + const isAtk = t => [ANIM_TYPES.SLASH, ANIM_TYPES.PIERCE_STRIKE, ANIM_TYPES.FIST_STRIKE, ANIM_TYPES.FIST_HOOK].includes(t) + const slots = [] + + const playerPos = combat.characters['player'].pos + if (bothWpn) { + const anim = animations.find(a => a.actor === 'player' && isAtk(a.type) && a.weaponId === lw) + const p = anim ? { x: getAnimPos(anim).x - playerPos.x, y: getAnimPos(anim).y - playerPos.y } : wpnOff(dir, 'front', off) + slots.push({ key: 'pw-both', ox: p.x, oy: p.y, fill: '#fbbf24' }) } else { - if (lw !== 'fists' && lw !== 'left_arm') { - circles.push( - - ) + const lAnim = animations.find(a => a.actor === 'player' && isAtk(a.type) && (a.weaponId === lw || a.weaponId === 'left_arm')) + const rAnim = animations.find(a => a.actor === 'player' && isAtk(a.type) && (a.weaponId === rw || a.weaponId === 'right_arm')) + const bothAnim = animations.find(a => a.actor === 'player' && isAtk(a.type) && a.weaponId === 'fists') + + if (!isFist(lw) || lAnim || bothAnim) { + const ap = (isFist(lw) && bothAnim) ? bothAnim : lAnim + const p = ap ? { x: getAnimPos(ap).x - playerPos.x, y: getAnimPos(ap).y - playerPos.y } : wpnOff(dir, 'left', off) + slots.push({ key: 'pw-left', ox: p.x, oy: p.y, fill: isFist(lw) ? player.fillColor : '#fbbf24' }) } - if (rw !== 'fists' && rw !== 'right_arm') { - circles.push( - - ) + if (!isFist(rw) || rAnim || bothAnim) { + const ap = (isFist(rw) && bothAnim) ? bothAnim : rAnim + const p = ap ? { x: getAnimPos(ap).x - playerPos.x, y: getAnimPos(ap).y - playerPos.y } : wpnOff(dir, 'right', off) + slots.push({ key: 'pw-right', ox: p.x, oy: p.y, fill: isFist(rw) ? player.fillColor : '#fbbf24' }) } } - return circles + return wpnDots(slots, bx, by) })()} - {/* Weapon circles — enemies */} - {combat.enemies.map((enemy, i) => { + {combat && combat.enemyIds.map(eid => { + const enemy = combat.characters[eid] const dead = isDead(enemy.integrity) if (dead) return null const wpn = enemy.weapon - if (!wpn || wpn === 'fists') return null - const enemyAnim = animations.find(a => a.actor === i && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE)) - const ePos = enemyAnim ? getAnimPos(enemyAnim) : null - const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy' - const ex = ePos ? ePos.x : (isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x) - const ey = ePos ? ePos.y : (isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y) - const dir = enemyDirsRef.current[i] ?? { dx: 0, dy: 1 } + if (!wpn) return null + const dir = characterDirsRef.current[eid] ?? { dx: 0, dy: 1 } + const shake = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' + const bx = shake ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x + const by = shake ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y + const isFist = wpn === 'fists' || wpn === 'left_arm' || wpn === 'right_arm' + const enemyAnim = animations.find(a => a.charId === eid && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE || a.type === ANIM_TYPES.FIST_STRIKE || a.type === ANIM_TYPES.FIST_HOOK)) + if (isFist && !enemyAnim) return null + const p = enemyAnim ? { x: getAnimPos(enemyAnim).x - enemy.pos.x, y: getAnimPos(enemyAnim).y - enemy.pos.y } : { x: -dir.dy * 7, y: dir.dx * 7 } + return + })} + {/* Projectile dots */} + {projectiles.map(p => ( + + ))} + {/* Direction arrows (on top, in front of character) */} + {combat && !isDead(player.integrity) && (() => { + const d = characterDirsRef.current['player'] ?? { dx: 0, dy: 1 } + const cx = hitShake && hitShake.actor === 'player' + ? combat.characters['player'].pos.x + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.x + const cy = hitShake && hitShake.actor === 'player' + ? combat.characters['player'].pos.y + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.y + const isHovered = hoveredChar === 'player' + const px = -d.dy, py = d.dx return ( - + + ) + })()} + {combat && combat.enemyIds.map(eid => { + const enemy = combat.characters[eid] + const dead = isDead(enemy.integrity) + if (dead) return null + const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' + const ex = isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x + const ey = isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y + const d = characterDirsRef.current[eid] ?? { dx: 0, dy: 1 } + const isHovered = hoveredChar === `enemy-${eid}` + const px = -d.dy, py = d.dx + return ( + ) })} @@ -1551,11 +1988,15 @@ export default function App() { {/* ── right: enemy list */}
- {(combat?.enemies || []).map((enemy, i) => ( -
{ setSelectedEnemy(i); setShowInventory(true); setPanelTarget(i) }}> + {(combat?.enemyIds ?? []).map(eid => { + const enemy = combat.characters[eid] + const idx = combat.enemyIds.indexOf(eid) + return ( +
{ setSelectedEnemy(idx); setShowInventory(true); setPanelTarget(eid) }}> {enemy.name}
- ))} + ) + })}
@@ -1571,7 +2012,10 @@ export default function App() { const skills = weapon?.skills ?? [] return (
-
{weaponName}
+
+ {weaponName} + {limbSelect === 'pistol' && Ammo: {player.weaponMagazines.pistol}/{PISTOL_MAG}} +
{/* skill list */} {skills.length > 0 && ( @@ -1579,8 +2023,12 @@ export default function App() {
Select Skill
{skills.map((skill, i) => ( - @@ -1593,10 +2041,11 @@ export default function App() {
Select Enemy
- {(combat?.enemies || []).map((enemy, i) => { - const inRange = canReach(combat.playerPos, enemy.pos, limbSelect, selectedSkill) + {(combat?.enemyIds ?? []).map((eid, i) => { + const enemy = combat.characters[eid] + const inRange = canReach(combat.characters['player'].pos, enemy.pos, limbSelect, selectedSkill) return ( -
- + {limbSelect !== 'pistol' && }
)} {/* ── combat over */} - {combat && (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) && ( + {combat && (isDead(player.integrity) || !(combat.enemyIds ?? []).some(eid => !isDead(combat.characters[eid].integrity))) && (
Combat Over
{fightRecord && } - +
)} @@ -1727,7 +2176,7 @@ export default function App() { {shops.some(s => s.locationId === l.id) && ( S )} - {!travelAnim && playerLoc === l.id && ( + {!travelAnim && player.location === l.id && ( <> P )} @@ -1757,7 +2206,7 @@ export default function App() {
-
Day {displayDay} {String(displayHour).padStart(2,'0')}:00
+
{dateString}, {String(displayHour).padStart(2,'0')}:00
{weatherLabels[weather]}
@@ -1790,7 +2239,8 @@ export default function App() {
@@ -1798,7 +2248,7 @@ export default function App() {
{locMap[selected].name}
{locMap[selected].description}
- {playerLoc !== selected && connectedTo(playerLoc).includes(selected) && ( + {player.location !== selected && connectedTo(player.location).includes(selected) && (
Travel here
@@ -1811,7 +2261,7 @@ export default function App() {
{item.name} {item.price}g - +
))}
@@ -1822,7 +2272,7 @@ export default function App() { ?
None
: liveCharacters.filter(c => c.location === selected).map(c => (
-
{c.name}
+
{ setShowInventory(true); setPanelTarget(c.id) }}>{c.name}
{c.money !== undefined && {c.money}g} {c.inventory?.length > 0 && {c.inventory.map(id => itemMap[id]?.name ?? id).join(', ')}} @@ -1831,7 +2281,7 @@ export default function App() { )) }
- {playerLoc === selected && ( + {player.location === selected && ( @@ -1892,7 +2342,7 @@ export default function App() { ?
No one here.
: liveCharacters.filter(c => c.id !== 'player' && c.location === showLocationModal).map(c => (
-
{c.name}
+
{ setShowInventory(true); setPanelTarget(c.id) }}>{c.name}
)) @@ -1918,7 +2368,16 @@ export default function App() { )} {/* ═══════════════════════════════════════════════════════ INVENTORY PANEL */} - {showInventory && ( + {showInventory && (() => { + const character = panelTarget === 'player' + ? player + : combat && combat.characters[panelTarget] + ? combat.characters[panelTarget] + : (() => { const npc = characters.find(c => c.id === panelTarget); return npc ? createCharacter(npc) : null })() + const isPlayer = character?.id === 'player' + const charWeight = character?.inventory?.reduce((sum, id) => sum + (itemMap[id]?.weight ?? 0) * ((character.itemCounts?.[id] ?? 0) || 1), 0) ?? 0 + const charHp = character ? totalHp(character.integrity) : 0 + return (
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }} @@ -1927,11 +2386,7 @@ export default function App() {
- {panelTarget === 'player' - ? - : combat?.enemies[panelTarget] - ? - : null} + {character && }
@@ -1940,158 +2395,113 @@ export default function App() {
- {panelTarget === 'player' ? ( - <> - {invTab === 'health' && ( -
-
Body Parts
- {Object.keys(MAX_BODY).map(part => { - const hp = bodyParts[part], max = MAX_BODY[part] - const injuries = bodyInjuries[part] ?? [] - return ( -
-
-
-
{bodyPartLabels[part]}
-
-
{hp}/{max}
-
- {injuries.length > 0 &&
{injuries.map((inj,i) => {inj.type} ({inj.severity}))}
} -
- ) - })} -
Integrity{playerHp}/{MAX_HP}
-
- )} - {invTab === 'inventory' && ( -
+ {character && invTab === 'health' && ( +
+
Body Parts
+ {Object.keys(MAX_BODY).map(part => { + const hp = character.integrity[part], max = MAX_BODY[part] + const injuries = character.injuries[part] ?? [] + return ( +
+
+
+
{bodyPartLabels[part]}
+
+
{hp}/{max}
+
+ {injuries.length > 0 &&
{injuries.map((inj,i) => {inj.type} ({inj.severity}))}
} +
+ ) + })} +
Integrity{charHp}/{MAX_HP}
+
+ )} + {character && invTab === 'inventory' && ( +
+ {isPlayer && ( + <>
Weapons
{(() => { - const lw = equippedWeapons.left, rw = equippedWeapons.right + const lw = character.equipped.left, rw = character.equipped.right if (lw === rw) return (
setHoverInfo({ type:'weapon', id:lw })} onMouseLeave={() => setHoverInfo(null)}> {lw === 'fists' ? 'Fists' : `Both: ${weaponMap[lw].name}`} - {lw !== 'fists' && <>DMG: {weaponMap[lw].damage}COND: {weaponConditions[lw] ?? weaponMap[lw].maxCondition}/{weaponMap[lw].maxCondition}{weaponMap[lw].passives.length > 0 && {weaponMap[lw].passives.join(', ')}}} + {lw !== 'fists' && <>DMG: {weaponMap[lw].damage}COND: {character.weaponConditions[lw] ?? weaponMap[lw].maxCondition}/{weaponMap[lw].maxCondition}{weaponMap[lw].passives.length > 0 && {weaponMap[lw].passives.join(', ')}}}
) return ['left','right'].map(side => { - const wid = equippedWeapons[side] + const wid = character.equipped[side] if (wid === 'fists') return null return (
setHoverInfo({ type:'weapon', id:wid })} onMouseLeave={() => setHoverInfo(null)}> {side === 'left' ? 'Left' : 'Right'}: {weaponMap[wid].name} DMG: {weaponMap[wid].damage} - COND: {weaponConditions[wid] ?? weaponMap[wid].maxCondition}/{weaponMap[wid].maxCondition} + COND: {character.weaponConditions[wid] ?? weaponMap[wid].maxCondition}/{weaponMap[wid].maxCondition} {weaponMap[wid].passives.length > 0 && {weaponMap[wid].passives.join(', ')}} +
) }) })()}
-
Inventory ({playerInventory.length})
- {playerInventory.length === 0 - ?
Empty
- : playerInventory.map((itemId, i) => ( -
setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}> - {itemMap[itemId]?.name ?? itemId} -
- )) - } -
Weight: {currentWeight}/{MAX_WEIGHT}
-
{playerMoney}g
-
+ )} - {invTab === 'stats' && ( -
-
Character
-
NameYou
-
Age24
-
Location{locMap[playerLoc]?.name ?? playerLoc}
-
-
Equipped{equippedWeapons.left === equippedWeapons.right ? (equippedWeapons.left === 'fists' ? 'Fists' : `Both: ${weaponMap[equippedWeapons.left].name}`) : [equippedWeapons.left !== 'fists' ? `L:${weaponMap[equippedWeapons.left].name}` : '', equippedWeapons.right !== 'fists' ? `R:${weaponMap[equippedWeapons.right].name}` : ''].filter(Boolean).join(' + ')}
+ {!isPlayer && character.weapon && character.weapon !== 'fists' && ( +
+
Equipped Weapon
{(() => { - const shown = new Set(), rows = [] - const addWeapon = wid => { - if (shown.has(wid) || wid === 'fists') return; shown.add(wid) - rows.push(
Skills{weaponMap[wid].skills.map(s=>s.name).join(', ')}
Weapon SPD{weaponMap[wid].speed}
) - } - addWeapon(equippedWeapons.left); addWeapon(equippedWeapons.right); return rows - })()} -
-
Integrity{playerHp}/{MAX_HP}
-
Items{playerInventory.length}
-
Weight{currentWeight}/{MAX_WEIGHT}
-
Money{playerMoney}g
-
- )} - - ) : combat?.enemies[panelTarget] ? ( - <> - {invTab === 'health' && ( -
-
Body Parts
- {Object.keys(MAX_BODY).map(part => { - const hp = combat.enemies[panelTarget].integrity[part], max = MAX_BODY[part] - const injuries = combat.enemies[panelTarget].injuries[part] ?? [] + const wep = weaponMap[character.weapon] return ( -
-
-
-
{bodyPartLabels[part]}
-
-
{hp}/{max}
-
- {injuries.length > 0 &&
{injuries.map((inj,i) => {inj.type} ({inj.severity}))}
} +
setHoverInfo({ type:'weapon', id:character.weapon })} onMouseLeave={() => setHoverInfo(null)}> + {wep?.name ?? character.weapon} + {wep && <>DMG: {character.attack}SPD: {character.speed}{wep.passives?.length > 0 && {wep.passives.join(', ')}}}
) - })} -
Integrity{Object.values(combat.enemies[panelTarget].integrity).reduce((a,b)=>a+b,0)}/{MAX_HP}
-
- )} - {invTab === 'inventory' && ( -
-
Inventory ({(combat.enemies[panelTarget].inventory||[]).length})
- {(combat.enemies[panelTarget].inventory||[]).length === 0 - ?
Empty
- : combat.enemies[panelTarget].inventory.map((itemId, i) => ( -
setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}> - {itemMap[itemId]?.name ?? itemId} -
- )) - } -
{combat.enemies[panelTarget].money || 0}g
-
- )} - {invTab === 'stats' && ( -
- {(() => { - const e = combat.enemies[panelTarget], wid = e.weapon ?? 'fists', wep = weaponMap[wid] - return (<> -
{e.name}
-
Name{e.name}
-
Age{e.age ?? '??'}
-
Location{e.location ?? 'Unknown'}
-
-
Weapon{wep?.name ?? wid}
- {wep && wid !== 'fists' && <>
DMG{e.attack}
SPD{e.speed}
} - {wep?.skills?.length > 0 &&
Skills{wep.skills.map(s=>s.name).join(', ')}
} -
-
Integrity{Object.values(e.integrity).reduce((a,b)=>a+b,0)}/{MAX_HP}
-
Items{(e.inventory||[]).length}
-
Money{e.money||0}g
- ) })()}
)} - - ) : null} + {!isPlayer && character.weapon && character.weapon !== 'fists' &&
} +
Inventory ({character.inventory.length})
+ {character.inventory.length === 0 + ?
Empty
+ : character.inventory.map((itemId, i) => { + const qty = character.itemCounts?.[itemId] + if (qty !== undefined && qty <= 0) return null + return ( +
setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}> + {itemMap[itemId]?.name ?? itemId} + {qty !== undefined && ×{qty}} + {isPlayer && weaponMap[itemId] && } +
+ ) + }) + } + {isPlayer &&
Weight: {charWeight}/{MAX_WEIGHT}
} +
{character.money || 0}g
+
+ )} + {character && invTab === 'stats' && ( +
+
{isPlayer ? 'Character' : character.name}
+
Name{isPlayer ? 'You' : character.name}
+
Age{character.age ?? '??'}
+ {isPlayer &&
Size{character.size ?? 1}x
} +
Location{locMap[character.location]?.name ?? character.location}
+
+
+
Integrity{charHp}/{MAX_HP}
+
Items{character.inventory.length}
+ {isPlayer &&
Weight{charWeight}/{MAX_WEIGHT}
} +
Money{character.money || 0}g
+
+ )}
- )} + )})()} {/* ═══════════════════════════════════════════════════════ SETTINGS */} {showSettings && ( @@ -2104,14 +2514,14 @@ export default function App() {
Game
-
Day{gameTime.day}
+
Date{getDateString(gameTime.day)}
Time{String(gameTime.hour).padStart(2,'0')}:00
Weather{weatherLabels[weather]}
Player
-
Location{locMap[playerLoc]?.name ?? playerLoc}
-
Money{playerMoney}g
+
Location{locMap[player.location]?.name ?? player.location}
+
Money{player.money}g
@@ -2124,10 +2534,10 @@ export default function App() {
World
-
Day{gameTime.day}
+
Date{getDateString(gameTime.day)}
Time{String(gameTime.hour).padStart(2,'0')}:00
Weather{weatherLabels[weather]}
-
Location{locMap[playerLoc]?.name ?? playerLoc}
+
Location{locMap[player.location]?.name ?? player.location}
@@ -2151,7 +2561,7 @@ export default function App() { {hoverInfo.id === 'left_arm' || hoverInfo.id === 'right_arm' ? <>
{weaponMap[hoverInfo.id].name}
Unarmed strike (DMG: 10)
: <>
{weaponMap[hoverInfo.id].name}
DMG: {weaponMap[hoverInfo.id].damage} SPD: {weaponMap[hoverInfo.id].speed}
-
Condition: {weaponConditions[hoverInfo.id] ?? weaponMap[hoverInfo.id].maxCondition}/{weaponMap[hoverInfo.id].maxCondition}
+
Condition: {player.weaponConditions[hoverInfo.id] ?? weaponMap[hoverInfo.id].maxCondition}/{weaponMap[hoverInfo.id].maxCondition}
Skills: {weaponMap[hoverInfo.id].skills.map(s=>`${s.name} (${s.damageType})`).join(', ')}
{weaponMap[hoverInfo.id].passives.length > 0 &&
Passives: {weaponMap[hoverInfo.id].passives.join(', ')}
} } @@ -2178,6 +2588,21 @@ export default function App() {
)} + + {/* ═══════════════════════════════════════════════════════ EQUIP ARM CHOICE */} + {equipArmChoice && ( +
setEquipArmChoice(null)}> +
e.stopPropagation()}> +
Equip {weaponMap[equipArmChoice].name}
+
+ + + +
+ +
+
+ )}
) } \ No newline at end of file