From c5e04472fb02666835783b90aeb62e3c1af60cf4 Mon Sep 17 00:00:00 2001 From: LosaDev Date: Sat, 7 Nov 2026 22:34:06 +0700 Subject: [PATCH] LosaDev Tools: Lost Saga modding tools in the browser --- .gitignore | 7 + README.md | 39 + app/.env.example | 6 + app/.gitignore | 27 + app/DEPLOY.md | 44 + app/index.html | 36 + app/package-lock.json | 943 ++++++ app/package.json | 27 + app/public/favicon.png | Bin 0 -> 10602 bytes app/public/legacy/chrome.html | 8 + app/public/legacy/host.js | 260 ++ app/public/legacy/js/anim-studio.js | 1954 ++++++++++++ app/public/legacy/js/animkit.js | 754 +++++ app/public/legacy/js/costume.js | 1218 +++++++ app/public/legacy/js/costumekit.js | 2426 ++++++++++++++ app/public/legacy/js/dds.js | 182 ++ app/public/legacy/js/effect-editor.js | 547 ++++ app/public/legacy/js/effects.js | 454 +++ app/public/legacy/js/fxlab.js | 1117 +++++++ app/public/legacy/js/gl3d.js | 666 ++++ app/public/legacy/js/hero-ini.js | 43 + app/public/legacy/js/hero.js | 847 +++++ app/public/legacy/js/imageset.js | 866 +++++ app/public/legacy/js/items.js | 232 ++ app/public/legacy/js/lsformats.js | 710 +++++ app/public/legacy/js/mapedit.js | 2217 +++++++++++++ app/public/legacy/js/meshkit.js | 193 ++ app/public/legacy/js/model.js | 1735 ++++++++++ app/public/legacy/js/quests.js | 1253 ++++++++ app/public/legacy/js/skin.js | 176 ++ app/public/legacy/js/tables.js | 768 +++++ app/public/legacy/js/texkit.js | 443 +++ app/public/legacy/js/texture.js | 724 +++++ app/public/legacy/js/uires.js | 671 ++++ app/public/legacy/js/uiwnd.js | 739 +++++ app/public/legacy/js/vtable.js | 283 ++ app/public/legacy/js/xmldoc.js | 227 ++ app/public/legacy/legacy.css | 4048 ++++++++++++++++++++++++ app/public/legacy/manifest.json | 45 + app/public/legacy/views/ans.html | 110 + app/public/legacy/views/cos.html | 86 + app/public/legacy/views/fx.html | 38 + app/public/legacy/views/fxlab.html | 50 + app/public/legacy/views/hero.html | 38 + app/public/legacy/views/items.html | 16 + app/public/legacy/views/map.html | 87 + app/public/legacy/views/mdl.html | 85 + app/public/legacy/views/quests.html | 84 + app/public/legacy/views/tables.html | 38 + app/public/legacy/views/tex.html | 53 + app/public/legacy/views/ui.html | 80 + app/public/legacy/views/wnd.html | 62 + app/public/logo.png | Bin 0 -> 22571 bytes app/server.mjs | 121 + app/src/api.js | 202 ++ app/src/cipher/cipher-page.css | 130 + app/src/cipher/cipher-page.js | 142 + app/src/cipher/cipher.js | 71 + app/src/game.js | 124 + app/src/iop/iop-page.css | 190 ++ app/src/iop/iop-page.js | 928 ++++++ app/src/iop/texdecode.js | 174 + app/src/keys.js | 34 + app/src/legacy/bridge.js | 178 ++ app/src/legacy/host-web-after.js | 10 + app/src/legacy/host-web.js | 19 + app/src/legacy/loader.js | 90 + app/src/legacy/workbench.css | 486 +++ app/src/legacy/workbench.js | 387 +++ app/src/main.js | 536 ++++ app/src/style.css | 336 ++ app/src/tools.js | 47 + app/src/worker/iop-web.js | 592 ++++ app/src/worker/keys.js | 52 + app/src/worker/lib/core/cfg.js | 65 + app/src/worker/lib/core/iop.js | 574 ++++ app/src/worker/lib/core/tools.js | 369 +++ app/src/worker/lib/main/assets.js | 302 ++ app/src/worker/lib/main/costumes.js | 168 + app/src/worker/lib/main/dattable.js | 444 +++ app/src/worker/lib/main/handlers.js | 296 ++ app/src/worker/lib/main/imagesets.js | 44 + app/src/worker/lib/main/inidoc.js | 198 ++ app/src/worker/lib/main/iopman.js | 47 + app/src/worker/lib/main/items.js | 595 ++++ app/src/worker/lib/main/lsmon/codec.js | 82 + app/src/worker/lib/main/models.js | 149 + app/src/worker/lib/main/quests.js | 667 ++++ app/src/worker/lib/main/questspec.js | 354 +++ app/src/worker/lib/main/store.js | 19 + app/src/worker/lib/main/xlsx.js | 191 ++ app/src/worker/setup.js | 5 + app/src/worker/shims/child_process.js | 12 + app/src/worker/shims/crypto.js | 12 + app/src/worker/shims/electron.js | 21 + app/src/worker/shims/fs-promises.js | 9 + app/src/worker/shims/os.js | 7 + app/src/worker/shims/zlib.js | 29 + app/src/worker/vfs.js | 419 +++ app/src/worker/worker.js | 359 +++ app/tools/extract-legacy.mjs | 98 + app/tools/gen-handlers.mjs | 78 + app/tools/legacy-patches.mjs | 173 + app/tools/port-module.mjs | 74 + app/vite.config.js | 70 + 105 files changed, 38541 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/.env.example create mode 100644 app/.gitignore create mode 100644 app/DEPLOY.md create mode 100644 app/index.html create mode 100644 app/package-lock.json create mode 100644 app/package.json create mode 100644 app/public/favicon.png create mode 100644 app/public/legacy/chrome.html create mode 100644 app/public/legacy/host.js create mode 100644 app/public/legacy/js/anim-studio.js create mode 100644 app/public/legacy/js/animkit.js create mode 100644 app/public/legacy/js/costume.js create mode 100644 app/public/legacy/js/costumekit.js create mode 100644 app/public/legacy/js/dds.js create mode 100644 app/public/legacy/js/effect-editor.js create mode 100644 app/public/legacy/js/effects.js create mode 100644 app/public/legacy/js/fxlab.js create mode 100644 app/public/legacy/js/gl3d.js create mode 100644 app/public/legacy/js/hero-ini.js create mode 100644 app/public/legacy/js/hero.js create mode 100644 app/public/legacy/js/imageset.js create mode 100644 app/public/legacy/js/items.js create mode 100644 app/public/legacy/js/lsformats.js create mode 100644 app/public/legacy/js/mapedit.js create mode 100644 app/public/legacy/js/meshkit.js create mode 100644 app/public/legacy/js/model.js create mode 100644 app/public/legacy/js/quests.js create mode 100644 app/public/legacy/js/skin.js create mode 100644 app/public/legacy/js/tables.js create mode 100644 app/public/legacy/js/texkit.js create mode 100644 app/public/legacy/js/texture.js create mode 100644 app/public/legacy/js/uires.js create mode 100644 app/public/legacy/js/uiwnd.js create mode 100644 app/public/legacy/js/vtable.js create mode 100644 app/public/legacy/js/xmldoc.js create mode 100644 app/public/legacy/legacy.css create mode 100644 app/public/legacy/manifest.json create mode 100644 app/public/legacy/views/ans.html create mode 100644 app/public/legacy/views/cos.html create mode 100644 app/public/legacy/views/fx.html create mode 100644 app/public/legacy/views/fxlab.html create mode 100644 app/public/legacy/views/hero.html create mode 100644 app/public/legacy/views/items.html create mode 100644 app/public/legacy/views/map.html create mode 100644 app/public/legacy/views/mdl.html create mode 100644 app/public/legacy/views/quests.html create mode 100644 app/public/legacy/views/tables.html create mode 100644 app/public/legacy/views/tex.html create mode 100644 app/public/legacy/views/ui.html create mode 100644 app/public/legacy/views/wnd.html create mode 100644 app/public/logo.png create mode 100644 app/server.mjs create mode 100644 app/src/api.js create mode 100644 app/src/cipher/cipher-page.css create mode 100644 app/src/cipher/cipher-page.js create mode 100644 app/src/cipher/cipher.js create mode 100644 app/src/game.js create mode 100644 app/src/iop/iop-page.css create mode 100644 app/src/iop/iop-page.js create mode 100644 app/src/iop/texdecode.js create mode 100644 app/src/keys.js create mode 100644 app/src/legacy/bridge.js create mode 100644 app/src/legacy/host-web-after.js create mode 100644 app/src/legacy/host-web.js create mode 100644 app/src/legacy/loader.js create mode 100644 app/src/legacy/workbench.css create mode 100644 app/src/legacy/workbench.js create mode 100644 app/src/main.js create mode 100644 app/src/style.css create mode 100644 app/src/tools.js create mode 100644 app/src/worker/iop-web.js create mode 100644 app/src/worker/keys.js create mode 100644 app/src/worker/lib/core/cfg.js create mode 100644 app/src/worker/lib/core/iop.js create mode 100644 app/src/worker/lib/core/tools.js create mode 100644 app/src/worker/lib/main/assets.js create mode 100644 app/src/worker/lib/main/costumes.js create mode 100644 app/src/worker/lib/main/dattable.js create mode 100644 app/src/worker/lib/main/handlers.js create mode 100644 app/src/worker/lib/main/imagesets.js create mode 100644 app/src/worker/lib/main/inidoc.js create mode 100644 app/src/worker/lib/main/iopman.js create mode 100644 app/src/worker/lib/main/items.js create mode 100644 app/src/worker/lib/main/lsmon/codec.js create mode 100644 app/src/worker/lib/main/models.js create mode 100644 app/src/worker/lib/main/quests.js create mode 100644 app/src/worker/lib/main/questspec.js create mode 100644 app/src/worker/lib/main/store.js create mode 100644 app/src/worker/lib/main/xlsx.js create mode 100644 app/src/worker/setup.js create mode 100644 app/src/worker/shims/child_process.js create mode 100644 app/src/worker/shims/crypto.js create mode 100644 app/src/worker/shims/electron.js create mode 100644 app/src/worker/shims/fs-promises.js create mode 100644 app/src/worker/shims/os.js create mode 100644 app/src/worker/shims/zlib.js create mode 100644 app/src/worker/vfs.js create mode 100644 app/src/worker/worker.js create mode 100644 app/tools/extract-legacy.mjs create mode 100644 app/tools/gen-handlers.mjs create mode 100644 app/tools/legacy-patches.mjs create mode 100644 app/tools/port-module.mjs create mode 100644 app/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15081fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +reference/ +*.log + +HANDOFF.md +PLAN.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..9088587 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# LosaDev Tools + +Lost Saga modding tools that run in the browser: **https://tools.losadev.wiki** + +Open a game folder, browse and edit `.iop` packs, preview models, animations, effects, maps, UI windows and textures, look up items, heroes, costumes and quests, and encode/decode server values. Game files never leave your PC: everything runs in your browser tab, and changed packs come back as downloads. + +Guides for every tool: https://losadev.wiki/tools/overview/ · tutorials: https://losadev.wiki/tools/tutorials/ + +## Run it locally + +Requirements: Node.js 20 or newer. + +```bash +cd app +npm install +npm run dev # http://localhost:4700 +``` + +`npm run build` writes the static site to `app/dist/`; `npm start` serves it with the small server in `app/server.mjs` (port and address in `app/.env`, see `app/.env.example` and `app/DEPLOY.md`). + +The folder picker needs a Chromium-based browser (Chrome, Edge) on HTTPS or `localhost`. + +## Layout + +| Path | What | +|---|---| +| `app/src/` | The app: game folder, worker with the pack reader (`src/worker/`), IOP Manager (`src/iop/`), Encode / Decode (`src/cipher/`), workbench layout (`src/legacy/workbench.*`) | +| `app/public/legacy/` | Tool views shared with the desktop LS Tools. **Generated**: change them through `app/tools/legacy-patches.mjs`, then `node tools/extract-legacy.mjs` | +| `app/tools/` | Build helpers (legacy extraction, handler generation) | + +## Contributing + +Pull requests are welcome and count toward your LosaDev tier: fixes, new file formats, better previews, UI work, tutorials. Read https://losadev.wiki/contribute/how-to/ first. + +- One change per pull request, with a short description and how you tested it (which client year, which pack). +- Never commit game files, packs, or passwords of your own server. The built-in pack passwords are the defaults from the shared sources. +- Pull requests are reviewed and merged by the **Core** team. + +Lost Saga is the property of its publishers. This project is fan-made, for development and preservation, and not affiliated with them. diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..8e254cc --- /dev/null +++ b/app/.env.example @@ -0,0 +1,6 @@ +# Copy to .env and change as needed. Read by server.mjs (npm start / npm run rebuild). +# Port the tools server listens on. +PORT=4700 +# Address to bind. 127.0.0.1 = only the local reverse proxy can reach it (recommended); +# 0.0.0.0 = open on every network interface. +HOST=127.0.0.1 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..9cd3025 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# server settings (copy .env.example) +.env diff --git a/app/DEPLOY.md b/app/DEPLOY.md new file mode 100644 index 0000000..ec0e36a --- /dev/null +++ b/app/DEPLOY.md @@ -0,0 +1,44 @@ +# Deploying LosaDev Tools + +The tools are a static site (Vite build in `dist/`) served by `server.mjs`, a small Node server with no extra dependencies. + +## Needs + +- Node.js 20.19+ or 22.12+ (same as Vite). +- HTTPS on the public domain (for example `tools.losadev.wiki` behind the panel's reverse proxy). Browsers only allow opening a game folder (File System Access API) on HTTPS or `localhost`. + +## First time + +```bash +npm install +cp .env.example .env # then edit PORT / HOST +npm run rebuild # = npm run build && npm run start +``` + +`npm install` must include the dev dependencies (Vite builds the site). Do not set `NODE_ENV=production` before installing. + +## Settings (`.env`) + +| Key | Default | Meaning | +|---|---|---| +| `PORT` | `4700` | Port the server listens on | +| `HOST` | `0.0.0.0` | Address to bind. Use `127.0.0.1` when a reverse proxy on the same machine forwards to it | + +Values already set in the real environment win over `.env`. + +## Scripts + +| Script | Does | +|---|---| +| `npm run build` | Builds the site into `dist/` | +| `npm run start` | Serves `dist/` (stops with a message if there is no build yet) | +| `npm run rebuild` | Build, then start. Use it after every update | +| `npm run dev` | Development server (port 4700, not for the public site) | + +## Updating + +Replace the files (keep your `.env`), run `npm install` if `package.json` changed, then `npm run rebuild` (or restart the process manager after `npm run build`). + +## Caching + +Files in `/assets/` carry a content hash and are cached for a year. `index.html` and the `legacy/` files are revalidated on every visit, so a new build shows up right away. diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..e2ea847 --- /dev/null +++ b/app/index.html @@ -0,0 +1,36 @@ + + + + + + + + LosaDev Tools + + + +
+ + + LosaDev Tools + +
+ +
+
+ +
+
+
+ + + diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..eef033b --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,943 @@ +{ + "name": "app", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app", + "version": "0.0.0", + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource/cinzel": "^5.3.0", + "buffer": "^6.0.3", + "fflate": "^0.8.3", + "path-browserify": "^1.0.1" + }, + "devDependencies": { + "acorn": "^8.19.0", + "vite": "^8.3.6" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/cinzel": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz", + "integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.32", + "rolldown": "~1.2.16", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..b9523b5 --- /dev/null +++ b/app/package.json @@ -0,0 +1,27 @@ +{ + "name": "app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "start": "node server.mjs", + "rebuild": "npm run build && npm run start", + "preview": "vite preview" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "devDependencies": { + "acorn": "^8.19.0", + "vite": "^8.3.6" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource/cinzel": "^5.3.0", + "buffer": "^6.0.3", + "fflate": "^0.8.3", + "path-browserify": "^1.0.1" + } +} diff --git a/app/public/favicon.png b/app/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..ff9a68d62494abce24a7458686a52b612e4b484b GIT binary patch literal 10602 zcmV-wDV5fVP)00009a7bBm000XT z000XT0n*)m`~UzT07*naRA_-+ng^5=W!CrSyzlqD!sO{#)v3CwLsf_BuFzefySh5( z$&pD612cqSh8c1kBnQbjpdw%ZWCaxi28^hHE(SywS6y>j6jxV8aZUZ5>IQe;@0{oK zISg}p?*FE`_1s_4b#mKva{D;BZJZpFJ{9(H3dgl$FP!lT`*@{eywXvua*kKw#VS{^ z+Fh)6mjGlbNR|N7DFw+gkSy0wl{&ggN6QT!g~0uLQ7R> z$Z`!?2D-~Zce$1-(-gkPU7{`=SFzfa|3mnA<-g6icK(-bMR@_(b&~bi|Nd{Abgur~ zR;+ZCsPPiDyA&i#K|(TDLzipla;>La>n+!LD|Fr}okwn9)G)6#@dm5kZ1b6|Ok*TC z*pk9f2DWl~3lEw&rID2zyfVF~LPJ()$Vx3$u8~d|NR>&SWGO(D0z^SZK|?{tH9tDX zE1g&El-r8NT@!HK|59lir@+Q5FzHh%SzoNiO8}xwLzhVwOQ%A|ROs1CJzHhqHjXE?3y{TBTF0lG3IGaFv2Yxz={|16u|0RR&%G`vJ44gatJs7*R1GW;>fFtQ?+t+sc`H=C*(M#Ms5BcHFgo z*7_N3+n3Fp+}4te*Vu3oM0{%42O>O(@Jb^q(|fD*Ug^tq-ZG7+OhcD~R0%*x=a0OS zlFBJXLkfs1$;Mu2RpIYrO>xr2v($vAhf^6nOzx)5JMW;moH{ z{@;>}f4kpNtimP714OxotkTm8hyf5zYxe1_0)z$(Xh3HbwP*-+#WLZ{aA))U{)rP? zJHnw_!jrT)LvAAEr9)9Kf6IcdNB1uG;a-O;gjfa0>^EBdPIovgCVCsQGh4HbvAEqC z08D<3B*UjL`ecx=%D`9XnDT3(Ccjr-GL zv@Ml#y2Caiju8o~D~8xZ5Gv{{0%#IYM=&YIda}v5KZ;Iax)T;x%w!M2R>A5FL71qwih4ANxgv=`q9KwX$cWAw(p!TXvtJJJm3r=~ z2Ce66NvMfpwG@i0T}X-lA;59f_gA|OIZjO|4AelQ|IS&$W6L7XZp^%VW8*?`^Y$ zd9C4qKWT9#Y-GwtCaqG+CFgZDxm`gNkC+@0$QIUHgBEAZO~$ig!p|kmjwnq>Rj|Lx zz*p+o3O!q)W6Csn4U!D9L`_I>kb2R-1r&OYvqUYGY%xIUP1K?$YDpIDB7rtrRSQ;C zkCgV<#@*Ri_3UKDeXa82U5XQvwP$v8yt=3JJEqC$LX zvsCf0Jz~O>CRfryCEcEci;UUusFTi6Yz^*;-#R*Q! z5Eju`f=Z)b0Sj_CP-XB{oDnVD3mH-T- z3302mc)8j>AV72cnii+>>wp?fQY9N4L+Z4%e{W%<6&Oeb*1V zi3I9OfYxX_oaxA95iD%52Gu4}iHLHezf#YaYnd_)U3fB#SGkJvB%R)@%acyT8W!|)?3q9L=0*LJo7)AUfn;l3p6Y@2&L?** ztO?g7#F}H<7k1aT>TO97jp?w69*qvPH+9xGz-SP{LTW^i=i6(AjxE#VBcOJVSGtM{ zwOpK^(xh<`BnktT!a%EyUO&m$QCg-Usz7I@3aeCEC$ol0G;r4}@A>8SZw~r?c&zc8 zCnx2;So`o&ag-^kA>eY5&>@Tw3t~i)V5>P_&BFffJD2p$?{2S2HO3?LG+)d3>nOes z_tvE1&AXP)tWPy|H%?eNGz}-SkTYW=vpOthcE(&peB<0{&8b>57Bpfa0Q;op^dAE9 zk2XnwG%%^%rE0fiy_S;cXcgoIU?$?_;tZ!Uc;s553dH50qg07yaA<(j?4C@$yM_Md zaOlE~;=j(#%3a*@$g<#if0^L0$@C0j3!v_}o5==(^|!5CaBAPuzLu77w4U>47;lXC zhWuR2$0b;Af+S)n7D8-s1WzM)#_Z0(c&Z`W0y`3VECOSpmQ>Ac%V+rLFoKCN764$r zQpXg6Pzur|YNBYI(pjvMrlo=axt>x%9<`BS2w#6%Fq&C4H82b!}r4tG-L$Qqq#lQUr_l6H5@ zYzte^z=Gbk#eEa(c7gSVjaI)*nj;H|Mx(py9PRsQX?X5kIZx0FIJlOQ^Etya6>-z2O#b?(DhiWT54$^1~ zd)TxPtew-{wzRKH3}mNwHQsac!Vk|L`1!qie*4R+&bm;JEnbbeEH=! z_v9|$^Xq4OU%qQ-O@FE}o#ps!#21fop>#A|o5&EZ;JU%~j#R>C59p+MiB}kXRgkY- z$CQF}QL$72l7MoMkm+d_5G9B%AV9O%y5&>sB)Vcb)gYmQ(+AJy&{D8+zgB%-pvJe|=-cmxr?N-qZQ|1B*U9 zGxoO!W_Lsdvn|w?N^Tx%We5?qi+ZykKnjC~6ojI@``x9WW3Ewb04mFW210ytDJT* zHX_0Lt2wb@`Q-TD?`!+|Sl1U1jr{hHr`|iUYE{Cp)K|4sRM)$WoZaR|odiY*Hmn9! z4&&nmTiFzhiVt+9xC^=eD%w zfg$%Vk2k)%C-&i?#>;2=a(^8;x!AiiCMR(gaYXHOX4@3+w}&Dxu8qEVU{db4^Pj(R zaFnlHE>w0i28|XgmIfBOLQ6}pbvm}n;3X{BdZFyXa>tdE6Ti8??Yq}+etLeO*QEqa ze!D{?-Jyl5|gKGpr&*5KP?V(zsm4~@81`W3j% zqqc;%cDXKYcRjg~JG&*C`{(Vs?;g2rGPQy$o!~K!liRC6m)bxAkVg%9R0dk9BNbZr zcoktY*>10uf4GDF;r{f)!{qv)EatMQjGP%0AXIeV;f6@EHk4l2(XzC&xiyxy+C;gY zD=Jn=0!jgb^#~ZsR>xB7XHCTIe0?PT=(bs#2iw=q?4H`z(pjB3vVJftMxq|#K&$qt zC2a0=`_C`;{r&ESOGm47&o$pS*jZa+!Tbp7_fg{X zrrKMUP366j03Qo5LBQbglf;7)wdb}Za&OQ5*XiybAFjFlVEWe+**{IUPjo5-96K|| z^wc2w!gA)TL(zY|HkA8x-KFP7b|ir)s;ktv4JO85@f)!q;)s|WG1wl_nFSE>0Y*lp zBP!I+5kdFCjl!X6LiB4ptL6m?52@Zt^W+}rc7Jd*kB zR{pC)!CxMXALzFwjk3)X(07&@kIf`sUg7=zMAK)-CcJZK!mn>EI@)dYA$kDzz*Y`M z{ir+PVrw0qYO6bm*dqp1)LR5C%#?v-+5x||mfJqTZig)88d`1SK$D;`3GqODu%Y_q zk-ncldi>oJW9{)evn{NI1xY};hLRf?rIGhB;mOr;i(NG3-|lo)(TIg~7Ezjq?O~@g zz|(=wh<9rWe0;I*mlJhAJrH|gndAI=;`ax_w@kv4P}S*G8DrcT)Mk{kOkCQw*&XP$SBEX*kBXE*W=zA81$-WpOEg`!fxU7@9=iwy%G=0Yv8 zsMaj`!%aqTGH$d-ZSFYhO-^lUJg{QY{#n^Y^~|P}_Wlv-%01zK9u%ILgFZXkn0qKZ zHqn-ZW$)Y?`eY|@q+54(weaQ9^l#^9e)Cx8kAGQ{`+Vo8hZ_c}JrwHpc>}>vlI=TShcmku?C!y+ra;OQ))pHBvbY+JTrLHC41qtl+-v*5t0{?=G1 z!O*MYx}!tHWb5=sW+EdcV$#Zdn_LvsQv!gNf)*U ze>^|>&8zEv{`j_^-{0}n?Mt@Ko;b59k%`3Ov1}?;AB)v`xRl^aq(gBH;uk1pPdBbN zQfioov7iNyM}u`sdM0ieoHo6sJ0{f4Y^d>bNx&3T!9t~uD=G&`xfCg$XTmL!u$zdO z9bqRCchjksOw+EB{=-YAF6)~p`qLQ7>r9--Nvw(K?wRHK@{YvwD`W5Nj=i`>{L77r zv56R3Rk5?v{PuFo6VnX$b%8H0bbN8F?e9n0zI|%amk;%P{^*jQzdV-v_TcXy?fdej z{YQ3PKYvPlV>~MOVm@Drpkt^rWI%&;VWBa|%OC-Dgcv40xuJRW)SjODcAl>ld>MjH zb!IZ@U`k_=2JA{bS5%>)6cB5$_)*MX7ml%11SeuXF5OetvZjC1@<}}{=|&V!n4M9R zEdau-8*^<;>W&TJpN*04-JE)Q*m82dGxuoi_BMnDWxPdy;RgC2dpz%Nr%nz*&&)(V zJy@4}eefU0+ut7xUwLEA4}Tu|*Hd-xjS0WMzxDE)dr#fBX~E1Mh6>soBI%90={U{A z&3N4IiZR|)CS2W~ZSec5AvA*6A{ZXCI-`w|WM?W3p<#_#tTOPD02RbR7Jp2LzHnf{ zhT(38ip*?j-8a9#x1lW}*4W7;;*7%f5R3-YFiY6nThrS62W)@e?fGC6etJ1|eii=x zUV2LtoG>XBGJU&X{r*JPPsgG!%(vWJ53UI*9$OK;^3uF7jy8XLqVw{D)gSKS-rUpt z+2QoRo~ZxnM9<|j%U?dea_;m_mJG1o2*W1bWE3Z2I2kwDBRWe6u|--l^#ML*#KMF- zdguD#$8K5RA!AxgNM_)RDz#FpGMIfyG4}pr>-LTIjWpH1aLa<3Ev>XSjSevBv%GR?nxKoLd`>HCDAz3tv|bcZv23W6>}71YTHZ z`%@RREd$)%?|%R2#N4T#&kxqTvOebnEXr(3W~`=h_Tw&LZ-HuTh` z+zyVUV-9!3jERkj>RH_#0+-mebYS(2?itOEekyeQrp2$^vzn!%T1!v{@kJF{T47*x zCfCFhmqbMG+<04|^d~WyQcJ1+Lrq>o*USDeY zcn9^)2K=q%*!Cu<#R0-vq(W^gm7|Oa8JkT0Wn1vA^`2)&?1$U+JJRYWmwNwkDDmZ8 z_0J9JKe?m!n}eahY^0u?V|#Y4>Evwl#fJudy!gmXtA|r8%Xq^x+8STFZOPR7+QF{& zWz%}5)z`jx_sZ_-x{2xR{LU5x4FM)mX7Cl2Ybcq)t3h}(CZ_!1WFU3t=)lL1Y`c4O zpf8i5JqbS-cM?&XE9Aian2n3j?)!TTPtG)-TWGql+hcl>xX?X-zqi&CS-w=aHwE($CDc{i)q>-(>imt)2^;nfGt7zq8$Uc0>5h-8FyN z9(!ed;P`a(?55h>rw=`MXkDGaPiv}u{+4+wCO7X~JkXe|TQ$9VZvafSub#2g}@UsyQ;lmwF zmT^7QqdPUv{{DLGqfPi*H)n5eN8VU$xxLFY+o!d`Se1qZj9ztqB~u9!IuI2thM|Cd zZ=3PK0qp*%=EsMupN=t~?`GfKOnthO{%SA#_E`ARk@~OhZu?+g%Llhl%6)m__}=Ro zoQ(^y#k~_&4NkT@!=XUNVh&$ZE>sSZRXUFv=CmfEC6$^`Qx_4^ zJ=yxPk^Wnk4c|Ct%8K6BDK+Umb6R@RA-m1n9dtd`t9^b6{rXbNXLmKax9lbM3cBYyNRZ z;LH8Oxz)t^_2l1g@qV(|_V?ZVR}T*6zCE#Guobol3=^lR7=(sElL*_Rh&8xuXhuV} zRRxQRf`BsV28C8ajM~UISJw^qbeL@smQ4+}H$;UL%~lIyO(LA`&cvD{0mjX)YjwOj z4|!~axUeJg+zR3F1kKZf#w&+?j}F`Cb1Dad%5=2e#3QJPV4?-{qZo%;D5KsHc44Do z^&+NpSp;ats+#SJ)vd0xW3@j&T>tJ)|BK6Am+tWYbsKkK75d3K<6C|4< zxh%nDg+Mysk45-E4e$N)GRN6Pjt}nbdv>YkK&$S;M(>5S_TNvWADCg9?Uf0b34z^q z)a$@luZs_R_y(TpOVHc<*iY_^{d98TudmKOy)M#(R@P#Q6e3-@?wu9<@#OSRZxz41 zQ~bvR&2R3Ae{~@6-dgnX{q1A3LV$slMoBEBM?(-63k9kd^>@Np3^0de249)RQxM=$ z8hO1{#2lgJGbcrYwKgJwx|2>%h8Jrh(VApDn-)V&e){;v%=d?bAKo+Y(uU~qN$RI( z8b8=6{PV$PLFn+a|ks6GGKhTJzd{qWrEx3@*lFGt_IG4RfI-wP|)w+~Fb_}ua@Uf%loi5c(R z)%4i|6E8kK_|@s9-@mrzmyh=5zI{6P^#fPVUH`W`Cp^87JbXkzkT`i_kX?X z#hWMHF_qZTU|f%6qKicl8Cvde}LfYGWF@u?E`L1m86g zeQ416%v}8B5c13n-7|yGD+^684;yM+hVe4ATxq8ecz!_BY_0tBM*mm)e2)yE$0i%z zUu}MQ0rbcKIugSa2G-^bW3FU2lwRDPMV&E?B_xOZ6*^A3Rw-4xD>SrB&!}M^jEQz< zaKk|J^p<*xN!Qdg9a-OgZcp3O8ycP+wB6fSwVE$p%*z%9fB~<177xr5z+p}`$f|nW zicV|E!<#xkdSuCDLfPyDMgsclquLcA)dF5VNLJ2gtF~u><8#~>_Xo}`LiW^Za3fr< zaws%-r3#zI=?7@V4RwaUZ^K_6B_ADezPS=THOIKO-7+tR0+1JVhCOs*MNdsfIt^iQ z&>WQIFG))Qs;ESTmusjhJ)?kFo!Jjt1MS)DjdMDtwm09urfKs8d?Kl7L91F^+9Zy6 zQLDpbgAABnZv!+oC5S2kt4fVn0pOwCgSlT$-9DKz$UzzCRA}8w=`xSdLZk)3DU&5^ z(YDzX8?w4brXwq&I-SN+1-R5YvRH}N5%AimdU*sqx7c)H)N*o;<*jANZLQ!~t9dYJ zF(O_B3-;7hZ=2QPz@r8%qCx^y`CWl@wU`$`R%$(RJ)<&m(pYK>teo2Lr+Kk@%4#v# zbP%D^6O|gm08_YwGh1lF$><=-IEB^)=$u*=ynn@n-0x3*ac0*_Uxh|vRcdgxo>m*Y z0K}>cUb)^=4w7YnOQo@)T4-3%tV!!tpk1k>0mxepk_?94QVZM=S3f;u{_~Lj)!EQ< zgV3flu(#7P&4-vQtlbe_KfR?oQLD8?H0Gef=+9sAQYC6vQL)k~?NULiQtOfFy#UN= z%tCjFOw(?K(Jwc6kHkv(v#y->*|Lvujxv%bg zWoP?JzSLlFstuGD;UH@Ov4xGOXfXS9FeBj-T34AGR{*w2tkGm5RS>6vS-H;RLh)nW z`u(-)Cx`4W3_-6gGHt8Y?5fvoZGd|jL~ru9#^b%!89f@)S;HzsltaD>t+x~)OH{6+ zVwD6zlu7r}v`jBmGYB&#vqxw0Sx`>80)f4diEW9+j&2;}Nx$39?OEJA($Q$PvR0Jg zDE94xZ9hL!{qyUKo?IyGN>n*5PBrAs?;)d3GG!-H7Dv=z4nSsJ2Q#37lItkMM8g(d zgYbG22N+qegLq*CdA!eXYOeFOrIxW~-6BDCu+O(gYzh za1~llshTKJ;YHG3Rpl%N+~xVpIcepu^Hk_OO1;O5aTc3kbBY+|ADcIEY<@507Fj~v zyR?5KpL&oT4aQwPu4x`1LEwTn zjJv`*OGsl0swMaHm3pRJLzPOqd%S48!d5^E$^ds^wNW@SomXe#?Rdyd#ymuD%TULz zc|C4CR3FRSwPr@l7jfbOP5A!2C46y=`2P8+xwjU*wwk>#PjgeR%VOmiPdMO9hx}=g zPi4dDwoHSYO7U!xXA*8AhS-9T#gCwZ&F(juxwYNQ#Vz)Cw=ho(yLP6OTfn5eNB{r{ z5J^NqRFc5mQ!KNC`sp6E$LWEsq6YD6OcIMB-An6PX@g(DSqcKA{iF&n&0i2zXs8Me zRjH-r1_ng@G#zV7Lmox9$0*k`YKT=qoC@|SAx>^!D|Maso^ zQRBr*XF-6YMCmNe->?^~uhe>~^eo{Hw6($U)7sn$0Rbl)=6efHp{H;!z5c=O_&bB5+mn$lL+$qJ25 zDs6Ryt@a>}iwqs)ab{zG@|QDHemy(%{Mz)oK;_;p>%Fr*J%oB;05lpL6&hNpmoOv+ z#L5kfOwUwkX^APy=g)YBqbN^z*Ipk|iWEZqIwj55+}qYK zM%vwclZ~z;m>QZ%5oDC6!kjl0_wuV-#7i%&&waD+;*-nns8QY23>})~nc@ML2ueR@ z9|US259{1WvlG@*2iSJ$^^Cuk_f-qNR8)v(1K|kCE^bMD@yhMF+&e$MfArKW>yd8#jxNU} z8eA->lTNr;>5^&4Dy>ADNJlGWPB}=Hs@$blX*6e^*vfNG;}y1&d@1KQW%&pgSf4l2 zny5awwEw$jx1YOz`;tkWO|fiuw%W(VP&~Y`$G4@{IFr})@lYRUn89loir|hK?d`R) zGmH5j-@P}NJNe=1l}iV@gm4?lH_>dJpG}3iSfd~;Y7h^sn)3Dg59M+ffBopek2kaT z_aSTQ(5bX)jt>y+=3jhCpL#Y*RR2|2^^0^|}Sl;@%F z5|vcSdBTY{So{`y$mdNg>FYgx?B@3#+xhJFk^Qqf+Edl3VDyGoJnceFh)V%FD%Ex+ zfI?aeZ-%FGhOM!x2ii4n?45GutxdT%hCe)1f8Xlru{qV-=F}cs*YVPQqnAH@G?zP< zyYlQ$=eE7Jn%>i99F1s~2NZpb&J5W~0Ya`ND+}(|N&+O`14J1>l&W1NO3C_S2|1H2 zFB&Jm%1RVVtVDj@l>gOoNrP7f`?VI)UgXDT z2aK%B;8E#mg_f)UiBf=sV3M&|GN`s4*Uk$fYcuApwdPw|&_iwTp?cZoK=B|^(TZ!L zb_7H06#!v?X(vkSjkH|vkx7~3t(5YfE);GV;JTJPdGt@R*?CpKHDJXd1tGulE5sqs zn^BcIMsD=0O`^scHrd07Jz{sqBSJbIOeaDa+M6`lqo^|sSw)Shu&Q7I$O}Rqh2A6A zd)1H!feEwGff+G3Z1y0q$85sQHmljCHxe=}S+1eY7H@NaO?nv!VU*JJm9JWf<7D#I zOHETs9?AMVSflt47lrtU4lQT~q?GMU8nd1IB`wLxin@(Za)KCgAa>gn$8I^aukYEC{mz%qsL=xz4LJ zFg6>X=6wBWG3gUDFsFuCCB(|~jO2bP0$!=D=Sfn@q1RU5QO~R8^2c2 z(z%8#N(%&;fmav>l_>~XLRw4E?1=JgqCK7Msjg|u)P=+voJ_#hpxYI8I7HkTU>wZK z>1~I%&c!XX-QhD^Sj5aBW{x02sZeZMQ>MQ|xnOW>mPAp1|Sw?LLk(BCx~`4NMz(0VI$Or=heAw>fgq-Y=kjU{Aq z#26;kkgS>4+rD_lq|xE&i)T(-G1Rwd*2FDSs$aWn`P=tzSUP3Goc4w-^QW$vJALiE z=_`gNjdV99Ln*T@q=Ex#*r&>m2@*yi{gu4+61ha?M}`71DwS_O^$X0dJ&!A1h5r86 zxfX;1GAosY7n9{c3YagGNDXOk<5gScKs+wV9@w6FYaU7`}JY$e)&U zeDVC)*QalMc-zSKSsnY<%^B>PkWJJ(-7(N4su59*1XMkFoP%QbWvNR_II z{JijQ)bc8N{BJn=DmVK76V>@d!pHxIfNM2Pyj42hA>3vd-qo|<-Cm#2;YAH!DoPJlWLEdT%j07*qoM6N<$ Ef~>C`cmMzZ literal 0 HcmV?d00001 diff --git a/app/public/legacy/chrome.html b/app/public/legacy/chrome.html new file mode 100644 index 0000000..271a82c --- /dev/null +++ b/app/public/legacy/chrome.html @@ -0,0 +1,8 @@ + + +
+ \ No newline at end of file diff --git a/app/public/legacy/host.js b/app/public/legacy/host.js new file mode 100644 index 0000000..5d97934 --- /dev/null +++ b/app/public/legacy/host.js @@ -0,0 +1,260 @@ +"use strict"; +// Generated by tools/extract-legacy.mjs: helpers copied from the desktop app.js + web additions. + +// ---- web additions (src/legacy/host-web.js) ---- +// The desktop app.js kept these as its own state; in the web version they come from the site. + +// The active "profile": the game folder opened on the Game Folder page (mounted at /game in the +// worker). profileSource() (copied below) turns it into the tools' default asset source. +var WS = null; +/** Called by the site when the game folder changes. kind: "packs" (has .iop) or "dir" (unpacked). */ +function setWebGameFolder(name, kind) { + WS = name ? { id: "web", name, gameDir: "/game", sourceKind: kind || "packs" } : null; +} + +// IOP Manager is not part of the web tools yet: no open sessions. +var IOP = { files: [], active: null, rows: [], cwd: "", profiles: [], index: null, expanded: new Set(), mode: "folders" }; + +/** Turn placeholders into SVG icons and wire data-cmd buttons (app.js did this once at load). */ +function hydrateLegacy(root) { + for (const n of root.querySelectorAll("[data-icon]")) n.replaceWith(Object.assign(icon(n.dataset.icon), { className: `ic ${n.className}`.trim() })); + for (const b of root.querySelectorAll("[data-cmd]")) b.addEventListener("click", () => $(b.dataset.cmd).onclick?.()); +} + + +// ---- app.js 6-103 ---- +const $ = (id) => document.getElementById(id); +const bridge = window.api; // (window.api itself is a read-only global from the preload) +const call = bridge.call; + +const el = (tag, props = {}, children = []) => { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (k === "class") n.className = v; + else if (k === "text") n.textContent = v; + else if (k === "style") n.style.cssText = v || ""; // CSSOM: allowed by the page's CSP, style="" isn't + else if (k.startsWith("on")) n.addEventListener(k.slice(2), v); + else if (v !== undefined && v !== null && v !== false) n.setAttribute(k, v === true ? "" : v); + } + for (const c of [].concat(children)) if (c != null) n.append(c); + return n; +}; +const size = (n) => (n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`); +const hex = (n) => (n >>> 0).toString(16).padStart(8, "0"); +const fmtDos = (date, time) => { + if (!date) return ""; + const y = 1980 + (date >> 9), m = (date >> 5) & 15, d = date & 31; + const hh = time >> 11, mm = (time >> 5) & 63; + return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; +}; +const MAX_ENTRIES = 65535; // client limit: ZipArchive without ZIP64, 16-bit entry index + +// ---------- toast / modal / task ---------- +function toast(msg, bad = false) { + const t = el("div", { class: `toast${bad ? " bad" : ""}`, text: msg, onclick: () => hide() }); + const hide = () => { + t.classList.add("out"); + setTimeout(() => t.remove(), 220); + }; + $("toast").append(t); + setTimeout(hide, bad ? 8000 : 4500); +} + +let modalSeq = 0; +function modal(title, body, buttons) { + return new Promise((resolve) => { + // a modal opened right after another closes: the old one's close timer must not hide it + const seq = ++modalSeq; + $("modal").classList.remove("closing"); + $("modal-title").textContent = title; + $("modal-body").replaceChildren(...[].concat(body).map((b) => (typeof b === "string" ? el("p", { text: b }) : b))); + $("modal-actions").replaceChildren( + ...buttons.map((b) => + el("button", { + class: `btn ${b.kind || ""}`, + text: b.label, + onclick: () => { + $("modal").classList.add("closing"); + setTimeout(() => { + if (seq !== modalSeq) return; + $("modal").hidden = true; + $("modal").classList.remove("closing"); + }, 160); + resolve(b.value); + }, + }), + ), + ); + $("modal").hidden = false; + $("modal-body").querySelector("input,select")?.focus(); + }); +} +const confirmBox = (title, text, ok = "OK", kind = "primary") => modal(title, text, [{ label: "Cancel", value: false }, { label: ok, kind, value: true }]); +async function prompt(title, label, value = "") { + const input = el("input", { type: "text", value, spellcheck: "false" }); + input.addEventListener("keydown", (e) => e.key === "Enter" && $("modal-actions").lastChild.click()); + const ok = await modal(title, [el("label", { text: label }), input], [{ label: "Cancel", value: false }, { label: "OK", kind: "primary", value: true }]); + return ok ? input.value.trim() : null; +} + +let taskSeq = 0; +const taskHandlers = new Map(); +bridge.onTask((p) => taskHandlers.get(p.taskId)?.(p)); + +/** Runs a main-process task with the progress bar; `label(p)` turns progress into text. */ +async function runTask(channel, args, { label = () => "Working…", onEvent } = {}) { + const id = `t${++taskSeq}`; + $("task").hidden = false; + $("task-text").textContent = "Starting…"; + $("task-bar").style.width = "0%"; + $("task-cancel").onclick = () => call("task:cancel", id); + taskHandlers.set(id, (p) => { + onEvent?.(p); + if (p.type === "log") return; + $("task-text").textContent = label(p); + if (p.total) $("task-bar").style.width = `${Math.min(100, (100 * (p.bytesTotal ? p.bytes / p.bytesTotal : p.done / p.total)).toFixed(1))}%`; + }); + try { + return await call(channel, id, ...args); + } finally { + taskHandlers.delete(id); + $("task").hidden = true; + } +} + +// ---- app.js 162-162 ---- +const baseName = (p) => p.replace(/\/$/, "").split("/").pop(); + +// ---- app.js 494-558 ---- +const ICONS = { + box: '', + pulse: '', + power: '', + upload: '', + gem: '', + rocket: '', + key: '', + plus: '', + more: '', + grid: '', + list: '', + up: '', + search: '', + download: '', + swap: '', + pencil: '', + trash: '', + fileplus: '', + folderplus: '', + folder: '', + external: '', + note: '', + apps: '', + shield: '', + zap: '', + lock: '', + refresh: '', + newfile: '', + import: '', + server: '', + gear: '', + image: '', + undo: '', + redo: '', + save: '', + minus: '', + fit: '', + draw: '', + copy: '', + cube: '', + play: '', + pause: '', + brush: '', + shirt: '', + bone: '', + target: '', + floor: '', + focus: '', + camera: '', + layout: '', + map: '', + scroll: '', + check: '', + edit: '', + grid: '', + undo: '', + redo: '', +}; +function icon(name) { + const i = document.createElement("i"); + i.className = "ic"; + i.innerHTML = `${ICONS[name] || ""}`; + return i; +} + +// ---- app.js 564-593 ---- +// ---------- menus (dropdowns + right-click) ---------- +function showMenu(items, { x, y, anchor } = {}) { + const m = $("menu"); + m.replaceChildren( + ...items.map((it) => + it === "-" + ? el("div", { class: "sep" }) + : el("button", { class: `item${it.danger ? " danger" : ""}`, disabled: !!it.disabled, onclick: () => (hideMenu(), it.run()) }, [icon(it.icon || "none"), el("span", { text: it.label }), it.hint ? el("kbd", { text: it.hint }) : null]), + ), + ); + m.hidden = false; + const r = anchor?.getBoundingClientRect(); + const w = m.offsetWidth; + const h = m.offsetHeight; + let left = anchor ? r.right - w : x; + let top = anchor ? r.bottom + 6 : y; + left = Math.max(8, Math.min(left, innerWidth - w - 8)); + if (top + h > innerHeight - 8) top = anchor ? r.top - h - 6 : innerHeight - h - 8; + m.style.left = `${left}px`; + m.style.top = `${top}px`; + replay(m, "open"); +} +function hideMenu() { + $("menu").hidden = true; +} +document.addEventListener("mousedown", (e) => !e.target.closest("#menu") && hideMenu()); +document.addEventListener("keydown", (e) => e.key === "Escape" && hideMenu()); +window.addEventListener("blur", hideMenu); + +const cmd = (id) => ({ disabled: $(id).disabled, run: () => $(id).onclick() }); + +// ---- app.js 818-823 ---- +/** Restarts a CSS animation class. */ +function replay(node, cls) { + node.classList.remove(cls); + void node.offsetWidth; + node.classList.add(cls); +} + +// ---- app.js 1051-1063 ---- +/** Editors with unsaved changes (their state lives in the window, a reload would drop it). */ +function unsavedWork() { + const out = []; + if (window.FXE && window.FXE.text !== window.FXE.saved) out.push("FX"); + if (window.MAP?.dirty) out.push("Map"); + if (window.WND?.dirty) out.push("Wnd"); + if (typeof IMS !== "undefined" && IMS.dirty) out.push("UI image sets"); // (imageset.js: a script-level const) + if (window.MDL?.dirtyItems?.().length) out.push("3D"); + if (window.ANS?.clip?.dirty) out.push("Anim"); + if (window.COS?.dirty) out.push("Costume"); + if (window.TEX?.dirty) out.push("Texture"); + return out; +} + +// ---- web overrides (src/legacy/host-web-after.js), loaded after the app.js copies ---- + +// Same as the desktop, but an unpacked game folder (no .iop) is a "dir" source, not "packs". +function profileSource() { + return WS?.gameDir ? { kind: WS.sourceKind === "dir" ? "dir" : "packs", folder: WS.gameDir, name: WS.name } : null; +} +function profileSourceOption() { + const s = profileSource(); + return s ? [{ value: s, title: `${WS.name} — game folder`, sub: s.kind === "dir" ? "unpacked files" : "its packs" }] : []; +} + diff --git a/app/public/legacy/js/anim-studio.js b/app/public/legacy/js/anim-studio.js new file mode 100644 index 0000000..b8a4653 --- /dev/null +++ b/app/public/legacy/js/anim-studio.js @@ -0,0 +1,1954 @@ +"use strict"; + +// Anim Studio (Anim tab): a character (model config or skeleton + meshes) and one animation clip to +// make or change — pose bones (every change keys the bone at the playhead), move / delete keys on +// the dope sheet, cut / join / speed / reverse / loop / mirror time ranges, retarget animations from +// other skeletons, and re-load .ani files Blender exports. The maths is in animkit.js; saves go +// through mdl:writeBack. + +(() => { + const K = AnimKit; + const { Q, V } = K; + const ANS = { + src: null, + rows: [], + kind: "model", + char: null, // { name, sk, skName, meshes: [] } + clip: null, // AnimKit clip + { where, source, dirty, file } + map: [], + time: 0, + playing: false, + speed: 1, + loop: true, + bone: -1, + keys: new Set(), // selected key objects + event: -1, + range: null, // [a, b] ms + tool: "view", + undo: [], + redo: [], + ghost: null, // { rig, info } of the last import / retarget + watch: null, // { file, mtime } + step: 1000 / 30, + }; + window.ANS = ANS; + let view = null; + let kit = null; + + const stem = (p) => p.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, ""); + const kindOf = (p) => { + const l = p.toLowerCase(); + if (l.endsWith(".msh")) return "msh"; + if (l.endsWith(".ani")) return "ani"; + if (l.endsWith(".skl")) return "skl"; + if (l.endsWith(".txt") && /(^|\/)model\//.test(l)) return "model"; + return null; + }; + const fmt = (ms) => `${(ms / 1000).toFixed(2)} s`; + const num = (v, d = 2) => (Math.abs(v) < 1e-9 ? 0 : v).toFixed(d); + const visible = () => $("view-ans").classList.contains("on"); + + function ensureView() { + if (view) return view; + try { + view = new GL3D.View($("ans-canvas")); + } catch (err) { + toast(`3D view unavailable: ${err.message}`, true); + return null; + } + kit = MeshKit.create(view, () => ANS.src); + ANS.view = view; + view.onPointerDown = pointerDown; + view.onCamera = () => requestAnimationFrame(drawOverlays); + return view; + } + function loading(on, text = "Loading…") { + $("ans-loading").hidden = !on; + $("ans-loading").querySelector("span").textContent = text; + } + + // ===================================================================== + // source + browser + // ===================================================================== + async function setSource(src, { quiet = false } = {}) { + try { + const d = await call("mdl:describe", src); + ANS.src = { ...src, label: d.label }; + } catch (err) { + return toast(err.message, true); + } + $("ans-src-label").textContent = ANS.src.kind === "dir" ? "Extracted folder" : ANS.src.kind === "packs" ? "Pack folder" : `Packs next to ${ANS.src.name}`; + $("ans-src").title = `Reading from ${ANS.src.label}`; + kit?.clearTextures(); + await loadList(); + if (!quiet) toast(`Reading from ${ANS.src.label}.`); + } + async function chooseSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const options = [ + ...profileSourceOption(), + ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), + { value: "packs", title: "A folder with .iop packs…", sub: "the client folder: map.iop, map2.iop, rs.iop…" }, + { value: "dir", title: "A folder of extracted files…", sub: "resource/skeleton, resource/animation, resource/mesh…" }, + ]; + let choice = options[0].value; + const list = el( + "div", + { class: "choice-list" }, + options.map((o, i) => + el("label", { class: "choice" }, [el("input", { type: "radio", name: "anssrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])]), + ), + ); + if (!(await modal("Where are the animations?", ["Skeletons, meshes and animations are spread over several packs. Pick the folder they are in.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false; + let src = choice; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); + if (!folder) return false; + src = { kind: choice, folder }; + } + await setSource(src); + return true; + } + $("ans-src").onclick = () => chooseSource(); + + const list = new VTable($("ans-list"), [{ key: 1, label: "Name", width: 200, grow: true, title: true }, { key: 2, label: "Pack", width: 70, cls: "mono dim" }], { multi: false, id: "ans" }); + list.keyOf = (r) => r[0]; + list.sortBy(1); + async function loadList() { + if (!ANS.src) return; + loading(true, "Reading the pack index…"); + try { + const rows = await call("mdl:list", ANS.src); + ANS.rows = rows.map(([k, path, pack]) => { + const kind = kindOf(path); + return [k, kind === "model" ? path.replace(/^.*?model\//i, "").replace(/\.txt$/i, "") : stem(path), pack || path.split("/").slice(0, -1).join("/"), kind, path]; + }); + } catch (err) { + toast(err.message, true); + ANS.rows = []; + } finally { + loading(false); + } + filterList(); + } + function filterList() { + const q = $("ans-search").value.trim().toLowerCase(); + const rows = ANS.rows.filter((r) => r[3] === ANS.kind && (!q || r[1].toLowerCase().includes(q))); + list.setRows(rows); + $("ans-count").textContent = rows.length.toLocaleString(); + } + let searchTimer = 0; + $("ans-search").addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(filterList, 120); + }); + for (const b of $("ans-kinds").children) + b.onclick = () => { + ANS.kind = b.dataset.kind; + for (const x of $("ans-kinds").children) x.classList.toggle("on", x === b); + filterList(); + }; + list.onOpen = (r) => openRow(r); + $("ans-list").addEventListener("contextmenu", (e) => { + const rowEl = e.target.closest(".vt-row"); + if (!rowEl) return; + e.preventDefault(); + const i = Number(rowEl.dataset.i); + const r = list.view[i]; + list.click(i, {}); + const items = []; + if (r[3] === "model" || r[3] === "skl") items.push({ label: "Use as the character", icon: "bone", run: () => openRow(r) }); + if (r[3] === "msh") items.push({ label: "Add to the character", icon: "plus", disabled: !ANS.char, run: () => openRow(r) }); + if (r[3] === "ani") { + items.push( + { label: "Open (edit this animation)", icon: "external", hint: "Double-click", run: () => openRow(r) }, + "-", + { label: "Append to the end of the clip", icon: "plus", disabled: !ANS.clip, run: () => joinRow(r, "append") }, + { label: "Insert at the playhead…", icon: "plus", disabled: !ANS.clip, run: () => joinRow(r, "insert") }, + { label: "Retarget onto this character…", icon: "swap", disabled: !ANS.char, run: () => retargetRow(r) }, + ); + } + items.push("-", { label: "Copy name", icon: "copy", run: () => navigator.clipboard.writeText(r[4].split("/").pop()) }); + showMenu(items, { x: e.clientX, y: e.clientY }); + }); + + async function readKey(k) { + const r = await call("mdl:readKey", ANS.src, k); + if (!r) throw new Error("Not found in the packs any more."); + return r; + } + const readNamed = (name, folder) => (ANS.src ? call("mdl:read", ANS.src, name, folder, false) : null); + + async function openRow(r) { + if (!r) return; + try { + if (r[3] === "model") await useModel(await readKey(r[0]), r[1]); + else if (r[3] === "skl") await useSkeleton(await readKey(r[0]), `${r[1]}.skl`); + else if (r[3] === "msh") await addMesh(await readKey(r[0]), r[1]); + else if (r[3] === "ani") await openAni(await readKey(r[0]), `${r[1]}.ani`); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + + // ===================================================================== + // character + // ===================================================================== + function newCharacter(name) { + if (!ensureView()) return null; + view.clear(); + ANS.char = { name, sk: null, skName: "", meshes: [] }; + ANS.bone = -1; + $("ans-empty").hidden = true; + return ANS.char; + } + async function prepareSkeleton(data, name) { + const sk = SkinKit.prepare(data); + if (data.linked) { + const h = await readNamed(data.linked, "skeleton"); + if (!h || !SkinKit.link(sk, LSF.parseSkl(h.bytes))) toast(`${name} links to ${data.linked}, which isn't in these packs (or has other bones); positions may be off.`, true); + } + sk.name = name; + return sk; + } + async function useSkeleton(hit, name, { keepMeshes = false } = {}) { + const old = ANS.char; + const c = keepMeshes && old ? old : newCharacter(stem(name)); + if (!c) return; + c.sk = await prepareSkeleton(LSF.parseSkl(hit.bytes), name); + c.skName = name; + for (const m of c.meshes) m.palette = SkinKit.palette(c.sk, m.data); + remap(); + frame(); + if (!ANS.clip) newClip("new_animation", { quiet: true }); + refresh(); + } + async function useModel(hit, name) { + loading(true, "Loading the model…"); + const cfg = LSF.parseModelCfg(LSF.decode(hit.bytes)); + if (!cfg.skeleton) throw new Error("This model has no skeleton (#SKELETON); it can't be animated."); + const sk = await readNamed(cfg.skeleton, "skeleton"); + if (!sk) throw new Error(`${cfg.skeleton} isn't in these packs.`); + const c = newCharacter(name); + c.sk = await prepareSkeleton(LSF.parseSkl(sk.bytes), cfg.skeleton); + c.skName = cfg.skeleton; + c.animList = cfg.animations; + const missing = []; + for (const n of cfg.meshes) { + const h = await readNamed(`${n}.msh`, "mesh"); + if (h) await addMesh(h, n, { quiet: true }); + else missing.push(n); + } + remap(); + frame(); + if (!ANS.clip && cfg.animations.length) { + const first = cfg.animations.find((a) => /delay/i.test(a)) || cfg.animations[0]; + const h = await readNamed(first, "animation"); + if (h) await openAni(h, first, { keepChar: true }); + } + if (!ANS.clip) newClip("new_animation", { quiet: true }); + if (missing.length) toast(`Not in these packs: ${missing.join(", ")}`, true); + refresh(); + } + async function addMesh(hit, name, { quiet = false } = {}) { + if (!ANS.char) throw new Error("Pick a character (model or skeleton) first."); + const m = await kit.loadMesh(hit, name); + m.palette = ANS.char.sk ? SkinKit.palette(ANS.char.sk, m.data) : null; + ANS.char.meshes.push(m); + kit.loadTextures(m).then(() => (view.dirty = true)); + if (!quiet) { + frame(); + refresh(); + } + return m; + } + /** human_man.skl (the heroes' skeleton) when nothing is loaded yet. */ + async function ensureCharacter() { + if (ANS.char?.sk) return true; + if (!ANS.src && !(await chooseSource())) return false; + const h = await readNamed("human_man.skl", "skeleton"); + if (!h) { + toast("Pick a character first: double-click a model or a skeleton (heroes use human_man.skl).", true); + return false; + } + await useSkeleton(h, "human_man.skl"); + return true; + } + + // ===================================================================== + // clip + // ===================================================================== + function remap() { + ANS.map = ANS.char?.sk && ANS.clip ? K.trackMap(ANS.char.sk, ANS.clip) : []; + } + function setClip(clip, { where = null, source = "", file = null, dirty = false } = {}) { + stop(); + ANS.clip = Object.assign(clip, { where, source, file, dirty }); + ANS.time = 0; + ANS.keys.clear(); + ANS.event = -1; + ANS.range = null; + ANS.undo = []; + ANS.redo = []; + ANS.watch = file ? { file, mtime: null } : null; + if (file) call("anim:mtime", file).then((t) => ANS.watch && (ANS.watch.mtime = t)); + remap(); + dope.fit(); + refresh(); + } + function newClip(name, { quiet = false } = {}) { + if (!ANS.char?.sk) return toast("Pick a character first (a model or a skeleton).", true); + setClip(K.emptyClip(name, 4002, 1000), { dirty: !quiet }); + } + $("ans-new").onclick = async () => { + if (!(await ensureCharacter()) || !(await confirmDiscard())) return; + const name = await prompt("New animation", "Name (saved as resource/animation/.ani)", "new_animation"); + if (!name) return; + newClip(name.replace(/\.ani$/i, "").trim()); + toast("Empty clip: click a bone, Rotate / Move and drag, or Key all to start from the rest pose."); + }; + + async function openAni(hit, name, { keepChar = false } = {}) { + if (!keepChar && !(await confirmDiscard())) return; + const data = LSF.parseAni(hit.bytes); + const clip = K.fromLsf(data, stem(name)); + const names = data.tracks.map((t) => t.name.text.toLowerCase()); + const fits = (sk) => sk && names.filter((n) => sk.byName.has(n)).length >= Math.max(1, names.length * 0.5); + if (!keepChar && !fits(ANS.char?.sk) && ANS.src) { + // a model that plays it, else the best skeleton + loading(true, "Finding a skeleton for this animation…"); + const used = await call("mdl:usedBy", ANS.src, { ani: `${stem(name)}.ani` }); + const modelRow = used.length && ANS.rows.find((x) => x[3] === "model" && x[4].toLowerCase().endsWith("/" + used[0])); + if (modelRow) { + ANS.clip = null; + await useModel(await readKey(modelRow[0]), modelRow[1]); + } else { + const c = await call("mdl:findSkeleton", ANS.src, { ani: `${stem(name)}.ani`, bones: names }); + const best = c.find((x) => /^human_man\.skl$/.test(x.name) && x.matched === x.total) || c[0]; + const sk = best && (await readNamed(best.name, "skeleton")); + if (sk) await useSkeleton(sk, best.name); + } + } + if (!ANS.char?.sk) return toast("No skeleton for this animation; pick a character first.", true); + setClip(clip, { where: hit.where, source: hit.source, file: hit.where?.kind === "file" ? hit.where.file : null }); + play(); + } + $("ans-open-file").onclick = async () => { + const file = await call("dialog:openExt", "Open an animation", "Animations", ["ani"]); + if (!file) return; + try { + if (!(await ensureCharacter())) return; + await openAni(await call("mdl:readFile", file), file.split(/[\\/]/).pop()); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + }; + + function anyDirty() { + return !!ANS.clip?.dirty; + } + async function confirmDiscard() { + if (!anyDirty()) return true; + return confirmBox("Unsaved changes", `${ANS.clip.name} has changes that aren't saved. Discard them?`, "Discard", "danger"); + } + + // ---------- undo ---------- + function snapshot() { + const { where, source, file, ...c } = ANS.clip; + return { clip: structuredClone(c), where, source, file, time: ANS.time }; + } + function pushUndo() { + if (!ANS.clip) return; + ANS.undo.push(snapshot()); + if (ANS.undo.length > 80) ANS.undo.shift(); + ANS.redo = []; + updateUndo(); + } + function restore(from, to) { + const s = from.pop(); + if (!s) return; + to.push(snapshot()); + ANS.clip = Object.assign(s.clip, { where: s.where, source: s.source, file: s.file }); + ANS.keys.clear(); + remap(); + refresh(); + } + function updateUndo() { + $("ans-undo").disabled = !ANS.undo.length; + $("ans-redo").disabled = !ANS.redo.length; + } + $("ans-undo").onclick = () => restore(ANS.undo, ANS.redo); + $("ans-redo").onclick = () => restore(ANS.redo, ANS.undo); + + /** After any edit of the clip. */ + function changed({ side = true } = {}) { + ANS.clip.dirty = true; + remap(); + applyPose(); + dope.draw(); + if (side) renderSide(); + else renderHead(); + } + + // ===================================================================== + // pose + drawing + // ===================================================================== + let pose = null; + function computePose() { + const sk = ANS.char?.sk; + return sk ? K.lsPose(sk, ANS.clip, ANS.map, ANS.time) : null; + } + function applyPose() { + if (!view || !ANS.char) return; + pose = computePose(); + if (pose) { + const sk = ANS.char.sk; + const skin = pose.wr.map((q, i) => SkinKit.M4.mul(sk.bst[i], SkinKit.M4.fromQT(q, pose.wp[i]))); + const show = toggled("ans-t-mesh"); + for (const m of ANS.char.meshes) { + view.setVisible(m.id, show && !m.hidden); + if (m.palette && show && !m.hidden) { + SkinKit.skin(m, m.palette, { skin }); + view.updateMesh(m.id, m.skinPos, m.skinNrm); + } + } + } + drawOverlays(); + renderClock(); + } + + const C = { bone: [0.96, 0.79, 0.35, 1], joint: [1, 0.9, 0.55, 1], root: [1, 0.42, 0.37, 1], sel: [0.35, 0.85, 1, 1], ghost: [0.45, 0.65, 1, 0.8], grid: [1, 1, 1, 0.16], axis: [1, 1, 1, 0.4] }; + const push = (a, p, c) => a.push(p[0], p[1], p[2], c[0], c[1], c[2], c[3]); + function drawOverlays() { + if (!view) return; + const labels = $("ans-labels"); + labels.replaceChildren(); + const sk = ANS.char?.sk; + if (sk && pose) { + const l = [], pts = [], sel = []; + sk.parent.forEach((pi, i) => { + push(i === ANS.bone ? sel : pts, pose.wp[i], pi < 0 ? C.root : i === ANS.bone ? C.sel : C.joint); + if (pi >= 0) { + const c = i === ANS.bone || pi === ANS.bone ? C.sel : C.bone; + push(l, pose.wp[pi], c); + push(l, pose.wp[i], c); + } + }); + const on = toggled("ans-t-bones"); + view.setOverlay("bones", new Float32Array(l), { depth: false }); + view.setOverlay("joints", new Float32Array(pts), { mode: "points", depth: false, point: 5 }); + view.setOverlay("sel", sel.length ? new Float32Array(sel) : null, { mode: "points", depth: false, point: 10 }); + view.showOverlay("bones", on); + view.showOverlay("joints", on); + view.showOverlay("sel", true); + if (ANS.bone >= 0) { + const sp = view.project(pose.wp[ANS.bone]); + if (sp) labels.append(el("span", { class: "mdl-tag", text: sk.data.bones[ANS.bone].name.text, style: `left:${sp[0]}px;top:${sp[1]}px` })); + } + } else { + for (const o of ["bones", "joints", "sel"]) view.setOverlay(o, null); + } + // the source skeleton of the last import / retarget, where it lands on this character + const g = ANS.ghost; + if (g && toggled("ans-t-ghost") && ANS.clip === g.clip) { + const S = g.rig.pose(ANS.time + g.start); + const at = (p) => V.add(g.info.restRoot, V.scale(V.sub(p, g.info.srcRoot), g.info.scale)); + const gl = []; + g.rig.parent.forEach((pi, i) => { + if (pi < 0 || /IK|全ての親|操作中心/.test(g.rig.names[i])) return; + push(gl, at(S.wp[pi]), C.ghost); + push(gl, at(S.wp[i]), C.ghost); + }); + view.setOverlay("ghost", new Float32Array(gl), { depth: false }); + } else view.setOverlay("ghost", null); + drawHitboxes(); + updateFace(); + view.dirty = true; + } + + function frame() { + const sk = ANS.char?.sk; + let min = [-50, 0, -50], max = [50, 150, 50]; + if (sk) { + const p = K.lsPose(sk, null, null, 0).wp; + min = [0, 1, 2].map((i) => Math.min(0, ...p.map((v) => v[i]))); + max = [0, 1, 2].map((i) => Math.max(...p.map((v) => v[i]))); + } + for (const m of ANS.char?.meshes || []) for (let i = 0; i < 3; i++) { + min[i] = Math.min(min[i], m.data.aabbMin[i]); + max[i] = Math.max(max[i], m.data.aabbMax[i]); + } + view.frame(min, max); + const r = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2], 40) * 1.6; + const step = 10 ** Math.floor(Math.log10(r / 4)); + const half = Math.ceil(r / step) * step; + const g = []; + for (let v = -half; v <= half + 1e-6; v += step) { + const c = Math.abs(v) < step / 2 ? C.axis : C.grid; + push(g, [v, 0, -half], c); + push(g, [v, 0, half], c); + push(g, [-half, 0, v], c); + push(g, [half, 0, v], c); + } + view.setOverlay("grid", new Float32Array(g)); + view.showOverlay("grid", toggled("ans-t-grid")); + } + + const toggled = (id) => $(id).classList.contains("on"); + for (const id of ["ans-t-mesh", "ans-t-bones", "ans-t-ghost", "ans-t-grid"]) + $(id).onclick = () => { + $(id).classList.toggle("on"); + view?.showOverlay("grid", toggled("ans-t-grid")); + applyPose(); + }; + $("ans-reset").onclick = () => view && frame(); + for (const b of $("ans-tools").children) b.onclick = () => setTool(b.dataset.tool); + function setTool(t) { + ANS.tool = t; + for (const b of $("ans-tools").children) b.classList.toggle("on", b.dataset.tool === t); + $("ans-canvas").classList.toggle("manip", t !== "view"); + } + + // ===================================================================== + // picking + manipulating bones in the view + // ===================================================================== + function pickBone(e) { + if (!pose || !view) return -1; + const b = $("ans-canvas").getBoundingClientRect(); + const x = e.clientX - b.left, y = e.clientY - b.top; + let best = -1, bd = 14; + pose.wp.forEach((p, i) => { + const s = view.project(p); + if (!s) return; + const d = Math.hypot(s[0] - x, s[1] - y); + if (d < bd) { + bd = d; + best = i; + } + }); + return best; + } + // a plain click (no drag) in View mode picks a bone + let clickAt = null; + $("ans-canvas").addEventListener("mousedown", (e) => (clickAt = e.button === 0 ? [e.clientX, e.clientY] : null)); + $("ans-canvas").addEventListener("mouseup", (e) => { + if (!clickAt || ANS.tool !== "view") return; + if (Math.hypot(e.clientX - clickAt[0], e.clientY - clickAt[1]) < 4) selectBone(pickBone(e)); + clickAt = null; + }); + + function pointerDown(e) { + if (ANS.tool === "view" || e.button !== 0 || !pose || !ANS.clip) return false; + const hit = pickBone(e); + if (hit >= 0 && hit !== ANS.bone) selectBone(hit); + if (ANS.bone < 0) return false; + startManip(e); + return true; + } + + function startManip(e) { + const sk = ANS.char.sk; + const b = ANS.bone; + const pi = sk.parent[b]; + const P = pose; + const Wp = pi >= 0 ? P.wr[pi] : Q.id(); + const W0 = P.wr[b]; + const pivot = P.wp[b]; + const p0 = V.scale(P.lp[b], 1 / sk.rate[b]); + const q0 = P.lr[b]; + const ax = view.cameraAxes(); + const scr = (p) => view.project(p) || [0, 0]; + const ps = scr(pivot); + const s = Math.max(5, view.cam.dist * 0.05); + // which way a positive rotation / move shows on screen (so dragging follows the mouse) + const front = V.sub(pivot, V.scale(ax.dir, s)); + const turn = (axis, a, p) => V.add(pivot, Q.rot(Q.axis(axis, a), V.sub(p, pivot))); + const sx = Math.sign(scr(turn(ax.up, 0.05, front))[0] - scr(front)[0]) || 1; + const sy = Math.sign(scr(turn(ax.right, 0.05, front))[1] - scr(front)[1]) || 1; + const side = V.add(pivot, V.scale(ax.right, s)); + const ang = (p) => Math.atan2(p[1] - ps[1], p[0] - ps[0]); + const st = Math.sign(ang(scr(turn(ax.dir, 0.05, side))) - ang(scr(side))) || 1; + const mr = Math.sign(scr(V.add(pivot, V.scale(ax.right, s)))[0] - ps[0]) || 1; + const mu = Math.sign(ps[1] - scr(V.add(pivot, V.scale(ax.up, s)))[1]) || 1; + const unit = (view.cam.dist * Math.tan(view.cam.fov / 2) * 2) / $("ans-canvas").clientHeight; + const rect = $("ans-canvas").getBoundingClientRect(); + const start = [e.clientX, e.clientY]; + const a0 = Math.atan2(e.clientY - rect.top - ps[1], e.clientX - rect.left - ps[0]); + const tr = trackOf(b, true); + pushUndo(); + stop(); + const t = Math.round(ANS.time); + const move = (ev) => { + const dx = ev.clientX - start[0], dy = ev.clientY - start[1]; + if (ANS.tool === "rotate") { + let D; + if (ev.ctrlKey) { + const a = Math.atan2(ev.clientY - rect.top - ps[1], ev.clientX - rect.left - ps[0]) - a0; + D = Q.axis(ax.dir, st * a); + } else D = Q.mul(Q.axis(ax.up, sx * dx * 0.01), Q.axis(ax.right, sy * dy * 0.01)); + K.setKey(tr, t, p0, Q.mul(Q.conj(Wp), Q.mul(D, W0))); + } else { + const d = V.add(V.scale(ax.right, mr * dx * unit), V.scale(ax.up, mu * -dy * unit)); + K.setKey(tr, t, V.add(p0, V.scale(Q.rot(Q.conj(Wp), d), 1 / sk.rate[b])), q0); + } + ANS.clip.dirty = true; + applyPose(); + dope.draw(); + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + changed(); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + } + + /** The clip's track of a bone (made when `create`). */ + function trackOf(b, create = false) { + const i = ANS.map.indexOf(b); + if (i >= 0) return ANS.clip.tracks[i]; + if (!create) return null; + const tr = { name: K.S(ANS.char.sk.data.bones[b].name.text), weight: 1, keys: [] }; + ANS.clip.tracks.push(tr); + remap(); + return tr; + } + function selectBone(b) { + ANS.bone = b; + applyPose(); + renderBone(); + dope.reveal(b); + dope.draw(); + } + + /** Keys bones at the playhead with their current pose. */ + function keyBones(bones) { + if (!ANS.clip || !pose) return; + pushUndo(); + const sk = ANS.char.sk; + for (const b of bones) K.setKey(trackOf(b, true), ANS.time, V.scale(pose.lp[b], 1 / sk.rate[b]), pose.lr[b]); + changed(); + } + $("ans-key").onclick = () => (ANS.bone >= 0 ? keyBones([ANS.bone]) : toast("Pick a bone first.", true)); + $("ans-key-all").onclick = () => ANS.char?.sk && keyBones(ANS.char.sk.data.bones.map((_, i) => i)); + function deleteKeys() { + if (!ANS.keys.size) return toast("Select keys on the dope sheet first (click, Shift-click, drag a box).", true); + pushUndo(); + for (const tr of ANS.clip.tracks) tr.keys = tr.keys.filter((k) => !ANS.keys.has(k)); + ANS.clip.tracks = ANS.clip.tracks.filter((t) => t.keys.length); + ANS.keys.clear(); + changed(); + } + $("ans-del-keys").onclick = deleteKeys; + + // ===================================================================== + // transport + // ===================================================================== + let raf = 0, last = 0; + function play() { + if (!ANS.clip || ANS.clip.total <= 0) return; + const [a, b] = ANS.range && ANS.loop ? ANS.range : [0, ANS.clip.total]; + if (ANS.time >= b) ANS.time = a; + ANS.playing = true; + $("ans-play").replaceChildren(icon("pause")); + last = performance.now(); + cancelAnimationFrame(raf); + soundEventsBetween(ANS.time - 1e-6, ANS.time); + const tick = (now) => { + if (!ANS.playing) return; + const [a, b] = ANS.range && ANS.loop ? ANS.range : [0, ANS.clip.total]; + const prev = ANS.time; + ANS.time += (now - last) * ANS.speed; + last = now; + if (ANS.time > b) { + soundEventsBetween(prev, b); + if (ANS.loop) { + ANS.time = a + ((ANS.time - a) % Math.max(1, b - a)); + soundEventsBetween(a - 1e-6, ANS.time); + } else { + ANS.time = b; + stop(); + } + } else soundEventsBetween(prev, ANS.time); + if (visible()) { + applyPose(); + dope.draw(); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + } + function stop() { + ANS.playing = false; + cancelAnimationFrame(raf); + $("ans-play").replaceChildren(icon("play")); + } + function seek(t) { + ANS.time = Math.max(0, Math.min(ANS.clip?.total || 0, t)); + applyPose(); + dope.draw(); + renderBone(); + } + $("ans-play").onclick = () => (ANS.playing ? (stop(), renderBone()) : play()); + $("ans-speed").onchange = () => (ANS.speed = Number($("ans-speed").value) || 1); + $("ans-loop").onclick = () => { + ANS.loop = !ANS.loop; + $("ans-loop").classList.toggle("on", ANS.loop); + }; + function renderClock() { + const c = ANS.clip; + $("ans-clock").textContent = c ? `${fmt(ANS.time)} / ${fmt(c.total)} · f${Math.round(ANS.time / ANS.step)}` : ""; + $("ans-range-label").textContent = ANS.range ? `Range ${fmt(ANS.range[0])} – ${fmt(ANS.range[1])}` : "Shift-drag the ruler for a range"; + } + + // ===================================================================== + // dope sheet (canvas): ruler, events row, a row per bone (tree order) + // ===================================================================== + const dope = (() => { + const cv = $("ans-dope-canvas"); + const g = cv.getContext("2d"); + const RULER = 22, EVROW = 16, ROW = 18, NAMEW = 180; + let rows = []; // [{ bone, depth, track }] + let scroll = 0; + let v0 = 0, v1 = 1000; // visible time range + function build() { + const sk = ANS.char?.sk; + rows = []; + if (sk) { + const kids = sk.parent.map(() => []); + sk.parent.forEach((p, i) => p >= 0 && kids[p].push(i)); + const walk = (i, d) => { + rows.push({ bone: i, depth: d }); + kids[i].forEach((c) => walk(c, d + 1)); + }; + sk.parent.forEach((p, i) => p < 0 && walk(i, 0)); + } + // tracks for bones this skeleton doesn't have + ANS.map.forEach((b, ti) => b < 0 && rows.push({ bone: -1, depth: 0, ti })); + } + const W = () => cv.clientWidth, H = () => cv.clientHeight; + const tx = (t) => NAMEW + ((t - v0) / Math.max(1, v1 - v0)) * (W() - NAMEW - 12); + const xt = (x) => v0 + ((x - NAMEW) / Math.max(1, W() - NAMEW - 12)) * (v1 - v0); + const keysOfRow = (r) => (r.bone >= 0 ? trackOf(r.bone)?.keys : ANS.clip.tracks[r.ti]?.keys) || []; + function fit() { + v0 = 0; + v1 = Math.max(100, ANS.clip?.total || 1000); + scroll = 0; + build(); + } + function draw() { + const dpr = devicePixelRatio || 1; + if (cv.width !== Math.round(W() * dpr) || cv.height !== Math.round(H() * dpr)) { + cv.width = Math.round(W() * dpr); + cv.height = Math.round(H() * dpr); + } + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.clearRect(0, 0, W(), H()); + g.font = "11px Segoe UI, sans-serif"; + const c = ANS.clip; + if (!c) return; + if (rows.length === 0 || rows.filter((r) => r.bone < 0).length !== ANS.map.filter((b) => b < 0).length) build(); + // range shade + if (ANS.range) { + g.fillStyle = "rgba(233,180,60,0.08)"; + g.fillRect(tx(ANS.range[0]), 0, tx(ANS.range[1]) - tx(ANS.range[0]), H()); + } + // rows + const top = RULER + EVROW; + g.save(); + g.beginPath(); + g.rect(0, top, W(), H() - top); + g.clip(); + rows.forEach((r, i) => { + const y = top + i * ROW - scroll; + if (y < top - ROW || y > H()) return; + if (r.bone === ANS.bone && r.bone >= 0) { + g.fillStyle = "rgba(90,200,255,0.10)"; + g.fillRect(0, y, W(), ROW); + } else if (i % 2) { + g.fillStyle = "rgba(255,255,255,0.02)"; + g.fillRect(0, y, W(), ROW); + } + const name = r.bone >= 0 ? ANS.char.sk.data.bones[r.bone].name.text.replace(/^Bip01 /, "") : K.nameOf(c.tracks[r.ti].name); + const keys = keysOfRow(r); + g.fillStyle = r.bone < 0 ? "#ff8f84" : keys.length ? "#ecebef" : "#6f6b77"; + g.fillText(name, 6 + r.depth * 8, y + 13, NAMEW - 10 - r.depth * 8); + for (const k of keys) { + const x = tx(k.t); + if (x < NAMEW - 4 || x > W()) continue; + const on = ANS.keys.has(k); + g.fillStyle = on ? "#5ad2ff" : k.raw && k.rv === c.version ? "#e9b43c" : "#ffd98a"; + g.beginPath(); + g.moveTo(x, y + 4); + g.lineTo(x + 4, y + 9); + g.lineTo(x, y + 14); + g.lineTo(x - 4, y + 9); + g.fill(); + } + }); + g.restore(); + // name column edge + g.fillStyle = "rgba(255,255,255,0.07)"; + g.fillRect(NAMEW - 1, 0, 1, H()); + // ruler + g.fillStyle = "#16151a"; + g.fillRect(0, 0, W(), RULER + EVROW); + const span = v1 - v0; + const step = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 30000].find((s) => (s / span) * (W() - NAMEW) > 60) || 60000; + g.fillStyle = "#a6a2ad"; + g.strokeStyle = "rgba(255,255,255,0.12)"; + for (let t = Math.ceil(v0 / step) * step; t <= v1; t += step) { + const x = tx(t); + g.fillRect(x, RULER - 6, 1, 6); + g.fillText(step < 1000 ? `${(t / 1000).toFixed(2)}` : `${t / 1000}s`, x + 3, 12); + } + // end of clip + g.fillStyle = "rgba(255,107,94,0.5)"; + g.fillRect(tx(c.total), 0, 1, H()); + // events + g.fillStyle = "#6f6b77"; + g.fillText("events", 6, RULER + 12); + c.events.forEach((e, i) => { + const x = tx(e.time); + g.fillStyle = i === ANS.event ? "#5ad2ff" : "#ff6b5e"; + g.fillRect(x - 3, RULER + 3, 7, 10); + }); + // playhead + const px = tx(ANS.time); + g.fillStyle = "#e9b43c"; + g.fillRect(px - 0.5, 0, 1.5, H()); + g.beginPath(); + g.moveTo(px - 5, 0); + g.lineTo(px + 5, 0); + g.lineTo(px, 7); + g.fill(); + // box select + if (box) { + g.strokeStyle = "#5ad2ff"; + g.strokeRect(Math.min(box.x0, box.x1), Math.min(box.y0, box.y1), Math.abs(box.x1 - box.x0), Math.abs(box.y1 - box.y0)); + } + } + function reveal(b) { + const i = rows.findIndex((r) => r.bone === b); + if (i < 0) return; + const y = i * ROW; + const h = H() - RULER - EVROW; + if (y < scroll) scroll = y; + else if (y + ROW > scroll + h) scroll = y + ROW - h; + } + let box = null; + const pos = (e) => { + const b = cv.getBoundingClientRect(); + return [e.clientX - b.left, e.clientY - b.top]; + }; + const rowAt = (y) => rows[Math.floor((y - RULER - EVROW + scroll) / ROW)]; + function keyAt(x, y) { + const r = rowAt(y); + if (!r || y < RULER + EVROW) return null; + let best = null, bd = 6; + for (const k of keysOfRow(r)) { + const d = Math.abs(tx(k.t) - x); + if (d < bd) { + bd = d; + best = k; + } + } + return best; + } + cv.addEventListener("mousedown", (e) => { + if (!ANS.clip) return; + cv.focus(); + const [x, y] = pos(e); + const snap = (t) => Math.round(Math.max(0, Math.min(ANS.clip.total, t))); + // ruler: scrub, Shift = range + if (y < RULER) { + stop(); + const t0 = snap(xt(x)); + if (e.shiftKey) ANS.range = null; + const mv = (ev) => { + const t = snap(xt(pos(ev)[0])); + if (e.shiftKey) ANS.range = t === t0 ? null : [Math.min(t0, t), Math.max(t0, t)]; + else seek(t); + draw(); + renderClock(); + renderOps(); + }; + mv(e); + drag(mv); + return; + } + // events row: pick / move an event + if (y < RULER + EVROW) { + const i = ANS.clip.events.findIndex((ev) => Math.abs(tx(ev.time) - x) < 6); + ANS.event = i; + renderEvents(); + draw(); + if (i < 0) return seek(snap(xt(x))); + const t0 = ANS.clip.events[i].time; + let pushed = false; + drag((ev) => { + const t = snap(t0 + xt(pos(ev)[0]) - xt(x)); + if (t === ANS.clip.events[i].time) return; + if (!pushed) (pushUndo(), (pushed = true)); + ANS.clip.events[i].time = t; + ANS.clip.dirty = true; + draw(); + }, () => pushed && changed()); + return; + } + const r = rowAt(y); + if (x < NAMEW) { + if (r?.bone >= 0) selectBone(r.bone); + return; + } + const k = keyAt(x, y); + if (k) { + if (e.shiftKey) ANS.keys.has(k) ? ANS.keys.delete(k) : ANS.keys.add(k); + else if (!ANS.keys.has(k)) { + ANS.keys.clear(); + ANS.keys.add(k); + } + if (r?.bone >= 0 && r.bone !== ANS.bone) selectBone(r.bone); + draw(); + // drag the selected keys in time + const sel = [...ANS.keys].map((key) => [key, key.t]); + let pushed = false; + drag((ev) => { + const dt = Math.round(xt(pos(ev)[0]) - xt(x)); + if (!dt && !pushed) return; + if (!pushed) { + pushUndo(); + pushed = true; + } + for (const [key, t0] of sel) key.t = Math.max(0, Math.min(ANS.clip.total, t0 + dt)); + for (const tr of ANS.clip.tracks) K.sortKeys(tr); + ANS.clip.dirty = true; + applyPose(); + draw(); + }, () => { + if (!pushed) return seek(k.t); + // two keys on the same ms in a track: the moved one wins + for (const tr of ANS.clip.tracks) tr.keys = tr.keys.filter((a) => ANS.keys.has(a) || !tr.keys.some((b) => b !== a && ANS.keys.has(b) && Math.round(b.t) === Math.round(a.t))); + changed(); + }); + return; + } + // empty: playhead + bone, drag = box select + if (!e.shiftKey) ANS.keys.clear(); + if (r?.bone >= 0 && r.bone !== ANS.bone) selectBone(r.bone); + seek(snap(xt(x))); + box = { x0: x, y0: y, x1: x, y1: y }; + drag( + (ev) => { + [box.x1, box.y1] = pos(ev); + draw(); + }, + () => { + if (Math.abs(box.x1 - box.x0) > 3 || Math.abs(box.y1 - box.y0) > 3) { + const [xa, xb] = [Math.min(box.x0, box.x1), Math.max(box.x0, box.x1)]; + const [ya, yb] = [Math.min(box.y0, box.y1), Math.max(box.y0, box.y1)]; + rows.forEach((row, i) => { + const yy = RULER + EVROW + i * ROW - scroll + ROW / 2; + if (yy < ya || yy > yb) return; + for (const key of keysOfRow(row)) if (tx(key.t) >= xa && tx(key.t) <= xb) ANS.keys.add(key); + }); + } + box = null; + draw(); + }, + ); + }); + cv.addEventListener("dblclick", (e) => { + const [x, y] = pos(e); + const k = keyAt(x, y); + if (k) seek(k.t); + }); + cv.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + if (!ANS.clip) return; + if (e.ctrlKey) { + const [x] = pos(e); + const t = xt(x); + const f = e.deltaY < 0 ? 0.8 : 1.25; + v0 = Math.max(-50, t - (t - v0) * f); + v1 = Math.max(v0 + 50, t + (v1 - t) * f); + } else if (e.shiftKey) { + const d = ((v1 - v0) * Math.sign(e.deltaY)) / 10; + v0 += d; + v1 += d; + } else { + scroll = Math.max(0, Math.min(Math.max(0, rows.length * ROW - (H() - RULER - EVROW)), scroll + e.deltaY)); + } + draw(); + }, + { passive: false }, + ); + function drag(move, end) { + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + end?.(); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + } + new ResizeObserver(() => draw()).observe(cv.parentElement); + return { draw, fit, reveal, build, rows: () => rows }; + })(); + ANS.dope = dope; + + // ===================================================================== + // side panel + // ===================================================================== + function renderHead() { + const c = ANS.clip; + $("ans-title").textContent = c ? `${ANS.char?.name || ""} · ${c.name}.ani` : ""; + $("ans-dirty").hidden = !c?.dirty; + $("ans-save").disabled = !c; + $("ans-clip-name").textContent = c ? `${c.name}${c.dirty ? " •" : ""}` : ""; + $("ans-info").textContent = ANS.char?.sk ? `${ANS.char.skName} · ${ANS.char.sk.data.bones.length} bones${ANS.char.meshes.length ? ` · ${ANS.char.meshes.length} meshes` : ""}` : ""; + updateUndo(); + } + function renderSide() { + renderHead(); + renderChar(); + renderClip(); + renderBone(); + renderOps(); + renderEvents(); + renderHit(); + renderBlender(); + } + function refresh() { + dope.build(); + applyPose(); + dope.draw(); + renderSide(); + } + + const field = (label, input) => [el("label", { text: label }), input]; + const numInput = (value, onchange, { step = "any", w } = {}) => { + const i = el("input", { type: "number", step, value: String(value), onchange: () => onchange(Number(i.value)) }); + if (w) i.style.width = w; + return i; + }; + + // ---------- character: the meshes it wears ---------- + function removeMeshes(list) { + for (const m of list) { + view.removeMesh(m.id); + ANS.char.meshes.splice(ANS.char.meshes.indexOf(m), 1); + } + applyPose(); + renderSide(); + } + // ---------- character presets: a skeleton + the meshes worn, one click to dress again ---------- + function charPresets() { try { return JSON.parse(lsGet("ans-char-presets") || "[]"); } catch { return []; } } + function storePresets(list) { lsSet("ans-char-presets", JSON.stringify(list)); } + async function saveCharPreset() { + const c = ANS.char; + if (!c?.meshes.length) return toast("Dress the character first (double-click meshes).", true); + const guess = (c.meshes.find((m) => /body/i.test(m.name)) || c.meshes[0]).name.replace(/_bodyd*/i, "").replace(/_hw$/i, " (woman)"); + const name = await prompt("Save character preset", "Name", guess); + if (!name?.trim()) return; + const list = charPresets().filter((p) => p.name.toLowerCase() !== name.trim().toLowerCase()); + list.push({ name: name.trim(), skeleton: c.skName, meshes: c.meshes.map((m) => m.name), hidden: c.meshes.filter((m) => m.hidden).map((m) => m.name) }); + list.sort((a, b) => a.name.localeCompare(b.name)); + storePresets(list); + toast(`Saved preset ${name.trim()} (${c.meshes.length} meshes).`); + renderChar(); + } + async function loadCharPreset(p) { + try { + if (!ANS.src && !(await chooseSource())) return; + loading(true, `Dressing ${p.name}…`); + if (!ANS.char?.sk || ANS.char.skName.toLowerCase() !== p.skeleton.toLowerCase()) { + const h = await readNamed(p.skeleton, "skeleton"); + if (!h) throw new Error(`${p.skeleton} isn't in these packs.`); + await useSkeleton(h, p.skeleton); + } + if (ANS.char.meshes.length) removeMeshes(ANS.char.meshes.slice()); + const missing = []; + for (const n of p.meshes) { + const h = await readNamed(`${n}.msh`, "mesh"); + if (!h) { missing.push(n); continue; } + const m = await addMesh(h, n, { quiet: true }); + if ((p.hidden || []).includes(n)) m.hidden = true; + } + frame(); + refresh(); + applyPose(); + if (missing.length) toast(`Not in these packs: ${missing.join(", ")}`, true); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + ANS.loadCharPreset = (name) => { const p = charPresets().find((x) => x.name === name); return p ? loadCharPreset(p) : null; }; + ANS.charPresets = charPresets; + function presetRow() { + const list = charPresets(); + return el("div", { class: "ans-presets" }, [ + ...list.map((p) => el("span", { class: "ans-preset", title: `${p.skeleton}: ${p.meshes.join(", ")}` }, [ + el("button", { class: "ans-preset-load", text: p.name, onclick: () => loadCharPreset(p) }), + el("button", { class: "ans-preset-del", title: "Delete this preset", text: "×", onclick: async () => { + if (!(await confirmBox("Delete preset", `Delete the character preset "${p.name}"?`, "Delete", "danger"))) return; + storePresets(charPresets().filter((x) => x.name !== p.name)); + renderChar(); + } }), + ])), + ANS.char?.meshes.length ? el("button", { class: "iconbtn", title: "Save this skeleton + the meshes worn as a preset", onclick: saveCharPreset }, [icon("save"), "Save preset"]) : null, + ]); + } + + function renderChar() { + const box = $("ans-char"); + const c = ANS.char; + $("ans-char-name").textContent = c?.skName || ""; + if (!c) return box.replaceChildren(el("div", { class: "ans-note", text: "Double-click a model or a skeleton, or load a preset." }), presetRow()); + const rows = c.meshes.map((m) => + el("div", { class: "ans-mesh" }, [ + el("input", { type: "checkbox", checked: !m.hidden, title: "Show / hide", onchange: (e) => ((m.hidden = !e.target.checked), applyPose()) }), + el("span", { class: "mono grow", text: m.name, title: m.name }), + el("button", { class: "iconbtn only danger", title: "Take it off (unequip)", onclick: () => removeMeshes([m]) }, [icon("trash")]), + ]), + ); + box.replaceChildren( + ...(rows.length ? rows : [el("div", { class: "ans-note", text: "No meshes: bones only. Double-click meshes in the list (Meshes) to dress it." })]), + c.meshes.length ? el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", title: "Take every mesh off", onclick: () => removeMeshes(c.meshes.slice()) }, [icon("trash"), "Remove all"])]) : null, + presetRow(), + ); + } + + function renderClip() { + const box = $("ans-clip"); + const c = ANS.clip; + if (!c) return box.replaceChildren(el("div", { class: "ans-note", text: "No clip. Open an animation or start a New one." })); + const keys = c.tracks.reduce((s, t) => s + t.keys.length, 0); + const name = el("input", { type: "text", value: c.name, spellcheck: "false", onchange: () => { + const v = name.value.replace(/\.ani$/i, "").trim(); + if (!/^[\x21-\x7e]+$/.test(v)) return toast("Use ASCII letters, digits, _ and - (the client's file names).", true); + pushUndo(); + c.name = v; + changed(); + } }); + const ver = el("select", { onchange: () => { + pushUndo(); + c.version = Number(ver.value); + changed(); + toast(`Saved as ${LSF.ANI_VERSIONS[c.version]}: every rotation is written in the new format.`); + } }, Object.entries(LSF.ANI_VERSIONS).map(([v, t]) => el("option", { value: v, selected: Number(v) === c.version, text: `${v} — ${t}` }))); + let stretch = true; + const dur = numInput(c.total, (v) => { + if (!(v > 0)) return; + pushUndo(); + if (stretch && c.total > 0) K.speed(c, 0, c.total, c.total / v); + c.total = Math.round(v); + changed(); + dope.fit(); + dope.draw(); + }, { step: "1" }); + const fps = el("select", { onchange: () => ((ANS.step = 1000 / Number(fps.value)), renderClock()) }, [24, 30, 60].map((f) => el("option", { value: f, selected: Math.abs(1000 / f - ANS.step) < 0.01, text: `${f} fps` }))); + box.replaceChildren( + el("div", { class: "ans-grid" }, [ + ...field("Name", name), + ...field("Format", ver), + ...field("Length ms", el("div", { class: "ans-vec" }, [dur, el("label", { class: "ans-check", title: "Move the keys and events with the new length" }, [el("input", { type: "checkbox", checked: true, onchange: (e) => (stretch = e.target.checked) }), "stretch"])])), + ...field("Frame step", fps), + ]), + el("div", { class: "ans-note", text: `${c.tracks.length} tracks · ${keys.toLocaleString()} keys · gold keys keep their stored bytes, light ones are new or edited` }), + ); + } + + function renderBone() { + const box = $("ans-bone"); + const sk = ANS.char?.sk; + const b = ANS.bone; + $("ans-bone-name").textContent = sk && b >= 0 ? sk.data.bones[b].name.text : ""; + if (!sk || b < 0 || !pose || !ANS.clip) return box.replaceChildren(el("div", { class: "ans-note", text: "Click a joint in the view or a name on the dope sheet." })); + const tr = trackOf(b); + const lp = V.scale(pose.lp[b], 1 / sk.rate[b]); + const e = Q.toEuler(pose.lr[b]); + const setRot = (i, v) => { + const ne = e.slice(); + ne[i] = v; + pushUndo(); + K.setKey(trackOf(b, true), ANS.time, lp, Q.fromEuler(ne)); + changed(); + }; + const setPos = (i, v) => { + const np = lp.slice(); + np[i] = v; + pushUndo(); + K.setKey(trackOf(b, true), ANS.time, np, pose.lr[b]); + changed(); + }; + const parent = sk.parent[b] >= 0 ? sk.data.bones[sk.parent[b]].name.text : "—"; + const rest = sk.data.bones[b]; + box.replaceChildren( + el("div", { class: "ans-grid" }, [ + ...field("Parent", el("span", { class: "mono", text: parent })), + ...field("Rotate °", el("div", { class: "ans-vec" }, e.map((v, i) => numInput(num(v, 1), (x) => setRot(i, x), { step: "1" })))), + ...field("Position", el("div", { class: "ans-vec" }, lp.map((v, i) => numInput(num(v, 2), (x) => setPos(i, x), { step: "0.5" })))), + ...field("Weight", tr ? numInput(tr.weight, (v) => (pushUndo(), (tr.weight = v), changed()), { step: "0.1" }) : el("span", { class: "muted", text: "no track (rest pose)" })), + ]), + el("div", { class: "ans-row" }, [ + el("button", { class: "iconbtn", title: "Key this bone at the playhead (K)", onclick: () => keyBones([b]) }, [icon("plus"), "Key"]), + el("button", { class: "iconbtn", title: "Key the skeleton's rest pose for this bone", onclick: () => { + pushUndo(); + K.setKey(trackOf(b, true), ANS.time, V.scale(rest.localPos, 1 / sk.rate[b]), rest.localRot); + changed(); + } }, [icon("undo"), "Rest"]), + el("button", { class: "iconbtn", title: "Select this bone's keys", disabled: !tr, onclick: () => { + for (const k of tr.keys) ANS.keys.add(k); + dope.draw(); + } }, [icon("check"), "Keys"]), + el("button", { class: "iconbtn danger", title: "Remove this bone's track (it goes back to the rest pose)", disabled: !tr, onclick: () => { + pushUndo(); + ANS.clip.tracks.splice(ANS.clip.tracks.indexOf(tr), 1); + changed(); + } }, [icon("trash")]), + ]), + el("div", { class: "ans-note", text: `${tr ? `${tr.keys.length} keys` : "No keys yet"} · rotation is local (relative to the parent), XYZ degrees` }), + ); + } + + // ---------- cut & join ---------- + function renderOps() { + const box = $("ans-ops"); + const c = ANS.clip; + if (!c) return box.replaceChildren(); + const r = ANS.range || [0, c.total]; + const a = numInput(Math.round(r[0]), (v) => setRange(v, b.valueAsNumber), { step: "1", w: "80px" }); + const b = numInput(Math.round(r[1]), (v) => setRange(a.valueAsNumber, v), { step: "1", w: "80px" }); + const sp = numInput(2, () => {}, { step: "0.1", w: "60px" }); + const op = (label, ic, title, fn, need = true) => + el("button", { class: "iconbtn", title, disabled: need && !ANS.range, onclick: () => { + pushUndo(); + fn(...(ANS.range || [0, c.total])); + ANS.keys.clear(); + changed(); + dope.fit(); + dope.draw(); + } }, [icon(ic), label]); + box.replaceChildren( + el("div", { class: "ans-row" }, [el("span", { class: "muted", text: "Range ms" }), a, "–", b, el("button", { class: "iconbtn only", title: "Clear the range", onclick: () => setRange(null) }, [icon("minus")])]), + el("div", { class: "ans-row" }, [ + op("Trim", "fit", "Keep only the range", (x, y) => { + K.trim(c, x, y); + ANS.range = null; + ANS.time = 0; + }), + op("Cut", "trash", "Remove the range (the rest closes up)", (x, y) => { + K.cut(c, x, y); + ANS.range = null; + }), + op("Reverse", "swap", "Play the range backwards", (x, y) => K.reverse(c, x, y)), + ]), + el("div", { class: "ans-row" }, [ + el("span", { class: "muted", text: "Speed ×" }), + sp, + op("Apply", "play", "Faster (> 1) or slower (< 1); the range or the whole clip", (x, y) => { + if (!(sp.valueAsNumber > 0)) return; + K.speed(c, x, y, sp.valueAsNumber); + if (ANS.range) ANS.range = [x, x + (y - x) / sp.valueAsNumber]; + }, false), + ]), + el("div", { class: "ans-row" }, [ + op("Make loop", "refresh", "The last pose becomes the first one (no jump when it repeats)", () => K.makeLoop(c), false), + op("Mirror L↔R", "swap", "Mirror the whole clip left / right", () => K.mirror(c, ANS.char.sk, "x"), false), + ]), + el("div", { class: "ans-note", text: "Join: right-click an animation in the list → Append / Insert at the playhead. Retarget: right-click → Retarget onto this character." }), + ); + } + function setRange(x, y) { + if (x === null || !ANS.clip) ANS.range = null; + else { + const a = Math.max(0, Math.min(x, y)), b = Math.min(ANS.clip.total, Math.max(x, y)); + ANS.range = b > a ? [a, b] : null; + } + renderOps(); + renderClock(); + dope.draw(); + } + + async function joinRow(r, how) { + if (!ANS.clip) return; + try { + const hit = await readKey(r[0]); + const other = K.fromLsf(LSF.parseAni(hit.bytes), r[1]); + const gapIn = el("input", { type: "number", value: how === "append" ? "0" : "100", step: "1" }); + const at = how === "append" ? ANS.clip.total : Math.round(ANS.time); + const ok = await modal(`${how === "append" ? "Append" : "Insert"} ${r[1]}.ani`, [ + `${r[1]} (${fmt(other.total)}, ${other.tracks.length} tracks) goes in at ${fmt(at)}. Keys keep their stored bytes when both use the same format.`, + el("label", { class: "fld" }, [el("span", { text: "Blend time (ms) into and out of it — 0 = hard cut" }), gapIn]), + ], [{ label: "Cancel", value: false }, { label: how === "append" ? "Append" : "Insert", kind: "primary", value: true }]); + if (!ok) return; + pushUndo(); + const sk = ANS.char.sk; + const bind = (name) => { + const b = sk.byName.get(name.toLowerCase()); + return b === undefined ? null : { p: V.scale(sk.data.bones[b].localPos, 1 / sk.rate[b]), q: sk.data.bones[b].localRot }; + }; + if (other.version !== ANS.clip.version) toast(`${r[1]} is ${other.version}, this clip ${ANS.clip.version}: its rotations are re-encoded.`); + K.insert(ANS.clip, other, at, Math.max(0, gapIn.valueAsNumber || 0), bind); + changed(); + dope.fit(); + dope.draw(); + } catch (err) { + toast(err.message, true); + } + } + + // ---------- events ---------- + // ---------- event types (ioClientAniEventHandler / ioAniEventHandler / ioBaseChar) ---------- + var EVENT_TYPES = [ + ["auto_sound", "Sound: resource/wave/", "sound"], + ["voice_sound", "Character voice: path without _1 / _hw (the game adds the voice type and _hw for women)", "voice"], + ["motion_sound", "Footstep / motion sound (… 1 = the map's floor sound)", "sound"], + ["shop_sound", "Sound only in the shop / lobby preview", "sound"], + ["auto_effect", "Effect on the character: resource/effect/.txt", "effect"], + ["shop_effect", "Effect only in the shop / lobby preview", "effect"], + ["face_change", "Face expression: .dds (the game adds the face number and _hw)", "face"], + ["fire_s", "Hit starts: uses attack_type n (box: r_weapon r_hand l_foot … special )", "hit"], + ["fire_e", "Hit ends", ""], + ["trail_s", "Weapon trail on: …", ""], + ["trail_e", "Weapon trail off", ""], + ["auto_shake", "Camera shake", ""], + ["key_reserve", "Next input is taken from here (combo)", ""], + ["fire_sliding", "Sliding attack movement", ""], + ["tracking_e", "Stops turning to the target", ""], + ["hide_weapon", "Hide the weapon mesh", ""], ["show_weapon", "Show the weapon mesh", ""], + ["hide_helm", "Hide the helmet", ""], ["show_helm", "Show the helmet", ""], + ["hide_cloak", "Hide the cloak", ""], ["show_cloak", "Show the cloak", ""], + ["hide_armor", "Hide the armor", ""], ["show_armor", "Show the armor", ""], + ["hide_hair", "Hide the hair", ""], ["hide_mesh", "Hide a mesh", ""], ["show_mesh", "Show a mesh", ""], + ["scale_s", "Scale starts", ""], ["scale_e", "Scale ends", ""], + ["air_jump", "Air jump", ""], ["input_jump_s", "Jump input accepted", ""], ["input_dash_s", "Dash input accepted", ""], + ["cmd_action", "Command action", ""], ["cmd_end", "Command ends", ""], ["attack_end", "Attack ends", ""], ["end_animation", "Animation end", ""], + ]; + var EVENT_KIND = Object.fromEntries(EVENT_TYPES.map(([t, , k]) => [t, k])); + const eventTypeList = () => $("ans-ev-types") || document.body.appendChild(el("datalist", { id: "ans-ev-types" }, EVENT_TYPES.map(([t, d]) => el("option", { value: t, label: d })))); + const packNames = new Map(); // folder → Promise + function namesOf(folder) { + if (!ANS.src) return Promise.resolve([]); + const key = `${folder}|${ANS.src.folder || ANS.src.sessionId || ""}`; + if (!packNames.has(key)) packNames.set(key, call("mdl:names", ANS.src, folder).catch(() => [])); + return packNames.get(key); + } + /** Searchable list modal → the picked row (rows: [{ value, label, sub }]), with an optional ▶ per row. */ + async function listPicker(title, rows, { listen = null, extra = [], initial = "" } = {}) { + const search = el("input", { type: "search", placeholder: `Search ${rows.length.toLocaleString()}…`, spellcheck: "false", value: initial }); + const listBox = el("div", { class: "fx-resource-list" }); + let choice = null; + const fill = () => { + const words = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hits = rows.filter((r) => words.every((w) => r.value.toLowerCase().includes(w))).slice(0, 400); + listBox.replaceChildren(...hits.map((r) => el("div", { class: `ans-snd${choice === r ? " on" : ""}` }, [ + listen ? el("button", { class: "iconbtn only", title: "Listen / preview", onclick: () => listen(r) }, [icon("play")]) : null, + el("button", { class: `fx-resource${choice === r ? " on" : ""}`, text: r.label || r.value, title: r.sub || r.value, onclick: () => { choice = r; fill(); listen?.(r); }, ondblclick: () => { choice = r; $("modal-actions").lastChild.click(); } }), + ]))); + }; + search.oninput = fill; + fill(); + const ok = await modal(title, [search, ...extra, listBox], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]); + return ok ? choice : null; + } + async function pickPackSound(kind) { + const names = (await namesOf("wave")).filter((n) => /\.(wav|ogg)$/i.test(n) && !/^bgm\//i.test(n)); + let rows; + if (kind === "voice") { + // voice_sound names are written without the voice type / woman tail: voice/attack_22.wav + const base = new Map(); + for (const n of names) { const m = /^(.*?)_\d+(_hw)?(\.\w+)$/i.exec(n); if (m && /voice|_voice/i.test(n)) base.set(`${m[1]}${m[3]}`, (base.get(`${m[1]}${m[3]}`) || 0) + 1); } + rows = [...base].sort().map(([v, n]) => ({ value: v, sub: `${n} variants (_1, _1_hw …)` })); + } else rows = names.map((v) => ({ value: v })); + const r = await listPicker(kind === "voice" ? "Voice (resource/wave)" : "Sound (resource/wave)", rows, { listen: (x) => playSound(x.value, kind === "voice") }); + return r?.value || null; + } + async function pickPackEffect() { + const names = (await namesOf("effect")).filter((n) => /\.txt$/i.test(n)); + const r = await listPicker("Effect (resource/effect)", names.map((v) => ({ value: v }))); + return r?.value || null; + } + /** Face expression bases: textures (_hw).dds with several face numbers. */ + async function pickFace(current = "") { + const groups = new Map(); + for (const n of await namesOf("texture")) { + const m = /^([a-z_][\w-]*?)(\d*)(_hw)?\.dds$/i.exec(n.split("/").pop()); + if (!m || !/face/i.test(m[1]) || /\//.test(n)) continue; + const g = groups.get(m[1].toLowerCase()) || { base: `${m[1]}.dds`, n: 0 }; + g.n++; + groups.set(m[1].toLowerCase(), g); + } + const rows = [...groups.values()].filter((g) => g.n >= 3).sort((a, b) => a.base.localeCompare(b.base)).map((g) => ({ value: g.base, sub: `${g.n} face variants` })); + const [cur = "", curMs = "500"] = current.split(/\s+/); + const ms = el("input", { type: "number", value: curMs || 500, min: 0, step: 50, title: "How long the face stays (ms)" }); + const r = await listPicker("Face expression (face_change)", rows, { initial: cur.replace(/\.dds$/i, ""), extra: [el("label", { class: "fxl-row" }, ["Duration ms ", ms])], listen: (x) => previewFace(x.value, 1500) }); + return r ? `${r.value} ${Number(ms.value) || 500}` : null; + } + async function pickInfo(kind, current) { + if (kind === "sound" || kind === "voice") return pickPackSound(kind); + if (kind === "effect") return pickPackEffect(); + if (kind === "face") return pickFace(current); + if (kind === "hit") { + const r = await listPicker("Hit box (fire_s)", ["r_weapon", "l_weapon", "r_hand", "l_hand", "r_foot", "l_foot", "special"].map((v) => ({ value: v }))); + return r?.value || null; + } + return null; + } + async function addEventWith(type) { + const c = ANS.clip; + if (!c) return; + const info = await pickInfo(EVENT_KIND[type], ""); + if (info === null) return; + pushUndo(); + c.events.push({ type: K.S(type), sub: K.S(info), time: Math.round(ANS.time) }); + ANS.event = c.events.length - 1; + changed(); + } + + // ---------- face expression preview: face_XX(_hw) mesh, its color texture swapped like ioBaseChar::FaceChange ---------- + let faceShown = "", facePreviewUntil = 0, facePreviewName = ""; + function faceMesh() { return ANS.char?.meshes.find((m) => /^face_?\d*(_hw)?$/i.test(m.name) && !m.hidden); } + function faceTexName(base, m) { + const fm = /^face_?(\d*)(_hw)?$/i.exec(m.name), type = Number(fm[1]) || 1, woman = !!fm[2]; + const stem = base.replace(/\.dds$/i, ""); + const male = type > 1 ? `${stem}${type}.dds` : `${stem}.dds`; + return woman ? [male.replace(/\.dds$/i, "_hw.dds"), male] : [male]; + } + function previewFace(base, ms) { facePreviewName = base; facePreviewUntil = performance.now() + ms; updateFace(); setTimeout(updateFace, ms + 20); } + async function updateFace() { + const m = faceMesh(); + if (!m || !kit) return; + let want = ""; + if (facePreviewUntil > performance.now()) want = facePreviewName; + else for (const e of ANS.clip?.events || []) { + if (K.nameOf(e.type).toLowerCase() !== "face_change") continue; + const [base, d] = K.nameOf(e.sub).split(/\s+/); + if (ANS.time >= e.time && ANS.time < e.time + (Number(d) || 0)) want = base; + } + const key = want ? `${m.id}:${want}` : ""; + if (key === faceShown) return; + faceShown = key; + let tex = null; + if (want) for (const n of faceTexName(want, m)) { const t = await kit.texture(n); if (t.tex) { tex = t.tex; break; } } + if (faceShown !== key) return; + m.groups.forEach((g, gi) => view.setGroupTexture(m.id, gi, tex || g.texInfo?.tex || null)); + view.dirty = true; + } + + ANS.faceState = () => faceShown; + ANS.pickInfoFor = (kind) => pickInfo(kind, ""); + function renderEvents() { + const box = $("ans-events"); + const c = ANS.clip; + $("ans-ev-count").textContent = c ? String(c.events.length) : ""; + if (!c || !EVENT_TYPES) return box.replaceChildren(); + eventTypeList(); + const rows = c.events.map((e, i) => { + const t = el("input", { type: "number", step: "1", value: String(Math.round(e.time)), onchange: () => (pushUndo(), (e.time = Number(t.value) || 0), changed()) }); + const ascii = (v) => /^[\x20-\x7e]*$/.test(v) || (toast("Event texts must be ASCII.", true), false); + const type = K.nameOf(e.type), kind = EVENT_KIND[type.toLowerCase()] || ""; + const ty = el("input", { type: "text", value: type, list: "ans-ev-types", spellcheck: "false", title: EVENT_TYPES.find(([x]) => x === type.toLowerCase())?.[1] || "Event type (pick from the list or type one)", onchange: () => ascii(ty.value) && (pushUndo(), (e.type = K.S(ty.value.trim())), changed()) }); + const su = el("input", { type: "text", value: K.nameOf(e.sub), spellcheck: "false", onchange: () => ascii(su.value) && (pushUndo(), (e.sub = K.S(su.value)), changed()) }); + const pick = kind ? el("button", { class: "iconbtn only", title: "Pick from the packs", onclick: async () => { + const v = await pickInfo(kind, K.nameOf(e.sub)); + if (v === null) return; + pushUndo(); e.sub = K.S(v); changed(); + } }, [icon("search")]) : el("span"); + const isSound = SOUND_EVENT.test(type); + return el("div", { class: `ans-ev${i === ANS.event ? " on" : ""}` }, [ + t, ty, su, pick, + isSound ? el("button", { class: "iconbtn only", title: "Listen", onclick: async () => { if (!(await playSound(K.nameOf(e.sub), /voice/i.test(type)))) toast(`${K.nameOf(e.sub)}: not in the packs`, true); } }, [icon("play")]) + : kind === "face" ? el("button", { class: "iconbtn only", title: "Preview on the face mesh", onclick: () => { const [b, d] = K.nameOf(e.sub).split(/\s+/); previewFace(b, Math.max(800, Number(d) || 500)); } }, [icon("play")]) : el("span"), + el("button", { class: "iconbtn only", title: "Go to it", onclick: () => seek(e.time) }, [icon("target")]), + el("button", { class: "iconbtn only danger", title: "Remove", onclick: () => (pushUndo(), c.events.splice(i, 1), (ANS.event = -1), changed()) }, [icon("trash")]), + ]); + }); + const typeSel = el("select", { title: "Type of the new event" }, EVENT_TYPES.map(([t, d]) => el("option", { value: t, text: t, title: d }))); + typeSel.value = lsGet("ans-ev-type") || "auto_sound"; + box.replaceChildren( + ...rows, + el("div", { class: "ans-row" }, [ + typeSel, + el("button", { class: "iconbtn", title: "Add an event of this type at the playhead (sounds, effects, faces and hit boxes open a list from the packs)", onclick: async () => { + lsSet("ans-ev-type", typeSel.value); + if (EVENT_KIND[typeSel.value]) return addEventWith(typeSel.value); + pushUndo(); + c.events.push({ type: K.S(typeSel.value), sub: K.S(""), time: Math.round(ANS.time) }); + ANS.event = c.events.length - 1; + changed(); + } }, [icon("plus"), "Event at playhead"]), + ]), + el("div", { class: "ans-row" }, [ + el("button", { class: "iconbtn", title: "auto_sound from the packs (resource/wave)", onclick: () => addEventWith("auto_sound") }, [icon("play"), "Sound…"]), + el("button", { class: "iconbtn", title: "voice_sound from the packs", onclick: () => addEventWith("voice_sound") }, [icon("play"), "Voice…"]), + el("button", { class: "iconbtn", title: "auto_effect from the packs (resource/effect)", onclick: () => addEventWith("auto_effect") }, [icon("zap"), "Effect…"]), + el("button", { class: "iconbtn", title: "face_change: a face expression for some ms", onclick: () => addEventWith("face_change") }, [icon("image"), "Face…"]), + el("label", { class: "ans-check", title: "Play sound events (auto_sound, voice_sound, motion_sound …) during playback" }, [el("input", { type: "checkbox", checked: ANS.soundOn, onchange: (ev) => { ANS.soundOn = ev.target.checked; lsSet("ans-sound", ANS.soundOn ? "1" : "0"); } }), "Play sounds"]), + ]), + el("div", { class: "ans-note", text: "time ms · type · info. Sounds, voices, effects, faces and hit boxes: 🔍 picks from the packs, ▶ plays / previews. face_change swaps the face mesh's texture (face_26_hw → 26_hw.dds) while it lasts." }), + ); + } + + // ===================================================================== + // event sounds: preview while playing + // ===================================================================== + function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } } + function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} } + var SOUND_EVENT = /sound|dound/i; // var: renderEvents may run before this line + let actx = null; + const sndCache = new Map(); + ANS.soundOn = lsGet("ans-sound") !== "0"; + /** Event path → decoded audio from the packs' resource/wave/. */ + function soundBuffer(info, voice = false) { + const key = `${String(info || "").toLowerCase()}${voice ? "|v" : ""}`; + if (!key) return Promise.resolve(null); + if (!sndCache.has(key)) { + sndCache.set(key, (async () => { + let bytes = null; + const wpath = String(info).replace(/\\/g, "/").split(/\s+/)[0]; + if (!bytes && ANS.src) bytes = (await call("mdl:readPath", ANS.src, `wave/${wpath}`).catch(() => null))?.bytes; + // voice_sound: the game adds the voice type (_1) to the name + if (!bytes && ANS.src && voice) bytes = (await call("mdl:readPath", ANS.src, `wave/${wpath.replace(/(\.\w+)$/, "_1$1")}`).catch(() => null))?.bytes; + if (!bytes) return null; + actx ??= new AudioContext(); + return actx.decodeAudioData(bytes.slice().buffer).catch(() => null); + })()); + } + return sndCache.get(key); + } + async function playSound(info, voice = false) { + const b = await soundBuffer(info, voice); + if (!b) return false; + if (actx.state === "suspended") await actx.resume().catch(() => {}); + const s = actx.createBufferSource(); + s.buffer = b; + s.connect(actx.destination); + s.start(); + return true; + } + ANS.playSound = playSound; + /** Sound events whose time lies in (t0, t1] while playing. */ + function soundEventsBetween(t0, t1) { + if (!ANS.soundOn || !ANS.clip) return; + for (const e of ANS.clip.events) if (SOUND_EVENT.test(K.nameOf(e.type)) && e.time > t0 && e.time <= t1) playSound(K.nameOf(e.sub), /voice/i.test(K.nameOf(e.type))); + } + + // ===================================================================== + // hitboxes: fire_s / fire_e windows, the .cms boxes they use, the hero config's type counts + // (ioBaseChar::RefreshFireTimeListWithBase: the i-th fire_s uses attack_type{i}, lasts until the + // i-th fire_e; its info picks the box: l_hand r_hand l_foot r_foot l_weapon r_weapon special) + // ===================================================================== + var HIT_TYPES = ["l_hand", "r_hand", "l_foot", "r_foot", "l_weapon", "r_weapon", "none", "special"]; // AniCollisionType order + var HIT_COLORS = { r_weapon: [1, 0.25, 0.2], l_weapon: [1, 0.55, 0.2], r_hand: [1, 0.85, 0.2], l_hand: [0.95, 0.95, 0.35], r_foot: [0.35, 0.8, 1], l_foot: [0.55, 0.6, 1], special: [0.85, 0.4, 1], none: [0.8, 0.8, 0.8] }; + ANS.hit = { on: lsGet("ans-hit") !== "0", weapon: lsGet("ans-hit-cms") || "", scale: Number(lsGet("ans-hit-scale")) || 1, offset: Number(lsGet("ans-hit-offset")) || 0, hero: lsGet("ans-hit-hero") || "", check: null, groups: new Map() }; + /** CMS version 5500: character attack boxes [{ type, center, axis[3], ext[3], bone }] (ioAniCollisionBoxGrp::InitBoxList) */ + function parseAniCms(bytes) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (String.fromCharCode(bytes[0], bytes[1], bytes[2]) !== "CMS") throw new Error("Not a .cms file"); + const version = dv.getInt32(4, true); + if (version !== 5500) throw new Error(`.cms version ${version}: not an attack box group (5500)`); + let o = 12; + const f = () => { const v = dv.getFloat32(o, true); o += 4; return v; }; + const boxes = []; + for (let n = dv.getInt32(8, true); n-- > 0;) { + const type = HIT_TYPES[dv.getInt32(o, true)] || "none"; o += 4; + const center = [f(), f(), f()], axis = [[f(), f(), f()], [f(), f(), f()], [f(), f(), f()]], ext = [f(), f(), f()]; + const len = dv.getInt32(o, true); o += 4; + const bone = String.fromCharCode(...bytes.subarray(o, o + len)); o += len; + boxes.push({ type, center, axis, ext, bone }); + } + return boxes; + } + async function hitGroup(name) { + if (!name || !ANS.src) return null; + const key = name.toLowerCase(); + if (!ANS.hit.groups.has(key)) { + ANS.hit.groups.set(key, null); + try { + const h = await readNamed(name.replace(/^.*[\\/]/, ""), "mesh"); + ANS.hit.groups.set(key, h ? parseAniCms(h.bytes) : null); + if (!h) toast(`${name} is not in the packs`, true); + } catch (err) { toast(`${name}: ${err.message}`, true); } + drawOverlays(); + renderHit(); + } + return ANS.hit.groups.get(key); + } + /** The clip's hit windows: i-th fire_s with the i-th fire_e (times in the clip, ms). */ + function hitWindows() { + const ev = (ANS.clip?.events || []).map((e) => ({ t: e.time, type: K.nameOf(e.type).toLowerCase(), info: K.nameOf(e.sub).trim() })).sort((a, b) => a.t - b.t); + const starts = ev.filter((e) => e.type === "fire_s"), ends = ev.filter((e) => e.type === "fire_e"); + return starts.map((s, i) => { + const [kind = "", special = ""] = s.info.split(/\s+/); + const type = HIT_TYPES.includes(kind.toLowerCase()) ? kind.toLowerCase() : "none"; + return { i, start: s.t, end: ends[i] ? ends[i].t : s.t, hasEnd: !!ends[i], type, special, raw: s.info }; + }); + } + /** Box for a window like ioBaseChar: weapon .cms (scaled / offset) → character default .cms. */ + function hitBoxFor(type) { + const weapon = ANS.hit.groups.get(ANS.hit.weapon.toLowerCase()); + const w = type !== "special" && weapon?.find((b) => b.type === type); + if (w) return { box: w, scale: ANS.hit.scale, offset: ANS.hit.offset, from: ANS.hit.weapon }; + const def = ANS.hit.groups.get("collision_char_default.cms"); + const d = def?.find((b) => b.type === type); + return d ? { box: d, scale: 1, offset: 0, from: "collision_char_default.cms" } : null; + } + function drawHitboxes() { + const sk = ANS.char?.sk; + if (!view) return; + if (!ANS.hit?.on || !sk || !pose || !ANS.clip) return view.setOverlay("hitbox", null); + hitGroup("collision_char_default.cms"); + if (ANS.hit.weapon) hitGroup(ANS.hit.weapon); + const lines = []; + for (const w of hitWindows()) { + const hb = hitBoxFor(w.type); + if (!hb) continue; + const b = sk.byName.get(hb.box.bone.toLowerCase()); + if (b === undefined) continue; + const active = ANS.time >= w.start && ANS.time <= Math.max(w.end, w.start + 16); + const near = !active && ANS.time >= w.start - 150 && ANS.time <= w.end + 150; + if (!active && !near) continue; + const c = HIT_COLORS[w.type] || HIT_COLORS.none, col = [...c, active ? 1 : 0.28]; + const { center, axis, ext } = hb.box; + const e = ext.map((x) => x * hb.scale); + const cen = V.sub(center, V.scale(axis[2], hb.offset)); + // the box is in the model's bind space, like the mesh: moved by the bone's skinning matrix + // (ioBiped::GetBipedSpaceFullXform = biped space TM × full transform), row vectors as in D3D + const M = SkinKit.M4.mul(sk.bst[b], SkinKit.M4.fromQT(pose.wr[b], pose.wp[b])); + const xf = (p) => [0, 1, 2].map((c) => p[0] * M[c] + p[1] * M[4 + c] + p[2] * M[8 + c] + M[12 + c]); + const corner = (sx, sy, sz) => xf(V.add(cen, V.add(V.scale(axis[0], e[0] * sx), V.add(V.scale(axis[1], e[1] * sy), V.scale(axis[2], e[2] * sz))))); + const P = [-1, 1].flatMap((x) => [-1, 1].flatMap((y) => [-1, 1].map((z) => corner(x, y, z)))); + for (const [i, j] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) push(lines, P[i], col), push(lines, P[j], col); + if (active) { + const sp = view.project(corner(0, 0, 0)); + if (sp) $("ans-labels").append(el("span", { class: "mdl-tag ans-hit-tag", text: `hit ${w.i + 1} · ${w.type}`, style: `left:${sp[0]}px;top:${sp[1]}px` })); + } + } + view.setOverlay("hitbox", lines.length ? new Float32Array(lines) : null, { depth: false }); + } + /** Hero config entries that play this clip, with their attack type counts. */ + async function checkHero() { + const id = ANS.hit.hero.trim(); + if (!id || !ANS.clip || !ANS.src) return; + const want = `${ANS.clip.name}.ani`.toLowerCase(); + const found = []; + for (const kind of ["item", "skill", "extraitem", "powerup_item", "powerup_extraitem"]) { + const h = await call("mdl:readPath", ANS.src, `config/mercenary/${id}/${id}_${kind}.ini`).catch(() => null); + if (!h) continue; + let text = ""; + for (let i = 0; i < h.bytes.length; i += 8192) text += String.fromCharCode(...h.bytes.subarray(i, i + 8192)); + const doc = HeroIni.parse(text); + for (const s of doc.sections) { + const get = (k) => s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value.replace(/\s*;.*$/, "").trim(); + for (const f of s.fields) { + if (f.value.replace(/\s*;.*$/, "").trim().toLowerCase() !== want) continue; + const k = f.key, stem = k.replace(/_(motion|animation|ani)$/i, ""); + const countKey = [`${k}_type_count`, `${stem}_type_count`, /^skill_motion$/i.test(k) ? "attack_type_count" : "", /skill_motion$/i.test(k) ? k.replace(/skill_motion$/i, "attack_type_count") : ""].find((c) => c && get(c) !== undefined); + const rateKey = [`${k}_time_rate`, `${stem}_time_rate`, /skill_motion$/i.test(k) ? k.replace(/skill_motion$/i, "animation_time_rate") : "", "animation_time_rate"].find((c) => c && get(c) !== undefined); + const types = countKey ? Array.from({ length: Number(get(countKey)) || 0 }, (_, n) => get(countKey.replace(/_count$/i, `${n + 1}`)) || "?") : []; + found.push({ file: `${id}_${kind}.ini`, section: s.name, key: k, countKey, count: countKey ? Number(get(countKey)) || 0 : null, rate: rateKey ? Number.parseFloat(get(rateKey)) || 1 : 1, types, + cms: get("collision_mesh"), cmsScale: Number.parseFloat(get("collision_mesh_scale") || "") || null, cmsOffset: Number.parseFloat(get("collision_mesh_offset") || "") || 0 }); + } + } + } + ANS.hit.check = { id, clip: ANS.clip.name, found }; + // the weapon's own box group, as the game uses it + const withCms = found.find((x) => x.cms); + if (withCms && !ANS.hit.weapon) { ANS.hit.weapon = withCms.cms; ANS.hit.scale = withCms.cmsScale || 1; ANS.hit.offset = withCms.cmsOffset; } + renderHit(); + drawOverlays(); + } + async function pickCms() { + if (!ANS.src) return; + const names = (await call("mdl:names", ANS.src, "mesh").catch(() => [])).filter((n) => /\.cms$/i.test(n) && !/_c\.cms$|^\d|building|map|stage/i.test(n)); + const search = el("input", { type: "search", placeholder: "Search collision meshes (sword, collision_…)", spellcheck: "false" }); + const listBox = el("div", { class: "fx-resource-list" }); + let choice = null; + const fill = () => { + const q = search.value.trim().toLowerCase(); + listBox.replaceChildren(...names.filter((n) => !q || n.toLowerCase().includes(q)).slice(0, 300).map((n) => el("button", { class: `fx-resource${choice === n ? " on" : ""}`, text: n, onclick: () => { choice = n; fill(); }, ondblclick: () => { choice = n; $("modal-actions").lastChild.click(); } }))); + }; + search.oninput = fill; fill(); + if (!(await modal("Weapon collision mesh (collision_mesh)", [search, listBox], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }])) || !choice) return; + setHit({ weapon: choice.split("/").pop() }); + } + function setHit(p) { + Object.assign(ANS.hit, p); + lsSet("ans-hit", ANS.hit.on ? "1" : "0"); lsSet("ans-hit-cms", ANS.hit.weapon); lsSet("ans-hit-scale", String(ANS.hit.scale)); lsSet("ans-hit-offset", String(ANS.hit.offset)); lsSet("ans-hit-hero", ANS.hit.hero); + renderHit(); + drawOverlays(); + } + function addHitWindow(type) { + const c = ANS.clip; + if (!c) return; + pushUndo(); + const t = Math.round(ANS.time); + c.events.push({ type: K.S("fire_s"), sub: K.S(type), time: t }, { type: K.S("fire_e"), sub: K.S(""), time: Math.min(c.total, t + 80) }); + changed(); + } + function renderHit() { + const box = $("ans-hit"); + if (!box) return; + const c = ANS.clip; + if (!c) return box.replaceChildren(el("small", { class: "muted", text: "Open or import an animation." })); + const wins = hitWindows(); + const ev = c.events.map((e) => K.nameOf(e.type).toLowerCase()); + const nS = ev.filter((t) => t === "fire_s").length, nE = ev.filter((t) => t === "fire_e").length; + const typeSel = el("select", {}, ["r_weapon", "l_weapon", "r_hand", "l_hand", "r_foot", "l_foot", "special"].map((t) => el("option", { value: t, text: t }))); + const rows = wins.map((w) => { + const hb = hitBoxFor(w.type); + return el("div", { class: `ans-hit-row${ANS.time >= w.start && ANS.time <= w.end ? " on" : ""}` }, [ + el("b", { text: `hit ${w.i + 1}` }), + el("span", { class: "mono", text: `${w.start}–${w.end} ms` }), + el("span", { text: w.raw || "(no box: front of the body)" }), + el("span", { class: "muted", text: hb ? hb.from : w.type === "none" ? "" : "no such box" }), + el("button", { class: "iconbtn only", title: "Go to it", onclick: () => seek(w.start) }, [icon("target")]), + ]); + }); + const warn = []; + if (nS !== nE) warn.push(`${nS} fire_s but ${nE} fire_e: a fire_s without its fire_e hits for one frame only.`); + for (const w of wins) if (w.end < w.start) warn.push(`hit ${w.i + 1}: fire_e before fire_s.`); + const ch = ANS.hit.check; + const checkRows = ch && ch.clip === c.name ? (ch.found.length ? ch.found.map((x) => { + const ok = x.count === null ? null : x.count === nS; + return el("div", { class: `ans-hit-check ${ok === false ? "bad" : ok ? "ok" : ""}` }, [ + el("b", { text: ok === false ? "✗" : ok ? "✓" : "?" }), + el("span", { text: `${x.file} [${x.section}] ${x.key}` }), + el("span", { class: "muted", text: x.count === null ? "no type count found" : `${x.countKey} = ${x.count} (${x.types.join(", ")}) · ${nS} fire_s${x.rate !== 1 ? ` · time rate ×${x.rate}` : ""}` }), + ]); + }) : [el("small", { class: "muted", text: `No entry of hero ${ch.id} plays ${c.name}.ani.` })]) : []; + box.replaceChildren( + el("div", { class: "ans-row" }, [ + el("label", { class: "ans-check", title: "Draw the box each fire_s window uses (bright while it hits)" }, [el("input", { type: "checkbox", checked: ANS.hit.on, onchange: (e) => setHit({ on: e.target.checked }) }), "Show hitboxes"]), + typeSel, + el("button", { class: "iconbtn", title: "Add fire_s (this box) at the playhead and fire_e 80 ms later", onclick: () => addHitWindow(typeSel.value) }, [icon("plus"), "Hit window"]), + ]), + el("div", { class: "ans-grid" }, [ + ...field("Weapon .cms", el("div", { class: "ans-vec" }, [ + el("span", { class: "mono", text: ANS.hit.weapon || "none (body boxes)", title: "collision_mesh of the weapon item" }), + el("button", { class: "iconbtn", onclick: pickCms }, ["Pick…"]), + ANS.hit.weapon ? el("button", { class: "iconbtn only", title: "No weapon boxes", onclick: () => setHit({ weapon: "" }) }, [icon("minus")]) : null, + ])), + ...field("Scale", numInput(ANS.hit.scale, (v) => setHit({ scale: v > 0 ? v : 1 }), { step: "0.05" })), + ...field("Offset", numInput(ANS.hit.offset, (v) => setHit({ offset: v || 0 }), { step: "1" })), + ...field("Hero ID", el("div", { class: "ans-vec" }, [ + el("input", { type: "text", value: ANS.hit.hero, placeholder: "001", spellcheck: "false", onchange: (e) => setHit({ hero: e.target.value.trim() }) }), + el("button", { class: "iconbtn", title: "Find this animation in config/mercenary//*.ini and compare its attack type count with the fire_s count", onclick: checkHero }, ["Check"]), + ])), + ]), + ...rows, + ...warn.map((t) => el("div", { class: "ans-hit-check bad" }, [el("b", { text: "!" }), el("span", { text: t })])), + ...checkRows, + el("div", { class: "ans-note", text: "fire_s n uses attack_type n of the config entry that plays this animation; the counts must match or the attack never fires. Box: weapon .cms (× scale, offset) → collision_char_default.cms; an empty info uses the area in front of the body." }), + ); + } + + ANS.setSource = setSource; + ANS.openRow = (r) => openRow(r); + ANS.checkHero = () => checkHero(); + ANS.setHit = (p) => setHit(p); + ANS.hitWindows = () => hitWindows(); + ANS.selectBone = (b) => selectBone(b); + ANS.seek = (t) => (stop(), seek(t)); + ANS.bytes = () => LSF.writeAni(K.toLsf(ANS.clip)); + + // ---------- retarget another Lost Saga animation ---------- + async function retargetRow(r) { + try { + if (!(await confirmDiscard())) return; + loading(true, "Finding the animation's skeleton…"); + const hit = await readKey(r[0]); + const data = LSF.parseAni(hit.bytes); + const src = K.fromLsf(data, r[1]); + const c = await call("mdl:findSkeleton", ANS.src, { ani: `${r[1]}.ani`, bones: data.tracks.map((t) => t.name.text) }); + loading(false); + if (!c.length) throw new Error("No skeleton in these packs fits this animation."); + const pick = el("select", {}, c.slice(0, 15).map((x, i) => el("option", { value: i, text: `${x.name} (${x.matched}/${x.total} bones${x.fromModel ? ", used by a model" : ""})` }))); + const ok = await modal(`Retarget ${r[1]}.ani`, [`Played on its own skeleton, then moved onto ${ANS.char.skName} by bone names, with the rest poses aligned and the feet kept on the floor.`, el("label", { class: "fld" }, [el("span", { text: "Source skeleton" }), pick])], [{ label: "Cancel", value: false }, { label: "Retarget", kind: "primary", value: true }]); + if (!ok) return; + loading(true, "Retargeting…"); + const sh = await readNamed(c[Number(pick.value)].name, "skeleton"); + const ssk = await prepareSkeleton(LSF.parseSkl(sh.bytes), c[Number(pick.value)].name); + const rig = K.lsRig(ssk, src); + const { clip, info } = K.retarget(rig, ANS.char.sk, { name: `${r[1]}_rt`, version: data.version, fps: 30 }); + setClip(clip, { dirty: true }); + ANS.ghost = { rig, info, clip, start: 0 }; + toast(`Retargeted: ${info.mapped}/${info.bones} bones matched, scale ×${info.scale.toFixed(2)}.`); + play(); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + + // ===================================================================== + // Blender round trip: export .ani + .skl, watch an .ani for re-exports + // ===================================================================== + function renderBlender() { + const box = $("ans-blender"); + const w = ANS.watch; + box.replaceChildren( + el("div", { class: "ans-row" }, [ + el("button", { class: "iconbtn", title: "Write the clip (.ani) and the character's skeleton (.skl) into a folder for the io_scene_lostsaga add-on", disabled: !ANS.clip, onclick: exportForBlender }, [icon("download"), "Export for Blender…"]), + ]), + el("div", { class: "ans-row" }, [ + el("label", { class: "ans-check", title: "Reload the opened .ani file whenever it changes on disk (re-export from Blender)" }, [ + el("input", { type: "checkbox", checked: !!w?.on, disabled: !w, onchange: (e) => (ANS.watch.on = e.target.checked) }), + w ? `Reload ${w.file.split(/[\\/]/).pop()} on change` : "Open an .ani file to watch it", + ]), + ]), + el("div", { class: "ans-note", text: "In Blender: File → Import → Lost Saga the .skl, then the .ani on the armature; edit; Export Lost Saga (Action → .ani) over the same file. The tool reloads it here; Save puts it in the pack." }), + ); + } + async function exportForBlender() { + const folder = await call("dialog:folder", "Folder for the Blender files"); + if (!folder) return; + const files = [{ name: `${ANS.clip.name}.ani`, data: LSF.writeAni(K.toLsf(ANS.clip)) }]; + const sh = await readNamed(ANS.char.skName, "skeleton"); + if (sh) files.push({ name: ANS.char.skName, data: sh.bytes }); + await call("mdl:writeFiles", folder, files); + toast(`Wrote ${files.map((f) => f.name).join(" + ")} to ${folder}.`); + } + setInterval(async () => { + const w = ANS.watch; + if (!w?.on || !ANS.clip) return; + const t = await call("anim:mtime", w.file); + if (t === null || t === w.mtime) return; + w.mtime = t; + try { + const hit = await call("mdl:readFile", w.file); + const clip = K.fromLsf(LSF.parseAni(hit.bytes), stem(w.file)); + pushUndo(); + const time = ANS.time; + Object.assign(ANS.clip, clip, { dirty: false }); + ANS.time = Math.min(time, clip.total); + ANS.keys.clear(); + remap(); + refresh(); + toast(`Reloaded ${w.file.split(/[\\/]/).pop()}.`); + } catch (err) { + toast(`Reload failed: ${err.message}`, true); + } + }, 1200); + + // ===================================================================== + // save / export + // ===================================================================== + const bytes = () => LSF.writeAni(K.toLsf(ANS.clip)); + async function save(where, label) { + try { + await call("mdl:writeBack", where, bytes()); + ANS.clip.where = { ...where, create: undefined }; + ANS.clip.dirty = false; + if (where.kind === "file") { + ANS.watch = { file: where.file, mtime: await call("anim:mtime", where.file), on: ANS.watch?.on }; + } else kit?.clearTextures(); + toast(`Saved ${label}.`); + renderSide(); + if (where.kind === "pack") loadList(); + } catch (err) { + toast(err.message, true); + } + } + async function saveAsFile() { + const file = await call("dialog:saveAs", `${ANS.clip.name}.ani`, "ani"); + if (file) await save({ kind: "file", file }, `to ${file}`); + } + /** Into a pack as resource/animation/.ani (replacing an entry of that name). */ + async function saveIntoPack() { + const packs = [...new Set(ANS.rows.filter((r) => r[3] === "ani").map((r) => r[2]))]; + if (!packs.length) return toast("These packs have no animations to go next to; choose the game's packs first.", true); + let pick = packs.includes("rs.iop") ? "rs.iop" : packs[0]; + const sel = el("select", { onchange: () => (pick = sel.value) }, packs.map((p) => el("option", { value: p, selected: p === pick, text: p }))); + const name = `${ANS.clip.name}.ani`; + const exists = ANS.rows.find((r) => r[3] === "ani" && r[4].toLowerCase().endsWith(`/${name.toLowerCase()}`)); + const ok = await modal("Save into a pack", [ + `${name} goes into the pack as resource/animation/${name}.${exists ? ` It replaces the ${name} in ${exists[2]} if you pick that pack — the game loads it for every model that plays it.` : " It's a new file: add it to a model config's #ANIMATION_LIST or a hero / skill INI to use it in the game."}`, + el("label", { class: "fld" }, [el("span", { text: "Pack" }), sel]), + ], [{ label: "Cancel", value: false }, { label: "Save", kind: "primary", value: true }]); + if (!ok) return; + const row = ANS.rows.find((r) => r[3] === "ani" && r[2] === pick); + const hit = await readKey(row[0]); + if (hit.where?.kind !== "pack") return toast("That source isn't a pack (extracted folder): use Save as file.", true); + const dir = hit.where.entry.replace(/[^/\\]+$/, ""); + await save({ kind: "pack", file: hit.where.file, entry: `${dir}${name}`, create: true }, `into ${pick}`); + } + $("ans-save").onclick = (e) => { + if (!ANS.clip) return; + const w = ANS.clip.where; + const items = []; + if (w?.kind === "pack") items.push({ label: `Save into ${w.file.split(/[\\/]/).pop()} (${w.entry.split("/").pop()})`, icon: "box", run: () => confirmBox("Save into the pack", `Replace ${w.entry} in ${w.file.split(/[\\/]/).pop()}?`, "Save").then((ok) => ok && save(w, `into ${w.file.split(/[\\/]/).pop()}`)) }); + if (w?.kind === "file") items.push({ label: `Save ${w.file.split(/[\\/]/).pop()}`, icon: "save", run: () => save(w, `to ${w.file}`) }); + items.push({ label: `Save into a pack as ${ANS.clip.name}.ani…`, icon: "box", run: saveIntoPack }, { label: "Save as file…", icon: "download", run: saveAsFile }); + showMenu(items, { anchor: e.currentTarget }); + }; + $("ans-export").onclick = (e) => + showMenu( + [ + { label: "Save the clip as .ani file…", icon: "download", disabled: !ANS.clip, run: saveAsFile }, + { label: "Export for Blender (.ani + .skl)…", icon: "cube", disabled: !ANS.clip, run: exportForBlender }, + ], + { anchor: e.currentTarget }, + ); + + // ===================================================================== + // keys + // ===================================================================== + let clipboard = null; + document.addEventListener("keydown", (e) => { + if (!visible() || !ANS.clip) return; + if (e.target.closest?.("input, textarea, select") || !$("modal").hidden) return; + const k = e.key; + const ctrl = e.ctrlKey || e.metaKey; + if (ctrl && k.toLowerCase() === "z") restore(ANS.undo, ANS.redo); + else if (ctrl && k.toLowerCase() === "y") restore(ANS.redo, ANS.undo); + else if (ctrl && k.toLowerCase() === "c" && ANS.bone >= 0 && pose) clipboard = { p: V.scale(pose.lp[ANS.bone], 1 / ANS.char.sk.rate[ANS.bone]), q: pose.lr[ANS.bone] }; + else if (ctrl && k.toLowerCase() === "v" && ANS.bone >= 0 && clipboard) { + pushUndo(); + K.setKey(trackOf(ANS.bone, true), ANS.time, clipboard.p, clipboard.q); + changed(); + } else if (k === " ") ANS.playing ? (stop(), renderBone()) : play(); + else if (k === "ArrowLeft" || k === "ArrowRight") { + stop(); + const dir = k === "ArrowLeft" ? -1 : 1; + if (e.altKey) { + // previous / next key of the selected bone (or any) + const ts = (ANS.bone >= 0 && trackOf(ANS.bone) ? trackOf(ANS.bone).keys : ANS.clip.tracks.flatMap((t) => t.keys)).map((x) => x.t); + const t = dir < 0 ? Math.max(...ts.filter((x) => x < ANS.time - 0.5), 0) : Math.min(...ts.filter((x) => x > ANS.time + 0.5), ANS.clip.total); + seek(t); + } else seek(Math.round((ANS.time + dir * (e.shiftKey ? 10 : 1) * ANS.step) / 1) ); + } else if (k === "Home") seek(0); + else if (k === "End") seek(ANS.clip.total); + else if (k.toLowerCase() === "k") e.shiftKey ? $("ans-key-all").onclick() : $("ans-key").onclick(); + else if (k === "Delete") ANS.event >= 0 && !ANS.keys.size ? (pushUndo(), ANS.clip.events.splice(ANS.event, 1), (ANS.event = -1), changed()) : deleteKeys(); + else if (k.toLowerCase() === "r" && !ctrl) setTool("rotate"); + else if (k.toLowerCase() === "g" && !ctrl) setTool("move"); + else if (k === "Escape") setTool("view"); + else return; + e.preventDefault(); + }); + + // first visit: the profile's packs + new MutationObserver(async () => { + if (!visible() || ANS.src) return; + const s = profileSource(); + if (s) await setSource(s, { quiet: true }); + }).observe($("view-ans"), { attributes: true, attributeFilter: ["class"] }); + + window.addEventListener("beforeunload", (e) => { + if (anyDirty()) e.returnValue = ""; + }); + renderSide(); +})(); diff --git a/app/public/legacy/js/animkit.js b/app/public/legacy/js/animkit.js new file mode 100644 index 0000000..6b7321e --- /dev/null +++ b/app/public/legacy/js/animkit.js @@ -0,0 +1,754 @@ +"use strict"; + +// Anim Studio maths, no UI (also runs under node for tests): +// Q quaternions the io3DEngine way (x, y, z, w; world = qmul(parentWorld, local), qrot rotates) +// clips an editable .ani: tracks of keys { t (ms), p, q, raw, rv } — raw / rv are the stored rotation +// words and the .ani version they belong to, written back untouched while the key isn't edited +// retarget any rig → a Lost Saga skeleton: world rotations moved over with the rest poses aligned, +// the root scaled by leg length, the feet put where the source's feet are (two-bone IK) so +// they stay on the floor, then baked to keys and thinned +// ops trim / cut / speed / reverse / insert / loop / mirror on clips + +const AnimKit = (() => { + // ===================================================================== + // quaternions + vectors + // ===================================================================== + const Q = { + id: () => [0, 0, 0, 1], + mul: (a, b) => [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + ], + conj: (q) => [-q[0], -q[1], -q[2], q[3]], + norm(q) { + const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1; + return [q[0] / l, q[1] / l, q[2] / l, q[3] / l]; + }, + rot(q, v) { + const [x, y, z, w] = q; + const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0]; + const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux; + return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)]; + }, + axis(a, ang) { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + const s = Math.sin(ang / 2) / l; + return [a[0] * s, a[1] * s, a[2] * s, Math.cos(ang / 2)]; + }, + /** Shortest rotation taking direction u to direction v. */ + fromTo(u, v) { + const a = V.norm(u), b = V.norm(v); + const d = V.dot(a, b); + if (d > 1 - 1e-9) return [0, 0, 0, 1]; + if (d < -1 + 1e-9) { + const ax = Math.abs(a[0]) < 0.9 ? V.cross(a, [1, 0, 0]) : V.cross(a, [0, 1, 0]); + return Q.axis(ax, Math.PI); + } + const c = V.cross(a, b); + return Q.norm([c[0], c[1], c[2], 1 + d]); + }, + slerp(a, b, f) { + let [bx, by, bz, bw] = b; + let cos = a[0] * bx + a[1] * by + a[2] * bz + a[3] * bw; + if (cos < 0) { + cos = -cos; + bx = -bx; by = -by; bz = -bz; bw = -bw; + } + let s0, s1; + if (cos > 0.9995) { + s0 = 1 - f; + s1 = f; + } else { + const th = Math.acos(cos); + const sin = Math.sin(th); + s0 = Math.sin((1 - f) * th) / sin; + s1 = Math.sin(f * th) / sin; + } + return Q.norm([s0 * a[0] + s1 * bx, s0 * a[1] + s1 * by, s0 * a[2] + s1 * bz, s0 * a[3] + s1 * bw]); + }, + /** Angle between two rotations (radians). */ + angle(a, b) { + const d = Math.abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]); + return 2 * Math.acos(Math.min(1, d)); + }, + /** Rotation whose basis maps the rest frame (x, y, z) onto the current one (orthonormal). */ + fromBases(rest, cur) { + const m = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // row-major R, R·r_i = c_i + for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) m[j * 3 + k] += cur[i][j] * rest[i][k]; + return Q.fromMat(m); + }, + fromMat(m) { + const [m00, m01, m02, m10, m11, m12, m20, m21, m22] = m; + const tr = m00 + m11 + m22; + let q; + if (tr > 0) { + const s = Math.sqrt(tr + 1) * 2; + q = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4]; + } else if (m00 > m11 && m00 > m22) { + const s = Math.sqrt(1 + m00 - m11 - m22) * 2; + q = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s]; + } else if (m11 > m22) { + const s = Math.sqrt(1 + m11 - m00 - m22) * 2; + q = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s]; + } else { + const s = Math.sqrt(1 + m22 - m00 - m11) * 2; + q = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s]; + } + return Q.norm(q); + }, + /** XYZ Euler angles in degrees (q = Rz·Ry·Rx applied to column vectors) and back. */ + toEuler(q) { + const [x, y, z, w] = q; + const sx = 2 * (w * x + y * z), cx = 1 - 2 * (x * x + y * y); + const sy = Math.max(-1, Math.min(1, 2 * (w * y - z * x))); + const sz = 2 * (w * z + x * y), cz = 1 - 2 * (y * y + z * z); + const d = 180 / Math.PI; + return [Math.atan2(sx, cx) * d, Math.asin(sy) * d, Math.atan2(sz, cz) * d]; + }, + fromEuler(e) { + const r = Math.PI / 180; + return Q.mul(Q.axis([0, 0, 1], e[2] * r), Q.mul(Q.axis([0, 1, 0], e[1] * r), Q.axis([1, 0, 0], e[0] * r))); + }, + }; + const V = { + add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]], + sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]], + scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s], + dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2], + cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]], + len: (a) => Math.hypot(a[0], a[1], a[2]), + norm(a) { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; + }, + lerp: (a, b, f) => [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f], + }; + /** Orthonormal frame from a direction and a reference vector (the bend plane). */ + function frame(dir, ref) { + const x = V.norm(dir); + let z = V.cross(x, ref); + if (V.len(z) < 1e-6) z = V.cross(x, Math.abs(x[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]); + z = V.norm(z); + return [x, V.cross(z, x), z]; + } + + // ===================================================================== + // clips + // ===================================================================== + const S = (text) => ({ raw: null, text }); + const nameOf = (s) => (typeof s === "string" ? s : s.text); + + /** LSF.parseAni result → editable clip. */ + function fromLsf(a, name = "") { + const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4; + return { + name, + version: a.version, + total: a.total, + events: a.events.map((e) => ({ type: e.type, sub: e.sub, time: e.time })), + tracks: a.tracks.map((t) => { + const keys = []; + for (let k = 0; k < t.times.length; k++) + keys.push({ + t: t.times[k], + p: [t.trans[k * 3], t.trans[k * 3 + 1], t.trans[k * 3 + 2]], + q: [t.rot[k * 4], t.rot[k * 4 + 1], t.rot[k * 4 + 2], t.rot[k * 4 + 3]], + raw: Array.from(t.rawRot.subarray(k * words, k * words + words)), + rv: a.version, + }); + return { name: t.name, weight: t.weight, keys }; + }), + trailing: a.trailing, + }; + } + + /** Clip → the object LSF.writeAni writes (untouched keys keep their stored words). */ + function toLsf(c) { + const words = c.version === 4001 ? 1 : c.version === 4002 ? 2 : 4; + return { + version: c.version, + events: c.events.map((e) => ({ type: typeof e.type === "string" ? S(e.type) : e.type, sub: typeof e.sub === "string" ? S(e.sub) : e.sub, time: e.time })), + total: Math.max(0, Math.round(c.total)), + tracks: c.tracks.map((t) => { + const keys = t.keys.slice().sort((a, b) => a.t - b.t); + const n = keys.length; + const out = { name: typeof t.name === "string" ? S(t.name) : t.name, weight: t.weight, times: new Int32Array(n), trans: new Float32Array(n * 3), rot: new Float32Array(n * 4), rawRot: new Uint32Array(n * words) }; + keys.forEach((k, i) => { + out.times[i] = Math.round(k.t); + out.trans.set(k.p, i * 3); + out.rot.set(k.q, i * 4); + const raw = k.raw && k.rv === c.version && k.raw.length === words ? k.raw : LSF.encodeRot(c.version, k.q); + out.rawRot.set(raw, i * words); + }); + return out; + }), + trailing: c.trailing, + }; + } + + function emptyClip(name, version = 4002, total = 1000) { + return { name, version, total, events: [], tracks: [], trailing: undefined }; + } + const key = (t, p, q) => ({ t, p: p.slice(), q: Q.norm(q), raw: null, rv: 0 }); + const copyKey = (k, t = k.t) => ({ t, p: k.p.slice(), q: k.q.slice(), raw: k.raw ? k.raw.slice() : null, rv: k.rv }); + + /** Interpolated key value at t (ms): lerp position, slerp rotation, held at the ends. */ + function sample(keys, t) { + const n = keys.length; + if (!n) return null; + if (t <= keys[0].t) return { p: keys[0].p.slice(), q: keys[0].q.slice() }; + if (t >= keys[n - 1].t) return { p: keys[n - 1].p.slice(), q: keys[n - 1].q.slice() }; + let lo = 0, hi = n - 1; + while (hi - lo > 1) { + const m = (lo + hi) >> 1; + if (keys[m].t <= t) lo = m; + else hi = m; + } + const a = keys[lo], b = keys[hi]; + const f = b.t > a.t ? (t - a.t) / (b.t - a.t) : 1; + return { p: V.lerp(a.p, b.p, f), q: Q.slerp(a.q, b.q, f) }; + } + + /** Puts a key at t (replacing one within 0.5 ms). → the key */ + function setKey(track, t, p, q) { + t = Math.round(t); + let i = track.keys.findIndex((k) => Math.abs(k.t - t) < 0.5); + const k = key(t, p, q); + if (i >= 0) track.keys[i] = k; + else { + i = track.keys.findIndex((x) => x.t > t); + track.keys.splice(i < 0 ? track.keys.length : i, 0, k); + } + return k; + } + const sortKeys = (tr) => tr.keys.sort((a, b) => a.t - b.t); + const allTimes = (c) => [...new Set([0, Math.round(c.total), ...c.tracks.flatMap((t) => t.keys.map((k) => Math.round(k.t)))])].sort((a, b) => a - b); + + /** Drops keys that the neighbours already give (within the tolerances). Keeps first / last. */ + function thin(keys, rotTol = 0.15 * (Math.PI / 180), posTol = 0.02) { + if (keys.length <= 2) { + if (keys.length === 2 && Q.angle(keys[0].q, keys[1].q) < rotTol && V.len(V.sub(keys[0].p, keys[1].p)) < posTol) return [keys[0]]; + return keys; + } + const out = [keys[0]]; + let a = 0; + for (let i = 1; i < keys.length - 1; i++) { + // can a → i+1 stand in for every key between them? + const A = keys[a], B = keys[i + 1]; + let ok = true; + for (let j = a + 1; j <= i && ok; j++) { + const f = (keys[j].t - A.t) / Math.max(1e-6, B.t - A.t); + ok = Q.angle(Q.slerp(A.q, B.q, f), keys[j].q) < rotTol && V.len(V.sub(V.lerp(A.p, B.p, f), keys[j].p)) < posTol; + } + if (!ok) { + out.push(keys[i]); + a = i; + } + } + const last = keys[keys.length - 1]; + if (out.length === 1 && Q.angle(out[0].q, last.q) < rotTol && V.len(V.sub(out[0].p, last.p)) < posTol) return out; + out.push(last); + return out; + } + + // ===================================================================== + // Lost Saga skeleton posing (SkinKit.prepare / link'ed skeleton + a clip) + // ===================================================================== + function trackMap(sk, clip) { + return clip ? clip.tracks.map((t) => sk.byName.get(nameOf(t.name).toLowerCase()) ?? -1) : []; + } + /** Local position / rotation of every bone at t (track positions × the link rate, like the client). */ + function locals(sk, clip, map, t, override) { + const bones = sk.data.bones; + const lp = bones.map((b) => b.localPos.slice()); + const lr = bones.map((b) => b.localRot.slice()); + if (clip) + clip.tracks.forEach((tr, ti) => { + const bi = map[ti]; + if (bi < 0 || tr.weight <= 0) return; + const s = sample(tr.keys, t); + if (!s) return; + const r = sk.rate[bi]; + lp[bi] = [s.p[0] * r, s.p[1] * r, s.p[2] * r]; + lr[bi] = s.q; + }); + if (override) for (const [bi, o] of override) { + if (o.p) lp[bi] = V.scale(o.p, sk.rate[bi]); + if (o.q) lr[bi] = o.q; + } + return { lp, lr }; + } + function world(sk, lp, lr) { + const n = lp.length; + const wp = new Array(n), wr = new Array(n); + for (const i of sk.order) { + const pi = sk.parent[i]; + if (pi < 0) { + wr[i] = lr[i]; + wp[i] = lp[i]; + } else { + wr[i] = Q.mul(wr[pi], lr[i]); + wp[i] = V.add(wp[pi], Q.rot(wr[pi], lp[i])); + } + } + return { wp, wr }; + } + function lsPose(sk, clip, map, t, override) { + const l = locals(sk, clip, map, t, override); + return Object.assign(world(sk, l.lp, l.lr), l); + } + + /** A Lost Saga skeleton + clip as a retarget source. */ + function lsRig(sk, clip) { + const map = trackMap(sk, clip); + const rest = lsPose(sk, null, null, 0); + return { + kind: "ls", + names: sk.data.bones.map((b) => b.name.text), + parent: sk.parent, + restWp: rest.wp, + restWr: rest.wr, + restLp: sk.data.bones.map((b) => b.localPos.slice()), + total: clip.total, + times: allTimes(clip), + events: clip.events, + pose: (t) => lsPose(sk, clip, map, t), + }; + } + + /** Knee position of a two-bone chain from H reaching T (lengths a, b), bending towards pole. */ + function twoBone(H, T, a, b, pole) { + const d0 = V.sub(T, H); + let d = V.len(d0); + const u = d > 1e-6 ? V.scale(d0, 1 / d) : [0, -1, 0]; + d = Math.max(Math.abs(a - b) + 1e-4, Math.min(a + b - 1e-4, d)); + const cosA = (a * a + d * d - b * b) / (2 * a * d); + const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA)); + let nrm = V.sub(pole, V.scale(u, V.dot(pole, u))); + if (V.len(nrm) < 1e-6) nrm = Math.abs(u[1]) < 0.9 ? V.cross(u, [0, 1, 0]) : [0, 0, -1]; + nrm = V.norm(nrm); + return V.add(H, V.add(V.scale(u, a * cosA), V.scale(nrm, a * sinA))); + } + + // ===================================================================== + // retarget: rig → Lost Saga skeleton + // ===================================================================== + /** Which child a bone points at, for aligning rest poses (first match wins). */ + const AIM = [/ head$/, / neck$/, / spine\d?$/, / calf$/, / foot$/, / toe0$/, / forearm$/, / hand$/, / upperarm$/, / finger2$/, / finger\d1$/, / finger\d2$/]; + + /** + * Default bone map target bone index → source bone index (-1 = keeps its rest local rotation). + * Names are matched case-insensitively. + */ + function defaultMap(src, sk) { + const names = sk.data.bones.map((b) => b.name.text); + const by = new Map(src.names.map((n, i) => [n.toLowerCase(), i])); + return names.map((n) => by.get(n.toLowerCase()) ?? -1); + } + + function legChain(sk, side) { + const b = (n) => sk.byName.get(`bip01 ${side} ${n}`) ?? -1; + const c = { hip: b("thigh"), knee: b("calf"), ankle: b("foot"), toe: b("toe0") }; + return c.hip >= 0 && c.knee >= 0 && c.ankle >= 0 ? c : null; + } + + /** + * Bakes src onto the Lost Saga skeleton sk. opts: + * map target bone → source bone (defaultMap) + * root target root bone (Bip01) / its source bone (the map's) + * scale source → target units (default: leg length ratio) + * footIK put the ankles where the source's ankles are (no sliding, feet on the floor) + * ground (no foot IK) move the root each frame so the lowest foot is as high as the source's + * inPlace drop the root's horizontal travel + * start/end source range (ms), fps of the baked keys, version, tolerance (degrees) for thinning + * → { clip, info } + */ + function retarget(src, sk, opts = {}) { + const map = opts.map || defaultMap(src, sk); + const bones = sk.data.bones; + const n = bones.length; + const rest = lsPose(sk, null, null, 0); + const root = sk.byName.get("bip01") ?? sk.order[0]; + const rootSrc = map[root]; + if (rootSrc < 0) throw new Error("The root bone (Bip01) has no source bone."); + const legs = ["l", "r"].map((s) => legChain(sk, s)).filter(Boolean); + // scale: leg length (hip → ankle) of the two rest poses, else heights + let scale = opts.scale; + if (!scale) { + const lens = legs + .map((l) => [l, map[l.hip], map[l.knee], map[l.ankle]]) + .filter(([, a, b, c]) => a >= 0 && b >= 0 && c >= 0) + .map(([l, a, b, c]) => { + const tl = V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])) + V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee])); + const sl = V.len(V.sub(src.restWp[b], src.restWp[a])) + V.len(V.sub(src.restWp[c], src.restWp[b])); + return tl / sl; + }); + scale = lens.length ? lens.reduce((a, b) => a + b) / lens.length : rest.wp[root][1] / Math.max(1e-6, src.restWp[rootSrc][1]) || 1; + } + // rest alignment: A[b] turns the target's rest direction onto the source's rest direction + const A = new Array(n).fill(null); + const childs = bones.map(() => []); + sk.parent.forEach((p, i) => p >= 0 && childs[p].push(i)); + for (const b of sk.order) { + if (map[b] < 0) continue; + // feet stand flat in both rest poses: the same posture, nothing to align + if (/ (foot|toe\d*)$/.test(bones[b].name.text.toLowerCase())) { + A[b] = Q.id(); + continue; + } + let aim = -1; + for (const re of AIM) { + aim = childs[b].find((c) => map[c] >= 0 && re.test(bones[c].name.text.toLowerCase())) ?? -1; + if (aim >= 0) break; + } + if (aim >= 0) { + const dt = V.sub(rest.wp[aim], rest.wp[b]); + const ds = V.sub(src.restWp[map[aim]], src.restWp[map[b]]); + // the source's rest direction is in its own rest frame; compare them in world + if (V.len(dt) > 1e-4 && V.len(ds) > 1e-4) A[b] = Q.fromTo(dt, ds); + } + if (!A[b]) { + // finger tips, the toe, the head: the alignment of the nearest aligned parent + let p = sk.parent[b]; + while (p >= 0 && !A[p]) p = sk.parent[p]; + A[b] = p >= 0 && /finger|toe/.test(bones[b].name.text.toLowerCase()) ? A[p] : Q.id(); + } + } + if (opts.alignRoot === false) A[root] = Q.id(); + const restLR = bones.map((b) => b.localRot.slice()); + const restLP = bones.map((b) => b.localPos.slice()); + + // local positions: the skeleton's own, or (Lost Saga sources) the source's, scaled to the bone + const lpOf = (b, S) => { + const m = map[b]; + if (src.kind !== "ls" || m < 0 || !S.lp) return restLP[b]; + const l = V.len(src.restLp[m]); + return V.scale(S.lp[m], l > 1e-6 ? V.len(restLP[b]) / l : 1); + }; + /** Target world rotations + positions for a source pose; rootPos placed by the caller. */ + function solve(S, rootPos) { + const wr = new Array(n), wp = new Array(n); + for (const b of sk.order) { + const pi = sk.parent[b]; + const m = map[b]; + if (m >= 0) { + const delta = Q.mul(S.wr[m], Q.conj(src.restWr[m])); + wr[b] = Q.norm(Q.mul(delta, Q.mul(A[b], rest.wr[b]))); + } else wr[b] = pi < 0 ? restLR[b] : Q.mul(wr[pi], restLR[b]); + const lp = b === root ? rootPos : lpOf(b, S); + wp[b] = pi < 0 ? lp : V.add(wp[pi], Q.rot(wr[pi], lp)); + } + return { wr, wp, S }; + } + // where the target root sits for a source pose: its rest place + the scaled source travel, lifted + // so the aligned rest pose's feet stand where the target's rest feet do + const srcRest = { wr: src.restWr, wp: src.restWp }; + const aligned = solve(srcRest, rest.wp[root]); + const footBones = legs.map((l) => l.ankle); + const lift = footBones.length ? footBones.reduce((s, b) => s + rest.wp[b][1] - aligned.wp[b][1], 0) / footBones.length : 0; + const restRoot = V.add(rest.wp[root], [0, lift, 0]); + const restAligned = solve(srcRest, restRoot); + // the body moves with the source's body joint (rootPos) + const posSrc = src.rootPos >= 0 ? src.rootPos : rootSrc; + const place = (p) => V.add(restRoot, V.scale(V.sub(p, src.restWp[posSrc]), scale)); + // where each target ankle goes: the source ankle's travel from rest, scaled, from the aligned rest + const ikLegs = legs + .filter((l) => map[l.ankle] >= 0) + .map((l) => ({ ...l, a: V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])), b: V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee])), src: map[l.ankle], srcToe: l.toe >= 0 ? map[l.toe] : -1 })); + const footLift = (P, l, ref) => Math.min(P.wp[l.ankle][1] - ref.wp[l.ankle][1], l.toe >= 0 ? P.wp[l.toe][1] - ref.wp[l.toe][1] : Infinity); + + const start = Math.max(0, opts.start ?? 0); + const end = Math.min(src.total, opts.end ?? src.total); + const fps = opts.fps || 30; + const times = []; + for (let t = start; t < end - 1e-6; t += 1000 / fps) times.push(t); + times.push(end); + const keys = bones.map(() => []); + let dropped = 0, maxDrop = 0, jumped = 0; + const jump = opts.jump === "on" || opts.jump === true; + for (const ts of times) { + const S = src.pose(ts); + let rp = place(S.wp[posSrc]); + // jump: both source feet off the floor while the source body stays put → the body goes up + // by the lower ankle's height (the feet stay where they are, the legs unfold to standing) + // jumpScale > 1 also lifts the feet: the whole jump gets higher + let extraY = 0; + if (jump && ikLegs.length) { + const up = Math.min(...ikLegs.map((l) => S.wp[l.src][1] - src.restWp[l.src][1])); + const dz = 0.15; // source units: heels / tiptoes aren't a jump + if (up > dz) { + extraY = (up - dz) * scale * Math.max(0, (opts.jumpScale ?? 1) - 1); + rp = V.add(rp, [0, (up - dz) * scale + extraY, 0]); + jumped++; + } + } + // in place: the root's travel over the floor comes off the root and the feet alike + const travel = opts.inPlace ? [rp[0] - restRoot[0], 0, rp[2] - restRoot[2]] : [0, 0, 0]; + rp = V.sub(rp, travel); + let P = solve(S, rp); + if (opts.footIK !== false && ikLegs.length) { + const targets = ikLegs.map((l) => V.add(V.sub(V.add(restAligned.wp[l.ankle], V.scale(V.sub(S.wp[l.src], src.restWp[l.src]), scale)), travel), [0, extraY, 0])); + // a planted foot the leg can't reach: lower the body just enough (else the foot would hang); + // feet in the air (kicks) just get a straight leg + let drop = 0; + ikLegs.forEach((l, i) => { + if (targets[i][1] - restAligned.wp[l.ankle][1] > 0.6 * scale) return; + const d = V.sub(targets[i], P.wp[l.hip]); + const L = (l.a + l.b) * 0.998; + const h2 = d[0] * d[0] + d[2] * d[2]; + if (V.len(d) > L && h2 < L * L) drop = Math.max(drop, Math.min(0.25 * L, -Math.sqrt(L * L - h2) - d[1])); + }); + if (drop > 1e-4) { + rp = V.sub(rp, [0, drop, 0]); + P = solve(S, rp); + dropped++; + maxDrop = Math.max(maxDrop, drop); + } + ikLegs.forEach((l, i) => legIK(P, l, targets[i])); + P = refk(P); + // toes as high above the floor as the source's (a Lost Saga foot is about twice as long as + // the source's, scaled: the same tilt would push its toes through the floor) + if (opts.toeFit !== false) { + let moved = false; + for (const l of ikLegs) { + if (l.toe < 0 || l.srcToe < 0) continue; + const want = restAligned.wp[l.toe][1] + (S.wp[l.srcToe][1] - src.restWp[l.srcToe][1]) * scale; + const A0 = P.wp[l.ankle]; + const v = V.sub(P.wp[l.toe], A0); + const len = V.len(v); + const flat = Math.hypot(v[0], v[2]); + if (len < 1e-4 || flat < 1e-4) continue; + const pitch = Math.asin(Math.max(-1, Math.min(1, v[1] / len))); + const goal = Math.asin(Math.max(-1, Math.min(1, (want - A0[1]) / len))); + if (Math.abs(goal - pitch) < 1e-4) continue; + // turn about the horizontal axis across the foot, whichever sign raises / lowers the toe + const axis = V.norm(V.cross([v[0], 0, v[2]], [0, 1, 0])); + let D = Q.axis(axis, goal - pitch); + if (Math.sign(Q.rot(D, v)[1] - v[1]) !== Math.sign(goal - pitch)) D = Q.axis(axis, pitch - goal); + P.wr[l.ankle] = Q.norm(Q.mul(D, P.wr[l.ankle])); + moved = true; + } + if (moved) P = refk(P); + } + } else if (opts.ground !== false && ikLegs.length) { + // the lowest foot as high above its rest as the source's lowest foot (scaled) + const want = Math.min(...ikLegs.map((l) => (S.wp[l.src][1] - src.restWp[l.src][1]) * scale)); + const have = Math.min(...ikLegs.map((l) => footLift(P, l, restAligned))); + if (isFinite(want) && isFinite(have)) P = solve(S, V.add(rp, [0, want - have, 0])); + } + const t = Math.round(ts - start); + for (let b = 0; b < n; b++) { + const pi = sk.parent[b]; + const q = pi < 0 ? P.wr[b] : Q.norm(Q.mul(Q.conj(P.wr[pi]), P.wr[b])); + const lp = b === root ? (pi < 0 ? P.wp[b] : Q.rot(Q.conj(P.wr[pi]), V.sub(P.wp[b], P.wp[pi]))) : lpOf(b, S); + keys[b].push(key(t, V.scale(lp, 1 / sk.rate[b]), q)); + } + } + // two-bone IK on the target: swing thigh and calf (twist kept), foot keeps its world rotation + function legIK(P, l, T) { + const H = P.wp[l.hip]; + let pole = V.sub(P.wp[l.knee], V.lerp(H, P.wp[l.ankle], 0.5)); + const K = twoBone(H, T, l.a, l.b, V.len(pole) > 1e-4 ? pole : Q.rot(P.wr[root], [0, 0, -1])); + const footW = P.wr[l.ankle]; + const sw1 = Q.fromTo(V.sub(P.wp[l.knee], H), V.sub(K, H)); + P.wr[l.hip] = Q.norm(Q.mul(sw1, P.wr[l.hip])); + const kneeDir = Q.rot(sw1, V.sub(P.wp[l.ankle], P.wp[l.knee])); + const A2 = V.add(K, V.scale(V.norm(V.sub(T, K)), l.b)); + const sw2 = Q.fromTo(kneeDir, V.sub(A2, K)); + P.wr[l.knee] = Q.norm(Q.mul(sw2, Q.mul(sw1, P.wr[l.knee]))); + P.wr[l.ankle] = footW; + // the foot's children (toe) keep their world rotation relative to the foot: nothing to do, + // their local rotations are taken from the world ones below + } + // world rotations after IK → positions again + function refk(P) { + const wp = new Array(n); + for (const b of sk.order) { + const pi = sk.parent[b]; + wp[b] = pi < 0 ? P.wp[b] : V.add(wp[pi], Q.rot(P.wr[pi], lpOf(b, P.S))); + } + return { wr: P.wr, wp, S: P.S }; + } + + const tol = (opts.tolerance ?? 0.15) * (Math.PI / 180); + const clip = emptyClip(opts.name || "retarget", opts.version || 4002, Math.round(end - start)); + for (let b = 0; b < n; b++) { + const k = thin(keys[b], tol, 0.02); + clip.tracks.push({ name: S(bones[b].name.text), weight: 1, keys: k }); + } + clip.events = (src.events || []).filter((e) => e.time >= start && e.time <= end).map((e) => ({ ...e, time: e.time - start })); + const mapped = map.filter((m) => m >= 0).length; + return { clip, info: { scale, mapped, bones: n, frames: times.length, keys: clip.tracks.reduce((s, t) => s + t.keys.length, 0), lift, restRoot, srcRoot: src.restWp[posSrc], dropped, maxDrop, jump, jumped } }; + } + + // ===================================================================== + // clip operations (range in ms; keys outside keep their stored bytes) + // ===================================================================== + /** Makes sure every track has a key exactly at t (sampled), so a cut there keeps the shape. */ + function splitAt(c, t) { + t = Math.round(t); + for (const tr of c.tracks) { + if (!tr.keys.length || tr.keys.some((k) => Math.abs(k.t - t) < 0.5)) continue; + if (t <= tr.keys[0].t || t >= tr.keys[tr.keys.length - 1].t) continue; + const s = sample(tr.keys, t); + tr.keys.push(key(t, s.p, s.q)); + sortKeys(tr); + } + } + const evShift = (c, fn) => (c.events = c.events.map((e) => ({ ...e, time: fn(e.time) })).filter((e) => e.time !== null)); + + /** Keeps [a, b] only, starting at 0. */ + function trim(c, a, b) { + for (const tr of c.tracks) { + if (!tr.keys.length) continue; + const sa = sample(tr.keys, a), sb = sample(tr.keys, b); + const mid = tr.keys.filter((k) => k.t > a + 0.5 && k.t < b - 0.5).map((k) => copyKey(k, k.t - a)); + const ka = tr.keys.find((k) => Math.abs(k.t - a) < 0.5); + const kb = tr.keys.find((k) => Math.abs(k.t - b) < 0.5); + tr.keys = [ka ? copyKey(ka, 0) : key(0, sa.p, sa.q), ...mid]; + if (b - a > 0.5) tr.keys.push(kb ? copyKey(kb, b - a) : key(Math.round(b - a), sb.p, sb.q)); + if (tr.keys.length === 2 && tr.keys[0].raw && tr.keys[1].raw && tr.keys[0].raw.join() === tr.keys[1].raw.join() && V.len(V.sub(tr.keys[0].p, tr.keys[1].p)) < 1e-6) tr.keys.pop(); + } + evShift(c, (t) => (t >= a && t <= b ? t - a : null)); + c.total = Math.round(b - a); + } + + /** Removes [a, b]; what was after b follows a straight away. */ + function cut(c, a, b) { + splitAt(c, a); + splitAt(c, b); + const d = b - a; + for (const tr of c.tracks) { + const keep = tr.keys.filter((k) => k.t <= a + 0.5 || k.t >= b - 0.5); + // two keys at a (the one at a and the one from b): keep the later pose + tr.keys = keep.map((k) => (k.t >= b - 0.5 ? copyKey(k, Math.round(k.t - d)) : k)).filter((k, i, arr) => !(Math.abs(k.t - a) < 0.5 && arr.some((o, j) => j > i && Math.abs(o.t - a) < 0.5))); + sortKeys(tr); + } + evShift(c, (t) => (t < a ? t : t > b ? t - d : null)); + c.total = Math.round(c.total - d); + } + + /** Plays [a, b] at `speed` × (2 = twice as fast); later keys move with it. */ + function speed(c, a, b, s) { + splitAt(c, a); + splitAt(c, b); + const d = (b - a) / s - (b - a); + const map = (t) => (t <= a ? t : t <= b ? a + (t - a) / s : t + d); + for (const tr of c.tracks) { + tr.keys = tr.keys.map((k) => (k.t > a ? copyKey(k, Math.round(map(k.t))) : k)); + sortKeys(tr); + } + evShift(c, map); + c.total = Math.round(c.total + d); + } + + /** Plays [a, b] backwards. */ + function reverse(c, a, b) { + splitAt(c, a); + splitAt(c, b); + for (const tr of c.tracks) { + tr.keys = tr.keys.map((k) => (k.t >= a - 0.5 && k.t <= b + 0.5 ? copyKey(k, Math.round(a + b - k.t)) : k)); + sortKeys(tr); + } + evShift(c, (t) => (t >= a && t <= b ? a + b - t : t)); + } + + /** + * Puts clip `o` in at time `at` (append: at = c.total), `gap` ms to blend into it and out of it. + * Bones only one of them moves hold their pose (or the skeleton's rest, `bind(name)` → {p, q}). + */ + function insert(c, o, at, gap = 0, bind = () => null) { + at = Math.round(Math.max(0, Math.min(c.total, at))); + splitAt(c, at); + const len = Math.round(o.total + 2 * gap); + const names = new Map(c.tracks.map((t) => [nameOf(t.name).toLowerCase(), t])); + for (const tr of c.tracks) { + const ot = o.tracks.find((x) => nameOf(x.name).toLowerCase() === nameOf(tr.name).toLowerCase()); + const before = tr.keys.filter((k) => k.t <= at + 0.5); + const after = tr.keys.filter((k) => k.t > at + 0.5).map((k) => copyKey(k, k.t + len)); + let mid; + if (ot?.keys.length) mid = ot.keys.map((k) => copyKey(k, k.t + at + gap)); + else { + const s = sample(tr.keys, at); + mid = s ? [key(at + gap, s.p, s.q), key(at + gap + Math.round(o.total), s.p, s.q)] : []; + } + if (gap === 0 && before.length && mid.length && Math.abs(before[before.length - 1].t - mid[0].t) < 0.5) before.pop(); + tr.keys = [...before, ...mid, ...after]; + if (!after.length && at < c.total) { + const s = sample(before, at); + if (s) tr.keys.push(key(at + len, s.p, s.q)); + } + sortKeys(tr); + } + for (const ot of o.tracks) { + if (names.has(nameOf(ot.name).toLowerCase()) || !ot.keys.length) continue; + const b = bind(nameOf(ot.name)) || { p: ot.keys[0].p, q: ot.keys[0].q }; + const keys = []; + if (at + gap > 0) keys.push(key(0, b.p, b.q), key(at, b.p, b.q)); + keys.push(...ot.keys.map((k) => copyKey(k, k.t + at + gap))); + if (at < c.total) keys.push(key(at + len, b.p, b.q), key(c.total + len, b.p, b.q)); + c.tracks.push({ name: ot.name, weight: ot.weight, keys: keys.filter((k, i, a) => !a.slice(i + 1).some((x) => Math.abs(x.t - k.t) < 0.5)) }); + } + evShift(c, (t) => (t <= at ? t : t + len)); + c.events.push(...o.events.map((e) => ({ ...e, time: e.time + at + gap }))); + c.total = Math.round(c.total + len); + } + + /** Last pose = first pose on every track (a key at the end, the first key's stored bytes). */ + function makeLoop(c) { + const end = Math.round(c.total); + for (const tr of c.tracks) { + if (tr.keys.length < 2) continue; + tr.keys = tr.keys.filter((k) => k.t < end - 0.5); + tr.keys.push(copyKey(tr.keys[0], end)); + } + } + + /** "Bip01 L Thigh" ↔ "Bip01 R Thigh" (also left/right, _l/_r). */ + function mirrorName(name) { + const swaps = [[/(^|[ _.])L([ _.]|$)/, "$1R$2"], [/(^|[ _.])R([ _.]|$)/, "$1L$2"], [/left/i, "right"], [/right/i, "left"]]; + for (const [re, to] of swaps) if (re.test(name)) return name.replace(re, to); + return name; + } + + /** Mirrors the whole clip left ↔ right across the plane x = 0 (axis "x") or z = 0 ("z"). */ + function mirror(c, sk, axis = "x") { + const ax = axis === "z" ? 2 : 0; + const flip = (v) => v.map((x, i) => (i === ax ? -x : x)); + const flipQ = (q) => (ax === 0 ? [q[0], -q[1], -q[2], q[3]] : [-q[0], -q[1], q[2], q[3]]); + const bones = sk.data.bones; + const n = bones.length; + const partner = bones.map((b) => sk.byName.get(mirrorName(b.name.text).toLowerCase()) ?? -1).map((p, i) => (p >= 0 ? p : i)); + const map = trackMap(sk, c); + const rest = lsPose(sk, null, null, 0); + // W'(b) = M·W(b')·M · (M·Wrest(b')·M)⁻¹ · Wrest(b); positions mirrored + const corr = bones.map((_, b) => Q.mul(Q.conj(flipQ(rest.wr[partner[b]])), rest.wr[b])); + const times = allTimes(c); + const keyed = new Set(map.filter((x) => x >= 0)); + const want = [...new Set([...keyed, ...[...keyed].map((b) => partner[b])])]; + const out = new Map(want.map((b) => [b, []])); + for (const t of times) { + const P = lsPose(sk, c, map, t); + const wr = new Array(n), wp = new Array(n); + for (let b = 0; b < n; b++) { + wr[b] = Q.norm(Q.mul(flipQ(P.wr[partner[b]]), corr[b])); + wp[b] = flip(P.wp[partner[b]]); + } + for (const b of want) { + const pi = sk.parent[b]; + const q = pi < 0 ? wr[b] : Q.norm(Q.mul(Q.conj(wr[pi]), wr[b])); + const lp = pi < 0 ? wp[b] : Q.rot(Q.conj(wr[pi]), V.sub(wp[b], wp[pi])); + out.get(b).push(key(t, V.scale(lp, 1 / sk.rate[b]), q)); + } + } + const weight = new Map(c.tracks.map((t, i) => [map[i], t.weight])); + const others = c.tracks.filter((_, i) => map[i] < 0); + c.tracks = [...want.map((b) => ({ name: S(bones[b].name.text), weight: weight.get(b) ?? weight.get(partner[b]) ?? 1, keys: thin(out.get(b), 0.05 * (Math.PI / 180), 0.005) })), ...others]; + c.events = c.events.map((e) => ({ ...e, sub: e.sub })); + } + + return { Q, V, S, nameOf, fromLsf, toLsf, emptyClip, key, copyKey, sample, setKey, sortKeys, allTimes, thin, trackMap, locals, world, lsPose, lsRig, retarget, defaultMap, trim, cut, speed, reverse, insert, makeLoop, mirror, mirrorName, splitAt, twoBone }; +})(); +if (typeof window !== "undefined") window.AnimKit = AnimKit; diff --git a/app/public/legacy/js/costume.js b/app/public/legacy/js/costume.js new file mode 100644 index 0000000..2dcb64b --- /dev/null +++ b/app/public/legacy/js/costume.js @@ -0,0 +1,1218 @@ +"use strict"; + +// Costume Studio (Costume tab): the hero body (sp2_human_man / _woman) in its rest pose, a 3D model +// imported onto it (.glb / .gltf / .obj) with weights from its own bones or from the body, played +// with any animation, and saved as a costume mesh (.msh + material script + .dds). "Body for +// Blender" writes what is shown as a rigged .glb to model on. The maths is in costumekit.js. + +(() => { + const CK = CostumeKit; + const COS = { + src: null, + rows: [], + kind: "msh", + sex: "man", + sk: null, + P: null, // rest pose + surface: null, // the hero body's surface (weights for the costume) + base: [], // the hero's own meshes + worn: [], // meshes put on from the list + anims: [], + ani: null, // { data, map, name } + time: 0, + playing: false, + tpose: true, + file: null, // { path, name, data (loadGltf / parseObj), meshNames } + mats: [], // per file material: { name, color, image: { name, bytes, mime }, img, tex } + out: {}, // part → CK.build result (body, hand, helmet, cloak) + views: {}, // part → the mesh in the view + opts: { parts: {}, hideFace: true, weights: "auto", bone: 0, scale: 0, offset: [0, 0, 0], rotY: 0, maxBones: CK.MAX_BONES, include: null, name: "", hw: true, outline: true, texMax: 1024, splitHands: true, outfit: false, skinTol: 1 }, + dirty: false, + }; + window.COS = COS; + try { + COS.texDir = localStorage.getItem("cos:texDir") || null; // the last textures folder picked: looked in again + } catch {} + let view = null; + let kit = null; + + const stem = (p) => p.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, ""); + const dirOf = (p) => p.replace(/[\\/][^\\/]*$/, ""); + const visible = () => $("view-cos").classList.contains("on"); + const toggled = (id) => $(id).classList.contains("on"); + // the costume parts a mesh of the file can be: what it is in the game and which hero mesh it replaces + const PARTS = { + body: { label: "Body", ini: "armor", key: "body_mesh", hides: /(^|_)body/i }, + hand: { label: "Hands", ini: "armor", key: "hand_mesh", hides: /hand/i }, + helmet: { label: "Helmet / head", ini: "helmet", key: "helmet_mesh", hides: /hair/i }, + cloak: { label: "Cloak / back", ini: "cloak", key: "cloak_mesh", hides: null }, + skip: { label: "— leave out —" }, + }; + const PART_ORDER = ["body", "hand", "helmet", "cloak"]; + /** The file name of a part: _body_01, _hand_01 … */ + const outName = (part) => `${COS.opts.name}_${part}_01`; + /** The first built part (the materials / report card start from it). */ + const anyOut = () => PART_ORDER.map((p) => COS.out[p]).find(Boolean) || null; + + function ensureView() { + if (view) return view; + try { + view = new GL3D.View($("cos-canvas")); + } catch (err) { + toast(`3D view unavailable: ${err.message}`, true); + return null; + } + kit = MeshKit.create(view, () => COS.src, { prepareTexture: skinTone }); + COS.view = view; + return view; + } + /** + * The hero textures keep the skin as transparent black (the game blends in #MODEL_COLOR there: + * blend_texture_alpha texture tfactor); the preview does the same, else the skin looks black. + */ + function skinTone({ img }) { + const c = COS.skin || [243, 210, 143]; + let d; + if (img instanceof ImageData) d = img; + else { + const cv = new OffscreenCanvas(img.width, img.height); + const g = cv.getContext("2d"); + g.drawImage(img, 0, 0); + d = g.getImageData(0, 0, img.width, img.height); + } + const p = d.data; + let clear = 0, dark = 0; + for (let i = 0; i < p.length; i += 4) if (p[i + 3] < 128) (clear++, p[i] + p[i + 1] + p[i + 2] < 72 && dark++); + if (!clear || dark < clear * 0.9) return img; // real transparency (cut-outs, glass): leave it + const out = new ImageData(new Uint8ClampedArray(p), d.width, d.height); + const o = out.data; + for (let i = 0; i < o.length; i += 4) { + const a = o[i + 3] / 255; + for (let k = 0; k < 3; k++) o[i + k] = c[k] * (1 - a) + o[i + k] * a; + o[i + 3] = 255; + } + return out; + } + function loading(on, text = "Loading…") { + $("cos-loading").hidden = !on; + $("cos-loading").querySelector("span").textContent = text; + } + + // ===================================================================== + // source + list + // ===================================================================== + async function setSource(src) { + try { + const d = await call("mdl:describe", src); + COS.src = { ...src, label: d.label }; + } catch (err) { + return toast(err.message, true); + } + $("cos-src-label").textContent = COS.src.kind === "dir" ? "Extracted folder" : COS.src.kind === "packs" ? "Pack folder" : `Packs next to ${COS.src.name}`; + $("cos-src").title = `Reading from ${COS.src.label}`; + kit?.clearTextures(); + await loadList(); + await loadHero(); + } + async function chooseSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const options = [ + ...profileSourceOption(), + ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), + { value: "packs", title: "A folder with .iop packs…", sub: "the client folder: map.iop, map2.iop, rs.iop…" }, + { value: "dir", title: "A folder of extracted files…", sub: "resource/skeleton, resource/mesh, resource/model…" }, + ]; + let choice = options[0].value; + const list = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [el("input", { type: "radio", name: "cossrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])]))); + if (!(await modal("Where is the hero body?", ["The hero skeleton, body meshes and animations are spread over several packs. Pick the folder they are in.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false; + let src = choice; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); + if (!folder) return false; + src = { kind: choice, folder }; + } + await setSource(src); + return true; + } + $("cos-src").onclick = () => chooseSource(); + + const list = new VTable($("cos-list"), [{ key: 1, label: "Name", width: 200, grow: true, title: true }, { key: 2, label: "Pack", width: 70, cls: "mono dim" }], { multi: false, id: "cos" }); + list.keyOf = (r) => r[0]; + list.sortBy(1); + async function loadList() { + if (!COS.src) return; + loading(true, "Reading the pack index…"); + try { + const rows = await call("mdl:list", COS.src); + COS.rows = rows + .map(([k, path, pack]) => { + const l = path.toLowerCase(); + const kind = l.endsWith(".msh") ? "msh" : l.endsWith(".ani") ? "ani" : null; + return kind && [k, stem(path), pack || path.split("/").slice(0, -1).join("/"), kind, path]; + }) + .filter(Boolean); + } catch (err) { + toast(err.message, true); + COS.rows = []; + } finally { + loading(false); + } + filterList(); + } + function filterList() { + const q = $("cos-search").value.trim().toLowerCase(); + const rows = COS.rows.filter((r) => r[3] === COS.kind && (!q || r[1].toLowerCase().includes(q))); + list.setRows(rows); + $("cos-count").textContent = rows.length.toLocaleString(); + } + let searchTimer = 0; + $("cos-search").addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(filterList, 120); + }); + for (const b of $("cos-kinds").children) + b.onclick = () => { + COS.kind = b.dataset.kind; + for (const x of $("cos-kinds").children) x.classList.toggle("on", x === b); + filterList(); + }; + list.onOpen = (r) => openRow(r); + $("cos-list").addEventListener("contextmenu", (e) => { + const rowEl = e.target.closest(".vt-row"); + if (!rowEl) return; + e.preventDefault(); + const r = list.view[Number(rowEl.dataset.i)]; + const items = r[3] === "msh" ? [{ label: "Put on the hero", icon: "plus", run: () => openRow(r) }] : [{ label: "Play on the hero", icon: "play", run: () => openRow(r) }]; + items.push("-", { label: "Copy name", icon: "copy", run: () => navigator.clipboard.writeText(r[4].split("/").pop()) }); + showMenu(items, { x: e.clientX, y: e.clientY }); + }); + async function openRow(r) { + if (!r || !COS.sk) return; + try { + const hit = await call("mdl:readKey", COS.src, r[0]); + if (!hit) throw new Error("Not found in the packs any more."); + if (r[3] === "msh") await wear(hit, r[1]); + else await playAni(hit, `${r[1]}.ani`); + } catch (err) { + toast(err.message, true); + } + } + const readNamed = (name, folder, strict = true) => (COS.src ? call("mdl:read", COS.src, name, folder, strict) : null); + + // ===================================================================== + // the hero + // ===================================================================== + async function loadHero() { + if (!COS.src || !ensureView()) return; + loading(true, "Loading the hero…"); + try { + const model = await kit.readModel(`animodel/sp2_human_${COS.sex}.txt`); + if (!model) throw new Error(`model/animodel/sp2_human_${COS.sex}.txt isn't in these packs.`); + const cfg = model.cfg; + if (cfg.color?.length >= 3 && String(cfg.color) !== String(COS.skin)) { + COS.skin = cfg.color.slice(0, 3); + kit.clearTextures(); + } + const sh = await readNamed(cfg.skeleton, "skeleton"); + if (!sh) throw new Error(`${cfg.skeleton} isn't in these packs.`); + const data = LSF.parseSkl(sh.bytes); + const sk = SkinKit.prepare(data); + if (data.linked) { + const lh = await readNamed(data.linked, "skeleton"); + if (!lh || !SkinKit.link(sk, LSF.parseSkl(lh.bytes))) toast(`${cfg.skeleton} links to ${data.linked}, which isn't in these packs; the body may look off.`, true); + } + sk.name = cfg.skeleton; + for (const m of [...COS.base, ...COS.worn]) view.removeMesh(m.id); + COS.sk = sk; + COS.P = CK.restPose(sk); + COS.base = []; + COS.worn = []; + const missing = []; + for (const n of cfg.meshes) { + const h = await readNamed(`${n}.msh`, "mesh"); + if (!h) { + missing.push(n); + continue; + } + const m = await kit.loadMesh(h, n); + m.palette = SkinKit.palette(sk, m.data); + COS.base.push(m); + kit.loadTextures(m).then(() => (view.dirty = true)); + } + const posedBase = COS.base.map((m) => CK.posed(sk, COS.P, m.data)); + COS.surface = CK.surface(posedBase); + // outfit only: the hero's body and hands go into the costume, its face tells the model's face apart + COS.heroParts = {}; + COS.base.forEach((m, i) => { + const k = /hand/i.test(m.name) ? "hand" : /face/i.test(m.name) ? "face" : /hair/i.test(m.name) ? null : /body/i.test(m.name) ? "body" : null; + if (k && !COS.heroParts[k]) COS.heroParts[k] = { i, m, posed: posedBase[i] }; + }); + COS.faceSurface = COS.heroParts.face ? CK.surface([COS.heroParts.face.posed]) : null; + // what Fit to the LS body measures: this hero's body, hands and face (not its hair) + COS.hero = CK.bodyPoints(posedBase.filter((_, i) => !/hair/i.test(COS.base[i].name))); + COS.anims = cfg.animations; + renderAnimSelect(); + $("cos-empty").hidden = true; + if (missing.length) toast(`Not in these packs: ${missing.join(", ")}`, true); + if (COS.ani) COS.ani.map = SkinKit.mapTracks(sk, COS.ani.data); + if (COS.file) rebuild(); + frame(); + applyPose(); + renderSide(); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + for (const b of $("cos-sex").children) + b.onclick = () => { + if (COS.sex === b.dataset.sex) return; + COS.sex = b.dataset.sex; + for (const x of $("cos-sex").children) x.classList.toggle("on", x === b); + loadHero(); + }; + + async function wear(hit, name) { + const m = await kit.loadMesh(hit, name); + m.palette = SkinKit.palette(COS.sk, m.data); + COS.worn.push(m); + kit.loadTextures(m).then(() => (view.dirty = true)); + // a costume body / hands / helmet replaces the hero's own one + for (const s of Object.values(PARTS)) if (s.hides && s.hides.test(name)) for (const b of COS.base) if (s.hides.test(b.name)) b.hidden = true; + applyPose(); + renderSide(); + } + + // ===================================================================== + // animation + pose + // ===================================================================== + function renderAnimSelect() { + const sel = $("cos-anim"); + const names = [...new Set([...(COS.ani ? [COS.ani.name] : []), ...COS.anims])]; + sel.replaceChildren(el("option", { value: "", text: "— rest pose (T-pose) —" }), ...names.map((n) => el("option", { value: n, text: n, selected: COS.ani?.name === n && !COS.tpose }))); + } + $("cos-anim").onchange = async () => { + const n = $("cos-anim").value; + if (!n) return showTpose(); + const h = await readNamed(n, "animation", false); + if (!h) return toast(`${n} isn't in these packs.`, true); + playAni(h, n); + }; + async function playAni(hit, name) { + if (!COS.sk) return; + const data = LSF.parseAni(hit.bytes); + COS.ani = { data, map: SkinKit.mapTracks(COS.sk, data), name, total: data.total || Math.max(1, ...data.tracks.map((t) => t.times?.[t.times.length - 1] || 0)) }; + COS.time = 0; + COS.tpose = false; + renderAnimSelect(); + play(); + } + function showTpose() { + stop(); + COS.tpose = true; + renderAnimSelect(); + applyPose(); + } + $("cos-tpose").onclick = showTpose; + + let raf = 0, last = 0; + function play() { + if (!COS.ani) return; + COS.tpose = false; + COS.playing = true; + $("cos-play").replaceChildren(icon("pause")); + last = performance.now(); + cancelAnimationFrame(raf); + const tick = (now) => { + if (!COS.playing) return; + COS.time = (COS.time + (now - last)) % Math.max(1, COS.ani.total); + last = now; + if (visible()) applyPose(); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + } + function stop() { + COS.playing = false; + cancelAnimationFrame(raf); + $("cos-play").replaceChildren(icon("play")); + } + $("cos-play").onclick = () => (COS.playing ? stop() : COS.ani ? play() : toast("Pick an animation (the list below the view, or Anims on the left).")); + $("cos-scrub").oninput = () => { + if (!COS.ani) return; + stop(); + COS.tpose = false; + COS.time = (Number($("cos-scrub").value) / 1000) * COS.ani.total; + applyPose(); + }; + + const allMeshes = () => [...COS.base, ...COS.worn, ...PART_ORDER.map((p) => COS.views[p]).filter(Boolean)]; + function applyPose() { + if (!view || !COS.sk) return; + const ps = COS.tpose || !COS.ani ? COS.P : SkinKit.pose(COS.sk, COS.ani.data, COS.ani.map, COS.time); + for (const m of allMeshes()) { + view.setVisible(m.id, !m.hidden); + if (m.hidden || !m.palette) continue; + SkinKit.skin(m, m.palette, ps); + view.updateMesh(m.id, m.skinPos, m.skinNrm); + } + // bones + if (toggled("cos-t-bones")) { + const l = []; + COS.sk.parent.forEach((pi, i) => { + if (pi < 0) return; + l.push(...ps.wp[pi], 0.96, 0.79, 0.35, 1, ...ps.wp[i], 0.96, 0.79, 0.35, 1); + }); + view.setOverlay("bones", new Float32Array(l), { depth: false }); + } else view.setOverlay("bones", null); + view.opts.wire = toggled("cos-t-wire"); + const t = COS.ani && !COS.tpose ? COS.time : 0; + $("cos-clock").textContent = COS.ani && !COS.tpose ? `${(t / 1000).toFixed(2)} / ${(COS.ani.total / 1000).toFixed(2)} s` : "rest pose"; + if (!$("cos-scrub").matches(":active")) $("cos-scrub").value = COS.ani ? String(Math.round((t / COS.ani.total) * 1000)) : "0"; + view.dirty = true; + } + + function frame() { + view.frame([-60, 0, -30], [60, 170, 30]); + const g = []; + const c = [1, 1, 1, 0.16], a = [1, 1, 1, 0.4]; + for (let v = -100; v <= 100; v += 20) { + const col = v === 0 ? a : c; + g.push(v, 0, -100, ...col, v, 0, 100, ...col, -100, 0, v, ...col, 100, 0, v, ...col); + } + view.setOverlay("grid", new Float32Array(g)); + view.showOverlay("grid", toggled("cos-t-grid")); + } + for (const id of ["cos-t-bones", "cos-t-grid", "cos-t-wire"]) + $(id).onclick = () => { + $(id).classList.toggle("on"); + view?.showOverlay("grid", toggled("cos-t-grid")); + applyPose(); + }; + $("cos-reset").onclick = () => view && frame(); + + // ===================================================================== + // import + // ===================================================================== + async function readImage(im) { + if (!im?.bytes) return null; + const n = (im.name || "").toLowerCase(); + if (n.endsWith(".dds") || (im.bytes[0] === 0x44 && im.bytes[1] === 0x44 && im.bytes[2] === 0x53)) return TexDecode.dds(im.bytes); + if (n.endsWith(".tga")) return TexDecode.tga(im.bytes); + return createImageBitmap(new Blob([im.bytes], { type: im.mime || "image/png" }), { premultiplyAlpha: "none" }); + } + /** + * A file a model points to: where it says, next to the model, or in a textures folder near it (Blender + * often writes broken absolute paths such as C:/11.png). → bytes (throws when not found) + */ + async function findFile(uri, dir) { + const name = uri.split(/[\\/]/).pop(); + const abs = /^([a-z]:|[\\/])/i.test(uri); + const up = dirOf(dir); + const tries = [abs ? uri : `${dir}/${uri}`, `${dir}/${name}`, `${dir}/textures/${name}`, `${dir}/texture/${name}`, `${up}/textures/${name}`, `${up}/texture/${name}`, ...(COS.texDir ? [`${COS.texDir}/${name}`] : [])]; + for (const f of tries) { + const hit = await call("mdl:readFile", f).catch(() => null); + if (hit) return hit.bytes; + } + throw new Error(`${name} not found`); + } + async function importFile(path) { + if (!COS.sk && !(COS.src || (await chooseSource()))) return; + if (!COS.sk) return toast("The hero body didn't load; pick the game's packs first.", true); + loading(true, "Reading the 3D model…"); + try { + const dir = dirOf(path); + const readRel = (uri) => findFile(uri, dir); + const bytes = (await call("mdl:readFile", path)).bytes; + let data; + if (/\.obj$/i.test(path)) { + const text = new TextDecoder().decode(bytes); + const lib = /^mtllib\s+(.+)$/m.exec(text)?.[1]?.trim(); + let mtl = ""; + if (lib) mtl = await readRel(lib).then((b) => new TextDecoder().decode(b)).catch(() => ""); + data = CK.parseObj(text, mtl); + for (const im of data.images) im.bytes = await readRel(im.name).catch(() => null); + for (const im of data.images) im.name = im.name.split(/[\\/]/).pop(); + } else if (/\.(glb|gltf)$/i.test(path)) data = await CK.loadGltf(bytes, readRel); + else if (/\.fbx$/i.test(path)) { + data = await CK.parseFbx(bytes); + for (const im of data.images) im.bytes = await readRel(im.name).catch(() => null); + for (const im of data.images) im.name = im.name.split(/[\\/]/).pop(); + } else throw new Error("Use a .glb, .gltf, .obj or .fbx file (Blender: File → Export → glTF 2.0)."); + await texturesFromJson(data, readRel); + for (const m of COS.mats) m.tex && view.gl.deleteTexture(m.tex); + COS.mats = await Promise.all( + data.materials.map(async (m) => { + const image = m.image >= 0 ? data.images[m.image] : null; + const img = await readImage(image).catch(() => null); + return { name: m.name, color: m.color, image: image?.bytes ? image : null, wanted: image && !img ? image.name : null, img, tex: img ? view.texture(img) : null }; + }), + ); + COS.file = { path, name: path.split(/[\\/]/).pop(), data, meshNames: data.meshes.map((m) => m.name) }; + const auto = CK.autoScale(COS.sk, COS.P, data); + COS.file.auto = auto; + COS.texPix = null; + // a rig whose bones aren't the hero's names: fit it onto the hero's proportions + const named = CK.jointMap(COS.sk, data.joints).filter((b) => b >= 0).length; + COS.opts.fit = data.joints.length > 0 && named < 8; + Object.assign(COS.opts, { scale: 0, offset: [0, 0, 0], rotY: 0, weights: "auto", name: safeName(stem(path)).replace(/_(body|hand|helmet|cloak)(_\d+)?$/, "").replace(/_[ta]_?pose$/, "") }); + // each mesh: what it hangs on (head → helmet, a cape → cloak) or what the file is called + const whole = guessPart(COS.file.name); + let guess = null; + try { + guess = data.joints.length ? CK.partsGuess(data, COS.sk, COS.P, { ...COS.opts, fitHero: COS.hero }) : null; + } catch {} + COS.opts.parts = Object.fromEntries(data.meshes.map((m) => [m.name, whole !== "body" ? whole : guess?.[m.name] || "body"])); + rebuild(); + COS.dirty = true; + toast(`${COS.file.name}: ${PART_ORDER.filter((p) => COS.out[p]).map((p) => `${p} ${COS.out[p].report.vertices.toLocaleString()}`).join(", ") || "nothing"} vertices, scale ${auto.scale.toFixed(2)} (${auto.by}).`); + } catch (err) { + toast(err.message, true); + console.error(err); + } finally { + loading(false); + } + } + /** + * Game rips (FModel / UE Viewer) keep the textures out of the model: Materials/.json lists + * them (MainTex, BaseColor, Diffuse…), the pictures are in Textures/. A material without a texture + * takes its diffuse from there, or a picture named like it (T__D.png). + */ + async function texturesFromJson(data, readRel) { + const read = async (rel) => (await readRel(rel).catch(() => null)) || null; + for (const m of data.materials) { + if (m.image >= 0 && data.images[m.image]?.bytes) continue; + const tries = []; + const js = (await read(`Materials/${m.name}.json`)) || (await read(`${m.name}.json`)); + if (js) { + try { + const t = JSON.parse(new TextDecoder().decode(js)).Textures || {}; + const keys = Object.keys(t); + const key = keys.find((k) => /^(MainTex|BaseColor|BaseColorMap|Diffuse|DiffuseMap|Albedo|Base_Color|Color)$/i.test(k)) || keys.find((k) => /(diffuse|basecolor|albedo|maintex)/i.test(k) && !/^PM_/i.test(k)) || keys.find((k) => /_D$/i.test(String(t[k]).split(".")[0])); + if (key) tries.push(String(t[key]).split("/").pop().split(".")[0]); + } catch {} + } + const base = m.name.replace(/^(MI_|M_|Mat_)/i, ""); + tries.push(`T_${base}_D`, `${base}_D`, base); + for (const n of tries) { + let hit = null, name = null; + for (const ext of ["png", "tga", "dds", "jpg"]) for (const rel of [`Textures/${n}.${ext}`, `${n}.${ext}`]) if (!hit && (hit = await read(rel))) name = `${n}.${ext}`; + if (hit) { + data.images.push({ name, bytes: hit, mime: /\.jpe?g$/i.test(name) ? "image/jpeg" : "image/png" }); + m.image = data.images.length - 1; + break; + } + } + } + } + const safeName = (s) => s.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "costume"; + const guessPart = (n) => (/hand|glove/i.test(n) ? "hand" : /helmet|hat|cap|mask/i.test(n) ? "helmet" : /cloak|cape|wing/i.test(n) ? "cloak" : "body"); + $("cos-import").onclick = async () => { + if (!COS.sk && !(await chooseSource())) return; + const file = await call("dialog:openExt", "Import a 3D model as a costume", "3D models", ["glb", "gltf", "fbx", "obj"]); + if (file) importFile(file); + }; + // drop a file on the view + $("cos-canvas").addEventListener("dragover", (e) => e.preventDefault()); + $("cos-canvas").addEventListener("drop", (e) => { + e.preventDefault(); + const f = e.dataTransfer.files[0]; + const p = f && bridge.pathOf?.(f); + if (p) importFile(p); + }); + + /** The hero meshes the costume's parts replace in the game are hidden (a helmet with a face: the face too). */ + function hideForParts() { + for (const b of COS.base) + b.hidden = PART_ORDER.some((p) => COS.out[p] && PARTS[p].hides?.test(b.name)) || (!!COS.out.helmet && COS.opts.hideFace && /face/i.test(b.name)); + } + + /** Puts a built mesh in the view (reusing old's id). → the view mesh */ + function showBuilt(b, old, name) { + const d = b.data; + const id = old?.id || `cos-${Date.now()}-${name}`; + const groups = b.groups.map((g) => { + const ls = lsMat(g.material); + if (ls) return Object.assign({}, ls.group, { start: g.start, count: g.count, tex: ls.group?.texInfo?.tex || null }); + const m = COS.mats[g.material]; + const look = kit.groupLook(null, d); + return Object.assign(look, { start: g.start, count: g.count, tex: m?.tex || null, color: !m?.tex && m ? [m.color[0], m.color[1], m.color[2], 1] : null }); + }); + view.addMesh(id, { pos: d.pos, normal: d.normal, uv0: d.uv0, indices: Uint32Array.from(d.indices), groups }); + return { id, name, data: d, groups, built: b, palette: SkinKit.palette(COS.sk, d), skinPos: new Float32Array(d.pos.length), skinNrm: new Float32Array(d.normal.length), costume: true, hidden: old?.hidden }; + } + + /** The hero's body / hand mesh at its rest pose for build's base (outfit only): its groups keep the game's material ("ls::"). */ + function heroBase(k, hand = false) { + const h = COS.heroParts?.[k]; + if (!h) return null; + const subs = h.posed.subs?.length ? h.posed.subs : [{ indexStart: 0, faceCount: h.posed.idx.length / 3 }]; + return { ...h.posed, subs, hand, mats: subs.map((_, s) => `ls:${h.i}:${s}`) }; + } + /** A built group's game material: "ls::" → { mesh, group, sub } */ + const lsMat = (key) => { + if (typeof key !== "string") return null; + const [, i, sub] = key.split(":").map(Number); + const mesh = COS.base[i]; + return mesh ? { mesh, group: mesh.groups[sub], sub } : null; + }; + /** The model's textures' colours (≤ 512 px), for Outfit only's skin tone: (material, u, v) → [r, g, b, a] | null */ + function texel() { + if (!COS.texPix) COS.texPix = COS.mats.map((m) => (m?.img ? pixels(m.img, 512) : null)); + return (mi, u, v) => { + const t = COS.texPix[mi]; + if (!t) return null; + u -= Math.floor(u); + v -= Math.floor(v); + const i = (Math.min(t.h - 1, Math.floor(v * t.h)) * t.w + Math.min(t.w - 1, Math.floor(u * t.w))) * 4; + return [t.rgba[i], t.rgba[i + 1], t.rgba[i + 2], t.rgba[i + 3]]; + }; + } + /** The top-level "material { … }" blocks of a material script, in order. */ + function materialBlocks(text) { + const out = []; + let depth = 0, start = -1; + const re = /\bmaterial\b|[{}]/g; + let m; + while ((m = re.exec(text))) { + if (m[0] === "material" && depth === 0 && start < 0) start = m.index; + else if (m[0] === "{") depth++; + else if (m[0] === "}" && --depth === 0 && start >= 0) out.push(text.slice(start, m.index + 1)), (start = -1); + } + return out; + } + + /** + * Builds every part from the file + options and shows them: one placement / fit for all (measured on + * every mesh that isn't left out), then a mesh per part; the body's hands split off unless the file + * has a hands part of its own. + */ + function rebuild() { + if (!COS.file || !COS.sk) return; + const o = COS.opts; + const parts = Object.entries(o.parts).filter(([, p]) => p !== "skip"); + const out = {}; + let rig = null; + try { + rig = CK.prepare(COS.file.data, COS.sk, COS.P, { ...o, fitHero: COS.hero, measure: new Set(parts.map(([n]) => n)) }); + } catch (err) { + toast(`Fit: ${err.message}`, true); + rig = CK.prepare(COS.file.data, COS.sk, COS.P, { ...o, fit: false }); + } + const handsPart = parts.some(([, p]) => p === "hand"); + const outfit = !!(o.outfit && o.fit && rig?.T); + for (const part of PART_ORDER) { + const include = new Set(parts.filter(([, p]) => p === part).map(([n]) => n)); + if (!include.size) continue; + try { + const splitHands = part === "body" && o.splitHands && !handsPart; + const base = !outfit ? null : part === "body" ? [heroBase("body"), splitHands ? heroBase("hand", true) : null].filter(Boolean) : part === "hand" ? [heroBase("hand")].filter(Boolean) : null; + const r = CK.build(COS.file.data, COS.sk, COS.P, COS.surface, { ...o, rig, include, splitHands, outfit, texel: outfit ? texel() : null, face: COS.faceSurface, base }); + if (!r) { + toast(`${PARTS[part].label}: only skin / face — nothing of it is left (Outfit only).`); + continue; + } + out[part] = r; + if (r.hand) out.hand = r.hand; + } catch (err) { + toast(`${PARTS[part].label}: ${err.message}`, true); + } + } + COS.out = out; + for (const p of PART_ORDER) { + if (out[p]) COS.views[p] = showBuilt(out[p], COS.views[p], outName(p)); + else if (COS.views[p]) view.removeMesh(COS.views[p].id), delete COS.views[p]; + } + hideForParts(); + $("cos-title").textContent = COS.file.name; + $("cos-dirty").hidden = !COS.dirty; + $("cos-save").disabled = !anyOut(); + applyPose(); + renderSide(); + } + let rebuildTimer = 0; + const rebuildSoon = () => { + clearTimeout(rebuildTimer); + rebuildTimer = setTimeout(rebuild, 150); + }; + + // ===================================================================== + // side cards + // ===================================================================== + const field = (label, input) => [el("label", { text: label }), input]; + const num = (value, onchange, step = "any") => el("input", { type: "number", step, value: String(value), onchange: (e) => onchange(Number(e.target.value)) }); + function renderSide() { + renderChar(); + renderOpts(); + renderMats(); + renderReport(); + renderReg(); + } + + function renderChar() { + const box = $("cos-char"); + $("cos-char-name").textContent = COS.sk ? `sp2_human_${COS.sex} · ${COS.sk.name}` : ""; + if (!COS.sk) return box.replaceChildren(el("div", { class: "ans-note", text: "Pick the game's packs (top left)." })); + const row = (m, removable) => + el("div", { class: "ans-mesh" }, [ + el("input", { type: "checkbox", checked: !m.hidden, title: "Show / hide", onchange: (e) => ((m.hidden = !e.target.checked), applyPose()) }), + el("span", { class: `mono grow${m.costume ? " ok" : ""}`, text: m.costume ? `${m.name} (import)` : m.name, title: m.name }), + removable ? el("button", { class: "iconbtn only danger", title: "Take it off", onclick: () => takeOff(m) }, [icon("trash")]) : null, + ]); + box.replaceChildren(...COS.base.map((m) => row(m, false)), ...COS.worn.map((m) => row(m, true)), ...PART_ORDER.filter((p) => COS.views[p]).map((p) => row(COS.views[p], false)), el("div", { class: "ans-note", text: "Unticked meshes are hidden (in the game the costume's body replaces human_body, its hands default_hand, a helmet the hair)." })); + } + function takeOff(m) { + view.removeMesh(m.id); + COS.worn.splice(COS.worn.indexOf(m), 1); + applyPose(); + renderSide(); + } + + function renderOpts() { + const box = $("cos-opts"); + const o = COS.opts; + $("cos-file-name").textContent = COS.file?.name || ""; + if (!COS.file) return box.replaceChildren(el("div", { class: "ans-note", text: "Import 3D… a .glb / .gltf / .fbx / .obj (or drop it on the view)." }), el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", onclick: () => $("cos-import").onclick() }, [icon("import"), "Import 3D…"])])); + const set = (k, v, rebuildNow = true) => { + o[k] = v; + COS.dirty = true; + if (rebuildNow) rebuildSoon(); + }; + const hasJoints = COS.file.data.joints.length > 0; + const weights = el("select", { onchange: () => (set("weights", weights.value), renderOpts()) }, [ + ["auto", hasJoints ? "Auto (the file's bones)" : "Auto (from the hero body)"], + ["file", "The file's bones (same names as the hero's)"], + ["body", "From the hero body (closest point)"], + ["bone", "All on one bone"], + ].map(([v, t]) => el("option", { value: v, text: t, selected: v === o.weights, disabled: v === "file" && !hasJoints }))); + const bone = el("select", { onchange: () => set("bone", Number(bone.value)) }, COS.sk.data.bones.map((b, i) => el("option", { value: i, text: b.name.text, selected: i === o.bone }))); + const auto = COS.file.auto; + const scale = num(o.scale || +auto.scale.toFixed(4), (v) => set("scale", v > 0 ? v : 0)); + const off = o.offset.map((v, i) => num(v, (x) => set("offset", Object.assign(o.offset.slice(), { [i]: x || 0 })))); + const rot = el("select", { onchange: () => set("rotY", Number(rot.value)) }, [0, 90, 180, -90].map((v) => el("option", { value: v, text: `${v}°`, selected: v === o.rotY }))); + // what each mesh of the file becomes + const meshes = el( + "div", + { class: "cos-parts" }, + COS.file.meshNames.map((n) => { + const verts = COS.file.data.meshes.find((m) => m.name === n)?.prims.reduce((t, p) => t + p.pos.length / 3, 0) || 0; + const sel = el("select", { onchange: () => set("parts", { ...o.parts, [n]: sel.value }) }, Object.entries(PARTS).map(([k, p]) => el("option", { value: k, text: p.label, selected: (o.parts[n] || "body") === k }))); + return el("div", { class: "ans-mesh" }, [el("span", { class: "mono grow", text: n, title: `${n}: ${verts.toLocaleString()} vertices` }), sel]); + }), + ); + const name = el("input", { type: "text", value: o.name, spellcheck: "false", onchange: () => { + const v = name.value.replace(/\.msh$/i, "").trim().toLowerCase(); + if (!/^[a-z0-9_\-]+$/.test(v)) return toast("Use a-z, 0-9, _ and - (the client's file names).", true); + o.name = v; + for (const p of PART_ORDER) if (COS.views[p]) COS.views[p].name = outName(p); + renderSide(); + renderChar(); + } }); + box.replaceChildren( + el("div", { class: "ans-grid" }, [ + ...field("Weights", weights), + ...(o.weights === "bone" ? field("Bone", bone) : []), + ...field("Scale", el("div", { class: "ans-vec" }, [scale, el("button", { class: "iconbtn only", title: `Auto: ${auto.scale.toFixed(3)} (${auto.by})`, onclick: () => (set("scale", 0), renderOpts()) }, [icon("refresh")])])), + ...field("Move x y z", el("div", { class: "ans-vec" }, off)), + ...field("Turn", rot), + ...field("Max bones", num(o.maxBones, (v) => set("maxBones", Math.max(1, Math.min(CK.MAX_BONES, Math.round(v) || CK.MAX_BONES))), "1")), + ...field("Save as", name), + ]), + el("div", { class: "ans-note", text: "Each mesh of the file is a part of the costume (saved and registered on its own):" }), + meshes, + el("label", { class: "ans-check", title: COS.file.data.joints.length ? "Finds the rig's hips, spine, head, arms, hands, legs and feet by their shape (any bone names) and reshapes the model to this hero's proportions (Man / Woman): limbs as long and thick as the hero's, the head as big, a cape and long hair hanging the body's length. The weights come from the rig, a cape goes onto the hero's Mantle bones." : "Needs the model's armature: export a .glb / .gltf with it (an .obj has no bones)" }, [el("input", { type: "checkbox", checked: !!o.fit, disabled: !COS.file.data.joints.length, onchange: (e) => set("fit", e.target.checked) }), "Fit to the LS body (" + (COS.sex === "woman" ? "woman" : "man") + ")"]), + o.fit ? el("label", { class: "ans-check", title: "On: the torso and hips take the hero's width but keep their own depth front to back (a bust, a bottom, a coat stay full). Off: as flat as the hero's body." }, [el("input", { type: "checkbox", checked: o.keepVolume !== false, onchange: (e) => set("keepVolume", e.target.checked) }), "Keep the body's volume (chest, hips)"]) : null, + o.fit ? el("div", { class: "ans-grid" }, [ + ...field("Head size %", el("div", { class: "ans-vec", title: "100 = as tall as the hero's head (chin to crown); the chin stays on the hero's chin" }, [num(Math.round((o.headScale || 1) * 100), (v) => set("headScale", Math.max(0.3, Math.min(3, (v || 100) / 100))), "5")])), + ...field("LS build %", el("div", { class: "ans-vec", title: "How thick arms, legs and torso are: 0 = the model's own build (kept near its shape), 100 = as thick as the hero's body everywhere, like the game's own costumes" }, [num(Math.round((o.lsBuild || 0) * 100), (v) => set("lsBuild", Math.max(0, Math.min(1, (v || 0) / 100))), "10")])), + ]) : null, + o.fit ? el("label", { class: "ans-check", title: "Takes only the clothes (and hair) of the model: its skin and face are cut away (the skin tone is read off its face's texture) and the costume's body mesh carries the hero's own body underneath — the Man / Woman button picks which. The hero's face shows; the hero's hands stay unless the model wears gloves." }, [el("input", { type: "checkbox", checked: !!o.outfit, onchange: (e) => { + o.outfit = e.target.checked; + if (o.outfit) o.hideFace = false; // the hero's own face shows under the hair + set("outfit", o.outfit); + renderOpts(); + } }), `Outfit only — on the LS ${COS.sex === "woman" ? "woman's" : "man's"} body`]) : null, + o.fit && o.outfit ? el("div", { class: "ans-grid" }, [ + ...field("Skin match %", el("div", { class: "ans-vec", title: "How close to the face's skin tone a texture colour must be to be cut away: lower if clothes in a skin-like colour (beige, pink) go missing, higher if bits of skin stay" }, [num(Math.round((o.skinTol ?? 1) * 100), (v) => set("skinTol", Math.max(0, Math.min(2, (v ?? 100) / 100))), "10")])), + ]) : null, + COS.out.helmet ? el("label", { class: "ans-check", title: "The helmet brings its own face: the hero's face is hidden (invisible_face = 1 when registered)" }, [el("input", { type: "checkbox", checked: o.hideFace, onchange: (e) => (set("hideFace", e.target.checked, false), hideForParts(), applyPose(), renderReg()) }), "Helmet hides the hero's face"]) : null, + Object.values(o.parts).includes("body") && !Object.values(o.parts).includes("hand") ? el("label", { class: "ans-check", title: "Faces that move with a hand or its fingers become a second mesh (hand_mesh, replaces default_hand): a mesh can use 24 bones, the body ~19 and the fingers 20. Only when the model has fingers." }, [el("input", { type: "checkbox", checked: o.splitHands, onchange: (e) => set("splitHands", e.target.checked) }), `Split the hands → ${outName("hand")}`]) : null, + el("label", { class: "ans-check", title: "Women look for _hw first; without it they wear the men's mesh" }, [el("input", { type: "checkbox", checked: o.hw, onchange: (e) => set("hw", e.target.checked, false) }), "Also save as _hw (women)"]), + el("div", { class: "ans-note", text: `Units: LS ≈ cm, the hero is ~140 tall; Blender metres × 100. Auto scale: ${auto.by}.` }), + ); + } + + function renderMats() { + const box = $("cos-mats"); + if (!anyOut()) return box.replaceChildren(el("div", { class: "ans-note", text: "—" })); + const o = COS.opts; + const used = usedMats(); + const rows = used.map((mi, k) => { + const m = COS.mats[mi]; + const texName = texNameOf(k); + return el("div", { class: "ans-mesh" }, [ + el("span", { class: "mono grow", text: `${m?.name || "no material"} → ${texName}`, title: m?.image ? `texture: ${m.image.name} ${m.img ? `${m.img.width}×${m.img.height}` : "(can't read)"}` : "no texture: a plain colour" }), + el("span", { class: m?.img ? "muted" : "warn", text: m?.img ? `${m.img.width}×${m.img.height}` : m?.wanted ? `${m.wanted}?` : "colour", title: m?.wanted ? `${m.wanted} isn't next to the model: Textures folder… (or the image button)` : "" }), + el("button", { class: "iconbtn only", title: "Use another texture (.png / .jpg / .dds / .tga)", onclick: () => pickTexture(mi) }, [icon("image")]), + ]); + }); + const max = el("select", { onchange: () => (o.texMax = Number(max.value)) }, [256, 512, 1024, 2048].map((v) => el("option", { value: v, text: `${v}`, selected: v === o.texMax }))); + const lost = used.filter((mi) => COS.mats[mi]?.wanted); + box.replaceChildren( + ...rows, + lost.length ? el("div", { class: "ans-note warn", text: `${lost.length} texture(s) not found: ${[...new Set(lost.map((mi) => COS.mats[mi].wanted))].join(", ")}.` }) : null, + lost.length ? el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", onclick: () => findTextures().catch((err) => toast(err.message, true)) }, [icon("folder"), "Textures folder…"])]) : null, + el("div", { class: "ans-grid" }, [...field("Texture max", max)]), + el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: o.outline, onchange: (e) => (o.outline = e.target.checked) }), "Toon outline (like the game's costumes)"]), + el("div", { class: "ans-note", text: "Textures are saved as 32-bit .dds (power-of-two sizes); a .dds you pick is kept as it is." }), + ); + } + /** Looks for the missing textures (by file name) in a folder the user picks. */ + async function findTextures() { + const folder = await call("dialog:folder", "Folder with the model's textures"); + if (!folder) return; + COS.texDir = folder.replace(/\\/g, "/"); + try { + localStorage.setItem("cos:texDir", COS.texDir); + } catch {} + let found = 0; + for (const m of COS.mats) { + if (!m?.wanted) continue; + const bytes = await call("mdl:readFile", `${COS.texDir}/${m.wanted}`).then((h) => h.bytes).catch(() => null); + if (!bytes) continue; + const image = { name: m.wanted, bytes, mime: /\.jpe?g$/i.test(m.wanted) ? "image/jpeg" : "image/png" }; + const img = await readImage(image).catch(() => null); + if (!img) continue; + Object.assign(m, { image, img, tex: view.texture(img), wanted: null }); + COS.texPix = null; + found++; + } + toast(found ? `Found ${found} texture(s).` : "None of the textures are in that folder.", !found); + if (found) (COS.dirty = true), rebuild(); + } + const texNameOf = (k) => `${COS.opts.name}${k ? `_${k + 1}` : ""}.dds`; + async function pickTexture(mi) { + const file = await call("dialog:openExt", "Texture for this material", "Images", ["png", "jpg", "jpeg", "dds", "tga", "bmp"]); + if (!file) return; + try { + const bytes = (await call("mdl:readFile", file)).bytes; + const name = file.split(/[\\/]/).pop(); + const image = { name, bytes, mime: /\.jpe?g$/i.test(name) ? "image/jpeg" : "image/png" }; + const img = await readImage(image); + const m = COS.mats[mi] || (COS.mats[mi] = { name: "material", color: [1, 1, 1, 1] }); + if (m.tex) view.gl.deleteTexture(m.tex); + Object.assign(m, { image, img, tex: view.texture(img) }); + COS.texPix = null; + COS.dirty = true; + rebuild(); + } catch (err) { + toast(`Can't read that image: ${err.message}`, true); + } + } + + function renderReport() { + const box = $("cos-report"); + const first = anyOut(); + if (!first) return box.replaceChildren(el("div", { class: "ans-note", text: "—" })); + const rows = []; + const warn = []; + for (const p of PART_ORDER) { + const r = COS.out[p]?.report; + if (!r) continue; + rows.push(...field(PARTS[p].label, el("span", { class: "mono", text: `${outName(p)} · ${r.vertices.toLocaleString()} v · ${r.faces.toLocaleString()} f · ${r.bones}/${CK.MAX_BONES} bones`, title: r.boneNames.join("\n") }))); + if (r.unweighted) warn.push(`${PARTS[p].label}: ${r.unweighted} vertices have no bone (they stay in place).`); + if (r.folded.length) warn.push(`${PARTS[p].label}: more than ${COS.opts.maxBones} bones, ${r.folded.length} merged into their parents (${r.folded.slice(0, 3).join(", ")}${r.folded.length > 3 ? "…" : ""}).`); + } + const r = first.report; + const fi = r.fit; + if (fi) { + const g = (k) => fi.info[k] || {}; + warn.push(`Fitted (rig: hips ${fi.joints.pelvis}, chest ${fi.joints.chest}, head ${fi.joints.head}, hands ${fi.joints.leftHand} / ${fi.joints.rightHand}${fi.joints.capes ? `, ${fi.joints.capes} cape chain(s)` : ""}): head ×${g("Bip01 Head").scale}, thighs ×${g("Bip01 L Thigh").length}, arms ×${g("Bip01 L UpperArm").length}, feet ×${g("Bip01 L Foot").length}, torso thickness ×${g("Bip01 Spine").thickness}.`); + } + const of = PART_ORDER.map((p) => COS.out[p]?.report.outfit).filter(Boolean); + if (of.length) { + const t = of[0].tone; + const hb = COS.out.body?.report.heroBody; + warn.push(`Outfit only: skin tone ${t ? `rgb(${t.join(", ")})` : "not found (no face texture) — only the face is cut"}; left out ${of.reduce((n, x) => n + x.skin, 0).toLocaleString()} skin and ${of.reduce((n, x) => n + x.face, 0).toLocaleString()} face faces.${hb ? ` The ${COS.sex}'s body is inside the body mesh: ${hb.kept.toLocaleString()} of its faces, ${hb.hidden.toLocaleString()} hidden under the clothes left out.` : ""}`); + } + if (COS.out.body?.handNote && COS.opts.splitHands) warn.push(`Split the hands: ${COS.out.body.handNote}.`); + if (r.mode === "body" && COS.out.body) warn.push("Long skirts / coats: weights from the body follow the closest leg; rig them in Blender for a better swing."); + box.replaceChildren( + el("div", { class: "ans-grid" }, [ + ...rows, + ...field("Weights", el("span", { text: r.mode === "file" ? `file's bones${r.fromBody ? ` (+${r.fromBody} from the body)` : ""}` : r.mode === "body" ? "from the hero body" : "one bone" })), + ...field("Scale", el("span", { class: "mono", text: r.scale.toFixed(3) })), + ]), + ...warn.map((w) => el("div", { class: "ans-note warn", text: w })), + ); + } + + // ===================================================================== + // register in game: a costume entry (costume_*.ini + its name) and the server's code list + // ===================================================================== + /** The costume INIs the built parts go into: armor (body / hands), helmet, cloak. */ + const kinds = () => [...new Set(PART_ORDER.filter((p) => COS.out[p]).map((p) => PARTS[p].ini))]; + const GRADES = [[0, "Normal"], [2, "Premium"], [3, "Rare"], [4, "Unique"]]; + const REG = { kind: null, info: {}, loading: null, form: null, last: null, serverFile: "" }; + try { + REG.serverFile = localStorage.getItem("cos:serverFile") || ""; + } catch {} + async function regInfo(kind, reload = false) { + if (!reload && REG.info[kind]) return REG.info[kind]; + REG.loading = kind; + renderReg(); + try { + REG.info[kind] = await call("cos:info", COS.src, kind); + } finally { + REG.loading = null; + } + return REG.info[kind]; + } + /** A new form for the current costume (kind from the slot, the next free code, the meshes it saves). */ + function newForm(kind, info) { + const o = COS.opts; + const last = info.list[info.list.length - 1]; + const meshes = {}; + for (const p of PART_ORDER) if (PARTS[p].ini === kind) meshes[PARTS[p].key] = COS.out[p] ? outName(p) : ""; + return { kind, code: info.next, name: COS.file ? stem(COS.file.name) : o.name, grade: 0, template: last?.section || "", icon: last?.icon || "", meshes, addServer: !!REG.serverFile }; + } + function renderReg() { + const box = $("cos-reg"); + if (!COS.src) return box.replaceChildren(el("div", { class: "ans-note", text: "—" })); + const ks = kinds(); + const kind = REG.kind && (ks.includes(REG.kind) || !ks.length) ? REG.kind : ks[0] || "armor"; + $("cos-reg-file").textContent = `costume_${kind}.ini`; + const info = REG.info[kind]; + if (!info) { + return box.replaceChildren( + el("div", { class: "ans-note", text: "Adds the costume to the game: an entry in config/costume/costume_*.ini (code, name, meshes), its name in the string tables, and its code in the server's sp2_costumemanager.ini." }), + el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", disabled: !!REG.loading, onclick: () => regInfo(kind).then(renderReg).catch((err) => toast(err.message, true)) }, [icon("refresh"), REG.loading ? "Reading the costume list…" : "Read the costume list"])]), + ); + } + if (!REG.form || REG.form.kind !== kind) REG.form = newForm(kind, info); + const f = REG.form; + // the costume was renamed / moved to another slot: its mesh names follow + const at = `${COS.opts.name}|${PART_ORDER.filter((p) => COS.out[p]).join(",")}`; + if (f.at && f.at !== at) Object.assign(f.meshes, newForm(kind, info).meshes); + f.at = at; + const kindSel = el("select", { onchange: () => ((REG.kind = kindSel.value), (REG.form = null), regInfo(REG.kind).then(renderReg).catch((err) => toast(err.message, true))) }, ["armor", "helmet", "cloak"].map((k) => el("option", { value: k, text: k, selected: k === kind }))); + const taken = new Set(info.list.map((c) => c.code)); + const code = el("input", { type: "number", step: "1", value: String(f.code), onchange: () => { + f.code = Number(code.value); + code.classList.toggle("bad", taken.has(f.code)); + if (taken.has(f.code)) toast(`${f.code} is already a costume (${info.list.find((c) => c.code === f.code).name}).`, true); + } }); + const name = el("input", { type: "text", value: f.name, placeholder: "shown in the game (any language)", oninput: () => (f.name = name.value) }); + const grade = el("select", { onchange: () => (f.grade = Number(grade.value)) }, GRADES.map(([v, t]) => el("option", { value: v, text: t, selected: v === f.grade }))); + const recent = info.list.slice(-40).reverse(); + const tpl = el("select", { title: "The other keys (glow colours, armour sound…) and the icon are copied from this costume", onchange: () => { + f.template = tpl.value; + const t = info.list.find((c) => c.section === f.template); + if (t?.icon) (f.icon = t.icon), (iconIn.value = t.icon); + } }, recent.map((c) => el("option", { value: c.section, text: `${c.code} ${c.name}`, selected: c.section === f.template }))); + const iconIn = el("input", { type: "text", value: f.icon, spellcheck: "false", placeholder: "imageset#icon (empty: the code's default)", onchange: () => (f.icon = iconIn.value.trim()) }); + const meshIns = Object.keys(f.meshes).map((k) => field(k, el("input", { type: "text", value: f.meshes[k], spellcheck: "false", placeholder: "(none)", onchange: (e) => (f.meshes[k] = e.target.value.trim().replace(/\.msh$/i, "")) }))); + const server = el("label", { class: "ans-check", title: REG.serverFile || "No file chosen" }, [ + el("input", { type: "checkbox", checked: f.addServer && !!REG.serverFile, disabled: !REG.serverFile, onchange: (e) => (f.addServer = e.target.checked) }), + REG.serverFile ? `Add the code to ${REG.serverFile.split(/[\\/]/).slice(-2).join("\\")}` : "Server list: not chosen", + ]); + const done = REG.last + ? el("div", { class: "ans-note" }, [ + `Registered [${REG.last.section}] = ${REG.last.code}. Give it to a player: Monitor → Gift items → `, + el("b", { class: "mono", text: `12.${REG.last.code}.0` }), + " (0 = for good).", + REG.last.server ? ` Server list: ${REG.last.server.added ? `added as [${REG.last.server.section}]` : `already [${REG.last.server.section}]`} — restart / reload the game server.` : " Add it to the server's sp2_costumemanager.ini too, else the server refuses it.", + ]) + : null; + box.replaceChildren( + el("div", { class: "ans-grid" }, [ + ...field("Kind", kindSel), + ...field("Code", code), + ...field("Name", name), + ...field("Grade", grade), + ...field("Copy from", tpl), + ...field("Icon", iconIn), + ...meshIns.flat(), + ]), + server, + el("div", { class: "ans-row" }, [ + el("button", { class: "iconbtn", title: "The server's config/sp2_costumemanager.ini (a copy you upload, or the server folder)", onclick: chooseServerFile }, [icon("folder"), "Server list…"]), + el("button", { class: "btn primary", onclick: () => register().catch((err) => toast(err.message, true)) }, [icon("plus"), "Register"]), + ks.length > 1 ? el("button", { class: "iconbtn", title: `One entry per part: ${ks.join(", ")} — the same name, each kind's next free code`, onclick: () => registerAll().catch((err) => toast(err.message, true)) }, [icon("plus"), `Register all (${ks.length})`]) : null, + ]), + done, + ); + } + async function chooseServerFile() { + const file = await call("dialog:openExt", "The game server's sp2_costumemanager.ini", "INI", ["ini"]); + if (!file) return; + REG.serverFile = file; + try { + localStorage.setItem("cos:serverFile", file); + } catch {} + if (REG.form) REG.form.addServer = true; + renderReg(); + } + /** What cos:register gets; a helmet takes the hero's hair off (and the face when it brings its own). */ + const entryOf = (f) => ({ + kind: f.kind, + code: f.code, + name: f.name.trim(), + grade: f.grade, + icon: f.icon, + meshes: f.meshes, + template: f.template, + serverFile: f.addServer ? REG.serverFile : null, + extra: f.kind === "helmet" ? { hair_change_hm: "1", hair_change_hw: "1", invisible_face: COS.opts.hideFace ? "1" : "0" } : {}, + }); + /** Every part at once: one entry per costume INI, the form's name / grade / icon, each kind's next code. */ + async function registerAll() { + const f = REG.form; + if (!f?.name.trim()) return toast("Give the costume a name.", true); + const list = []; + for (const k of kinds()) { + const info = await regInfo(k, true); + const nf = { ...newForm(k, info), name: f.name, grade: f.grade, icon: f.icon, addServer: f.addServer }; + list.push(nf); + } + const missing = []; + for (const nf of list) for (const m of Object.values(nf.meshes).filter(Boolean)) if (!(await readNamed(`${m}.msh`, "mesh"))) missing.push(m); + const body = el("div", {}, [ + el("p", { text: `"${f.name}" as ${list.length} costumes:` }), + el("div", { class: "mono ans-note" }, list.map((nf) => el("div", { text: `costume_${nf.kind}.ini ${nf.code} ${Object.entries(nf.meshes).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(" ")}` }))), + missing.length ? el("p", { class: "warn", text: `Not in the packs yet: ${missing.join(", ")} — Save into the packs first.` }) : null, + ]); + if (!(await modal("Register every part", body, [{ label: "Cancel", value: false }, { label: "Register all", kind: "primary", value: true }]))) return; + loading(true, "Registering…"); + try { + const done = []; + for (const nf of list) { + const r = await call("cos:register", COS.src, entryOf(nf)); + for (const w of r.warnings) toast(w, true); + done.push(r); + await regInfo(nf.kind, true); + } + REG.form = null; + REG.last = done[done.length - 1]; + REG.lastAll = done; + renderReg(); + toast(`Registered ${done.map((r) => r.code).join(", ")}. Gifts: ${done.map((r) => `12.${r.code}.0`).join(" ")}`); + } finally { + loading(false); + } + } + async function register() { + const f = REG.form; + const info = REG.info[f.kind]; + if (!f.name.trim()) return toast("Give the costume a name.", true); + if (info.list.some((c) => c.code === f.code)) return toast(`${f.code} is already a costume.`, true); + const meshes = Object.entries(f.meshes).filter(([, v]) => v); + if (!meshes.length) return toast("Fill in the mesh it wears (e.g. body_mesh).", true); + // the meshes should be in the packs already (Save first) + const missing = []; + for (const [, m] of meshes) if (!(await readNamed(`${m}.msh`, "mesh"))) missing.push(m); + const body = el("div", {}, [ + el("p", { text: `[costume${info.count + 1}] in config/costume/costume_${f.kind}.ini:` }), + el("div", { class: "mono ans-note" }, [`costum_code = ${f.code}`, `name = STR(1) → "${f.name}" (${info.texts.map((t) => `${t.file} ${t.encoding}`).join(", ")})`, ...meshes.map(([k, v]) => `${k} = ${v}`), f.icon ? `icon_name = ${f.icon}` : ""].filter(Boolean).map((t) => el("div", { text: t }))), + missing.length ? el("p", { class: "warn", text: `Not in the packs yet: ${missing.join(", ")}.msh — Save the costume into the packs first, else the game shows nothing.` }) : null, + f.addServer && REG.serverFile ? el("p", { class: "muted", text: `+ item_code = ${f.code} in ${REG.serverFile} (the old one is kept as .bak).` }) : el("p", { class: "warn", text: "The server's sp2_costumemanager.ini isn't changed: add the code there yourself." }), + ]); + if (!(await modal("Register the costume", body, [{ label: "Cancel", value: false }, { label: "Register", kind: "primary", value: true }]))) return; + loading(true, "Writing the costume list and the string tables…"); + try { + const r = await call("cos:register", COS.src, entryOf(f)); + REG.last = r; + for (const w of r.warnings) toast(w, true); + toast(`Registered ${f.code}: ${r.written.length} files written.`); + await regInfo(f.kind, true); + REG.form = null; + renderReg(); + REG.last = r; + renderReg(); + } finally { + loading(false); + } + } + + // ===================================================================== + // save + // ===================================================================== + /** Pixels of an image at a power-of-two size (≤ max). → { rgba, w, h } */ + function pixels(img, max) { + const p2 = (n) => Math.min(max, 2 ** Math.round(Math.log2(Math.max(1, n)))); + const w = p2(img.width), h = p2(img.height); + const c = new OffscreenCanvas(w, h); + const g = c.getContext("2d"); + if (img instanceof ImageData) { + const t = new OffscreenCanvas(img.width, img.height); + t.getContext("2d").putImageData(img, 0, 0); + g.drawImage(t, 0, 0, w, h); + } else g.drawImage(img, 0, 0, w, h); + return { rgba: g.getImageData(0, 0, w, h).data, w, h }; + } + /** Every file of the costume: [{ folder: mesh|material|texture, name, data }] */ + /** The file materials the costume uses (body, then hands): texture k is [_k+1].dds. */ + const usedMats = () => [...new Set(PART_ORDER.flatMap((p) => COS.out[p]?.groups || []).map((g) => g.material).filter((m) => typeof m === "number"))]; + function files() { + const o = COS.opts; + if (!anyOut() || !o.name) throw new Error("Nothing to save."); + const used = usedMats(); + const out = []; + used.forEach((mi, k) => { + const m = COS.mats[mi]; + let data; + if (m?.image && /\.dds$/i.test(m.image.name)) data = m.image.bytes; + else if (m?.img) { + const p = pixels(m.img, o.texMax); + data = CK.encodeDds(p.rgba, p.w, p.h); + } else { + const c = (m?.color || [0.8, 0.8, 0.8, 1]).map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255)); + const px = new Uint8Array(8 * 8 * 4); + for (let i = 0; i < 64; i++) px.set([c[0], c[1], c[2], 255], i * 4); + data = CK.encodeDds(px, 8, 8); + } + out.push({ folder: "texture", name: texNameOf(k), data }); + }); + const meshes = []; + for (const [part, name] of PART_ORDER.filter((p) => COS.out[p]).map((p) => [COS.out[p], outName(p)])) { + const msh = LSF.buildMsh(part.data); + const mat = new TextEncoder().encode( + part.groups + .map((g) => { + const ls = lsMat(g.material); + if (!ls) return CK.materialText([texNameOf(used.indexOf(g.material))], { outline: o.outline }); + // the hero's own body: its material as the game has it (the skin colour blend) + const block = ls.mesh.mat ? materialBlocks(ls.mesh.mat.text)[ls.sub] : null; + return block ? block.replace(/\r?\n/g, "\r\n") + "\r\n" : CK.materialText([ls.group?.texName || null], { outline: o.outline }); + }) + .join(""), + ); + meshes.push({ folder: "mesh", name: `${name}.msh`, data: msh }, { folder: "material", name: `${name}.txt`, data: mat }); + if (o.hw) out.push({ folder: "mesh", name: `${name}_hw.msh`, data: msh }, { folder: "material", name: `${name}_hw.txt`, data: mat }); + } + out.unshift(...meshes); + return out; + } + const REF = { mesh: "human_body.msh", material: "human_body.txt", texture: "human_body.dds" }; + async function saveIntoPacks() { + const list = files(); + // a file that's already in the packs is replaced where it is; a new one goes next to the hero body's + const plan = []; + for (const f of list) { + const hit = await readNamed(f.name, f.folder); + const ref = hit || (await readNamed(REF[f.folder], f.folder)); + if (ref?.where?.kind !== "pack") throw new Error(`No pack with resource/${f.folder} (or the source is an extracted folder): use Save to a folder.`); + const dir = ref.where.entry.replace(/[^/\\]+$/, ""); + plan.push({ f, where: { kind: "pack", file: ref.where.file, entry: hit ? ref.where.entry : `${dir}${f.name}`, create: !hit }, replace: !!hit }); + } + const replaced = plan.filter((p) => p.replace); + const body = el("div", {}, [ + el("p", { text: `${plan.length} files go into the packs:` }), + el("div", { class: "mono ans-note" }, plan.map((p) => el("div", { text: `${p.replace ? "replace" : "new "} ${p.where.file.split(/[\\/]/).pop()} ${p.where.entry}` }))), + replaced.length ? el("p", { class: "warn", text: `${replaced.length} existing file(s) are replaced — every costume that uses them changes in the game.` }) : null, + el("p", { class: "muted", text: `In the game: register it (Register in game) — ${PART_ORDER.filter((p) => COS.out[p]).map((p) => `${PARTS[p].key} = ${outName(p)}`).join(", ")}.` }), + ]); + if (!(await modal("Save the costume into the packs", body, [{ label: "Cancel", value: false }, { label: "Save", kind: "primary", value: true }]))) return; + loading(true, "Saving…"); + try { + for (const p of plan) await call("mdl:writeBack", p.where, p.f.data); + kit.clearTextures(); + COS.dirty = false; + $("cos-dirty").hidden = true; + toast(`Saved ${plan.length} files into the packs.`); + loadList(); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + async function saveToFolder() { + const list = files(); + const folder = await call("dialog:folder", "Folder to save the costume in (resource/… is made inside)"); + if (!folder) return; + try { + for (const sub of ["mesh", "material", "texture"]) { + const fs = list.filter((f) => f.folder === sub); + if (fs.length) await call("mdl:writeFiles", `${folder}/resource/${sub}`, fs.map((f) => ({ name: f.name, data: f.data }))); + } + COS.dirty = false; + $("cos-dirty").hidden = true; + toast(`Saved ${list.length} files in ${folder}\\resource.`); + } catch (err) { + toast(err.message, true); + } + } + $("cos-save").onclick = (e) => + showMenu( + [ + { label: `Save into the packs (${PART_ORDER.filter((p) => COS.out[p]).map(outName).join(", ")})`, icon: "box", disabled: !anyOut(), run: () => saveIntoPacks().catch((err) => toast(err.message, true)) }, + { label: "Save to a folder…", icon: "download", disabled: !anyOut(), run: () => saveToFolder().catch((err) => toast(err.message, true)) }, + ], + { anchor: e.currentTarget }, + ); + + // ===================================================================== + // the body for Blender + // ===================================================================== + async function png(img) { + const c = new OffscreenCanvas(img.width, img.height); + const g = c.getContext("2d"); + if (img instanceof ImageData) g.putImageData(img, 0, 0); + else g.drawImage(img, 0, 0); + return new Uint8Array(await (await c.convertToBlob({ type: "image/png" })).arrayBuffer()); + } + /** What is shown, as a rigged .glb. → { bytes, meshes } */ + async function bodyGlb() { + const shown = allMeshes().filter((m) => !m.hidden); + const images = [], meshes = []; + for (const m of shown) { + const p = CK.posed(COS.sk, COS.P, m.data); + const groups = []; + for (const [gi, g] of m.groups.entries()) { + let image = null; + const ls = m.costume ? lsMat(m.built.groups[gi].material) : null; + const img = ls ? ls.group?.texInfo?.img : m.costume ? COS.mats[m.built.groups[gi].material]?.img : g.texInfo?.img; + const nm = m.costume && !ls ? `${m.name}_${gi + 1}.png` : (ls ? ls.group : g)?.texName ? (ls ? ls.group : g).texName.replace(/\.[^.]+$/, ".png") : null; + if (img && nm) { + if (!images.some((x) => x.name === nm)) images.push({ name: nm, png: await png(img) }); + image = nm; + } + groups.push({ start: g.start, count: g.count, image }); + } + meshes.push({ name: m.name, ...p, groups }); + } + return { bytes: CK.exportGlb(COS.sk, COS.P, meshes, images), meshes }; + } + async function exportBody() { + if (!COS.sk && !(await chooseSource())) return; + if (!COS.sk) return; + const { bytes, meshes } = await bodyGlb(); + const file = await call("dialog:saveAs", `lostsaga_${COS.sex}_body.glb`, "glb"); + if (!file) return; + await call("mdl:writeBack", { kind: "file", file }, bytes); + toast(`Saved ${file}: ${meshes.length} meshes + the ${COS.sk.data.bones.length}-bone skeleton. In Blender: File → Import → glTF 2.0.`); + } + $("cos-blender").onclick = () => exportBody().catch((err) => toast(err.message, true)); + + // ===================================================================== + // keys + first show + // ===================================================================== + document.addEventListener("keydown", (e) => { + if (!visible() || e.target.closest?.("input, textarea, select") || !$("modal").hidden) return; + if (e.key === " ") { + e.preventDefault(); + $("cos-play").onclick(); + } + }); + // the hero appears when the tab is first opened with the profile's packs + new MutationObserver(() => { + if (visible() && !COS.src && !view) { + const s = profileSource(); + if (s) setSource(s); + } + }).observe($("view-cos"), { attributes: true, attributeFilter: ["class"] }); + Object.assign(COS, { REG, regInfo, importFile, exportBody, bodyGlb, files, rebuild, playAni, setSource, loadHero }); + renderSide(); +})(); diff --git a/app/public/legacy/js/costumekit.js b/app/public/legacy/js/costumekit.js new file mode 100644 index 0000000..dc55dc3 --- /dev/null +++ b/app/public/legacy/js/costumekit.js @@ -0,0 +1,2426 @@ +"use strict"; + +// Costume maths (Costume tab), pure and node-testable: the hero body as a .glb for Blender, glTF / +// OBJ imports, weights from the file's bones or taken over from the LS body, the bind-pose fix of +// linked skeletons and the new .msh fields, material script and .dds. +// +// Spaces: "posed" = the skeleton's rest pose (SkinKit.pose without an animation) in LS units, where +// every mesh is rigidly skinned; a vertex is stored as u · A⁻¹ with A = Σ w · BipedSpaceTM · World so +// the game puts it back exactly. glTF: right-handed, metres → LS (x, y, -z) · 100, winding reversed. + +const CostumeKit = (() => { + const SK = window.SkinKit; + const M4 = SK.M4; + const UNIT = 100; // LS units per glTF metre + const MAX_BONES = 24; // no client mesh uses more (7 509 skinned meshes checked) + + const qmul = (a, b) => [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + ]; + const qconj = (q) => [-q[0], -q[1], -q[2], q[3]]; + function qrot(q, v) { + const [x, y, z, w] = q; + const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0]; + const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux; + return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)]; + } + // row-vector affine maths (SkinKit's layout; a glTF column-major matrix array is the same numbers) + const xf = (M, x, y, z, o, i) => { + o[i] = x * M[0] + y * M[4] + z * M[8] + M[12]; + o[i + 1] = x * M[1] + y * M[5] + z * M[9] + M[13]; + o[i + 2] = x * M[2] + y * M[6] + z * M[10] + M[14]; + }; + const xn = (M, x, y, z, o, i) => { + o[i] = x * M[0] + y * M[4] + z * M[8]; + o[i + 1] = x * M[1] + y * M[5] + z * M[9]; + o[i + 2] = x * M[2] + y * M[6] + z * M[10]; + }; + function trs(t = [0, 0, 0], q = [0, 0, 0, 1], s = [1, 1, 1]) { + const m = M4.fromQT(q, t); + for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) m[r * 4 + c] *= s[r]; + return m; + } + const normName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, ""); + + // ===================================================================== + // the LS side: rest pose, meshes in it + // ===================================================================== + /** The skeleton's rest pose (wp, wr, skin) — where the costume is modelled. */ + const restPose = (sk) => SK.pose(sk, null, null, 0); + + /** + * An LS mesh (parseMsh fields) in the rest pose: posed positions / normals, and its weights by + * skeleton bone index (4 per vertex). → { pos, nrm, uv, idx, bone, w, subs } + */ + function posed(sk, P, data) { + const pal = SK.palette(sk, data); + const m = { data, skinPos: new Float32Array(data.pos.length), skinNrm: data.normal ? new Float32Array(data.normal.length) : null }; + if (pal) SK.skin(m, pal, P); + else m.skinPos.set(data.pos); + const V = data.vertexCount ?? data.pos.length / 3; + const bone = new Int16Array(V * 4).fill(-1); + const w = new Float32Array(V * 4); + if (pal) for (let i = 0; i < V * 4; i++) { + const b = pal[data.boneIdx[i]]; + if (data.weights[i] > 0 && b >= 0) { + bone[i] = b; + w[i] = data.weights[i]; + } + } + return { pos: m.skinPos, nrm: m.skinNrm, uv: data.uv0, idx: data.indices, bone, w, subs: data.subs }; + } + + // ===================================================================== + // .glb export (the body, or any LS mesh, for Blender) + // ===================================================================== + /** + * meshes: [{ name, pos, nrm, uv, idx, bone, w, groups: [{ start, count, image }] }] in the rest + * pose (posed()); images: [{ name, png }]. Every skeleton bone becomes a joint. + */ + function exportGlb(sk, P, meshes, images = []) { + const bin = []; + let binLen = 0; + const json = { asset: { version: "2.0", generator: "LS Tools · Costume" }, scene: 0, scenes: [{ nodes: [] }], nodes: [], meshes: [], accessors: [], bufferViews: [], buffers: [], materials: [], textures: [], images: [], samplers: [{ magFilter: 9729, minFilter: 9729 }], skins: [] }; + function view(bytes, target) { + const pad = (4 - (binLen % 4)) % 4; + if (pad) bin.push(new Uint8Array(pad)), (binLen += pad); + json.bufferViews.push(Object.assign({ buffer: 0, byteOffset: binLen, byteLength: bytes.byteLength }, target ? { target } : {})); + bin.push(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)); + binLen += bytes.byteLength; + return json.bufferViews.length - 1; + } + function acc(arr, type, componentType, target, extra = {}) { + const n = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 }[type]; + json.accessors.push(Object.assign({ bufferView: view(arr, target), componentType, count: arr.length / n, type }, extra)); + return json.accessors.length - 1; + } + // joints: the bones in the rest pose (LS → glTF: z mirrored, metres) + const n = sk.data.bones.length; + const toG = (p) => [p[0] / UNIT, p[1] / UNIT, -p[2] / UNIT]; + const qG = (q) => [-q[0], -q[1], q[2], q[3]]; + const ibm = new Float32Array(n * 16); + for (let i = 0; i < n; i++) { + const pi = sk.parent[i]; + const node = { name: sk.data.bones[i].name.text }; + let q = P.wr[i], t = P.wp[i]; + if (pi >= 0) { + const c = qconj(P.wr[pi]); + q = qmul(c, P.wr[i]); + t = qrot(c, [P.wp[i][0] - P.wp[pi][0], P.wp[i][1] - P.wp[pi][1], P.wp[i][2] - P.wp[pi][2]]); + } + const ql = Math.hypot(...q); + node.translation = toG(t); + node.rotation = qG(q.map((v) => v / ql)); + json.nodes.push(node); + ibm.set(M4.invertAffine(M4.fromQT(qG(P.wr[i]), toG(P.wp[i]))), i * 16); + } + sk.parent.forEach((pi, i) => pi >= 0 && (json.nodes[pi].children ||= []).push(i)); + const roots = sk.parent.map((p, i) => (p < 0 ? i : -1)).filter((i) => i >= 0); + json.skins.push({ name: "LS skeleton", joints: [...Array(n).keys()], inverseBindMatrices: acc(ibm, "MAT4", 5126), skeleton: roots[0] }); + // textures + const imgIndex = new Map(); + for (const im of images) { + if (!im?.png || imgIndex.has(im.name)) continue; + json.images.push({ name: im.name, mimeType: "image/png", bufferView: view(im.png) }); + json.textures.push({ sampler: 0, source: json.images.length - 1 }); + imgIndex.set(im.name, json.textures.length - 1); + } + const matIndex = new Map(); + const material = (image) => { + const key = image || ""; + if (!matIndex.has(key)) { + const m = { name: image ? image.replace(/\.[^.]+$/, "") : "plain", pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1 }, doubleSided: true }; + if (image && imgIndex.has(image)) m.pbrMetallicRoughness.baseColorTexture = { index: imgIndex.get(image) }; + else m.pbrMetallicRoughness.baseColorFactor = [0.8, 0.8, 0.8, 1]; + json.materials.push(m); + matIndex.set(key, json.materials.length - 1); + } + return matIndex.get(key); + }; + // meshes: one primitive per draw group + for (const m of meshes) { + const V = m.pos.length / 3; + const pos = new Float32Array(V * 3), nrm = m.nrm ? new Float32Array(V * 3) : null; + const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; + for (let v = 0; v < V; v++) { + const p = toG([m.pos[v * 3], m.pos[v * 3 + 1], m.pos[v * 3 + 2]]); + pos.set(p, v * 3); + for (let k = 0; k < 3; k++) (min[k] = Math.min(min[k], p[k])), (max[k] = Math.max(max[k], p[k])); + if (nrm) { + const x = m.nrm[v * 3], y = m.nrm[v * 3 + 1], z = -m.nrm[v * 3 + 2]; + const l = Math.hypot(x, y, z) || 1; + nrm.set([x / l, y / l, z / l], v * 3); + } + } + const joints = new Uint16Array(V * 4), weights = new Float32Array(V * 4); + let skinned = false; + for (let v = 0; v < V; v++) { + let t = 0; + for (let j = 0; j < 4; j++) if (m.bone[v * 4 + j] >= 0) t += m.w[v * 4 + j]; + for (let j = 0; j < 4; j++) { + const b = m.bone[v * 4 + j]; + if (b >= 0 && t > 0) { + joints[v * 4 + j] = b; + weights[v * 4 + j] = m.w[v * 4 + j] / t; + skinned = true; + } + } + if (t <= 0) weights[v * 4] = 1, (joints[v * 4] = roots[0]); + } + const attributes = { POSITION: acc(pos, "VEC3", 5126, 34962, { min, max }) }; + if (nrm) attributes.NORMAL = acc(nrm, "VEC3", 5126, 34962); + if (m.uv) attributes.TEXCOORD_0 = acc(Float32Array.from(m.uv), "VEC2", 5126, 34962); + if (skinned) { + attributes.JOINTS_0 = acc(joints, "VEC4", 5123, 34962); + attributes.WEIGHTS_0 = acc(weights, "VEC4", 5126, 34962); + } + const groups = m.groups?.length ? m.groups : [{ start: 0, count: m.idx.length, image: null }]; + const primitives = groups.map((g) => { + const ix = new Uint32Array(g.count); + for (let i = 0; i < g.count; i += 3) { + // mirrored: clockwise (D3D) faces become counter-clockwise ones by swapping two corners + ix[i] = m.idx[g.start + i]; + ix[i + 1] = m.idx[g.start + i + 2]; + ix[i + 2] = m.idx[g.start + i + 1]; + } + return { attributes, indices: acc(ix, "SCALAR", 5125, 34963), material: material(g.image) }; + }); + json.meshes.push({ name: m.name, primitives }); + json.nodes.push(Object.assign({ name: m.name, mesh: json.meshes.length - 1 }, skinned ? { skin: 0 } : {})); + json.scenes[0].nodes.push(json.nodes.length - 1); + } + json.scenes[0].nodes.push(...roots); + for (const k of ["materials", "textures", "images"]) if (!json[k].length) delete json[k]; + if (!json.images) delete json.samplers; + json.buffers.push({ byteLength: binLen }); + return glbPack(json, bin, binLen); + } + + function glbPack(json, chunks, binLen) { + const enc = new TextEncoder().encode(JSON.stringify(json)); + const jl = Math.ceil(enc.length / 4) * 4; + const bl = Math.ceil(binLen / 4) * 4; + const out = new Uint8Array(12 + 8 + jl + 8 + bl); + const dv = new DataView(out.buffer); + dv.setUint32(0, 0x46546c67, true); // glTF + dv.setUint32(4, 2, true); + dv.setUint32(8, out.length, true); + dv.setUint32(12, jl, true); + dv.setUint32(16, 0x4e4f534a, true); // JSON + out.set(enc, 20); + out.fill(0x20, 20 + enc.length, 20 + jl); + dv.setUint32(20 + jl, bl, true); + dv.setUint32(24 + jl, 0x004e4942, true); // BIN + let o = 28 + jl; + for (const c of chunks) out.set(c, o), (o += c.length); + return out; + } + + // ===================================================================== + // glTF / .glb import + // ===================================================================== + const COMP = { 5120: [Int8Array, 1, 127], 5121: [Uint8Array, 1, 255], 5122: [Int16Array, 2, 32767], 5123: [Uint16Array, 2, 65535], 5125: [Uint32Array, 4, 0], 5126: [Float32Array, 4, 0] }; + const SIZE = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }; + + function b64(s) { + if (typeof atob === "function") return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + return new Uint8Array(Buffer.from(s, "base64")); + } + const dataUri = (uri) => { + const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(uri); + return m ? (m[2] ? b64(m[3]) : new TextEncoder().encode(decodeURIComponent(m[3]))) : null; + }; + + /** + * A .glb / .gltf → { meshes: [{ name, prims: [{ pos, nrm, uv, idx, joints, weights, material }] }], + * joints: [{ name, parent, world }], materials: [{ name, image, color }], images: [{ name, bytes, mime }] } + * with every position already where the file shows it (node transforms, skins at their rest pose). + * readRel(uri) → Uint8Array for external .bin / images. + */ + async function loadGltf(bytes, readRel = null) { + let json, binChunk = null; + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length >= 12 && dv.getUint32(0, true) === 0x46546c67) { + let o = 12; + while (o + 8 <= bytes.length) { + const len = dv.getUint32(o, true), type = dv.getUint32(o + 4, true); + const chunk = bytes.subarray(o + 8, o + 8 + len); + if (type === 0x4e4f534a) json = JSON.parse(new TextDecoder().decode(chunk)); + else if (type === 0x004e4942) binChunk = chunk; + o += 8 + len; + } + } else json = JSON.parse(new TextDecoder().decode(bytes)); + if (!json) throw new Error("Not a glTF file."); + if (!/^2/.test(json.asset?.version || "")) throw new Error(`glTF ${json.asset?.version || "?"}: only glTF 2.0 files can be read (export again from Blender).`); + const buffers = []; + for (const [i, b] of (json.buffers || []).entries()) { + if (b.uri == null) buffers.push(binChunk); + else if (b.uri.startsWith("data:")) buffers.push(dataUri(b.uri)); + else if (readRel) buffers.push(await readRel(decodeURIComponent(b.uri))); + else throw new Error(`The .bin file ${b.uri} is needed.`); + if (!buffers[i]) throw new Error(`Buffer ${i} is missing.`); + } + const viewBytes = (vi) => { + const v = json.bufferViews[vi]; + const b = buffers[v.buffer]; + return { bytes: b.subarray(v.byteOffset || 0, (v.byteOffset || 0) + v.byteLength), stride: v.byteStride || 0 }; + }; + function read(ai) { + const a = json.accessors[ai]; + const n = SIZE[a.type]; + const [T, size, norm] = COMP[a.componentType]; + const out = new Float32Array(a.count * n); + if (a.bufferView != null) { + const { bytes: vb, stride } = viewBytes(a.bufferView); + const step = stride || size * n; + const d = new DataView(vb.buffer, vb.byteOffset, vb.byteLength); + const get = { 5120: "getInt8", 5121: "getUint8", 5122: "getInt16", 5123: "getUint16", 5125: "getUint32", 5126: "getFloat32" }[a.componentType]; + for (let i = 0; i < a.count; i++) for (let k = 0; k < n; k++) { + let v = d[get]((a.byteOffset || 0) + i * step + k * size, true); + if (a.normalized && norm) v = Math.max(v / norm, -1); + out[i * n + k] = v; + } + } + if (a.sparse) { + const s = a.sparse; + const ib = viewBytes(s.indices.bufferView).bytes, vb = viewBytes(s.values.bufferView).bytes; + const id = new DataView(ib.buffer, ib.byteOffset + (s.indices.byteOffset || 0)); + const vd = new DataView(vb.buffer, vb.byteOffset + (s.values.byteOffset || 0)); + const ig = { 5121: ["getUint8", 1], 5123: ["getUint16", 2], 5125: ["getUint32", 4] }[s.indices.componentType]; + for (let i = 0; i < s.count; i++) { + const at = id[ig[0]](i * ig[1], true); + for (let k = 0; k < n; k++) out[at * n + k] = vd.getFloat32((i * n + k) * 4, true); + } + } + void T; + return out; + } + // node world matrices (rest: the file's own TRS) + const nodes = json.nodes || []; + const local = nodes.map((nd) => (nd.matrix ? Float64Array.from(nd.matrix) : trs(nd.translation, nd.rotation, nd.scale))); + const parent = new Int32Array(nodes.length).fill(-1); + nodes.forEach((nd, i) => (nd.children || []).forEach((c) => (parent[c] = i))); + const world = new Array(nodes.length); + const worldOf = (i) => world[i] || (world[i] = parent[i] >= 0 ? M4.mul(local[i], worldOf(parent[i])) : local[i]); + // joints of every skin, by node + const jointIndex = new Map(); + const joints = []; + for (const s of json.skins || []) for (const ni of s.joints) if (!jointIndex.has(ni)) { + jointIndex.set(ni, joints.length); + joints.push({ node: ni, name: nodes[ni].name || `joint${ni}`, parent: -1, world: worldOf(ni) }); + } + for (const j of joints) { + let p = parent[j.node]; + while (p >= 0 && !jointIndex.has(p)) p = parent[p]; + j.parent = p >= 0 ? jointIndex.get(p) : -1; + } + const materials = (json.materials || []).map((m, i) => { + const pbr = m.pbrMetallicRoughness || {}; + const t = pbr.baseColorTexture; + return { name: m.name || `material${i}`, image: t ? (json.textures?.[t.index]?.source ?? -1) : -1, uvSet: t?.texCoord || 0, color: pbr.baseColorFactor || [1, 1, 1, 1], blend: m.alphaMode === "BLEND" || m.alphaMode === "MASK" }; + }); + const images = []; + for (const [i, im] of (json.images || []).entries()) { + let b = null; + try { + if (im.bufferView != null) b = viewBytes(im.bufferView).bytes; + else if (im.uri?.startsWith("data:")) b = dataUri(im.uri); + else if (im.uri && readRel) b = await readRel(decodeURIComponent(im.uri)); + } catch { + b = null; + } + images.push({ name: im.name || (im.uri && !im.uri.startsWith("data:") ? im.uri.split(/[\\/]/).pop() : `image${i}`), bytes: b, mime: im.mimeType || (/\.jpe?g$/i.test(im.uri || "") ? "image/jpeg" : "image/png") }); + } + // mesh instances + const meshes = []; + nodes.forEach((nd, ni) => { + if (nd.mesh == null) return; + const mesh = json.meshes[nd.mesh]; + const skin = nd.skin != null ? json.skins[nd.skin] : null; + // a skinned vertex at the rest pose: v · IBM · JointWorld (the mesh node's own transform is ignored) + let mats = null, jmap = null; + if (skin) { + const ib = skin.inverseBindMatrices != null ? read(skin.inverseBindMatrices) : null; + mats = skin.joints.map((jn, k) => M4.mul(ib ? Float64Array.from(ib.subarray(k * 16, k * 16 + 16)) : trs(), worldOf(jn))); + jmap = skin.joints.map((jn) => jointIndex.get(jn)); + } + const W = worldOf(ni); + const prims = []; + for (const p of mesh.primitives) { + const mode = p.mode ?? 4; + if (mode !== 4 && mode !== 5 && mode !== 6) continue; + const A = p.attributes; + if (A.POSITION == null) continue; + const src = read(A.POSITION); + const V = src.length / 3; + const nsrc = A.NORMAL != null ? read(A.NORMAL) : null; + const mat = p.material != null ? materials[p.material] : null; + const uvKey = `TEXCOORD_${mat?.uvSet || 0}`; + const uv = A[uvKey] != null ? read(A[uvKey]) : A.TEXCOORD_0 != null ? read(A.TEXCOORD_0) : null; + const pos = new Float32Array(V * 3), nrm = nsrc ? new Float32Array(V * 3) : null; + let jw = null, ww = null; + if (skin && A.JOINTS_0 != null && A.WEIGHTS_0 != null) { + const j0 = read(A.JOINTS_0), w0 = read(A.WEIGHTS_0); + const j1 = A.JOINTS_1 != null ? read(A.JOINTS_1) : null, w1 = A.WEIGHTS_1 != null ? read(A.WEIGHTS_1) : null; + const k = j1 && w1 ? 8 : 4; + jw = new Int32Array(V * k); + ww = new Float32Array(V * k); + for (let v = 0; v < V; v++) { + for (let i = 0; i < 4; i++) (jw[v * k + i] = jmap[j0[v * 4 + i]] ?? -1), (ww[v * k + i] = w0[v * 4 + i]); + if (k === 8) for (let i = 0; i < 4; i++) (jw[v * k + 4 + i] = jmap[j1[v * 4 + i]] ?? -1), (ww[v * k + 4 + i] = w1[v * 4 + i]); + // rest-pose position + const M = new Float64Array(16); + let t = 0; + for (let i = 0; i < 4; i++) { + const w = w0[v * 4 + i]; + if (w <= 0) continue; + const S = mats[j0[v * 4 + i]]; + if (!S) continue; + for (let e = 0; e < 16; e++) M[e] += w * S[e]; + t += w; + } + if (k === 8) for (let i = 0; i < 4; i++) { + const w = w1[v * 4 + i]; + const S = w > 0 && mats[j1[v * 4 + i]]; + if (!S) continue; + for (let e = 0; e < 16; e++) M[e] += w * S[e]; + t += w; + } + const use = t > 0 ? M.map((e) => e / t) : W; + xf(use, src[v * 3], src[v * 3 + 1], src[v * 3 + 2], pos, v * 3); + if (nrm) xn(use, nsrc[v * 3], nsrc[v * 3 + 1], nsrc[v * 3 + 2], nrm, v * 3); + } + jw.k = k; + } else + for (let v = 0; v < V; v++) { + xf(W, src[v * 3], src[v * 3 + 1], src[v * 3 + 2], pos, v * 3); + if (nrm) xn(W, nsrc[v * 3], nsrc[v * 3 + 1], nsrc[v * 3 + 2], nrm, v * 3); + } + let idx = p.indices != null ? Uint32Array.from(read(p.indices)) : Uint32Array.from({ length: V }, (_, i) => i); + if (mode === 5) idx = strip(idx); + if (mode === 6) idx = fan(idx); + // a mirrored node (negative scale) turns its faces inside out + if (det3(skin ? null : W) < 0) for (let i = 0; i < idx.length; i += 3) [idx[i + 1], idx[i + 2]] = [idx[i + 2], idx[i + 1]]; + prims.push(compact({ pos, nrm, uv, idx, joints: jw, weights: ww, material: p.material ?? -1 })); + } + if (prims.length) meshes.push({ name: nd.name || mesh.name || `mesh${meshes.length}`, prims }); + }); + if (!meshes.length) throw new Error("No meshes in this file."); + return { meshes, joints, materials, images, ccw: true }; + } + /** Only the vertices a primitive's faces use (primitives often share one vertex list), in order. */ + function compact(p) { + const V = p.pos.length / 3; + const used = new Int32Array(V).fill(-1); + for (const i of p.idx) used[i] = 0; + let n = 0; + for (let v = 0; v < V; v++) if (used[v] === 0) used[v] = n++; + if (n === V) return p; + const pick = (a, k) => { + if (!a) return a; + const o = new a.constructor(n * k); + for (let v = 0; v < V; v++) if (used[v] >= 0) for (let j = 0; j < k; j++) o[used[v] * k + j] = a[v * k + j]; + return o; + }; + const k = p.joints?.k || 4; + const joints = pick(p.joints, k); + if (joints) joints.k = k; + return { ...p, pos: pick(p.pos, 3), nrm: pick(p.nrm, 3), uv: pick(p.uv, 2), joints, weights: pick(p.weights, k), idx: p.idx.map((i) => used[i]) }; + } + const det3 = (m) => (m ? m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8]) + m[2] * (m[4] * m[9] - m[5] * m[8]) : 1); + function strip(ix) { + const out = []; + for (let i = 2; i < ix.length; i++) out.push(...(i % 2 ? [ix[i - 1], ix[i - 2], ix[i]] : [ix[i - 2], ix[i - 1], ix[i]])); + return Uint32Array.from(out); + } + function fan(ix) { + const out = []; + for (let i = 2; i < ix.length; i++) out.push(ix[0], ix[i - 1], ix[i]); + return Uint32Array.from(out); + } + + // ===================================================================== + // .obj import (no bones: weights come from the body) + // ===================================================================== + /** → same shape as loadGltf; images carry only names (map_Kd), the caller loads them. */ + function parseObj(text, mtlText = "") { + const mtl = new Map(); + let cur = null; + for (const line of mtlText.split(/\r?\n/)) { + const t = line.trim().split(/\s+/); + if (t[0] === "newmtl") mtl.set((cur = line.trim().slice(7).trim()), { kd: [1, 1, 1], map: null }); + else if (cur && t[0] === "Kd") mtl.get(cur).kd = t.slice(1, 4).map(Number); + else if (cur && /^map_Kd$/i.test(t[0])) mtl.get(cur).map = line.trim().replace(/^map_Kd\s+(-\S+\s+\S+\s+)*/i, "").trim(); + } + const P = [], T = [], N = []; + // one mesh per object ("o" / "g"), one primitive per material in it + const objects = new Map(); // name → Map(material → { map, pos, uv, nrm, idx }) + let obj = "default", mat = "default", g = null; + const use = () => { + if (!objects.has(obj)) objects.set(obj, new Map()); + const o = objects.get(obj); + if (!o.has(mat)) o.set(mat, { map: new Map(), pos: [], uv: [], nrm: [], idx: [] }); + g = o.get(mat); + }; + use(); + for (const line of text.split(/\r?\n/)) { + const t = line.trim().split(/\s+/); + if (t[0] === "v") P.push(+t[1], +t[2], +t[3]); + else if (t[0] === "vt") T.push(+t[1], 1 - +(t[2] ?? 0)); // OBJ v grows upwards, D3D downwards + else if (t[0] === "vn") N.push(+t[1], +t[2], +t[3]); + else if (t[0] === "usemtl") (mat = line.trim().slice(7).trim()), use(); + else if (t[0] === "o" || (t[0] === "g" && t.length > 1)) (obj = line.trim().slice(2).trim() || obj), use(); + else if (t[0] === "f") { + const corner = (s) => { + if (g.map.has(s)) return g.map.get(s); + const [a, b, c] = s.split("/").map((x) => (x ? parseInt(x, 10) : 0)); + const fix = (i, len) => (i < 0 ? len / 3 + i : i - 1); + const pi = fix(a, P.length); + g.pos.push(P[pi * 3], P[pi * 3 + 1], P[pi * 3 + 2]); + const ti = b ? (b < 0 ? T.length / 2 + b : b - 1) : -1; + g.uv.push(ti >= 0 ? T[ti * 2] : 0, ti >= 0 ? T[ti * 2 + 1] : 0); + const ni = c ? fix(c, N.length) : -1; + g.nrm.push(ni >= 0 ? N[ni * 3] : 0, ni >= 0 ? N[ni * 3 + 1] : 0, ni >= 0 ? N[ni * 3 + 2] : 0); + g.map.set(s, g.pos.length / 3 - 1); + return g.pos.length / 3 - 1; + }; + const c = t.slice(1).map(corner); + for (let i = 2; i < c.length; i++) g.idx.push(c[0], c[i - 1], c[i]); + } + } + const materials = [], images = [], meshes = []; + const matIndex = new Map(); + const material = (name) => { + if (matIndex.has(name)) return matIndex.get(name); + const m = mtl.get(name); + let image = -1; + if (m?.map) { + image = images.findIndex((x) => x.name === m.map); + if (image < 0) images.push({ name: m.map, bytes: null, mime: /\.jpe?g$/i.test(m.map) ? "image/jpeg" : "image/png" }), (image = images.length - 1); + } + materials.push({ name, image, color: [...(m?.kd || [1, 1, 1]), 1], blend: false }); + matIndex.set(name, materials.length - 1); + return materials.length - 1; + }; + for (const [name, mats] of objects) { + const prims = []; + for (const [mname, gr] of mats) { + if (!gr.idx.length) continue; + const hasN = gr.nrm.some((v) => v !== 0); + prims.push({ pos: Float32Array.from(gr.pos), nrm: hasN ? Float32Array.from(gr.nrm) : null, uv: Float32Array.from(gr.uv), idx: Uint32Array.from(gr.idx), joints: null, weights: null, material: material(mname) }); + } + if (prims.length) meshes.push({ name, prims }); + } + if (!meshes.length) throw new Error("No faces in this .obj file."); + return { meshes, joints: [], materials, images, ccw: true }; + } + + + // ===================================================================== + // .fbx import (binary FBX 7.x: Blender, Maya, Unreal / FModel / UE Viewer exports) + // ===================================================================== + /** zlib data → bytes (the browser's DecompressionStream; node tests pass their own). */ + async function inflateZlib(b) { + const s = new Blob([b]).stream().pipeThrough(new DecompressionStream("deflate")); + return new Uint8Array(await new Response(s).arrayBuffer()); + } + + /** Binary FBX → its node tree [{ name, props, kids }]. */ + async function fbxTree(bytes, inflate = inflateZlib) { + const head = String.fromCharCode(...bytes.subarray(0, 18)); + if (head !== "Kaydara FBX Binary") throw new Error(/^; FBX/.test(new TextDecoder().decode(bytes.subarray(0, 64))) ? "A text (ASCII) .fbx: export it as binary FBX (Blender: the default)." : "Not an .fbx file."); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const version = dv.getUint32(23, true); + const wide = version >= 7500; + const u = (o) => (wide ? Number(dv.getBigUint64(o, true)) : dv.getUint32(o, true)); + const W = wide ? 8 : 4; + const latin = new TextDecoder("utf-8"); + async function array(o, type) { + const len = dv.getUint32(o, true), enc = dv.getUint32(o + 4, true), clen = dv.getUint32(o + 8, true); + let raw = bytes.subarray(o + 12, o + 12 + clen); + if (enc === 1) raw = await inflate(raw); + const d = new DataView(raw.buffer, raw.byteOffset, raw.byteLength); + const out = type === "d" ? new Float64Array(len) : type === "f" ? new Float32Array(len) : type === "l" ? new Float64Array(len) : type === "b" ? new Uint8Array(len) : new Int32Array(len); + for (let i = 0; i < len; i++) + out[i] = type === "d" ? d.getFloat64(i * 8, true) : type === "f" ? d.getFloat32(i * 4, true) : type === "l" ? Number(d.getBigInt64(i * 8, true)) : type === "b" ? d.getUint8(i) : d.getInt32(i * 4, true); + return [out, 12 + clen]; + } + async function node(o) { + const end = u(o), np = u(o + W); + const nl = bytes[o + 3 * W]; + if (!end) return [null, o + 3 * W + 1]; + const name = String.fromCharCode(...bytes.subarray(o + 3 * W + 1, o + 3 * W + 1 + nl)); + let p = o + 3 * W + 1 + nl; + const props = []; + for (let i = 0; i < np; i++) { + const t = String.fromCharCode(bytes[p++]); + if (t === "Y") props.push(dv.getInt16(p, true)), (p += 2); + else if (t === "C") props.push(!!bytes[p]), (p += 1); + else if (t === "I") props.push(dv.getInt32(p, true)), (p += 4); + else if (t === "F") props.push(dv.getFloat32(p, true)), (p += 4); + else if (t === "D") props.push(dv.getFloat64(p, true)), (p += 8); + else if (t === "L") props.push(Number(dv.getBigInt64(p, true))), (p += 8); + else if (t === "S" || t === "R") { + const l = dv.getUint32(p, true); + props.push(t === "S" ? latin.decode(bytes.subarray(p + 4, p + 4 + l)) : bytes.subarray(p + 4, p + 4 + l)); + p += 4 + l; + } else if ("fdlib".includes(t)) { + const [a, n] = await array(p, t); + props.push(a); + p += n; + } else throw new Error(`FBX: unknown property type ${t}`); + } + const kids = []; + while (p < end) { + const [k, next] = await node(p); + if (!k) { + p = next; + break; + } + kids.push(k); + p = next; + } + return [{ name, props, kids }, end]; + } + const top = []; + let o = 27; + while (o < bytes.length - 3 * W - 1) { + const [n, next] = await node(o); + if (!n) break; + top.push(n); + o = next; + } + return { version, top }; + } + + /** + * A binary .fbx → same shape as loadGltf: one mesh per model and material (parts of their own), the + * bones (LimbNode / Null under a skin) as joints at their bind pose, weights from the skin clusters. + * Units and axes from the file's GlobalSettings → metres, y up; faces as counter-clockwise. + * images carry only names (texture file names), the caller loads them. + */ + async function parseFbx(bytes, inflate) { + const { top } = await fbxTree(bytes, inflate); + const kid = (n, name) => n?.kids.find((k) => k.name === name); + const P70 = (n) => { + const out = {}; + for (const p of kid(n, "Properties70")?.kids || []) out[p.props[0]] = p.props.slice(4); + return out; + }; + // units and axes + const gs = P70(kid(top.find((n) => n.name === "GlobalSettings"), "")); + const g = P70(top.find((n) => n.name === "GlobalSettings")); + void gs; + const unit = (g.UnitScaleFactor?.[0] ?? 1) / 100; // cm → m + const axis = (k, s) => [0, 1, 2].map((i) => (i === k ? s : 0)); + const up = axis(g.UpAxis?.[0] ?? 1, g.UpAxisSign?.[0] ?? 1); + const front = axis(g.FrontAxis?.[0] ?? 2, g.FrontAxisSign?.[0] ?? 1); + const coord = axis(g.CoordAxis?.[0] ?? 0, g.CoordAxisSign?.[0] ?? 1); + // file vector v → (v·coord, v·up, v·front) × unit + const C = [coord, up, front]; + const conv = (x, y, z, s = unit) => C.map((a) => (a[0] * x + a[1] * y + a[2] * z) * s); + const det = coord[0] * (up[1] * front[2] - up[2] * front[1]) - coord[1] * (up[0] * front[2] - up[2] * front[0]) + coord[2] * (up[0] * front[1] - up[1] * front[0]); + // objects and connections + const objs = new Map(); + for (const n of top.find((t) => t.name === "Objects")?.kids || []) objs.set(n.props[0], n); + const childrenOf = new Map(), parentsOf = new Map(); + for (const c of top.find((t) => t.name === "Connections")?.kids || []) { + const [, child, parent, prop] = c.props; + if (!childrenOf.has(parent)) childrenOf.set(parent, []); + childrenOf.get(parent).push({ id: child, prop }); + if (!parentsOf.has(child)) parentsOf.set(child, []); + parentsOf.get(child).push({ id: parent, prop }); + } + const kidsOf = (id, type) => (childrenOf.get(id) || []).map((c) => objs.get(c.id)).filter((o) => o && (!type || o.name === type)); + const parentsOfType = (id, type) => (parentsOf.get(id) || []).map((c) => objs.get(c.id)).filter((o) => o && (!type || o.name === type)); + const nameOf = (o) => String(o.props[1]).split("\u0000")[0]; + const subType = (o) => o.props[2]; + // world matrices (column-major, like glTF = this kit's row-vector layout): bind pose, else Lcl + const bind = new Map(); + for (const pose of [...objs.values()].filter((o) => o.name === "Pose")) + for (const pn of pose.kids.filter((k) => k.name === "PoseNode")) { + const id = kid(pn, "Node")?.props[0], m = kid(pn, "Matrix")?.props[0]; + if (id != null && m && !bind.has(id)) bind.set(id, Float64Array.from(m)); + } + const D2R = Math.PI / 180; + const euler = (r) => { + // FBX XYZ order: x first (column vectors R = Rz · Ry · Rx) → a quaternion + const [x, y, z] = r.map((v) => (v || 0) * D2R / 2); + const qx = [Math.sin(x), 0, 0, Math.cos(x)], qy = [0, Math.sin(y), 0, Math.cos(y)], qz = [0, 0, Math.sin(z), Math.cos(z)]; + const mul = (a, b) => [a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]]; + return mul(qz, mul(qy, qx)); + }; + const localOf = (o) => { + const p = P70(o); + const R = M4.fromQT(euler(p["Lcl Rotation"] || [0, 0, 0]), [0, 0, 0]); + const pre = M4.fromQT(euler(p.PreRotation || [0, 0, 0]), [0, 0, 0]); + const post = M4.fromQT(euler(p.PostRotation || [0, 0, 0]), [0, 0, 0]); + // row vectors: v · S · post⁻¹ · R · pre · T + const s = p["Lcl Scaling"] || [1, 1, 1]; + let m = trs([0, 0, 0], [0, 0, 0, 1], s); + m = M4.mul(m, M4.invertAffine(post)); + m = M4.mul(m, R); + m = M4.mul(m, pre); + const t = p["Lcl Translation"] || [0, 0, 0]; + m[12] += t[0], m[13] += t[1], m[14] += t[2]; + return m; + }; + const worldCache = new Map(); + const worldOf = (o) => { + const id = o.props[0]; + if (bind.has(id)) return bind.get(id); + if (worldCache.has(id)) return worldCache.get(id); + const par = parentsOfType(id, "Model")[0]; + const w = par ? M4.mul(localOf(o), worldOf(par)) : localOf(o); + worldCache.set(id, w); + return w; + }; + // bones: every model a skin cluster points at, with their model ancestors + const clusters = [...objs.values()].filter((o) => o.name === "Deformer" && subType(o) === "Cluster"); + const boneIds = new Set(); + const linkOf = new Map(); + for (const c of clusters) { + const b = kidsOf(c.props[0], "Model")[0]; + if (!b) continue; + linkOf.set(c.props[0], b.props[0]); + const tl = kid(c, "TransformLink")?.props[0]; + if (tl && !bind.has(b.props[0])) bind.set(b.props[0], Float64Array.from(tl)); + for (let x = b; x; x = parentsOfType(x.props[0], "Model")[0]) { + if (subType(x) === "Mesh") break; + boneIds.add(x.props[0]); + } + } + // no skin: the skeleton as it is (with one, bones nothing is weighted to — weapon / camera + // sockets far off — would only blur the rig's shape) + if (!boneIds.size) for (const o of objs.values()) if (o.name === "Model" && subType(o) === "LimbNode") boneIds.add(o.props[0]); + const boneList = [...boneIds]; + const boneIndex = new Map(boneList.map((id, i) => [id, i])); + const joints = boneList.map((id) => { + const o = objs.get(id), w = worldOf(o); + const par = parentsOfType(id, "Model")[0]; + // the joint's place in metres, y up (only its position is used: the rig's shape) + return { name: nameOf(o), parent: par && boneIndex.has(par.props[0]) ? boneIndex.get(par.props[0]) : -1, world: trs(conv(w[12], w[13], w[14])) }; + }); + // materials + textures + const matList = [], matIndex = new Map(), images = []; + const materialOf = (o) => { + const id = o.props[0]; + if (matIndex.has(id)) return matIndex.get(id); + const p = P70(o); + const tex = (childrenOf.get(id) || []).filter((c) => /diffuse|base/i.test(c.prop || "")).map((c) => objs.get(c.id)).find((t) => t?.name === "Texture") || kidsOf(id, "Texture")[0]; + let image = -1; + if (tex) { + const f = String(kid(tex, "RelativeFilename")?.props[0] || kid(tex, "FileName")?.props[0] || "").replace(/\\/g, "/"); + if (f) { + image = images.findIndex((i) => i.name === f); + if (image < 0) images.push({ name: f, bytes: null, mime: /\.jpe?g$/i.test(f) ? "image/jpeg" : "image/png" }), (image = images.length - 1); + } + } + const col = p.DiffuseColor || p.Diffuse || [1, 1, 1]; + matList.push({ name: nameOf(o), image, color: [col[0], col[1], col[2], 1], blend: false }); + matIndex.set(id, matList.length - 1); + return matList.length - 1; + }; + // meshes + const meshes = []; + const used = new Map(); + for (const geo of [...objs.values()].filter((o) => o.name === "Geometry" && subType(o) === "Mesh")) { + const model = parentsOfType(geo.props[0], "Model")[0]; + const cp = kid(geo, "Vertices")?.props[0], pvi = kid(geo, "PolygonVertexIndex")?.props[0]; + if (!cp || !pvi) continue; + const N = cp.length / 3; + // control points at the bind pose (the skin's mesh matrix, else the model's world) + const skin = kidsOf(geo.props[0], "Deformer").find((d) => subType(d) === "Skin"); + const cl = skin ? kidsOf(skin.props[0], "Deformer").filter((d) => subType(d) === "Cluster") : []; + // (a cluster's Transform takes the mesh into its bone's space: mesh = Transform · TransformLink) + const c0 = cl.find((c) => kid(c, "Transform") && kid(c, "TransformLink")); + const meshM = c0 ? M4.mul(Float64Array.from(kid(c0, "Transform").props[0]), Float64Array.from(kid(c0, "TransformLink").props[0])) : model ? worldOf(model) : trs(); + const P = new Float32Array(N * 3); + for (let v = 0; v < N; v++) { + const x = cp[v * 3], y = cp[v * 3 + 1], z = cp[v * 3 + 2]; + const q = [x * meshM[0] + y * meshM[4] + z * meshM[8] + meshM[12], x * meshM[1] + y * meshM[5] + z * meshM[9] + meshM[13], x * meshM[2] + y * meshM[6] + z * meshM[10] + meshM[14]]; + P.set(conv(...q), v * 3); + } + // weights per control point: the 4 biggest + const ws = Array.from({ length: N }, () => []); + for (const c of cl) { + const b = boneIndex.get(linkOf.get(c.props[0])); + const ix = kid(c, "Indexes")?.props[0], wt = kid(c, "Weights")?.props[0]; + if (b == null || !ix || !wt) continue; + for (let i = 0; i < ix.length; i++) if (wt[i] > 0) ws[ix[i]]?.push([b, wt[i]]); + } + // layers: normals, uvs, materials (by polygon vertex / control point, direct / indexed) + const layer = (name, data, index) => { + const l = kid(geo, name); + if (!l) return null; + return { map: kid(l, "MappingInformationType")?.props[0], ref: kid(l, "ReferenceInformationType")?.props[0], data: kid(l, data)?.props[0], index: kid(l, index)?.props[0] }; + }; + const ln = layer("LayerElementNormal", "Normals", "NormalsIndex"), lu = layer("LayerElementUV", "UV", "UVIndex"), lm = layer("LayerElementMaterial", "Materials", null); + const pick = (l, corner, vert, poly) => { + if (!l?.data) return -1; + const at = l.map === "ByPolygonVertex" ? corner : l.map === "ByPolygon" ? poly : l.map === "AllSame" ? 0 : vert; + return l.ref === "IndexToDirect" || l.ref === "Index" ? l.index?.[at] ?? at : at; + }; + const modelMats = model ? kidsOf(model.props[0], "Material") : []; + // corners → vertices (split where normals / uvs differ), faces fanned, grouped by material + const groups = new Map(); + let poly = 0, start = 0; + for (let i = 0; i < pvi.length; i++) { + if (pvi[i] >= 0) continue; + const corners = []; + for (let k = start; k <= i; k++) corners.push(k); + const mi = lm?.data ? lm.data[lm.map === "AllSame" ? 0 : poly] ?? 0 : 0; + if (!groups.has(mi)) groups.set(mi, { key: new Map(), pos: [], nrm: [], uv: [], jw: [], ww: [], idx: [] }); + const gr = groups.get(mi); + const vid = corners.map((k) => { + const v = pvi[k] < 0 ? ~pvi[k] : pvi[k]; + const ni = pick(ln, k, v, poly), ui = pick(lu, k, v, poly); + const key = `${v}/${ni}/${ui}`; + if (gr.key.has(key)) return gr.key.get(key); + const id = gr.pos.length / 3; + gr.key.set(key, id); + gr.pos.push(P[v * 3], P[v * 3 + 1], P[v * 3 + 2]); + if (ni >= 0) { + // turned like the vertices (the mesh matrix, then the file's axes) + const nx = ln.data[ni * 3], ny = ln.data[ni * 3 + 1], nz = ln.data[ni * 3 + 2]; + gr.nrm.push(...conv(nx * meshM[0] + ny * meshM[4] + nz * meshM[8], nx * meshM[1] + ny * meshM[5] + nz * meshM[9], nx * meshM[2] + ny * meshM[6] + nz * meshM[10], 1)); + } + else gr.nrm.push(0, 0, 0); + gr.uv.push(ui >= 0 ? lu.data[ui * 2] : 0, ui >= 0 ? 1 - lu.data[ui * 2 + 1] : 0); // FBX v grows upwards + const w4 = ws[v].sort((a, b) => b[1] - a[1]).slice(0, 4); + for (let j = 0; j < 4; j++) gr.jw.push(w4[j] ? w4[j][0] : -1), gr.ww.push(w4[j] ? w4[j][1] : 0); + return id; + }); + for (let k = 2; k < vid.length; k++) det < 0 ? gr.idx.push(vid[0], vid[k], vid[k - 1]) : gr.idx.push(vid[0], vid[k - 1], vid[k]); + poly++; + start = i + 1; + } + const base = model ? nameOf(model) : "mesh"; + for (const [mi, gr] of groups) { + const mo = modelMats[mi]; + const material = mo ? materialOf(mo) : -1; + let nm = groups.size > 1 && mo ? nameOf(mo) : base; + used.set(nm, (used.get(nm) || 0) + 1); + if (used.get(nm) > 1) nm += `_${used.get(nm)}`; + const jw = Int32Array.from(gr.jw); + jw.k = 4; + const hasN = gr.nrm.some((x) => x !== 0); + meshes.push({ name: nm, prims: [{ pos: Float32Array.from(gr.pos), nrm: hasN ? Float32Array.from(gr.nrm) : null, uv: Float32Array.from(gr.uv), idx: Uint32Array.from(gr.idx), joints: cl.length ? jw : null, weights: cl.length ? Float32Array.from(gr.ww) : null, material }] }); + } + } + if (!meshes.length) throw new Error("No meshes in this .fbx file."); + // facing: the toes in front of the ankles (+z); a model looking the other way is turned round + const data = { meshes, joints, materials: matList, images, ccw: true }; + faceForward(data); + return data; + } + + /** Turns a model whose feet point to −z half round (y), so it faces +z like a glTF. */ + function faceForward(data) { + const ys = data.joints.map((j) => j.world[13]); + if (!ys.length) return; + const lowY = Math.min(...ys), H = Math.max(...ys) - lowY; + // the lowest joints (toes) against their parents (the ankles): toes stick out forwards + let dz = 0, n = 0; + for (const j of data.joints) { + const p = data.joints[j.parent]; + if (!p || j.world[13] > lowY + 0.06 * H || p.world[13] <= j.world[13] + 0.02 * H) continue; + dz += j.world[14] - p.world[14]; + n++; + } + if (!n || dz >= 0) return; + const flip = (arr) => { + for (let i = 0; i < arr.length; i += 3) (arr[i] = -arr[i]), (arr[i + 2] = -arr[i + 2]); + }; + for (const j of data.joints) (j.world[12] = -j.world[12]), (j.world[14] = -j.world[14]); + for (const m of data.meshes) for (const p of m.prims) flip(p.pos), p.nrm && flip(p.nrm); + } + + // ===================================================================== + // weights from the body: the closest point on the LS body's surface + // ===================================================================== + /** sources: posed() meshes → a searchable surface. */ + function surface(sources) { + const tris = []; + for (const s of sources) { + for (let i = 0; i + 2 < s.idx.length; i += 3) { + const c = [s.idx[i], s.idx[i + 1], s.idx[i + 2]]; + const p = c.map((v) => [s.pos[v * 3], s.pos[v * 3 + 1], s.pos[v * 3 + 2]]); + const ctr = [0, 1, 2].map((k) => (p[0][k] + p[1][k] + p[2][k]) / 3); + const r = Math.max(...p.map((q) => Math.hypot(q[0] - ctr[0], q[1] - ctr[1], q[2] - ctr[2]))); + // the outward normal (the mesh's own vertex normals) + const n = s.nrm ? [0, 1, 2].map((k) => c.reduce((t, v) => t + s.nrm[v * 3 + k], 0)) : null; + tris.push({ p, ctr, r, w: c.map((v) => weightsOf(s, v)), n: n && Math.hypot(...n) ? n.map((x) => x / Math.hypot(...n)) : null }); + } + } + return gridOf(tris); + } + /** A uniform grid (LS units) over faces, so a point only looks at the faces near it. */ + function gridOf(tris) { + const cell = 6; + const map = new Map(); + tris.forEach((t, ti) => { + const lo = [0, 1, 2].map((k) => Math.floor(Math.min(t.p[0][k], t.p[1][k], t.p[2][k]) / cell)); + const hi = [0, 1, 2].map((k) => Math.floor(Math.max(t.p[0][k], t.p[1][k], t.p[2][k]) / cell)); + for (let x = lo[0]; x <= hi[0]; x++) for (let y = lo[1]; y <= hi[1]; y++) for (let z = lo[2]; z <= hi[2]; z++) { + const key = `${x},${y},${z}`; + if (!map.has(key)) map.set(key, []); + map.get(key).push(ti); + } + }); + tris.grid = { cell, map }; + return tris; + } + function weightsOf(s, v) { + const out = []; + for (let j = 0; j < 4; j++) if (s.bone[v * 4 + j] >= 0 && s.w[v * 4 + j] > 0) out.push([s.bone[v * 4 + j], s.w[v * 4 + j]]); + return out; + } + // closest point on a triangle (Ericson), → barycentric [u, v, w] + function closest(p, a, b, c) { + const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]], ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]]; + const dot = (x, y) => x[0] * y[0] + x[1] * y[1] + x[2] * y[2]; + const d1 = dot(ab, ap), d2 = dot(ac, ap); + if (d1 <= 0 && d2 <= 0) return [1, 0, 0]; + const bp = [p[0] - b[0], p[1] - b[1], p[2] - b[2]]; + const d3 = dot(ab, bp), d4 = dot(ac, bp); + if (d3 >= 0 && d4 <= d3) return [0, 1, 0]; + const vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) { + const v = d1 / (d1 - d3); + return [1 - v, v, 0]; + } + const cp = [p[0] - c[0], p[1] - c[1], p[2] - c[2]]; + const d5 = dot(ab, cp), d6 = dot(ac, cp); + if (d6 >= 0 && d5 <= d6) return [0, 0, 1]; + const vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) { + const w = d2 / (d2 - d6); + return [1 - w, 0, w]; + } + const va = d3 * d6 - d5 * d4; + if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { + const w = (d4 - d3) / (d4 - d3 + (d5 - d6)); + return [0, 1 - w, w]; + } + const den = 1 / (va + vb + vc); + const v = vb * den, w = vc * den; + return [1 - v - w, v, w]; + } + /** The body's weights at the surface point closest to p. → [[bone, weight]…] */ + function transfer(tris, p) { + const hit = nearest(tris, p); + if (!hit) return []; + const acc = new Map(); + hit.b.forEach((bw, k) => { + for (const [bone, w] of hit.t.w[k]) acc.set(bone, (acc.get(bone) || 0) + bw * w); + }); + return [...acc]; + } + /** The surface point closest to p: { t (face), b (barycentric), q (point), d (distance) } or null. */ + function nearest(tris, p) { + let best = Infinity, hit = null; + const test = (t) => { + const dc = Math.hypot(p[0] - t.ctr[0], p[1] - t.ctr[1], p[2] - t.ctr[2]) - t.r; + if (dc > 0 && dc * dc >= best) return; + const b = closest(p, ...t.p); + const q = [0, 1, 2].map((k) => b[0] * t.p[0][k] + b[1] * t.p[1][k] + b[2] * t.p[2][k]); + const d = (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2; + if (d < best) (best = d), (hit = { t, b }); + }; + const g = tris.grid; + if (g) { + // rings of cells around p; done once the best face is closer than the next ring can be + const c = p.map((v) => Math.floor(v / g.cell)); + const seen = new Set(); + for (let k = 0; k <= 12 && !(hit && Math.sqrt(best) <= k * g.cell); k++) + for (let x = -k; x <= k; x++) for (let y = -k; y <= k; y++) for (let z = -k; z <= k; z++) { + if (Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) !== k) continue; + for (const ti of g.map.get(`${c[0] + x},${c[1] + y},${c[2] + z}`) || []) if (!seen.has(ti)) seen.add(ti), test(tris[ti]); + } + if (!hit || Math.sqrt(best) > 12 * g.cell) for (const t of tris) test(t); // far from the body: every face + } else for (const t of tris) test(t); + if (!hit) return null; + const q = [0, 1, 2].map((k) => hit.b[0] * hit.t.p[0][k] + hit.b[1] * hit.t.p[1][k] + hit.b[2] * hit.t.p[2][k]); + return { ...hit, q, d: Math.sqrt(best) }; + } + + + // ===================================================================== + // fit to the LS body: a humanoid rig (any bone names) reshaped to the hero's proportions + // ===================================================================== + const v3 = { + sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]], + add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]], + mul: (a, s) => [a[0] * s, a[1] * s, a[2] * s], + dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2], + cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]], + len: (a) => Math.hypot(a[0], a[1], a[2]), + unit: (a) => { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; + }, + }; + // 3×3 matrices, row-major, acting on column vectors + const m3 = { + id: () => [1, 0, 0, 0, 1, 0, 0, 0, 1], + mul: (a, b) => [0, 1, 2].flatMap((r) => [0, 1, 2].map((c) => a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c])), + app: (m, v) => [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]], + /** The smallest rotation taking unit u onto unit w. */ + rot(u, w) { + const c = v3.dot(u, w); + if (c < -0.9999) { + const p = v3.unit(Math.abs(u[0]) < 0.9 ? v3.cross(u, [1, 0, 0]) : v3.cross(u, [0, 1, 0])); + return [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => 2 * p[i] * p[j] - (i === j ? 1 : 0))); + } + const [x, y, z] = v3.cross(u, w); + const f = 1 / (1 + c); + return [c + f * x * x, f * x * y - z, f * x * z + y, f * x * y + z, c + f * y * y, f * y * z - x, f * x * z - y, f * y * z + x, c + f * z * z]; + }, + /** Scales by s along unit u and by r across it. */ + along: (u, s, r) => [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => (i === j ? r : 0) + (s - r) * u[i] * u[j])), + }; + const sorted = (a) => Float64Array.from(a).sort(); + const pct = (a, p) => (a.length ? sorted(a)[Math.min(a.length - 1, Math.floor(a.length * p))] : 0); + + /** + * The joints of a humanoid rig found from its shape, whatever their names (rest pose, LS space): + * the pelvis has a leg on each side going down and the spine going up, the chest is where the arms + * branch off sideways, neck / head go highest, the hand is where the fingers branch, the thumb is + * the finger reaching furthest forward. Other children of the chest: hanging far down = a cape, + * staying up at the neck = hair and the like. + */ + function humanoid(joints, pos) { + const n = joints.length; + const parent = joints.map((j) => j.parent); + const kids = joints.map(() => []); + parent.forEach((p, i) => p >= 0 && kids[p].push(i)); + const lo = new Float64Array(n), hi = new Float64Array(n), xlo = new Float64Array(n), xhi = new Float64Array(n), cnt = new Int32Array(n), depth = new Int32Array(n); + const stat = (i, d) => { + depth[i] = d; + lo[i] = hi[i] = pos[i][1]; + xlo[i] = xhi[i] = pos[i][0]; + cnt[i] = 1; + for (const k of kids[i]) { + stat(k, d + 1); + lo[i] = Math.min(lo[i], lo[k]); + hi[i] = Math.max(hi[i], hi[k]); + xlo[i] = Math.min(xlo[i], xlo[k]); + xhi[i] = Math.max(xhi[i], xhi[k]); + cnt[i] += cnt[k]; + } + }; + parent.forEach((p, i) => p < 0 && stat(i, 0)); + const H = Math.max(...hi) - Math.min(...lo); + if (!(H > 0)) throw new Error("The rig has no height."); + const floor = Math.min(...lo); + const best = (list, score) => list.reduce((a, b) => (a < 0 || score(b) > score(a) ? b : a), -1); + // what goes up from a joint: its own children, else a branch beside it further up the tree + // (e.g. legs that hang from a lower-body bone with the spine as its sibling) + const upFrom = (i) => { + const y = pos[i][1]; + for (let c = i, a = i; a >= 0; c = a, a = parent[a]) { + const up = kids[a].filter((k) => (a === i || k !== c) && hi[k] > y + 0.3 * H); + if (up.length) return up; + } + return []; + }; + // pelvis: the deepest joint with a leg on each side going down and something going up + let pelvis = -1; + for (let i = 0; i < n; i++) { + const [x, y] = pos[i]; + // legs reach the floor (coat tails hanging from the chest don't) + const down = kids[i].filter((k) => lo[k] < y - 0.3 * H && lo[k] < floor + 0.15 * H); + if (down.some((k) => pos[k][0] > x + 0.01 * H) && down.some((k) => pos[k][0] < x - 0.01 * H) && upFrom(i).length) + if (pelvis < 0 || depth[i] > depth[pelvis]) pelvis = i; + } + if (pelvis < 0) throw new Error("No humanoid in this rig: no hips with two legs and a spine."); + const same = (a, b) => v3.len(v3.sub(pos[a], pos[b])) < 0.004 * H; + /** Follows the best-scoring child; a joint sitting on the previous one replaces it. */ + const chain = (start, score, stop = () => false) => { + const out = [start]; + let i = start; + while (kids[i].length && !stop(i)) { + i = best(kids[i], score); + if (same(i, out[out.length - 1])) out[out.length - 1] = i; + else out.push(i); + } + return out; + }; + const [px, py] = pos[pelvis]; + const leg = (k) => { + const c = chain(k, (b) => -lo[b] + cnt[b] * 1e-6); + return { thigh: c[0], calf: c[1] ?? -1, foot: c[2] ?? -1, toe: c[3] ?? -1 }; + }; + const legs = kids[pelvis].filter((k) => lo[k] < py - 0.3 * H && lo[k] < floor + 0.15 * H).map(leg); // (not skirt / thigh helpers) + // the spine, up to where the arms branch + // two different children reaching far to each side + const armsAt = (i) => { + const l = kids[i].filter((k) => xhi[k] > pos[i][0] + 0.2 * H), r = kids[i].filter((k) => xlo[k] < pos[i][0] - 0.2 * H); + return l.length > 0 && r.length > 0 && (l.length > 1 || r.length > 1 || l[0] !== r[0]); + }; + const spine = []; + let chest = best(upFrom(pelvis), (k) => cnt[k]); + for (;;) { + spine.push(chest); + if (armsAt(chest) || !kids[chest].length) break; + chest = best(kids[chest], (k) => hi[k] + cnt[k] * 1e-6); + } + if (!armsAt(chest)) throw new Error("No arms in this rig."); + const armRootL = best(kids[chest], (k) => xhi[k]); + const armRootR = best(kids[chest], (k) => -xlo[k]); + const rest = kids[chest].filter((k) => k !== armRootL && k !== armRootR); + // the neck: the remaining child reaching highest near the middle (most joints: the face's) + const headRoot = best(rest.filter((k) => Math.abs(pos[k][0] - px) < 0.05 * H), (k) => hi[k] + cnt[k] * 1e-3); + const neckChain = headRoot >= 0 ? chain(headRoot, (b) => hi[b] + cnt[b] * 1e-3, (i) => kids[i].length > 3 && i !== headRoot) : []; + const neck = neckChain[0] ?? -1; + const head = neckChain.length > 1 ? neckChain[1] : neck; + // arms: outwards to the hand (two or more children with joints of their own: finger chains; + // single-joint helpers such as twist bones don't count) + const fingersAt = (i) => kids[i].filter((k) => cnt[k] > 1).length >= 2; + const arm = (root, side) => { + // the hand: the joint furthest from the shoulder where three or more chains branch (fingers; + // sleeve / cloth chains on the forearm branch too, but nearer); the arm is the way to it + const sub = []; + const walkSub = (i) => (sub.push(i), kids[i].forEach(walkSub)); + walkSub(root); + const hands = sub.filter((j) => kids[j].filter((k) => cnt[k] > 1).length >= 3); + let full; + if (hands.length) { + const hand = best(hands, (j) => v3.len(v3.sub(pos[j], pos[root]))); + full = []; + for (let j = hand; j >= 0; j = j === root ? -1 : parent[j]) full.unshift(j); + // joints on the same spot: the later one + full = full.filter((j, k) => k === full.length - 1 || !same(j, full[k + 1])); + } else full = chain(root, (b) => (side > 0 ? xhi[b] : -xlo[b]) + cnt[b] * 1e-6); + // twist bones (marked as twist in the file) sit halfway along the arm: not joints of it + let at = -1; + full.forEach((j, k) => fingersAt(j) && (at = k)); + const c = (at >= 0 ? full.slice(0, at + 1) : full).filter((j, k, all) => !joints[j].twist || k === all.length - 1); + // no finger chains (one-joint fingers): the hand is the joint with the most children + let hi2 = c.length - 1; + if (!fingersAt(c[hi2])) { + const most = c.reduce((a, j, k) => (kids[j].length > kids[c[a]].length ? k : a), 0); + if (kids[c[most]].length >= 3) hi2 = most; + } + const hand = c[hi2]; + const before = c.slice(0, hi2); + const leaves = []; + const walk = (i, path) => (kids[i].length ? kids[i].forEach((k) => walk(k, [...path, k])) : leaves.push(path)); + kids[hand].forEach((k) => walk(k, [k])); + const paths = leaves + .map((p) => p.filter((j) => v3.len(v3.sub(pos[j], pos[hand])) > 0.01 * H)) + .filter((p) => p.length >= 2) // one-joint leaves are helpers (palm, twist), not fingers + // nor a weapon / prop chain reaching far past the fingertips + .filter((p) => !before.length || v3.len(v3.sub(pos[p[p.length - 1]], pos[hand])) < 0.9 * v3.len(v3.sub(pos[hand], pos[before[before.length - 1]]))); + // thumb: the finger branching off most from the others (its first joint's direction from the + // hand against theirs; a hand turned in an A-pose has no clear "forward"), then from the + // thumb outwards: index, middle, ring, little + const dir = (p) => v3.unit(v3.sub(pos[p[0]], pos[hand])); + const mean = v3.unit(paths.reduce((acc, p) => v3.add(acc, dir(p)), [0, 0, 0])); + const thumb = paths.length >= 3 ? paths.reduce((a, p) => (v3.dot(dir(p), mean) < v3.dot(dir(a), mean) ? p : a)) : null; + let fingers = paths.sort((a, b) => pos[a[a.length - 1]][2] - pos[b[b.length - 1]][2]); + if (thumb) { + // the others along the line of their knuckles (the two bases furthest apart), the end + // nearer the thumb first + const rest = paths.filter((p) => p !== thumb); + let ends = [rest[0], rest[0]], far = -1; + for (const a of rest) for (const b of rest) { + const d = v3.len(v3.sub(pos[a[0]], pos[b[0]])); + if (d > far) (far = d), (ends = [a, b]); + } + const ax = v3.unit(v3.sub(pos[ends[1][0]], pos[ends[0][0]])); + const tc = v3.mul(thumb.reduce((acc, j) => v3.add(acc, pos[j]), [0, 0, 0]), 1 / thumb.length); + const near = (p) => v3.len(v3.sub(pos[p[0]], tc)); + const sign = near(ends[0]) <= near(ends[1]) ? 1 : -1; + fingers = [thumb, ...rest.sort((a, b) => sign * (v3.dot(pos[a[0]], ax) - v3.dot(pos[b[0]], ax)))]; + } + return { hand, fore: before[before.length - 1] ?? -1, upper: before[before.length - 2] ?? -1, clav: before.length >= 3 ? before[before.length - 3] : -1, fingers }; + }; + if (legs.length < 2) throw new Error("No legs in this rig."); + const L = { ...arm(armRootL, 1), ...legs.reduce((a, b) => (pos[b.thigh][0] > pos[a.thigh][0] ? b : a)) }; + const R = { ...arm(armRootR, -1), ...legs.reduce((a, b) => (pos[b.thigh][0] < pos[a.thigh][0] ? b : a)) }; + for (const [s, side] of [[L, "left"], [R, "right"]]) if (s.upper < 0 || s.fore < 0 || s.calf < 0 || s.foot < 0) throw new Error(`The ${side} arm or leg of the rig has too few joints (${["clav", "upper", "fore", "hand", "thigh", "calf", "foot"].map((k) => `${k} ${s[k] >= 0 ? joints[s[k]].name : "-"}`).join(", ")}).`); + const neckY = pos[neck >= 0 ? neck : chest][1]; + const capes = rest.filter((k) => k !== headRoot && lo[k] < neckY - 0.25 * H); + const headStuff = rest.filter((k) => k !== headRoot && lo[k] >= neckY - 0.25 * H && pos[k][1] >= neckY - 0.03 * H); + return { pelvis, spine, chest, neck, head, L, R, capes, headStuff, kids, parent, pos, H }; + } + + /** File joint → LS bone for a found humanoid (human_man.skl's names). */ + function humanoidMap(sk, h) { + const bone = (name) => sk.byName.get(name.toLowerCase()) ?? -1; + const n = h.parent.length; + const map = new Int32Array(n).fill(-1); + const set = (j, name) => { + const b = bone(name); + if (j >= 0 && b >= 0) map[j] = b; + }; + set(h.pelvis, "Bip01 Pelvis"); + const sp = h.spine; + sp.forEach((j, i) => set(j, i === sp.length - 1 ? "Bip01 Spine2" : i === 0 ? "Bip01 Spine" : "Bip01 Spine1")); + if (h.neck !== h.head) set(h.neck, "Bip01 Neck"); + set(h.head, "Bip01 Head"); + for (const [S, s] of [["L", h.L], ["R", h.R]]) { + set(s.clav, `Bip01 ${S} Clavicle`); + set(s.upper, `Bip01 ${S} UpperArm`); + set(s.fore, `Bip01 ${S} Forearm`); + set(s.hand, `Bip01 ${S} Hand`); + set(s.thigh, `Bip01 ${S} Thigh`); + set(s.calf, `Bip01 ${S} Calf`); + set(s.foot, `Bip01 ${S} Foot`); + // toes stay on the foot: no hero mesh uses Toe0, its pivot sits at the tip of the shoe + set(s.toe, `Bip01 ${S} Foot`); + // fingers: thumb → Finger0, the next → Finger1, the others → Finger2 (the hero has three); + // a joint shared by fingers of two hero fingers stays with the hand + const owner = new Map(); + s.fingers.forEach((path, fi) => { + const f = Math.min(fi, 2); + path.forEach((j, d) => { + const name = `Bip01 ${S} Finger${f}${d ? Math.min(d, 2) : ""}`; + owner.set(j, owner.has(j) && owner.get(j) !== name ? null : name); + }); + }); + for (const [j, name] of owner) if (name) set(j, name); + } + // a cape: along its chains onto the hero's mantle (Mantle01…05), else the chest + const mantle = [1, 2, 3, 4, 5].map((i) => bone(`Bip01 Mantle0${i}`)); + const walkCape = (j, d) => { + map[j] = mantle[Math.min(d, 4)] >= 0 ? mantle[Math.min(d, 4)] : bone("Bip01 Spine2"); + for (const k of h.kids[j]) walkCape(k, d + 1); + }; + h.capes.forEach((j) => walkCape(j, 0)); + for (const j of h.headStuff) map[j] = bone("Bip01 Head"); + // the rest moves with its closest mapped ancestor (twist bones, face bones…); above the hips: the pelvis + const out = new Int32Array(n); + for (let j = 0; j < n; j++) { + let k = j; + while (k >= 0 && map[k] < 0) k = h.parent[k]; + out[j] = k >= 0 ? map[k] : bone("Bip01 Pelvis"); + } + return out; + } + + /** The hero body's vertices with their main bone (posed() meshes): what the fit measures against. */ + function bodyPoints(lsBody) { + const out = []; + for (const m of lsBody) { + const V = m.pos.length / 3; + for (let v = 0; v < V; v++) { + let bb = -1, bw = 0; + for (let j = 0; j < 4; j++) if (m.bone[v * 4 + j] >= 0 && m.w[v * 4 + j] > bw) (bw = m.w[v * 4 + j]), (bb = m.bone[v * 4 + j]); + if (bb >= 0) out.push([[m.pos[v * 3], m.pos[v * 3 + 1], m.pos[v * 3 + 2]], bb]); + } + } + return out; + } + + /** + * Median distance from the line a→b of the points owned by `owners`, between 15% and 85% along it; + * with `side` (a unit vector across the line): only that component (half the width / depth). + */ + function radius(points, owners, a, b, side = null, minN = 8) { + const ab = v3.sub(b, a), l = v3.len(ab), u = v3.unit(ab); + const out = []; + for (const [p, o] of points) { + if (!owners.has(o)) continue; + const d = v3.sub(p, a); + const t = v3.dot(d, u); + if (t < 0.15 * l || t > 0.85 * l) continue; + const across = v3.sub(d, v3.mul(u, t)); + out.push(side ? Math.abs(v3.dot(across, side)) : v3.len(across)); + } + return out.length >= minN ? pct(out, 0.5) : 0; + } + + /** + * Per LS bone, how the model (LS space, before the fit) lands on the hero: arms, legs and torso + * keep their joints on the hero's joints (turned onto them, the hero's lengths and thickness), the + * head, hands and toes scale as a whole, a cape keeps the body's height. points: the model's + * vertices [pos, main LS bone, hair?]; hero: bodyPoints(). → { T: Map(bone → { a, A, M, N }), info } + */ + function fitTransforms(sk, P, h, points, hero, opts = {}) { + const b = (name) => sk.byName.get(name.toLowerCase()) ?? -1; + const W = (name) => P.wp[b(name)]; + const J = (j) => h.pos[j]; + const T = new Map(); + const info = {}; + const put = (name, a, A, M, N) => b(name) >= 0 && T.set(b(name), { a, A, M, N }); + const owners = (names) => new Set(names.map(b).filter((x) => x >= 0)); + // how far limbs / torso go from the model's own build (kept within limits) to exactly the hero's + // thickness (0…1): at 1 a costume is as thick as the hero's body everywhere, like the game's own + // costumes (victory_cn…), and bends like it in every animation + const tB = Math.max(0, Math.min(1, opts.lsBuild ?? 0)); + const lsBuild = (own, hero) => own ** (1 - tB) * hero ** tB; + const seg = (name, ja, jb, la, lb, radNames = [name]) => { + const a = J(ja), e = J(jb), A = W(la), E = W(lb); + const u = v3.unit(v3.sub(e, a)), U = v3.unit(v3.sub(E, A)); + const s = v3.len(v3.sub(E, A)) / (v3.len(v3.sub(e, a)) || 1); + const rl = radNames.length ? radius(hero, owners(radNames), A, E) : 0; + const rm = radNames.length ? radius(points, owners(radNames), a, e) : 0; + // as thick as the hero's limb, but not much off the model's own build (a slim sleeve on the man's + // muscled forearm swells up) + // (and never thinner than the model itself unless the hero is: a short arm scaled down came out + // a stick) + const r = rl && rm ? Math.max(0.6 * s, Math.min(2 * s, lsBuild(Math.max(0.6 * s, Math.min(Math.max(1.25 * s, Math.min(1, rl / rm)), rl / rm)), rl / rm))) : s; + const R = m3.rot(u, U); + put(name, a, A, m3.mul(R, m3.along(u, s, r)), m3.mul(R, m3.along(u, 1 / s, 1 / r))); + info[name] = { length: +s.toFixed(2), thickness: +r.toFixed(2) }; + return { R, s, r, u }; + }; + const whole = (name, a, A, s, R = m3.id()) => { + put(name, a, A, m3.mul(R, m3.along([0, 1, 0], s, s)), m3.mul(R, m3.along([0, 1, 0], 1 / s, 1 / s))); + info[name] = { scale: +s.toFixed(2) }; + }; + const same = (name, from) => b(name) >= 0 && b(from) >= 0 && T.has(b(from)) && T.set(b(name), T.get(b(from))); + // torso: the first spine joint → the neck, as thick as the hero's + const neckJ = h.neck >= 0 ? h.neck : h.head; + // torso: as long and as wide as the hero's; front to back it keeps its own shape (scaled like the + // length) unless keepVolume is off — the hero's chest is flat, a bust or a coat would be pressed flat + const torso = (() => { + const a = J(h.spine[0]), e = J(neckJ), A = W("Bip01 Spine"), E = W("Bip01 Neck"); + const u = v3.unit(v3.sub(e, a)), U = v3.unit(v3.sub(E, A)); + const sl = v3.len(v3.sub(E, A)) / (v3.len(v3.sub(e, a)) || 1); + const across = (axis, dir) => v3.unit(v3.sub(dir, v3.mul(axis, v3.dot(dir, axis)))); + const xm = across(u, [1, 0, 0]), xl = across(U, [1, 0, 0]); + const zm = v3.cross(u, xm); + const own = owners(["Bip01 Spine", "Bip01 Spine1", "Bip01 Spine2"]); + const ratio = (side, sideL) => { + const rl = radius(hero, own, A, E, sideL), rm = radius(points, own, a, e, side); + // like the limbs: the hero's width, not far off the model's own build (a thin inner body + // under armour / a cape measured alone blew the chest up into a barrel) + return rl && rm ? Math.max(0.6 * sl, Math.min(1.5 * sl, lsBuild(Math.max(0.6 * sl, Math.min(1.5 * sl, rl / rm)), rl / rm))) : sl; + }; + const rw = ratio(xm, xl); + const rd = opts.keepVolume ? lsBuild(Math.max(sl, rw), Math.max(rw, ratio(zm, v3.cross(U, xl)))) : ratio(zm, v3.cross(U, xl)); + const R = m3.rot(u, U); + const S = (a1, b1, c1) => [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => a1 * u[i] * u[j] + b1 * xm[i] * xm[j] + c1 * zm[i] * zm[j])); + put("Bip01 Spine", a, A, m3.mul(R, S(sl, rw, rd)), m3.mul(R, S(1 / sl, 1 / rw, 1 / rd))); + info["Bip01 Spine"] = { length: +sl.toFixed(2), thickness: +rw.toFixed(2), depth: +rd.toFixed(2) }; + return { R, s: sl, r: rw }; + })(); + same("Bip01 Spine1", "Bip01 Spine"); + same("Bip01 Spine2", "Bip01 Spine"); + // hips: to the hero's hip width, standing on the hero's pelvis + const hip = v3.len(v3.sub(W("Bip01 L Thigh"), W("Bip01 R Thigh"))) / (v3.len(v3.sub(J(h.L.thigh), J(h.R.thigh))) || 1); + const pu = v3.unit(v3.sub(J(h.spine[0]), J(h.pelvis))); + const PR = m3.rot(pu, v3.unit(v3.sub(W("Bip01 Spine"), W("Bip01 Pelvis")))); + // across: as wide as the hero's hips; front to back like the torso (its volume kept or not) + const px = v3.unit(v3.sub([1, 0, 0], v3.mul(pu, pu[0]))), pz = v3.cross(pu, px); + const hd = opts.keepVolume ? Math.max(torso.s, hip) : hip; + const PS = (a1, b1, c1) => [0, 1, 2].flatMap((i) => [0, 1, 2].map((j) => a1 * pu[i] * pu[j] + b1 * px[i] * px[j] + c1 * pz[i] * pz[j])); + put("Bip01 Pelvis", J(h.pelvis), W("Bip01 Pelvis"), m3.mul(PR, PS(torso.s, hip, hd)), m3.mul(PR, PS(1 / torso.s, 1 / hip, 1 / hd))); + info["Bip01 Pelvis"] = { width: +hip.toFixed(2) }; + same("Bip01", "Bip01 Pelvis"); + // a cape: hangs from the neck, as long as the body is tall + const tall = W("Bip01 Neck")[1] / (J(neckJ)[1] || 1); + for (let i = 1; i <= 5; i++) put(`Bip01 Mantle0${i}`, J(neckJ), W("Bip01 Neck"), m3.along([0, 1, 0], tall, torso.r), m3.along([0, 1, 0], 1 / tall, 1 / torso.r)); + info.cape = { height: +tall.toFixed(2) }; + if (h.neck !== h.head && h.neck >= 0) seg("Bip01 Neck", h.neck, h.head, "Bip01 Neck", "Bip01 Head", []); + // head: as a whole, as TALL as the hero's (its face mesh, chin to crown) — measured on what + // hangs on the head joint itself (face, skull), so elf ears, a ponytail or hair bones don't + // count; width alone took ears and long hair for a big head. The chin sits on the hero's chin, + // so the neck stays the hero's length (top-aligned, a smaller head left a long neck). + const headPts = (pts) => pts.filter((p) => p[1] === b("Bip01 Head") && !p[2]).map(([p]) => p); + const centre = (ps) => (ps.length ? v3.mul(ps.reduce((acc, p) => v3.add(acc, p), [0, 0, 0]), 1 / ps.length) : null); + const own = points.filter((p) => p[3]).map(([p]) => p); + const hpL = headPts(hero), hpM = own.length >= 30 ? own : headPts(points); + const box = (ps) => [0, 1, 2].map((k) => [pct(ps.map((p) => p[k]), 0.005), pct(ps.map((p) => p[k]), 0.995)]); + const bL = hpL.length ? box(hpL) : null, bM = hpM.length ? box(hpM) : null; + const mid = (bx) => bx.map(([lo, hi]) => (lo + hi) / 2); + const cL = bL ? mid(bL) : centre(hpL) || W("Bip01 Head"), cM = bM ? mid(bM) : centre(hpM) || J(h.head); + const hl = bL ? bL[1][1] - bL[1][0] : 0, hmM = bM ? bM[1][1] - bM[1][0] : 0; + const hs = (hl && hmM ? hl / hmM : 1) * (opts.headScale || 1); + const cLs = bL ? [cL[0], bL[1][0] + (cM[1] - bM[1][0]) * hs, cL[2]] : cL; + whole("Bip01 Head", cM, cLs, hs); + const hm = hmM / 2; + // long hair: the head's scale only around the head, the body's (the cape's) further down + if (T.has(b("Bip01 Head")) && hm) T.get(b("Bip01 Head")).soft = { o: [0, cM[1] - hm, 0], u: [0, -1, 0], R: hm, alt: T.get(b("Bip01 Mantle01")) || T.get(b("Bip01 Spine")) }; + info.headSize = { hero: +hl.toFixed(1), model: +hmM.toFixed(1) }; + for (const [S, s] of [["L", h.L], ["R", h.R]]) { + if (s.clav >= 0) seg(`Bip01 ${S} Clavicle`, s.clav, s.upper, `Bip01 ${S} Clavicle`, `Bip01 ${S} UpperArm`, []); + seg(`Bip01 ${S} UpperArm`, s.upper, s.fore, `Bip01 ${S} UpperArm`, `Bip01 ${S} Forearm`); + seg(`Bip01 ${S} Forearm`, s.fore, s.hand, `Bip01 ${S} Forearm`, `Bip01 ${S} Hand`); + // hand: the hero's hand length, turned to where the hero's fingers point + const bases = s.fingers.slice(1).map((p) => p.find((j) => v3.len(v3.sub(J(j), J(s.hand))) > 0.01 * h.H)).filter((j) => j !== undefined); + const mb = bases.length ? v3.mul(bases.reduce((acc, j) => v3.add(acc, J(j)), [0, 0, 0]), 1 / bases.length) : v3.add(J(s.hand), v3.sub(J(s.hand), J(s.fore))); + const lb = v3.mul(v3.add(W(`Bip01 ${S} Finger1`), W(`Bip01 ${S} Finger2`)), 0.5); + const hs = v3.len(v3.sub(lb, W(`Bip01 ${S} Hand`))) / (v3.len(v3.sub(mb, J(s.hand))) || 1); + // as long as the hero's hand, as thick as its palm (a realistic hand scaled to the chibi's long + // hand as a whole comes out a big fist) + const hu = v3.unit(v3.sub(mb, J(s.hand))), HU = v3.unit(v3.sub(lb, W(`Bip01 ${S} Hand`))); + const palmL = radius(hero, owners([`Bip01 ${S} Hand`]), W(`Bip01 ${S} Hand`), lb, null, 3); + const palmM = radius(points, owners([`Bip01 ${S} Hand`]), J(s.hand), mb, null, 3); + const hr = palmL && palmM ? Math.max(0.5 * hs, Math.min(hs, palmL / palmM)) : hs; + const HR = m3.rot(hu, HU); + put(`Bip01 ${S} Hand`, J(s.hand), W(`Bip01 ${S} Hand`), m3.mul(HR, m3.along(hu, hs, hr)), m3.mul(HR, m3.along(hu, 1 / hs, 1 / hr))); + info[`Bip01 ${S} Hand`] = { length: +hs.toFixed(2), thickness: +hr.toFixed(2) }; + const hlen = v3.len(v3.sub(mb, J(s.hand))); + if (T.has(b(`Bip01 ${S} Hand`))) T.get(b(`Bip01 ${S} Hand`)).soft = { o: J(s.hand), u: v3.unit(v3.sub(J(s.fore), J(s.hand))), R: hlen * 0.6, alt: T.get(b(`Bip01 ${S} Forearm`)) }; + const groups = [[], [], []]; + s.fingers.forEach((p, fi) => groups[Math.min(fi, 2)].push(p)); + groups.forEach((paths, g) => { + const names = [`Bip01 ${S} Finger${g}`, `Bip01 ${S} Finger${g}1`, `Bip01 ${S} Finger${g}2`]; + if (paths.length && names.every((n) => b(n) >= 0) && paths.every((p) => p.length >= 2)) { + const at = (d) => v3.mul(paths.reduce((acc, p) => v3.add(acc, J(p[Math.min(d, p.length - 1)])), [0, 0, 0]), 1 / paths.length); + const hasThird = paths.some((p) => p.length >= 3); + let last = null; + const means = []; + for (let d = 0; d < 2; d++) { + if (d === 1 && !hasThird) break; + const a = at(d), e = at(d + 1), A = W(names[d]), E = W(names[d + 1]); + const u = v3.unit(v3.sub(e, a)), U = v3.unit(v3.sub(E, A)); + const sl = v3.len(v3.sub(E, A)) / (v3.len(v3.sub(e, a)) || 1); + // as thick as the hero's finger there (its hand mesh; three fingers on one chain together + // as wide as the hero's mitten), else as the palm, never fatter than the palm + const own = owners([names[d]]); + const rl = radius(hero, own, A, E, null, 3), rm = radius(points, own, a, e, null, 3); + const r = rl && rm ? Math.max(0.5 * sl, Math.min(Math.max(hr, sl), rl / rm)) : Math.min(hr, Math.max(sl, hr)); + const R = m3.rot(u, U); + put(names[d], a, A, m3.mul(R, m3.along(u, sl, r)), m3.mul(R, m3.along(u, 1 / sl, 1 / r))); + means[d] = { a, A, M: T.get(b(names[d])).M, u, r }; + last = { R, sl, r, d, u }; + } + // the tip: as the joint before it, from its own joint + const d = last.d + 1; + // the tip: as the joint before it (as long as that, not as long as the hand is thick: realistic + // finger tips came out claws on the hero's short fingers) + put(names[d], at(d), W(names[d]), m3.mul(last.R, m3.along(last.u, last.sl, last.r)), m3.mul(last.R, m3.along(last.u, 1 / last.sl, 1 / last.r))); + means[d] = { a: at(d), A: W(names[d]), M: T.get(b(names[d])).M, u: means[d - 1].u, r: last.r }; + // several fingers on one chain: each its own way, its knuckles on the hero's pivots (moved + // only sideways, across the finger), so a fist curls every finger at its own joints — the + // mean alone bends a shorter little finger halfway along its bones + if (paths.length > 1) { + const SNUG = 0.75; // a bit closer together, like the 5-finger CN hands + const joint = (p, k) => J(p[Math.min(k, p.length - 1)]); + const land = (p, k) => { + const m = means[k], off = v3.sub(joint(p, k), m.a); + return v3.add(m.A, m3.app(m.M, v3.mul(v3.sub(off, v3.mul(m.u, v3.dot(off, m.u))), SNUG))); + }; + for (let k = 0; k <= d; k++) { + const t = T.get(b(names[k])); + t.multi = paths.map((p) => { + const a = joint(p, k), A = land(p, k); + let M = means[k].M, N = t.N; + if (k < d) { + const e = joint(p, k + 1), E = land(p, k + 1), le = v3.len(v3.sub(e, a)); + if (le > 1e-6) { + const u = v3.unit(v3.sub(e, a)), R = m3.rot(u, v3.unit(v3.sub(E, A))), sl = v3.len(v3.sub(E, A)) / le, r = means[k].r; + (M = m3.mul(R, m3.along(u, sl, r))), (N = m3.mul(R, m3.along(u, 1 / sl, 1 / r))); + } + } + return { path: p.map(J), a, A, M, N }; + }); + } + } + if (d === 1) same(names[2], names[1]); + info[`${S} fingers ${g}`] = { fingers: paths.length, length: +last.sl.toFixed(2), thickness: +last.r.toFixed(2) }; + } else for (const n of names) same(n, `Bip01 ${S} Hand`); + }); + seg(`Bip01 ${S} Thigh`, s.thigh, s.calf, `Bip01 ${S} Thigh`, `Bip01 ${S} Calf`); + seg(`Bip01 ${S} Calf`, s.calf, s.foot, `Bip01 ${S} Calf`, `Bip01 ${S} Foot`); + if (s.toe >= 0) { + // foot + toes as one piece: as long as the hero's shoe (its mesh, the toe bone sits far + // ahead of the ankle) and as thick, so a boot keeps its shape instead of stretching thin + // turned only about the vertical (the hero's toe bone points down at a different slope: + // following it tipped the shoe toes-up), as high as the hero's ankle so the sole stays flat + // on the floor, as long forwards as the hero's shoe and as wide + const a = J(s.foot), A = W(`Bip01 ${S} Foot`); + const flat = (d) => v3.unit([d[0], 0, d[2]]); + const u = flat(v3.sub(J(s.toe), a)), U = flat(v3.sub(W(`Bip01 ${S} Toe0`), A)); + const own = owners([`Bip01 ${S} Foot`, `Bip01 ${S} Toe0`]); + const reach = (pts, c, dir) => pct(pts.filter(([, o]) => own.has(o)).map(([p]) => v3.dot(v3.sub(p, c), dir)).filter((t) => t > 0), 0.98); + const el = reach(hero, A, U), em = reach(points, a, u); + const sj = v3.len(v3.sub(W(`Bip01 ${S} Toe0`), A)) / (v3.len(v3.sub(J(s.toe), a)) || 1); + const sl = el && em ? el / em : sj; + const rl = radius(hero, own, A, v3.add(A, v3.mul(U, el || 1))), rm = radius(points, own, a, v3.add(a, v3.mul(u, em || 1))); + const r = rl && rm ? Math.max(0.5 * sl, Math.min(2 * sl, rl / rm)) : sl; + const R = m3.rot(u, U); + const sv = a[1] > 1 ? Math.max(0.5 * sl, Math.min(2.5, A[1] / a[1])) : r; // ankle height (the floor at 0) + const side = v3.cross([0, 1, 0], u); + const S3 = (x, y, z) => m3.mul(m3.mul(m3.along(u, x, 1), m3.along([0, 1, 0], y, 1)), m3.along(side, z, 1)); + put(`Bip01 ${S} Foot`, a, A, m3.mul(R, S3(sl, sv, r)), m3.mul(S3(1 / sl, 1 / sv, 1 / r), m3.rot(U, u))); + same(`Bip01 ${S} Toe0`, `Bip01 ${S} Foot`); + info[`Bip01 ${S} Foot`] = { length: +sl.toFixed(2), thickness: +r.toFixed(2), height: +sv.toFixed(2) }; + } else same(`Bip01 ${S} Foot`, `Bip01 ${S} Calf`); + } + return { T, info }; + } + + /** A point (and its normal) through the fit: Σ w · (A + M (p − a)). */ + /** Of several fingers on one bone: the one whose joints p is closest to. */ + function nearestPath(list, p) { + let best = list[0], bd = Infinity; + for (const f of list) + for (let i = 0; i < f.path.length; i++) { + const a = f.path[i], e = f.path[Math.min(i + 1, f.path.length - 1)], ab = v3.sub(e, a), l2 = v3.dot(ab, ab); + const t = l2 ? Math.max(0, Math.min(1, v3.dot(v3.sub(p, a), ab) / l2)) : 0; + const dd = v3.len(v3.sub(p, v3.add(a, v3.mul(ab, t)))); + if (dd < bd) (bd = dd), (best = f); + } + return best; + } + + function fitPoint(T, ws, p, nrm) { + let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0, tw = 0; + for (const [bn, w] of ws) { + let t = T.get(bn); + if (!t) continue; + if (t.multi) t = nearestPath(t.multi, p); + let q = v3.add(t.A, m3.app(t.M, v3.sub(p, t.a))), nn = m3.app(t.N, nrm); + const sf = t.soft; + const depth = sf && sf.alt ? v3.dot(v3.sub(p, sf.o), sf.u) : 0; + if (depth > 0) { + const f = Math.min(1, depth / sf.R), a = sf.alt; + const q2 = v3.add(a.A, m3.app(a.M, v3.sub(p, a.a))), n2 = m3.app(a.N, nrm); + // it carries on from where the part leaves it: the neighbour's transform shifted to meet it + const edge = v3.sub(p, v3.mul(sf.u, depth)); + const shift = v3.sub(v3.add(t.A, m3.app(t.M, v3.sub(edge, t.a))), v3.add(a.A, m3.app(a.M, v3.sub(edge, a.a)))); + q = v3.add(v3.mul(q, 1 - f), v3.mul(v3.add(q2, shift), f)); + nn = v3.add(v3.mul(nn, 1 - f), v3.mul(n2, f)); + } + t = null; + x += w * q[0]; + y += w * q[1]; + z += w * q[2]; + nx += w * nn[0]; + ny += w * nn[1]; + nz += w * nn[2]; + tw += w; + } + if (!tw) return null; + const l = Math.hypot(nx, ny, nz) || 1; + return [[x / tw, y / tw, z / tw], [nx / l, ny / l, nz / l]]; + } + + // ===================================================================== + // the costume: imported file → .msh fields + // ===================================================================== + /** File joint names → skeleton bone indices (same names, any case / spacing). */ + function jointMap(sk, joints) { + const byNorm = new Map(sk.data.bones.map((b, i) => [normName(b.name.text), i])); + const direct = joints.map((j) => byNorm.get(normName(j.name)) ?? -1); + // an unknown joint (e.g. an extra skirt bone) moves with its closest known ancestor + return joints.map((j, i) => { + let k = i; + while (k >= 0 && direct[k] < 0) k = joints[k].parent; + return k >= 0 ? direct[k] : -1; + }); + } + + /** LS-space scale of the file: from bones both have, else metres vs centimetres by height. */ + function autoScale(sk, P, file) { + const map = jointMap(sk, file.joints); + let a = 0, b = 0, n = 0; + file.joints.forEach((j, i) => { + const bi = map[i]; + if (bi < 0 || normName(j.name) !== normName(sk.data.bones[bi].name.text)) return; + a += Math.hypot(P.wp[bi][0], P.wp[bi][1], P.wp[bi][2]); + b += Math.hypot(j.world[12], j.world[13], j.world[14]); + n++; + }); + if (n >= 3 && b > 1e-6) return { scale: a / b, by: `${n} matching bones` }; + let lo = Infinity, hi = -Infinity; + for (const m of file.meshes) for (const p of m.prims) for (let v = 1; v < p.pos.length; v += 3) (lo = Math.min(lo, p.pos[v])), (hi = Math.max(hi, p.pos[v])); + const h = hi - lo; + return h < 10 ? { scale: UNIT, by: `height ${h.toFixed(2)} → metres` } : { scale: 1, by: `height ${h.toFixed(0)} → LS units` }; + } + + /** + * What every part of one import shares: glTF → LS placement, the rig's bones on the hero's (by name, + * or found by shape and fitted onto the hero's proportions — measured once on every mesh of the + * costume, opts.measure, so the body, a helmet and a cape fit the same way). + */ + function prepare(file, sk, P, opts = {}) { + const scale = opts.scale || autoScale(sk, P, file).scale; + const off = opts.offset || [0, 0, 0]; + const ry = ((opts.rotY || 0) * Math.PI) / 180; + const cy = Math.cos(ry), sy = Math.sin(ry); + // glTF → LS: mirror z, scale, turn about y, move + const toLs = (x, y, z) => { + x *= scale; + y *= scale; + z *= -scale; + return [x * cy + z * sy + off[0], y + off[1], -x * sy + z * cy + off[2]]; + }; + const turnN = (x, y, z) => [x * cy - z * sy, y, -x * sy - z * cy]; // a normal: mirrored and turned + const hasFileWeights = file.meshes.some((m) => m.prims.some((p) => p.joints)); + let hum = null, hairJ = null, headJ = null; + if (opts.fit && file.joints.length && hasFileWeights) { + hum = humanoid(file.joints, file.joints.map((j) => toLs(j.world[12], j.world[13], j.world[14]))); + hairJ = new Uint8Array(file.joints.length); + const mark = (j) => ((hairJ[j] = 1), hum.kids[j].forEach(mark)); + hum.headStuff.forEach(mark); + // the head and everything on it (face rigs: jaw, lids, brows… and the hair) + headJ = new Uint8Array(file.joints.length); + const markH = (j) => ((headJ[j] = 1), hum.kids[j].forEach(markH)); + if (hum.head >= 0) markH(hum.head); + } + const jmap = hum ? humanoidMap(sk, hum) : jointMap(sk, file.joints); + const mode = hum ? "file" : opts.weights === "auto" || !opts.weights ? (hasFileWeights && jmap.some((b) => b >= 0) ? "file" : "body") : opts.weights; + /** A skinned vertex's weights on the hero's bones, and whether it hangs on hair-like joints. */ + const weightsAt = (p, v) => { + const k = p.joints.k || 4; + const acc = new Map(); + let top = -1, tw = 0; + for (let j = 0; j < k; j++) { + const w = p.weights[v * k + j], ji = p.joints[v * k + j]; + const b = w > 0 && ji >= 0 ? jmap[ji] : -1; + if (b >= 0) acc.set(b, (acc.get(b) || 0) + w); + if (w > tw && ji >= 0) (tw = w), (top = ji); + } + // onHead: mostly on the head joint itself (the face, the skull — not hair / ear / eye bones) + return { ws: [...acc], hair: !!hairJ && top >= 0 && hairJ[top] === 1, onHead: !!hum && top === hum.head && tw >= 0.5, headish: !!headJ && top >= 0 && headJ[top] === 1 }; + }; + let T = null, fit = null; + if (hum) { + const points = []; + for (const m of file.meshes) { + if (opts.measure && !opts.measure.has(m.name)) continue; + for (const p of m.prims) { + if (!p.joints) continue; + for (let v = 0; v < p.pos.length / 3; v++) { + const { ws, hair, onHead } = weightsAt(p, v); + if (!ws.length) continue; + const top = ws.reduce((a, c) => (c[1] > a[1] ? c : a)); + points.push([toLs(p.pos[v * 3], p.pos[v * 3 + 1], p.pos[v * 3 + 2]), top[0], hair, onHead]); + } + } + } + const r = fitTransforms(sk, P, hum, points, opts.fitHero || [], { keepVolume: opts.keepVolume !== false, headScale: opts.headScale || 1, lsBuild: opts.lsBuild }); + T = r.T; + const name = (j) => file.joints[j]?.name ?? "—"; + fit = { info: r.info, joints: { pelvis: name(hum.pelvis), chest: name(hum.chest), head: name(hum.head), leftHand: name(hum.L.hand), rightHand: name(hum.R.hand), leftFoot: name(hum.L.foot), capes: hum.capes.length, hair: hum.headStuff.length } }; + } + return { scale, toLs, turnN, hasFileWeights, hum, jmap, mode, weightsAt, T, fit }; + } + + /** Which costume part each mesh of the file looks like: mostly on the head → helmet, on a cape → cloak, else body. */ + const WEAPON = /weapon|sword|axe|staff|wand|spear|lance|gun|rifle|pistol|bow|shield|blade|katana|dagger|knife|scythe|hammer|club/i; + function partsGuess(file, sk, P, opts = {}) { + const R = opts.rig || prepare(file, sk, P, opts); + const head = sk.byName.get("bip01 head") ?? -1; + const neck = sk.byName.get("bip01 neck") ?? -1; + const out = {}; + for (const m of file.meshes) { + let h = 0, c = 0, t = 0; + for (const p of m.prims) { + if (!p.joints) continue; + for (let v = 0; v < p.pos.length / 3; v++) + for (const [b, w] of R.weightsAt(p, v).ws) { + t += w; + if (b === head || b === neck) h += w; + else if (/mantle/i.test(sk.data.bones[b].name.text)) c += w; + } + } + // a weapon the model holds isn't clothes (the game's weapons are items of their own) + out[m.name] = WEAPON.test(m.name) ? "skip" : !t ? "body" : h / t > 0.6 ? "helmet" : c / t > 0.6 ? "cloak" : "body"; + } + return out; + } + + /** + * file (loadGltf / parseObj), the skeleton + its rest pose, the body surface for weights, and + * opts { scale, offset: [x, y, z], rotY (°), weights: "auto" | "file" | "body" | "bone", bone, + * maxBones, minWeight, include: Set of mesh names (all when missing), splitHands, fit, fitHero, + * measure, rig (a prepare() to share between parts) }. + * → { data (parseMsh-like fields), groups: [{ material, start, count }], report, hand? } — hand: the + * same for the hand mesh when splitHands. + */ + function build(file, sk, P, body, opts = {}) { + const R = opts.rig || prepare(file, sk, P, opts); + const { scale, toLs, turnN, mode } = R; + const maxBones = opts.maxBones || MAX_BONES; + const minW = opts.minWeight ?? 0.01; + const report = { scale, mode, vertices: 0, faces: 0, fromBody: 0, folded: [], bones: 0, subs: 0, fit: R.fit }; + // group primitives by material → sub-meshes + const byMat = new Map(); + for (const m of file.meshes) { + if (opts.include && !opts.include.has(m.name)) continue; + for (const p of m.prims) { + if (!byMat.has(p.material)) byMat.set(p.material, []); + byMat.get(p.material).push(p); + } + } + const pos = [], nrm = [], uv = [], idx = [], groupsAll = []; + const vb = []; // per vertex: [[bone, w]…] + const headV = []; // per vertex: 1 = on the head joint itself (face, eyes, mouth), 2 = hair + const tris = mode === "body" || mode === "file" ? body : null; + let hasNrm = true; + for (const [mi, prims] of byMat) { + const start = idx.length; + for (const p of prims) { + const o = pos.length / 3; + const V = p.pos.length / 3; + for (let v = 0; v < V; v++) { + const q = toLs(p.pos[v * 3], p.pos[v * 3 + 1], p.pos[v * 3 + 2]); + pos.push(...q); + if (p.nrm) nrm.push(...turnN(p.nrm[v * 3], p.nrm[v * 3 + 1], p.nrm[v * 3 + 2])); + else (hasNrm = false), nrm.push(0, 0, 0); + uv.push(p.uv ? p.uv[v * 2] : 0, p.uv ? p.uv[v * 2 + 1] : 0); + let ws = []; + if (mode === "bone") ws = [[opts.bone ?? 0, 1]]; + else if (mode === "file" && p.joints) { + const r = R.weightsAt(p, v); + ws = r.ws; + headV.push(r.headish ? (r.hair ? 2 : 1) : 0); + } + if (headV.length < pos.length / 3) headV.push(0); + if (!ws.length && tris && !R.T) { + ws = transfer(tris, q); + report.fromBody++; + } + vb.push(ws); + } + for (let i = 0; i < p.idx.length; i += 3) idx.push(o + p.idx[i], o + p.idx[i + 2], o + p.idx[i + 1]); // mirrored: back to clockwise + } + groupsAll.push({ material: mi, start, count: idx.length - start }); + } + const V = pos.length / 3; + if (!V) throw new Error("Nothing to import (no meshes picked)."); + if (!hasNrm) smoothNormals(pos, idx, nrm); + let skirtSet = null; + let bareHands = false; // outfit only: the model's hands are skin — the hero's own are worn + if (R.T) { + // every vertex through its bones' fit; parts without bones then take the body's weights + for (let v = 0; v < V; v++) { + const p = [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]]; + const r = vb[v].length ? fitPoint(R.T, vb[v], p, [nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2]]) : null; + if (r) for (let k = 0; k < 3; k++) (pos[v * 3 + k] = r[0][k]), (nrm[v * 3 + k] = r[1][k]); + else if (tris) { + vb[v] = transfer(tris, p); + report.fromBody++; + } + } + if (tris && opts.torsoFromBody === true) { + const torso = new Set(["bip01 pelvis", "bip01 spine", "bip01 spine1", "bip01 spine2"].map((n) => sk.byName.get(n) ?? -1)); + for (let v = 0; v < V; v++) { + if (!vb[v].length || !vb[v].every(([bn]) => torso.has(bn))) continue; + const ws = transfer(tris, [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]]).filter(([bn]) => torso.has(bn)); + if (ws.length) vb[v] = ws; + } + } + // outfit only: the model's own skin and face go, the hero's body is worn under the clothes + if (opts.outfit) { + const drop = outfitDrop(pos, uv, idx, groupsAll, headV, { ...opts, matNames: file.materials.map((m) => m.name), body: tris, tone: opts.tone !== undefined ? opts.tone : fileTone(file, R, opts) }); + report.outfit = drop.report; + // the model's hands: gloves (mostly kept) or bare (mostly skin: the hero's hands are worn) + const hb = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text)); + const hs = vb.map((ws) => ws.reduce((t, [b, w]) => t + (hb[b] ? w : 0), 0)); + let hAll = 0, hKept = 0; + for (let i = 0; i < idx.length; i += 3) if (hs[idx[i]] + hs[idx[i + 1]] + hs[idx[i + 2]] > 1.5) (hAll++, (hKept += drop.tri[i / 3] ? 0 : 1)); + bareHands = hAll > 0 && hKept < 0.6 * hAll; + report.outfit.gloves = hAll > 0 && !bareHands; + const keepIdx = []; + for (const g of groupsAll) { + const s = keepIdx.length; + for (let i = g.start; i < g.start + g.count; i += 3) if (!drop.tri[i / 3]) keepIdx.push(idx[i], idx[i + 1], idx[i + 2]); + g.start = s; + g.count = keepIdx.length - s; + } + idx.length = keepIdx.length; + for (let i = 0; i < keepIdx.length; i++) idx[i] = keepIdx[i]; + for (let g = groupsAll.length - 1; g >= 0; g--) if (!groupsAll[g].count) groupsAll.splice(g, 1); + if (!idx.length) return null; // nothing but skin + } + skirtSet = new Set(); + report.skirt = skirtWeights(sk, P, opts.fitHero, pos, vb, idx, skirtSet); + const mantle = [1, 2, 3, 4, 5].map((i) => sk.byName.get(`bip01 mantle0${i}`) ?? -1).filter((x) => x >= 0); + if (mantle.length > 1) { + const isMantle = new Set(mantle); + const ys = mantle.map((m) => P.wp[m][1]); + for (let v = 0; v < V; v++) { + if (!vb[v].length || !vb[v].every(([bn]) => isMantle.has(bn))) continue; + const y = pos[v * 3 + 1]; + let k = 0; + while (k < ys.length - 2 && y < ys[k + 1]) k++; + const t = Math.max(0, Math.min(1, (ys[k] - y) / (ys[k] - ys[k + 1] || 1))); + vb[v] = t <= 0 ? [[mantle[k], 1]] : t >= 1 ? [[mantle[k + 1], 1]] : [[mantle[k], 1 - t], [mantle[k + 1], t]]; + } + } + } + if (R.T && tris && opts.cover !== false) report.covered = cover(sk, tris, pos, vb, idx, skirtSet); + // 4 biggest, normalised + const limit = (ws) => { + ws = ws.filter(([, w]) => w > 0).sort((a, b) => b[1] - a[1]).slice(0, 4); + const t = ws.reduce((s, [, w]) => s + w, 0); + ws = ws.map(([b, w]) => [b, w / t]).filter(([, w]) => w >= minW); + const t2 = ws.reduce((s, [, w]) => s + w, 0); + return ws.map(([b, w]) => [b, w / t2]); + }; + // smooth weights: neighbours (edges, and vertices on the same spot — UV / material / piece borders) + // average their weights a few times, so no edge goes from one bone to another at once — that + // stretched the belly / hips open when the hero's 4 spine bones turn and the rig had 2 + const passes = opts.smoothWeights ?? (R.T ? 3 : 0); // fitted rigs only: a rig with the hero's bones keeps its weights exactly + if (passes > 0) { + const nb = Array.from({ length: V }, () => new Set()); + for (let i = 0; i < idx.length; i += 3) + for (const [a, b] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) nb[a].add(b), nb[b].add(a); + const cell = new Map(); + const key = (v) => `${Math.round(pos[v * 3] / 0.2)},${Math.round(pos[v * 3 + 1] / 0.2)},${Math.round(pos[v * 3 + 2] / 0.2)}`; + for (let v = 0; v < V; v++) { + const k = key(v); + if (!cell.has(k)) cell.set(k, []); + cell.get(k).push(v); + } + for (const vs of cell.values()) if (vs.length > 1) for (const a of vs) for (const b of vs) if (a !== b) nb[a].add(b); + for (let it = 0; it < passes; it++) { + const next = vb.map((ws, v) => { + if (!ws.length || !nb[v].size) return ws; + const acc = new Map(); + for (const [bn, w] of ws) acc.set(bn, w * 0.5); + let n = 0; + for (const u of nb[v]) if (vb[u].length) n++; + if (!n) return ws; + for (const u of nb[v]) for (const [bn, w] of vb[u]) acc.set(bn, (acc.get(bn) || 0) + (0.5 * w) / n); + return [...acc]; + }); + for (let v = 0; v < V; v++) vb[v] = next[v]; + } + } + const W0 = vb.map(limit); + // vertices on the same spot (UV / material seams, split normals) move as one: their weights + // averaged — a few hundredths apart they opened holes along the seams in a pose + if (R.T || passes > 0) { + const at = new Map(); + for (let v = 0; v < V; v++) { + const k = `${Math.round(pos[v * 3] * 1000)},${Math.round(pos[v * 3 + 1] * 1000)},${Math.round(pos[v * 3 + 2] * 1000)}`; + if (!at.has(k)) at.set(k, []); + at.get(k).push(v); + } + for (const vs of at.values()) { + if (vs.length < 2 || vs.every((v) => W0[v].length === W0[vs[0]].length && W0[v].every(([b, w], j) => W0[vs[0]][j][0] === b && W0[vs[0]][j][1] === w))) continue; + const acc = new Map(); + for (const v of vs) for (const [b, w] of W0[v]) acc.set(b, (acc.get(b) || 0) + w / vs.length); + const one = limit([...acc]); + for (const v of vs) W0[v] = one; + } + } + + // outfit only: the hero's own body (and hands) inside the clothes, without the faces the + // clothes hide (they'd poke through in a pose) — its weights and material as the game's + const nOut = idx.length; // the model's faces; the hero's come after + const heroHand = new Set(); // faces of the hero's hand mesh + if (opts.base?.length) { + const occ = rayGrid(pos, idx, skirtSet); + let hidden = 0, kept = 0; + // shoes / gloves: the hero's feet / hands inside them go (a hero toe is longer than most shoes) + const footB = sk.data.bones.map((b) => /^bip01 [lr] (foot|toe)/i.test(b.name.text)); + const handB = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text)); + // shoes: the model's clothes reach below the hero's ankles (by place: shoes are often on the calf's weights) + const ankle = Math.max(...["l", "r"].map((S) => P.wp[sk.byName.get(`bip01 ${S} foot`) ?? 0]?.[1] ?? -Infinity)); + let shod = 0; + for (let i = 0; i < idx.length; i += 3) if ((pos[idx[i] * 3 + 1] + pos[idx[i + 1] * 3 + 1] + pos[idx[i + 2] * 3 + 1]) / 3 < ankle) shod++; + const gloved = !!report.outfit?.gloves; + const heroOn = (b, v, B) => { + let t = 0; + for (let j = 0; j < 4; j++) if (b.bone[v * 4 + j] >= 0 && B[b.bone[v * 4 + j]]) t += b.w[v * 4 + j]; + return t > 0.5; + }; + for (const b of opts.base) { + const o = pos.length / 3, Vb = b.pos.length / 3; + for (let v = 0; v < Vb; v++) { + pos.push(b.pos[v * 3], b.pos[v * 3 + 1], b.pos[v * 3 + 2]); + if (b.nrm) nrm.push(b.nrm[v * 3], b.nrm[v * 3 + 1], b.nrm[v * 3 + 2]); + else nrm.push(0, 1, 0); + uv.push(b.uv[v * 2], b.uv[v * 2 + 1]); + W0.push(limit(weightsOf(b, v))); + } + const at = (v) => [b.pos[v * 3], b.pos[v * 3 + 1], b.pos[v * 3 + 2]]; + const nAt = (v) => (b.nrm ? [b.nrm[v * 3], b.nrm[v * 3 + 1], b.nrm[v * 3 + 2]] : [0, 0, 0]); + b.subs.forEach((s, k) => { + const start = idx.length; + for (let i = s.indexStart; i < s.indexStart + s.faceCount * 3; i += 3) { + const c = [b.idx[i], b.idx[i + 1], b.idx[i + 2]]; + const ps = c.map(at), ns = c.map(nAt); + ps.push(v3.mul(v3.add(v3.add(ps[0], ps[1]), ps[2]), 1 / 3)); + ns.push(v3.add(v3.add(ns[0], ns[1]), ns[2])); + const inShoe = shod > 20 && c.every((v) => heroOn(b, v, footB)); + const inGlove = gloved && c.every((v) => heroOn(b, v, handB)); + if (inShoe || inGlove || (occ && ps.every((p, j) => occ.covered(p, v3.unit(ns[j]))))) { + hidden++; + continue; + } + kept++; + if (b.hand) heroHand.add(idx.length); + idx.push(o + c[0], o + c[1], o + c[2]); + } + if (idx.length > start) groupsAll.push({ material: b.mats[k], start, count: idx.length - start }); + }); + } + report.heroBody = { kept, hidden }; + } + + /** One mesh from the faces keep(t) picks: its own vertices, ≤ maxBones bones, bind-space positions. */ + function part(keep, rep) { + const subs = [], groups = [], idxOut = [], map = new Int32Array(pos.length / 3).fill(-1), from = []; + for (const g of groupsAll) { + const start = idxOut.length, base = from.length; + // the group's vertices in their file order (a whole mesh comes back exactly as it was) + const picked = []; + for (let i = g.start; i < g.start + g.count; i += 3) if (keep(i)) picked.push(i); + const usedV = [...new Set(picked.flatMap((i) => [idx[i], idx[i + 1], idx[i + 2]]))].sort((a, b) => a - b); + for (const v of usedV) (map[v] = from.length), from.push(v); + for (const i of picked) idxOut.push(map[idx[i]], map[idx[i + 1]], map[idx[i + 2]]); + if (idxOut.length === start) continue; + subs.push({ minIndex: base, vertexCount: from.length - base, indexStart: start, faceCount: (idxOut.length - start) / 3 }); + groups.push({ material: g.material, start, count: idxOut.length - start }); + } + const n = from.length; + if (!n) return null; + if (n > 65535) throw new Error(`${n.toLocaleString()} vertices: the game reads at most 65 535 per mesh. Decimate it in Blender (or split it into body / hand meshes).`); + // at most maxBones different bones: the least used one goes into its parent, again and again + let W = from.map((v) => W0[v]); + for (;;) { + const total = new Map(); + for (const ws of W) for (const [b, w] of ws) total.set(b, (total.get(b) || 0) + w); + if (total.size <= maxBones) break; + const [drop] = [...total].filter(([b]) => sk.parent[b] >= 0).sort((a, b) => a[1] - b[1])[0]; + const into = sk.parent[drop]; + rep.folded.push(`${sk.data.bones[drop].name.text} → ${sk.data.bones[into].name.text}`); + W = W.map((ws) => { + if (!ws.some(([b]) => b === drop)) return ws; + const acc = new Map(); + for (const [b, w] of ws) acc.set(b === drop ? into : b, (acc.get(b === drop ? into : b) || 0) + w); + return limit([...acc]); + }); + } + // palette + the stored (bind space) positions: u · A⁻¹ + const used = [...new Set(W.flat().map(([b]) => b))].sort((a, b) => a - b); + const slot = new Map(used.map((b, i) => [b, i])); + const weights = new Float32Array(n * 4), boneIdx = new Uint8Array(n * 4); + const out = new Float32Array(n * 3), onrm = new Float32Array(n * 3), ouv = new Float32Array(n * 2); + const A = new Float64Array(16); + let unweighted = 0; + for (let i = 0; i < n; i++) { + const v = from[i]; + ouv[i * 2] = uv[v * 2]; + ouv[i * 2 + 1] = uv[v * 2 + 1]; + const ws = W[i]; + A.fill(0); + if (!ws.length) { + unweighted++; + out.set(pos.slice(v * 3, v * 3 + 3), i * 3); + onrm.set(nrm.slice(v * 3, v * 3 + 3), i * 3); + continue; + } + ws.forEach(([b, w], j) => { + weights[i * 4 + j] = w; + boneIdx[i * 4 + j] = slot.get(b); + const S = P.skin[b]; + for (let e = 0; e < 16; e++) A[e] += w * S[e]; + }); + const inv = M4.invertAffine(A); + xf(inv, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], out, i * 3); + xn(inv, nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2], onrm, i * 3); + const l = Math.hypot(onrm[i * 3], onrm[i * 3 + 1], onrm[i * 3 + 2]) || 1; + for (let k = 0; k < 3; k++) onrm[i * 3 + k] /= l; + } + Object.assign(rep, { vertices: n, faces: idxOut.length / 3, bones: used.length, subs: subs.length, unweighted, boneNames: used.map((b) => sk.data.bones[b].name.text) }); + const data = { + version: 2000, + type: 1, + subs, + vertexCount: n, + faceCount: idxOut.length / 3, + pos: out, + normal: onrm, + uv0: ouv, + boneNames: used.map((b) => ({ raw: null, text: sk.data.bones[b].name.text })), + weights, + boneIdx, + indices: Uint16Array.from(idxOut), + controlPoints: [], + }; + return { data, groups, report: rep }; + } + + const notHeroHand = (i) => !heroHand.has(i); + if (!opts.splitHands) return part(notHeroHand, report); + if (bareHands) { + // what's left of bare hands (nails, a ring) goes too: the game's default hands are worn + const hb = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text)); + const hs = W0.map((ws) => ws.reduce((t, [b, w]) => t + (hb[b] ? w : 0), 0)); + const r = part((i) => notHeroHand(i) && !(i < nOut && hs[idx[i]] + hs[idx[i + 1]] + hs[idx[i + 2]] > 1.5), report); + return Object.assign(r, { handNote: "bare hands: the hero's own hands are worn" }); + } + // hands apart (they replace default_hand): faces that mostly move with a hand or its fingers + const handBone = sk.data.bones.map((b) => /^bip01 [lr] (hand|finger)/i.test(b.name.text)); + const handShare = W0.map((ws) => ws.reduce((s, [b, w]) => s + (handBone[b] ? w : 0), 0)); + const isHand = (i) => handShare[idx[i]] + handShare[idx[i + 1]] + handShare[idx[i + 2]] > 1.5; + // only real hands (with fingers): cuffs alone would take the bare hands off in the game + const fingered = idx.some((v, i) => i < nOut && i % 3 === 0 && isHand(i) && [0, 1, 2].some((k) => W0[idx[i + k]].some(([b]) => /finger/i.test(sk.data.bones[b].name.text)))); + if (!fingered) return Object.assign(part(notHeroHand, report), { handNote: "no fingers in the model: nothing split" }); + // the hero's hand mesh (outfit only) goes with the model's gloves / sleeves on the hands + const inHand = (i) => heroHand.has(i) || (i < nOut && isHand(i)); + const hand = part(inHand, { ...report, folded: [] }); + const main = part((i) => !inHand(i), report); + if (!main) throw new Error("Everything moves with the hands: untick Split the hands."); + main.hand = hand; + report.handVertices = hand ? hand.report.vertices : 0; + return main; + } + + /** + * A long skirt / dress the model hangs on its legs tears between them when the hero runs (the + * cloth between the knees follows one calf or the other): its cloth — leg-weighted vertices well + * outside the hero's leg — goes onto the hips and both thighs instead, like the game's own skirts + * (more thigh lower down, left / right by side). Trousers, boots and tights stay on the legs. + */ + function skirtWeights(sk, P, hero, pos, vb, idx, skirt = null) { + const b = (n) => sk.byName.get(n.toLowerCase()) ?? -1; + const W = (n) => P.wp[b(n)]; + if (!idx || ["Bip01 Pelvis", "Bip01 L Thigh", "Bip01 R Thigh", "Bip01 L Calf"].some((n) => !W(n))) return 0; + const legBones = new Set(["L", "R"].flatMap((S) => [`Bip01 ${S} Thigh`, `Bip01 ${S} Calf`, `Bip01 ${S} Foot`, `Bip01 ${S} Toe0`].map(b))); + const pelvis = b("Bip01 Pelvis"), thL = b("Bip01 L Thigh"), thR = b("Bip01 R Thigh"); + const hips = new Set([pelvis, b("Bip01"), b("Bip01 Spine")]); + const ty = W("Bip01 L Thigh")[1], ky = W("Bip01 L Calf")[1], xL = W("Bip01 L Thigh")[0], xR = W("Bip01 R Thigh")[0]; + const xc = (xL + xR) / 2, half = Math.abs(xL - xR) / 2; + const V = vb.length; + // pieces of the mesh (connected faces) + const root = Int32Array.from({ length: V }, (_, i) => i); + const find = (i) => (root[i] === i ? i : (root[i] = find(root[i]))); + for (let i = 0; i < idx.length; i += 3) for (let k = 1; k < 3; k++) root[find(idx[i + k])] = find(idx[i]); + // pieces touching (vertices on the same spot: a hem trim, a lining) are one piece + const at = new Map(); + for (let v = 0; v < V; v++) { + const k = `${Math.round(pos[v * 3] * 100)},${Math.round(pos[v * 3 + 1] * 100)},${Math.round(pos[v * 3 + 2] * 100)}`; + if (at.has(k)) root[find(v)] = find(at.get(k)); + else at.set(k, v); + } + // a skirt / dress is a piece that crosses the middle between the legs well below the crotch + // (trousers part there, boots and socks hang on one leg) + const below = ty - 0.25 * (ty - ky); + const mid = new Map(); + const low = new Map(); + for (let v = 0; v < V; v++) { + if (pos[v * 3 + 1] >= below) continue; + low.set(find(v), (low.get(find(v)) || 0) + 1); + if (Math.abs(pos[v * 3] - xc) < 0.35 * half) mid.set(find(v), (mid.get(find(v)) || 0) + 1); + } + // (a good share of it: trousers / legs with a strap or pouch in the middle aren't a skirt) + const isSkirt = (r) => (mid.get(r) || 0) >= Math.max(2, 0.03 * (low.get(r) || 0)); + const legOrHips = (ws) => ws.reduce((t, [bn, w]) => t + (legBones.has(bn) || hips.has(bn) ? w : 0), 0); + let n = 0; + for (let v = 0; v < V; v++) { + if (!vb[v].length || !isSkirt(find(v))) continue; + const x = pos[v * 3], y = pos[v * 3 + 1]; + if (y > ty || legOrHips(vb[v]) < 0.6) continue; + // more thigh further down, the side's thigh (sharpened: in front of a leg it follows that leg) + const share = Math.max(0, Math.min(0.95, (ty - y) / (ty - ky || 1))); + const t = Math.max(0, Math.min(1, (x - xR) / (xL - xR || 1))); + const side = t * t * (3 - 2 * t); + vb[v] = [[pelvis, 1 - share], [thL, share * side], [thR, share * (1 - side)]].filter(([, w]) => w > 0.001); + n++; + skirt?.add(v); + } + return n; + } + + /** + * The costume covers the hero's body: a vertex of a body part that ends up inside the hero's body + * surface (thinner legs, a narrow waist…) is pushed out onto it, a little outside, so the game's + * body never shows through — the costume is at least the hero's size, like the game's own + * costumes. Torso, arms and legs only (not head, hands, capes); the push is spread over the + * neighbours so the cloth stays smooth. → how many vertices moved + */ + /** + * Outfit only: which faces of the model are its own body. The skin tone is read off the face (the + * faces on the head joint itself); every face whose texture is that tone goes, and so does the + * face itself — skin, eyes, mouth: whatever lies inside the hero's head (opts.face, its surface), + * the hero's own face shows there. Hair, hats and clothes stay. A few faces either way along a + * border follow their neighbours (no specks). opts.texel(material, u, v) → [r, g, b, a] | null; + * opts.skinTol scales the colour match (1 = normal). + * → { tri: Uint8Array (1 = drop), report } + */ + function outfitDrop(pos, uv, idx, groups, headV, opts) { + const F = idx.length / 3; + const tri = new Uint8Array(F); + const mat = new Int32Array(F).fill(-1); + for (const g of groups) for (let i = g.start; i < g.start + g.count; i += 3) mat[i / 3] = g.material; + const texel = opts.texel || (() => null); + const hsv = (c) => { + const r = c[0] / 255, g = c[1] / 255, b = c[2] / 255; + const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; + let h = 0; + if (d) h = mx === r ? ((g - b) / d + 6) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; + return [h * 60, mx ? d / mx : 0, mx]; + }; + // texture colour at a point of face f: k = 0…2 near a corner, 3 the middle + const sample = (f, k) => { + const i = f * 3; + const w = k === 3 ? [1 / 3, 1 / 3, 1 / 3] : [0, 1, 2].map((j) => (j === k ? 0.7 : 0.15)); + let u = 0, v = 0; + for (let j = 0; j < 3; j++) (u += w[j] * uv[idx[i + j] * 2]), (v += w[j] * uv[idx[i + j] * 2 + 1]); + const c = texel(mat[f], u, v); + // alpha is often a mask (game rips): only a clear, black texel is "nothing" + return c && !((c[3] ?? 255) < 16 && c[0] + c[1] + c[2] < 30) ? hsv(c) : null; + }; + // on the head (the head joint or any below it: a face rig, the hair) + const onHead = (f) => headV[idx[f * 3]] > 0 && headV[idx[f * 3 + 1]] > 0 && headV[idx[f * 3 + 2]] > 0; + const isHair = (f) => headV[idx[f * 3]] === 2 || headV[idx[f * 3 + 1]] === 2 || headV[idx[f * 3 + 2]] === 2; + // how far out of the hero's head (its surface, + outside) a face of the model is; null: not near it + const head = opts.face?.length ? opts.face : null; + const depth = (f) => { + if (!head) return null; + const c = [0, 1, 2].map((k) => (pos[idx[f * 3] * 3 + k] + pos[idx[f * 3 + 1] * 3 + k] + pos[idx[f * 3 + 2] * 3 + k]) / 3); + const h = nearest(head, c); + return h && h.t.n && h.d < 6 ? v3.dot(v3.sub(c, h.q), h.t.n) : null; + }; + const name = (f) => opts.matNames?.[mat[f]] || ""; + // not skin by their material name: hair, eyes, a mouth, glasses… + const NOT_SKIN = /hair|bang|fringe|tail|hat|cap|helm|eye|iris|pupil|brow|lash|teeth|tooth|tongue|mouth|glass|lens|mask|shadow|highlight|sclera|cheek|blush|髪|目|瞳|眉|歯|舌/i; + // the face's own bits, gone with it (the hero's face has its own) + const FACE_BITS = /eye|iris|pupil|brow|lash|teeth|tooth|tongue|mouth|sclera|highlight|shadow|blush|cheek|目|瞳|眉|歯|舌/i; + const tone = opts.tone !== undefined ? opts.tone : skinTone(F, (f) => onHead(f) && !isHair(f) && !NOT_SKIN.test(name(f)) && depth(f) !== null, sample); + if (opts.toneOnly) return { tone }; + const tol = opts.skinTol ?? 1; + const skin = (c) => { + if (!c || !tone) return false; + const dh = Math.abs(((c[0] - tone[0] + 540) % 360) - 180); + return dh <= 16 * tol && c[1] >= tone[1] * (1 - 0.5 * tol) && c[1] <= tone[1] * (1 + 0.9 * tol) + 0.04 && c[2] >= tone[2] - 0.35 * tol && c[2] <= tone[2] + 0.25 * tol; + }; + // skin lies on the hero's body (the fit put the limbs there); a boot's toe, a cuff in a skin-like + // colour stands off it + const bodyS = opts.body?.length ? opts.body : null; + const onBody = (f) => { + if (!bodyS) return true; + const c = [0, 1, 2].map((k) => (pos[idx[f * 3] * 3 + k] + pos[idx[f * 3 + 1] * 3 + k] + pos[idx[f * 3 + 2] * 3 + k]) / 3); + const h = nearest(bodyS, c); + if (opts.debugBody) opts.debugBody(h, c); + return !!h && (!h.t.n || v3.dot(v3.sub(c, h.q), h.t.n) < (opts.skinOff ?? 2.5)); + }; + const skinFace = (f) => { + let n = 0; + for (let k = 0; k < 4; k++) n += skin(sample(f, k)) ? 1 : 0; + return n >= 3; + }; + let face = 0; + const fixed = new Uint8Array(F); + for (let f = 0; f < F; f++) { + if (onHead(f)) { + // the face: skin near or in the hero's head, the face's bits, anything well inside it; + // hair, hats, glasses outside it stay + fixed[f] = 1; + const d = depth(f); + const hair = isHair(f) || /hair|bang|fringe|髪/i.test(name(f)); + if (!hair && (skinFace(f) || FACE_BITS.test(name(f)) || (d !== null && d < 0.3))) tri[f] = 1; + else if (hair && d !== null && d < -0.5) tri[f] = 1; + continue; + } + tri[f] = skinFace(f) && onBody(f) ? 1 : 0; + } + // the face's material (mostly skin on the head) goes whole: eyes, lashes, a mouth painted or + // modelled in it aren't skin-coloured + { + const tot = new Map(), sk = new Map(); + for (let f = 0; f < F; f++) if (onHead(f) && !isHair(f)) { + tot.set(mat[f], (tot.get(mat[f]) || 0) + 1); + sk.set(mat[f], (sk.get(mat[f]) || 0) + tri[f]); + } + for (let f = 0; f < F; f++) if (onHead(f) && !isHair(f) && !/hair|bang|fringe|髪/i.test(name(f)) && (sk.get(mat[f]) >= 0.25 * tot.get(mat[f]) || /face|skin|head|顔|肌/i.test(name(f)))) tri[f] = 1; + } + // a piece on the head (connected faces of one material: the face, an eye, the hair) goes or stays + // whole — what's painted on a face (brows, a mouth) isn't skin-coloured but goes with it + { + const root = new Int32Array(F).map((_, i) => i); + const find = (i) => (root[i] === i ? i : (root[i] = find(root[i]))); + const byV = new Map(); + for (let f = 0; f < F; f++) { + if (!onHead(f)) continue; + for (let j = 0; j < 3; j++) { + const k = `${idx[f * 3 + j]}:${mat[f]}`; + if (byV.has(k)) root[find(f)] = find(byV.get(k)); + else byV.set(k, f); + } + } + const tot = new Map(), dropN = new Map(); + for (let f = 0; f < F; f++) if (onHead(f)) { + const r = find(f); + tot.set(r, (tot.get(r) || 0) + 1); + dropN.set(r, (dropN.get(r) || 0) + tri[f]); + } + for (let f = 0; f < F; f++) if (onHead(f)) { + const r = find(f); + tri[f] = dropN.get(r) >= 0.5 * tot.get(r) ? 1 : 0; + face += tri[f]; + } + } + // borders: a face follows its neighbours (vertices by position, so across seams too) + const key = (v) => `${Math.round(pos[v * 3] * 100)},${Math.round(pos[v * 3 + 1] * 100)},${Math.round(pos[v * 3 + 2] * 100)}`; + const at = new Map(); + const vid = new Int32Array(idx.length); + for (let i = 0; i < idx.length; i++) { + const k = key(idx[i]); + if (!at.has(k)) at.set(k, at.size); + vid[i] = at.get(k); + } + for (let it = 0; it < 2; it++) { + const sum = new Float32Array(at.size), cnt = new Float32Array(at.size); + for (let i = 0; i < idx.length; i++) (sum[vid[i]] += tri[(i / 3) | 0]), cnt[vid[i]]++; + const next = Uint8Array.from(tri); + for (let f = 0; f < F; f++) { + if (fixed[f]) continue; + let s = 0; + for (let j = 0; j < 3; j++) s += sum[vid[f * 3 + j]] / cnt[vid[f * 3 + j]]; + next[f] = s / 3 > 0.5 ? 1 : 0; + } + tri.set(next); + } + let dropped = 0; + for (let f = 0; f < F; f++) dropped += tri[f]; + return { tri, tone, report: { tone: hsvRgb(tone), faces: F, skin: dropped - face, face, kept: F - dropped } }; + } + const hsvRgb = (t) => { + if (!t) return null; + const [h, s, v] = t, c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; + const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]; + return [r, g, b].map((q) => Math.round((q + m) * 255)); + }; + /** The model's skin tone over all its meshes (its face may be another part than the body). Cached on the rig. */ + function fileTone(file, R, opts) { + if (R.tone !== undefined) return R.tone; + const pos = [], uv = [], idx = [], headV = [], groups = []; + for (const m of file.meshes) for (const p of m.prims) { + if (!p.joints) continue; + const o = pos.length / 3, start = idx.length; + for (let v = 0; v < p.pos.length / 3; v++) { + const w = R.weightsAt(p, v); + const q = R.toLs(p.pos[v * 3], p.pos[v * 3 + 1], p.pos[v * 3 + 2]); + const r = R.T && w.ws.length ? fitPoint(R.T, w.ws, q, [0, 1, 0]) : null; + pos.push(...(r ? r[0] : q)); + uv.push(p.uv ? p.uv[v * 2] : 0, p.uv ? p.uv[v * 2 + 1] : 0); + headV.push(w.headish ? (w.hair ? 2 : 1) : 0); + } + for (let i = 0; i < p.idx.length; i++) idx.push(o + p.idx[i]); + groups.push({ material: p.material, start, count: idx.length - start }); + } + R.tone = idx.length ? outfitDrop(pos, uv, idx, groups, headV, { ...opts, matNames: file.materials.map((m) => m.name), tone: undefined, toneOnly: true }).tone : null; + return R.tone; + } + /** The skin tone [h, s, v]: the commonest colour (hue 10°, saturation / value 0.1 bins) of the faces pick(f) picks — the face's skin. */ + function skinTone(F, pick, sample) { + const seen = []; + for (let f = 0; f < F; f++) if (pick(f)) for (let k = 0; k < 4; k++) { + const c = sample(f, k); + if (c && c[1] > 0.04 && c[1] < 0.75 && c[2] > 0.35) seen.push(c); + } + let tone = null; + if (seen.length >= 20) { + const bin = (c) => [Math.floor(c[0] / 10) % 36, Math.min(9, Math.floor(c[1] * 10)), Math.min(9, Math.floor(c[2] * 10))]; + const count = new Map(); + for (const c of seen) { + const k = bin(c).join(); + count.set(k, (count.get(k) || 0) + 1); + } + let best = null, bn = -1; + for (const k of count.keys()) { + const [h, s, v] = k.split(",").map(Number); + let n = 0; + for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) for (let c = -1; c <= 1; c++) n += count.get(`${(h + a + 36) % 36},${s + b},${v + c}`) || 0; + if (n > bn) (bn = n), (best = [h, s, v]); + } + const near = seen.filter((c) => { + const b = bin(c); + return Math.min(Math.abs(b[0] - best[0]), 36 - Math.abs(b[0] - best[0])) <= 1 && Math.abs(b[1] - best[1]) <= 1 && Math.abs(b[2] - best[2]) <= 1; + }); + const med = (k) => near.map((c) => c[k]).sort((a, b) => a - b)[near.length >> 1]; + // hue: around the bin's middle (it can wrap at red) + const h0 = best[0] * 10 + 5; + const dh = near.map((c) => ((c[0] - h0 + 540) % 360) - 180).sort((a, b) => a - b)[near.length >> 1]; + tone = [(h0 + dh + 360) % 360, med(1), med(2)]; + } + return tone; + } + + /** The faces of a mesh (not the vertices in skip) for rays: covered(p, n) — a face within 9 out along n (or 4 in). */ + function rayGrid(pos, idx, skip = null) { + const cell = 4, map = new Map(); + const P = (v) => [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]]; + const T = []; + for (let i = 0; i < idx.length; i += 3) { + if (skip && (skip.has(idx[i]) || skip.has(idx[i + 1]) || skip.has(idx[i + 2]))) continue; + const t = [P(idx[i]), P(idx[i + 1]), P(idx[i + 2])]; + const ti = T.push(t) - 1; + const lo = [0, 1, 2].map((k) => Math.floor(Math.min(t[0][k], t[1][k], t[2][k]) / cell)); + const hi = [0, 1, 2].map((k) => Math.floor(Math.max(t[0][k], t[1][k], t[2][k]) / cell)); + for (let x = lo[0]; x <= hi[0]; x++) for (let y = lo[1]; y <= hi[1]; y++) for (let z = lo[2]; z <= hi[2]; z++) { + const k = `${x},${y},${z}`; + if (!map.has(k)) map.set(k, []); + map.get(k).push(ti); + } + } + if (!T.length) return null; + const hit = (o, d, t, max) => { + const e1 = v3.sub(t[1], t[0]), e2 = v3.sub(t[2], t[0]); + const p = v3.cross(d, e2), det = v3.dot(e1, p); + if (Math.abs(det) < 1e-9) return false; + const s = v3.sub(o, t[0]), u = v3.dot(s, p) / det; + if (u < 0 || u > 1) return false; + const q = v3.cross(s, e1), v = v3.dot(d, q) / det; + if (v < 0 || u + v > 1) return false; + const h = v3.dot(e2, q) / det; + return h >= 0 && h <= max; + }; + return { + covered(p, n) { + if (!n[0] && !n[1] && !n[2]) return false; + const o = v3.sub(p, v3.mul(n, 4)), max = 13; // from 4 inside (clothes a bit under the hero's skin: it pokes through) to 9 out + const seen = new Set(); + for (let s = 0; s <= max; s += cell / 2) { + const q = v3.add(o, v3.mul(n, s)); + const k = q.map((x) => Math.floor(x / cell)).join(); + for (const ti of map.get(k) || []) { + if (seen.has(ti)) continue; + seen.add(ti); + if (hit(o, n, T[ti], max)) return true; + } + } + return false; + }, + }; + } + + function cover(sk, body, pos, vb, idx, skirt = null, offBody = 1.0, offSkirt = 3.0) { + const keep = new Set(["Pelvis", "Spine", "Spine1", "Spine2", "L Clavicle", "R Clavicle", "L UpperArm", "R UpperArm", "L Forearm", "R Forearm", "L Thigh", "R Thigh", "L Calf", "R Calf", "L Foot", "R Foot", "L Toe0", "R Toe0"].map((n) => sk.byName.get(`bip01 ${n}`.toLowerCase()) ?? -1)); + const top = (ws) => ws.reduce((a, c) => (c[1] > a[1] ? c : a), [-1, 0])[0]; + const sub = gridOf(body.filter((t) => t.n && t.w.every((ws) => keep.has(top(ws))))); + if (!sub.length) return 0; + const V = vb.length; + let disp = new Float32Array(V * 3); + let n = 0; + for (let v = 0; v < V; v++) { + if (!vb[v].length || !keep.has(top(vb[v]))) continue; + const p = [pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]]; + const h = nearest(sub, p); + if (!h || h.d > 8) continue; + // a skirt stands further off: the thighs swing forwards inside it + const off = skirt?.has(v) ? offSkirt : offBody; + const depth = v3.dot(v3.sub(p, h.q), h.t.n); + if (depth >= off) continue; + const push = Math.min(8, off - depth); + disp.set(v3.mul(h.t.n, push), v * 3); + n++; + } + if (!n) return 0; + // layers move together: every vertex near a pushed one (any piece: a jacket over a bodysuit) + // takes the biggest push around it, so an outer layer stays outside the inner one instead of + // both landing on the hero's surface and poking through each other + { + const R = 2.5, key = (x, y, z) => `${Math.floor(x / R)},${Math.floor(y / R)},${Math.floor(z / R)}`; + const grid = new Map(); + for (let v = 0; v < V; v++) { + if (!disp[v * 3] && !disp[v * 3 + 1] && !disp[v * 3 + 2]) continue; + const k = key(pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]); + if (!grid.has(k)) grid.set(k, []); + grid.get(k).push(v); + } + const big = Float32Array.from(disp); + for (let v = 0; v < V; v++) { + if (!vb[v].length || !keep.has(top(vb[v]))) continue; + const x = pos[v * 3], y = pos[v * 3 + 1], z = pos[v * 3 + 2]; + const cx = Math.floor(x / R), cy = Math.floor(y / R), cz = Math.floor(z / R); + let best = Math.hypot(disp[v * 3], disp[v * 3 + 1], disp[v * 3 + 2]), bi = -1; + for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) for (let c = -1; c <= 1; c++) + for (const u of grid.get(`${cx + a},${cy + b},${cz + c}`) || []) { + if (Math.hypot(pos[u * 3] - x, pos[u * 3 + 1] - y, pos[u * 3 + 2] - z) > R) continue; + const l = Math.hypot(disp[u * 3], disp[u * 3 + 1], disp[u * 3 + 2]); + if (l > best) (best = l), (bi = u); + } + if (bi >= 0) big.set(disp.subarray(bi * 3, bi * 3 + 3), v * 3); + } + disp = big; + } + // spread: a vertex takes its neighbours' push where it is bigger than its own (never less) + const nb = Array.from({ length: V }, () => []); + for (let i = 0; i < idx.length; i += 3) for (const [a, e] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) nb[a].push(e), nb[e].push(a); + for (let it = 0; it < 2; it++) { + const next = Float32Array.from(disp); + for (let v = 0; v < V; v++) { + if (!nb[v].length || !vb[v].length || !keep.has(top(vb[v]))) continue; + const avg = [0, 0, 0]; + for (const u of nb[v]) for (let k = 0; k < 3; k++) avg[k] += disp[u * 3 + k] / nb[v].length; + const own = [disp[v * 3], disp[v * 3 + 1], disp[v * 3 + 2]]; + if (v3.len(avg) * 0.6 > v3.len(own)) for (let k = 0; k < 3; k++) next[v * 3 + k] = avg[k] * 0.6 + own[k] * 0.4; + } + disp = next; + } + // vertices on the same spot (seams) move together + const at = new Map(); + for (let v = 0; v < V; v++) { + const k = `${Math.round(pos[v * 3] * 1000)},${Math.round(pos[v * 3 + 1] * 1000)},${Math.round(pos[v * 3 + 2] * 1000)}`; + if (!at.has(k)) at.set(k, []); + at.get(k).push(v); + } + for (const vs of at.values()) { + if (vs.length < 2) continue; + const m = [0, 1, 2].map((k) => vs.reduce((t, v) => t + disp[v * 3 + k], 0) / vs.length); + for (const v of vs) disp.set(m, v * 3); + } + for (let i = 0; i < V * 3; i++) pos[i] += disp[i]; + return n; + } + + function smoothNormals(pos, idx, nrm) { + nrm.fill(0); + for (let i = 0; i < idx.length; i += 3) { + const [a, b, c] = [idx[i] * 3, idx[i + 1] * 3, idx[i + 2] * 3]; + const e1 = [pos[b] - pos[a], pos[b + 1] - pos[a + 1], pos[b + 2] - pos[a + 2]], e2 = [pos[c] - pos[a], pos[c + 1] - pos[a + 1], pos[c + 2] - pos[a + 2]]; + const n = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]]; + for (const v of [a, b, c]) for (let k = 0; k < 3; k++) nrm[v + k] += n[k]; + } + for (let v = 0; v < nrm.length; v += 3) { + const l = Math.hypot(nrm[v], nrm[v + 1], nrm[v + 2]) || 1; + for (let k = 0; k < 3; k++) nrm[v + k] /= l; + } + } + + // ===================================================================== + // material script + texture + // ===================================================================== + /** One toon material per sub-mesh (the costume look: texture, multiply pass, outline). */ + function materialText(textures, { outline = true, outlineColor = [0.133, 0.125, 0.106], thickness = 1.45, twoSided = true } = {}) { + const one = (tex) => { + const cull = twoSided ? "\t\t\tcull none\n" : ""; + const t = tex ? `\t\t\t\ttexture ${tex}\n` : ""; + let s = `material\n{\n\ttechnique\n\t{\n\t\tpass\n\t\t{\n${cull}\t\t\tshader_group AnimateToonShade\n\t\t\tshadow_cast true\n\t\t\tlight_iterate true\n\t\t\ttexture_unit\n\t\t\t{\n\t\t\t\tuse_light_texture true\n\t\t\t\ttex_address_mode clamp\n\t\t\t\talpha_blend selectarg2 texture diffuse\n\t\t\t}\n\t\t\ttexture_unit\n\t\t\t{\n${t}\t\t\t\ttex_coord_set 1\n\t\t\t\tfiltering none\n\t\t\t\tcolor_blend add current tfactor\n\t\t\t\talpha_blend modulate current texture\n\t\t\t}\n\t\t}\n`; + s += `\t\tpass\n\t\t{\n\t\t\tscene_blend add zero src_color\n\t\t\tdepth_write false\n${cull}\t\t\tlighting false\n\t\t\tshader_group Animate\n\t\t\ttexture_unit\n\t\t\t{\n${t}\t\t\t\tfiltering none\n\t\t\t}\n\t\t}\n`; + if (outline) s += `\t\tpass\n\t\t{\n\t\t\tcull clockwise\n\t\t\tdepth_bias 0.0 0.5\n\t\t\tshader_group AnimateOutLine\n\t\t\tcustom_param OutLineColor colorvalue ${outlineColor.map((v) => v.toFixed(3)).join(" ")} 1.000\n\t\t\tcustom_param OutLineThickness float ${thickness.toFixed(3)}\n\t\t\ttexture_unit\n\t\t\t{\n\t\t\t\tcolor_blend selectarg2 texture diffuse\n\t\t\t\talpha_blend selectarg2 texture diffuse\n\t\t\t}\n\t\t}\n`; + return s + "\t}\n}\n"; + }; + return textures.map(one).join("").replace(/\n/g, "\r\n"); + } + + /** RGBA pixels → an uncompressed 32-bit .dds (A8R8G8B8, no mip maps). */ + function encodeDds(rgba, w, h) { + const out = new Uint8Array(128 + w * h * 4); + const dv = new DataView(out.buffer); + dv.setUint32(0, 0x20534444, true); // "DDS " + dv.setUint32(4, 124, true); + dv.setUint32(8, 0x100f, true); // caps | height | width | pitch | pixel format + dv.setUint32(12, h, true); + dv.setUint32(16, w, true); + dv.setUint32(20, w * 4, true); + dv.setUint32(76, 32, true); + dv.setUint32(80, 0x41, true); // RGB | alpha pixels + dv.setUint32(88, 32, true); + dv.setUint32(92, 0x00ff0000, true); + dv.setUint32(96, 0x0000ff00, true); + dv.setUint32(100, 0x000000ff, true); + dv.setUint32(104, 0xff000000, true); + dv.setUint32(108, 0x1000, true); // texture + for (let i = 0, o = 128; i < w * h * 4; i += 4, o += 4) { + out[o] = rgba[i + 2]; + out[o + 1] = rgba[i + 1]; + out[o + 2] = rgba[i]; + out[o + 3] = rgba[i + 3]; + } + return out; + } + + return { UNIT, MAX_BONES, restPose, posed, exportGlb, loadGltf, parseObj, parseFbx, surface, transfer, nearestSurface: (t, p) => nearest(t, p), jointMap, autoScale, humanoid, humanoidMap, bodyPoints, prepare, partsGuess, build, materialText, encodeDds }; +})(); +window.CostumeKit = CostumeKit; diff --git a/app/public/legacy/js/dds.js b/app/public/legacy/js/dds.js new file mode 100644 index 0000000..446f72e --- /dev/null +++ b/app/public/legacy/js/dds.js @@ -0,0 +1,182 @@ +"use strict"; + +// Texture decoding for the UI Image Set editor: DDS (DXT1/DXT3/DXT5, 32/24/16-bit RGB masks) and +// TGA (true-colour, RLE) → ImageData. BMP/PNG/JPG go through the browser (createImageBitmap). + +const TexDecode = (() => { + const rgb565 = (c) => [((c >> 11) & 31) * 255 / 31, ((c >> 5) & 63) * 255 / 63, (c & 31) * 255 / 31]; + + /** One 4×4 DXT colour block → 16 RGB(A) values into out (at pixel x,y). */ + function colorBlock(src, o, out, w, h, x, y, dxt1, alphaSrc) { + const c0 = src[o] | (src[o + 1] << 8); + const c1 = src[o + 2] | (src[o + 3] << 8); + const p0 = rgb565(c0); + const p1 = rgb565(c1); + const pal = [p0, p1]; + if (!dxt1 || c0 > c1) { + pal.push([(2 * p0[0] + p1[0]) / 3, (2 * p0[1] + p1[1]) / 3, (2 * p0[2] + p1[2]) / 3]); + pal.push([(p0[0] + 2 * p1[0]) / 3, (p0[1] + 2 * p1[1]) / 3, (p0[2] + 2 * p1[2]) / 3]); + } else { + pal.push([(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, (p0[2] + p1[2]) / 2]); + pal.push(null); // transparent black (DXT1 1-bit alpha) + } + const bits = src[o + 4] | (src[o + 5] << 8) | (src[o + 6] << 16) | (src[o + 7] << 24); + for (let i = 0; i < 16; i++) { + const px = x + (i & 3); + const py = y + (i >> 2); + if (px >= w || py >= h) continue; + const c = pal[(bits >>> (2 * i)) & 3]; + const d = (py * w + px) * 4; + if (!c) { + out[d] = out[d + 1] = out[d + 2] = out[d + 3] = 0; + continue; + } + out[d] = c[0]; + out[d + 1] = c[1]; + out[d + 2] = c[2]; + out[d + 3] = alphaSrc ? alphaSrc[i] : 255; + } + } + + function dxt(src, offset, w, h, kind) { + const out = new Uint8ClampedArray(w * h * 4); + const blockBytes = kind === "DXT1" ? 8 : 16; + let o = offset; + const alpha = new Uint8Array(16); + for (let y = 0; y < h; y += 4) { + for (let x = 0; x < w; x += 4) { + let a = null; + if (kind === "DXT3") { + for (let i = 0; i < 8; i++) { + const b = src[o + i]; + alpha[i * 2] = (b & 15) * 17; + alpha[i * 2 + 1] = (b >> 4) * 17; + } + a = alpha; + } else if (kind === "DXT5") { + const a0 = src[o]; + const a1 = src[o + 1]; + const pal = [a0, a1]; + if (a0 > a1) for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7); + else { + for (let i = 1; i < 5; i++) pal.push(((5 - i) * a0 + i * a1) / 5); + pal.push(0, 255); + } + // 48 bits of 3-bit indices + let lo = src[o + 2] | (src[o + 3] << 8) | (src[o + 4] << 16); + let hi = src[o + 5] | (src[o + 6] << 8) | (src[o + 7] << 16); + for (let i = 0; i < 8; i++) alpha[i] = pal[(lo >> (3 * i)) & 7]; + for (let i = 0; i < 8; i++) alpha[8 + i] = pal[(hi >> (3 * i)) & 7]; + a = alpha; + } + colorBlock(src, o + (kind === "DXT1" ? 0 : 8), out, w, h, x, y, kind === "DXT1", a); + o += blockBytes; + } + } + return new ImageData(out, w, h); + } + + const shiftOf = (mask) => { + if (!mask) return [0, 0]; + let s = 0; + while (!((mask >>> s) & 1)) s++; + let n = 0; + while ((mask >>> (s + n)) & 1) n++; + return [s, n]; + }; + + function masked(src, offset, w, h, bpp, masks, hasAlpha) { + const out = new Uint8ClampedArray(w * h * 4); + const bytes = bpp / 8; + const pitch = w * bytes; + const chans = masks.map(shiftOf); + const dv = new DataView(src.buffer, src.byteOffset, src.byteLength); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const o = offset + y * pitch + x * bytes; + const v = bytes === 4 ? dv.getUint32(o, true) : bytes === 3 ? src[o] | (src[o + 1] << 8) | (src[o + 2] << 16) : dv.getUint16(o, true); + const d = (y * w + x) * 4; + for (let c = 0; c < 3; c++) { + const [s, n] = chans[c]; + out[d + c] = n ? (((v >>> s) & ((1 << n) - 1)) * 255) / ((1 << n) - 1) : 0; + } + const [as, an] = chans[3]; + out[d + 3] = hasAlpha && an ? (((v >>> as) & ((1 << an) - 1)) * 255) / ((1 << an) - 1) : 255; + } + } + return new ImageData(out, w, h); + } + + function dds(bytes) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (dv.getUint32(0, true) !== 0x20534444) throw new Error("Not a DDS file"); + const h = dv.getUint32(12, true); + const w = dv.getUint32(16, true); + const pf = dv.getUint32(80, true); + const fourCC = String.fromCharCode(bytes[84], bytes[85], bytes[86], bytes[87]); + const offset = 128; + if (pf & 4) { + if (fourCC === "DXT1" || fourCC === "DXT3" || fourCC === "DXT5") return dxt(bytes, offset, w, h, fourCC); + if (fourCC === "DXT2" || fourCC === "DXT4") return dxt(bytes, offset, w, h, fourCC === "DXT2" ? "DXT3" : "DXT5"); + throw new Error(`DDS format ${fourCC} isn't supported`); + } + const bpp = dv.getUint32(88, true); + const masks = [dv.getUint32(92, true), dv.getUint32(96, true), dv.getUint32(100, true), dv.getUint32(104, true)]; + return masked(bytes, offset, w, h, bpp, masks, !!(pf & 1)); + } + + function tga(bytes) { + const idLen = bytes[0]; + const type = bytes[2]; + const w = bytes[12] | (bytes[13] << 8); + const h = bytes[14] | (bytes[15] << 8); + const bpp = bytes[16]; + const topDown = (bytes[17] & 0x20) !== 0; + if (![2, 10].includes(type) || ![24, 32].includes(bpp)) throw new Error("Only true-colour TGA (24/32-bit) is supported"); + const px = bpp / 8; + const out = new Uint8ClampedArray(w * h * 4); + let o = 18 + idLen; + let i = 0; + const put = (b, g, r, a) => { + const x = i % w; + const y = Math.floor(i / w); + const d = ((topDown ? y : h - 1 - y) * w + x) * 4; + out[d] = r; + out[d + 1] = g; + out[d + 2] = b; + out[d + 3] = a; + i++; + }; + while (i < w * h) { + if (type === 2) { + put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255); + o += px; + continue; + } + const hdr = bytes[o++]; + const n = (hdr & 127) + 1; + if (hdr & 128) { + const c = [bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255]; + o += px; + for (let k = 0; k < n; k++) put(...c); + } else { + for (let k = 0; k < n; k++) { + put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255); + o += px; + } + } + } + return new ImageData(out, w, h); + } + + /** Any supported texture → ImageBitmap. */ + async function decode(bytes, ext) { + const e = (ext || "").toLowerCase(); + if (e === "dds") return createImageBitmap(dds(bytes)); + if (e === "tga") return createImageBitmap(tga(bytes)); + return createImageBitmap(new Blob([bytes])); + } + + return { decode, dds, tga }; +})(); +window.TexDecode = TexDecode; diff --git a/app/public/legacy/js/effect-editor.js b/app/public/legacy/js/effect-editor.js new file mode 100644 index 0000000..796e216 --- /dev/null +++ b/app/public/legacy/js/effect-editor.js @@ -0,0 +1,547 @@ +"use strict"; + +// The script is the source of truth. Property edits replace only their own line so comments, +// unknown keys, model/light blocks, and the original byte encoding survive a round trip. +(() => { + const FXE = { src: null, rows: [], where: null, name: "", text: "", saved: "", selected: null, playing: true }; + window.FXE = FXE; + let view = null, kit = null, inst = null, last = 0, loadSeq = 0, modelLoadSeq = 0; + const textures = new Map(); + let modelViews = []; + const bytesToRaw = (bytes) => { + let out = ""; + for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192)); + return out; + }; + const rawToBytes = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255); + const ending = () => FXE.text.includes("\r\n") ? "\r\n" : FXE.text.includes("\r") ? "\r" : "\n"; + const field = (key, label, type = "text", options = []) => ({ key, label, type, options }); + const commonSystem = [ + field("quota", "Max particles", "number"), field("cull_each", "Cull each", "bool"), + field("follow_parent", "Follow parent"), field("end_condition", "End condition", "select", ["never_end", "time_limit"]), + field("limit_time", "End time (ms)", "number"), field("start_create_particles", "Start count", "number"), + field("link_biped_name", "Linked bone"), field("random_sync_index", "Random sync", "number"), + ]; + const schema = { + info: [ + ["General", field("direction", "Direction", "vec3"), field("collision_box", "Collision box", "text"), + field("skeleton_name", "Skeleton"), field("animation_name", "Animation"), field("update_always", "Update always", "bool")], + ], + particle: [ + ["System", ...commonSystem], + ["Appearance", field("texture", "Texture"), field("default_width", "Width min / max", "vec2"), + field("default_height", "Height min / max", "vec2"), field("render_type", "Render type", "select", ["add", "src_alpha", "screen", "lighten", "darken", "subtract", "rev_subtract"]), + field("tex_filtering", "Texture filter", "select", ["bilinear", "point", "trilinear"]), field("depth_check", "Depth check", "bool"), + field("modulate2x", "Modulate ×2", "bool"), field("billboard_type", "Billboard", "select", ["point", "common", "direction"]), + field("common_dir", "Common direction", "vec3"), field("common_dir_none_billboard", "Fixed direction", "bool")], + ["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")], + ], + model: [ + ["Model", field("mesh_file", "Mesh file"), ...commonSystem], + ["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")], + ], + emitter: [ + ["Emission", field("emit_pos", "Position", "vec3"), field("emit_dir", "Direction", "vec3"), + field("emit_angle", "Angle", "number"), field("emit_rate", "Per second", "number"), + field("emit_speed", "Speed min / max", "vec2"), field("emit_live_time", "Life min / max", "vec2"), + field("emit_wait_time", "Wait (ms)", "number"), field("emit_start_tex_rotation", "Texture rotation", "vec2"), + field("start_rot_axis", "Start rotation axis", "vec3"), field("start_rot_angle", "Start rotation angle", "vec2"), + field("area_size", "Area size", "vec3"), field("inner_size", "Inner size", "vec3")], + ["Curves", field("color_rate", "Colour curve"), field("alpha_rate", "Alpha curve")], + ], + light: [ + ["Light", field("type", "Type", "select", ["point", "spot", "directional"]), + field("position", "Position", "vec3"), field("targetpos", "Target", "vec3"), field("direction", "Direction", "vec3"), + field("diffuse", "Diffuse", "vec4"), field("specular", "Specular", "vec4"), field("attenuation", "Attenuation", "vec3"), + field("spot_theta", "Spot inner", "number"), field("spot_phi", "Spot outer", "number"), + field("spot_falloff", "Falloff", "number"), field("light_start", "Start", "number"), field("light_end", "End", "number"), + field("light_texture", "Light texture"), field("decal_texture", "Decal texture")], + ], + }; + const affectorSchema = { + linear: [field("force_vector", "Force", "vec3"), field("force_method", "Method"), field("force_rate", "Force curve")], + scale: [field("scale_rate", "Scale curve")], + texrotation: [field("rot_range", "Rotation range", "vec2"), field("rot_speed", "Speed min / max", "vec2")], + rotation: [field("rot_axis", "Axis", "vec3"), field("rot_speed", "Speed min / max", "vec2")], + emit: [field("power", "Power", "number"), field("force_rate", "Force curve"), field("change_direction", "Change direction", "bool")], + }; + + function tree(text) { + const lines = text.split(/\r\n|\n|\r/), roots = [], stack = []; + let header = -1; + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t || t.startsWith("//")) continue; + if (t === "{" || t.endsWith("{")) { + const h = t === "{" ? lines[header]?.trim() || "" : t.slice(0, -1).trim(); + const [kind = "", ...rest] = h.split(/\s+/); + const node = { kind: kind.toLowerCase(), type: rest.join(" "), start: t === "{" ? header : i, open: i, end: -1, children: [], parent: stack.at(-1) || null }; + if (node.parent) node.parent.children.push(node); else roots.push(node); + stack.push(node); + } else if (t === "}") { + if (!stack.length) throw new Error(`Unexpected } at line ${i + 1}`); + stack.pop().end = i; + } else if (!stack.length || /^(info|particle|model|light|emitter|affector)\b/i.test(t)) header = i; + } + if (stack.length) throw new Error(`Missing } for ${stack.at(-1).kind}`); + return { lines, roots }; + } + const flatten = (nodes) => nodes.flatMap((n) => [n, ...flatten(n.children)]); + const nodeAt = (parsed, start) => flatten(parsed.roots).find((n) => n.start === start); + function attrs(parsed, node) { + const nested = node.children.map((n) => [n.start, n.end]); + const out = []; + for (let i = node.open + 1; i < node.end; i++) { + if (nested.some(([a, b]) => i >= a && i <= b)) continue; + const m = /^\s*([\w-]+)(?:\s+(.*))?$/.exec(parsed.lines[i]); + if (m && !m[1].startsWith("//")) out.push({ line: i, key: m[1], value: m[2] || "" }); + } + return out; + } + function setText(text, selection = FXE.selected) { + FXE.text = text; + if ($("fx-script").value !== text) $("fx-script").value = text; + let parsed; + try { + parsed = tree(text); + $("fx-parse-status").textContent = `${parsed.roots.length} blocks`; + } catch (err) { + $("fx-parse-status").textContent = err.message; + parsed = null; + } + FXE.parsed = parsed; + FXE.selected = parsed && nodeAt(parsed, selection) ? selection : parsed?.roots[0]?.start ?? null; + renderHierarchy(); + renderProperties(); + refreshPreview(); + const dirty = text !== FXE.saved; + $("fx-dirty").hidden = !dirty; + $("fx-save").disabled = !dirty; + $("fx-save-as").disabled = !text; + $("fx-title").textContent = FXE.name || "Untitled"; + } + function renderHierarchy() { + const box = $("fx-hierarchy"); + if (!FXE.parsed) return box.replaceChildren(el("small", { class: "muted", text: "Fix the script braces to inspect blocks." })); + const rows = []; + const totals = { particle: 0, model: 0 }; + const walk = (nodes, depth) => nodes.forEach((n) => { + const label = n.kind === "particle" || n.kind === "model" ? `${n.kind === "model" ? "Model" : "Particle"} ${totals[n.kind]++}` : n.kind === "emitter" || n.kind === "affector" ? `${n.kind} ${n.type}` : n.kind; + rows.push(el("button", { class: `fx-node${FXE.selected === n.start ? " on" : ""}`, style: `padding-left:${8 + depth * 16}px`, text: label, onclick: () => { FXE.selected = n.start; renderHierarchy(); renderProperties(); } })); + walk(n.children, depth + 1); + }); + walk(FXE.parsed.roots, 0); + box.replaceChildren(...rows); + } + function setField(node, key, value) { + const parsed = tree(FXE.text), current = nodeAt(parsed, node.start); + if (!current) return; + const lines = parsed.lines; + const a = attrs(parsed, current).find((x) => x.key.toLowerCase() === key.toLowerCase()); + if (a) { + const indent = /^\s*/.exec(lines[a.line])[0]; + lines[a.line] = `${indent}${a.key}${value ? ` ${value}` : ""}`; + } else { + const first = attrs(parsed, current)[0]; + const indent = first ? /^\s*/.exec(lines[first.line])[0] : /^\s*/.exec(lines[current.open])[0] + "\t"; + const at = current.children[0]?.start ?? current.end; + lines.splice(at, 0, `${indent}${key}${value ? ` ${value}` : ""}`); + } + setText(lines.join(ending()), current.start); + } + function setBlockType(node, type) { + const parsed = tree(FXE.text), lines = parsed.lines; + const line = lines[node.start]; + lines[node.start] = line.replace(/^(\s*(?:emitter|affector)\s+)\S+/i, `$1${type}`); + setText(lines.join(ending()), node.start); + } + async function browseResource(node, key, current) { + if (!FXE.src) return toast("Choose an effect source first.", true); + const mesh = key === "mesh_file"; + let names; + try { names = await call("mdl:names", FXE.src, mesh ? "mesh" : "texture"); } + catch (err) { return toast(err.message, true); } + const list = el("div", { class: "fx-resource-list" }); + const search = el("input", { type: "search", placeholder: mesh ? "Search meshes" : "Search textures", spellcheck: "false" }); + let chosen = current; + const shown = el("small", { class: "muted" }); + const render = () => { + const q = search.value.trim().toLowerCase(); + const matches = names.filter((n) => n.toLowerCase().includes(q)); + shown.textContent = `${matches.length.toLocaleString()} matches · showing first 100`; + list.replaceChildren(...matches.slice(0, 100).map((name) => { + const value = mesh ? name.replace(/\.msh$/i, "") : name; + return el("button", { class: `fx-resource${value.toLowerCase() === chosen.toLowerCase() ? " on" : ""}`, text: name, + onclick: () => { chosen = value; render(); } }); + })); + }; + search.oninput = render; + render(); + const ok = await modal(mesh ? "Choose effect mesh" : "Choose effect texture", [search, shown, list], + [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]); + if (ok && chosen) setField(node, key, chosen); + } + function control(node, spec, value) { + if (/^vec[234]$/.test(spec.type)) { + const size = Number(spec.type.slice(-1)), parts = value.trim().split(/\s+/).filter(Boolean); + const wrap = el("div", { class: "fx-vector" }); + const inputs = Array.from({ length: size }, (_, i) => el("input", { + type: "number", step: "any", value: parts[i] || "", placeholder: ["X", "Y", "Z", "W"][i], + onchange: () => setField(node, spec.key, inputs.map((x) => x.value || "0").join(" ")), + })); + wrap.replaceChildren(...inputs); + return wrap; + } + if (spec.type === "bool" || spec.type === "select") { + const options = spec.type === "bool" ? ["true", "false"] : spec.options; + const values = value && !options.some((x) => x.toLowerCase() === value.toLowerCase()) ? [value, ...options] : options; + const select = el("select", { onchange: (e) => setField(node, spec.key, e.target.value) }, [ + el("option", { value: "", text: "—" }), ...values.map((x) => el("option", { value: x, text: x })), + ]); + select.value = values.find((x) => x.toLowerCase() === value.toLowerCase()) || ""; + return select; + } + const input = el("input", { type: spec.type === "number" ? "number" : "text", step: spec.type === "number" ? "any" : undefined, + value, spellcheck: "false", onchange: (e) => setField(node, spec.key, e.target.value.trim()) }); + if (["mesh_file", "texture", "light_texture", "decal_texture"].includes(spec.key)) { + return el("div", { class: "fx-resource-field" }, [input, el("button", { class: "iconbtn only", title: "Browse resources", text: "…", onclick: () => browseResource(node, spec.key, input.value.trim()) })]); + } + return input; + } + function renderProperties() { + const node = FXE.parsed && nodeAt(FXE.parsed, FXE.selected); + $("fx-selection").textContent = node ? `${node.kind} ${node.type}` : ""; + $("fx-add-field").disabled = !node; + const system = node?.kind === "particle" || node?.kind === "model" ? node : node?.parent; + const hasSystem = system?.kind === "particle" || system?.kind === "model"; + $("fx-add-emitter").disabled = !hasSystem || system.children.some((x) => x.kind === "emitter"); + $("fx-add-affector").disabled = !hasSystem; + $("fx-duplicate-block").disabled = !node || !["particle", "model"].includes(node.kind); + const siblings = node && FXE.parsed ? FXE.parsed.roots.filter((x) => x.kind === node.kind) : []; + $("fx-move-up").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) <= 0; + $("fx-move-down").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) >= siblings.length - 1; + $("fx-delete-block").disabled = !node || node.kind === "info"; + const box = $("fx-properties"); + if (!node) return box.replaceChildren(el("span", { class: "muted", text: "Select a block." })); + const groups = node.kind === "affector" ? [[`${node.type || "Affector"} settings`, field("wait_time", "Wait (ms)", "number"), ...(affectorSchema[node.type.toLowerCase()] || [])]] : schema[node.kind] || []; + const fields = [], existing = attrs(FXE.parsed, node), shown = new Set(); + if (node.kind === "emitter" || node.kind === "affector") { + fields.push(el("label", { text: "Type" })); + const options = node.kind === "emitter" ? ["Point", "Box", "Cylinder", "Ellipse", "Ellipsoid", "HollowEllipsoid"] : ["Linear", "Scale", "TexRotation", "Rotation", "Emit"]; + const values = options.some((x) => x.toLowerCase() === node.type.toLowerCase()) ? options : [node.type, ...options]; + const select = el("select", { onchange: (e) => setBlockType(node, e.target.value) }, values.map((x) => el("option", { value: x, text: x }))); + select.value = values.find((x) => x.toLowerCase() === node.type.toLowerCase()); + fields.push(select); + } + for (const group of groups) { + fields.push(el("div", { class: "fx-group", text: group[0] })); + for (const spec of group.slice(1)) { + const a = existing.find((x) => x.key.toLowerCase() === spec.key); + shown.add(spec.key); + fields.push(el("label", { text: spec.label, title: spec.key })); + fields.push(control(node, spec, a?.value || "")); + } + } + const other = existing.filter((a) => !shown.has(a.key.toLowerCase())); + if (other.length) fields.push(el("div", { class: "fx-group", text: "Other fields" })); + for (const a of other) { + fields.push(el("label", { text: a.key, title: a.key })); + fields.push(control(node, field(a.key, a.key), a.value)); + } + box.replaceChildren(...fields); + } + function mutateLines(fn, selected = FXE.selected) { + if (!FXE.parsed) return; + const lines = FXE.parsed.lines.slice(); + fn(lines); + setText(lines.join(ending()), selected); + } + + function ensureView() { + if (view) return view; + try { view = new GL3D.View($("fx-canvas"), { alpha: false }); } + catch (err) { toast(`Effect preview unavailable: ${err.message}`, true); return null; } + // ioEffectEdit renders directly to an opaque back buffer. Additive DDS + // textures carry black RGB around their glow; SRC_ALPHA + ONE leaves that + // black unchanged on the gray target without changing the texture data. + view.env = { clear: [94 / 255, 94 / 255, 94 / 255] }; + kit = MeshKit.create(view, () => FXE.src); + view.opts.lit = false; + view.frame([-180, -40, -180], [180, 220, 180]); + drawGrid(); + return view; + } + function drawGrid() { + if (!view) return; + if (!$("fx-grid").classList.contains("on")) return view.setOverlay("fx-grid", null); + const a = []; + const line = (x, z, xx, zz, c) => a.push(x, 0, z, ...c, xx, 0, zz, ...c); + for (let v = -300; v <= 300; v += 50) { + const c = v === 0 ? [0.82, 0.69, 0.48, 0.62] : [0.7, 0.7, 0.7, 0.35]; + line(v, -300, v, 300, c); line(-300, v, 300, v, c); + } + view.setOverlay("fx-grid", new Float32Array(a)); + } + function clearModels() { + modelLoadSeq++; + for (const entry of modelViews) for (const id of entry?.slots || []) view?.removeMesh(id); + modelViews = []; + } + async function loadModel(index, spec, seq) { + const name = (spec.attrs.mesh_file || "").trim().replace(/\.msh$/i, ""); + if (!name || name.toLowerCase() === "none" || !FXE.src) return; + try { + const hit = await kit.readNamed(`${name}.msh`, "mesh", true); + if (!hit || seq !== modelLoadSeq) return; + const mesh = await kit.loadMesh(hit, name); + if (seq !== modelLoadSeq) return view.removeMesh(mesh.id); + view.setVisible(mesh.id, false); + modelViews[index] = { mesh, slots: [mesh.id] }; + await kit.loadTextures(mesh); + if (seq !== modelLoadSeq) return view.removeMesh(mesh.id); + syncModels(); + } catch (err) { if (seq === modelLoadSeq) console.warn(`Effect model ${name}: ${err.message}`); } + } + function syncModels() { + if (!view || !inst) return; + inst.modelSystems.forEach((system, index) => { + const entry = modelViews[index]; + if (!entry) return; + kit.animate(entry.mesh, system.time / 1000); + system.parts.forEach((part, i) => { + let id = entry.slots[i]; + if (!id) { + id = `fx-model-${modelLoadSeq}-${index}-${i}`; + const m = entry.mesh; + view.addMesh(id, { pos: m.data.pos, normal: m.data.normal, uv0: m.data.uv0, uv1: m.data.uv1, indices: m.data.indices, groups: m.groups }); + entry.slots[i] = id; + } + view.setTransformQuaternion(id, part.pos, part.modelScale, part.modelRotation); + const t = Math.min(1, part.age / part.life), color = system.color(t); + view.setTint(id, [color[0] / 255, color[1] / 255, color[2] / 255, Math.max(0, Math.min(1, system.alpha(t)[0]))]); + view.setVisible(id, true); + }); + entry.slots.forEach((id, i) => { if (i >= system.parts.length) view.setVisible(id, false); }); + }); + } + function refreshPreview() { + inst = null; + clearModels(); + if (!FXE.text || !ensureView()) return; + try { inst = Effects.instance(Effects.parse(FXE.text), [0, 0, 0]); } + catch (err) { $("fx-parse-status").textContent = err.message; } + view.setParticles([]); + if (inst) { + const seq = modelLoadSeq; + inst.def.models.forEach((spec, index) => loadModel(index, spec, seq)); + } + // Loaded textures stay attached to this WebGL context across script edits. + } + function texture(name) { + if (!name || !FXE.src || !view) return null; + const key = name.toLowerCase(); + if (!textures.has(key)) { + textures.set(key, null); + kit.texture(name).then((t) => { + if (!t.tex) return; + const gl = view.gl; + gl.bindTexture(gl.TEXTURE_2D, t.tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + textures.set(key, t.tex); + }); + } + return textures.get(key); + } + async function tick(now) { + const dt = Math.min(100, Math.max(0, now - (last || now))); + last = now; + if (view && inst && FXE.playing && $("view-fx").classList.contains("on")) { + Effects.update(inst, dt); + syncModels(); + const parts = Effects.build(inst, view.cameraAxes()); + for (const p of parts) p.tex = texture(p.texture); + view.setParticles(parts); + $("fx-stats").textContent = `${Effects.count(inst)} particles · ${inst.systems.length} particle systems · ${inst.modelSystems.length} model systems`; + } + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + + const list = new VTable($("fx-list"), [{ key: 1, label: "Effect", width: 220, grow: true, title: true }, { key: 2, label: "Pack", width: 75, cls: "mono dim" }], { multi: false, id: "fx" }); + list.keyOf = (r) => r[0]; + list.sortBy(1); + list.onOpen = (r) => openHit(() => call("mdl:readKey", FXE.src, r[0]), r[1]); + function filterList() { + const q = $("fx-search").value.trim().toLowerCase(); + const rows = FXE.rows.filter((r) => r[1].toLowerCase().includes(q)); + list.setRows(rows); + $("fx-count").textContent = rows.length.toLocaleString(); + } + $("fx-search").oninput = filterList; + async function loadList() { + if (!FXE.src) return; + try { + const entries = await call("fx:list", FXE.src); + FXE.rows = entries.filter(([, path]) => /(^|\/)resource\/effect\/.+\.txt$/i.test(path)) + .map(([key, path, pack]) => [key, path.replace(/^.*?resource\/effect\//i, ""), pack || "", path]); + filterList(); + } catch (err) { toast(err.message, true); } + } + async function chooseSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const options = [...profileSourceOption(), ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file })), + { value: "packs", title: "Folder with .iop packs…", sub: "Game client packs" }, { value: "dir", title: "Extracted resource folder…", sub: "Folder containing resource/effect" }]; + let choice = options[0]?.value; + const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [ + el("input", { type: "radio", name: "fxsrc", checked: i === 0, onchange: () => { choice = o.value; } }), + el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), + ]))); + if (!(await modal("Effect source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with .iop packs" : "Extracted resources"); + if (!folder) return; + choice = { kind: choice, folder }; + } + try { + const d = await call("mdl:describe", choice); + FXE.src = choice; + $("fx-src-label").textContent = choice.kind === "dir" ? "Extracted folder" : "Packs"; + $("fx-src").title = d.label; + kit?.clearTextures(); textures.clear(); refreshPreview(); + await loadList(); + } catch (err) { toast(err.message, true); } + } + $("fx-src").onclick = chooseSource; + async function openHit(read, name) { + if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return; + const seq = ++loadSeq; + try { + const hit = await read(); + if (!hit || seq !== loadSeq) return; + FXE.where = hit.where; FXE.name = name || hit.source; FXE.saved = bytesToRaw(hit.bytes); + FXE.selected = null; setText(FXE.saved); + $("fx-empty").hidden = true; + view?.resize(); + } catch (err) { toast(err.message, true); } + } + $("fx-open-file").onclick = async () => { + const file = await call("fx:openFile"); + if (file) await openHit(() => call("mdl:readFile", file), file.replace(/^.*[\\/]/, "")); + }; + $("fx-new").onclick = async () => { + if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return; + FXE.where = null; FXE.name = "new_effect.txt"; FXE.saved = ""; FXE.selected = null; + setText("info\r\n{\r\n}\r\n\r\nparticle\r\n{\r\nquota 100\r\ndefault_width 50 50\r\ndefault_height 50 50\r\nrender_type add\r\ntexture effect.dds\r\nemitter point\r\n{\r\nemit_rate 20\r\nemit_speed 40 80\r\nemit_live_time 1000 1500\r\n}\r\n}\r\n"); + $("fx-empty").hidden = true; + view?.resize(); + }; + $("fx-script").oninput = (e) => setText(e.target.value); + $("fx-add-particle").onclick = () => { + const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/); + const at = lines.length; + lines.push("particle", "{", "quota 100", "default_width 50 50", "default_height 50 50", "render_type add", "emitter point", "{", "emit_rate 20", "emit_speed 40 80", "emit_live_time 1000 1500", "}", "}"); + setText(lines.join(ending()), at); + }; + $("fx-add-model").onclick = () => { + const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/); + const at = lines.length; + lines.push("model", "{", "mesh_file none", "quota 20", "cull_each false", "follow_parent none", "end_condition never_end", "limit_time 1000", "start_create_particles 1", "link_biped_name none", "random_sync_index -1", "pos_offset 0 0 0", "rot_offset 0 0 0 1", "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 1", "emit_speed 0 0", "emit_live_time 1000 1000", "}", "}"); + setText(lines.join(ending()), at); + }; + $("fx-add-emitter").onclick = () => { + const selected = nodeAt(FXE.parsed, FXE.selected); + const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent; + if (!system || !["particle", "model"].includes(system.kind) || system.children.some((x) => x.kind === "emitter")) return; + mutateLines((lines) => lines.splice(system.end, 0, "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 10", "emit_speed 0 0", "emit_live_time 1000 1000", "}"), system.start); + }; + $("fx-add-affector").onclick = async () => { + const selected = nodeAt(FXE.parsed, FXE.selected); + const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent; + if (!system || !["particle", "model"].includes(system.kind)) return; + const type = await prompt("Add affector", "Type (Linear, Scale, TexRotation, Rotation, Emit)", "Linear"); + if (!type || !/^[a-z][\w-]*$/i.test(type)) return; + mutateLines((lines) => lines.splice(system.end, 0, `affector ${type}`, "{", "wait_time 0", "}"), system.start); + }; + $("fx-duplicate-block").onclick = () => { + const node = nodeAt(FXE.parsed, FXE.selected); + if (!node || !["particle", "model"].includes(node.kind)) return; + const lines = FXE.parsed.lines.slice(), block = lines.slice(node.start, node.end + 1); + lines.splice(node.end + 1, 0, "", ...block); + setText(lines.join(ending()), node.end + 2); + }; + function moveBlock(direction) { + const node = nodeAt(FXE.parsed, FXE.selected); + if (!node || !["particle", "model"].includes(node.kind)) return; + const siblings = FXE.parsed.roots.filter((x) => x.kind === node.kind); + const other = siblings[siblings.indexOf(node) + direction]; + if (!other) return; + const [a, b] = node.start < other.start ? [node, other] : [other, node]; + const lines = FXE.parsed.lines.slice(); + const first = lines.slice(a.start, a.end + 1), gap = lines.slice(a.end + 1, b.start), second = lines.slice(b.start, b.end + 1); + lines.splice(a.start, b.end - a.start + 1, ...second, ...gap, ...first); + const selected = node === a ? a.start + second.length + gap.length : a.start; + setText(lines.join(ending()), selected); + } + $("fx-move-up").onclick = () => moveBlock(-1); + $("fx-move-down").onclick = () => moveBlock(1); + $("fx-delete-block").onclick = async () => { + const node = nodeAt(FXE.parsed, FXE.selected); + if (!node || node.kind === "info") return; + if (!(await confirmBox("Remove block", `Remove ${node.kind} ${node.type}?`, "Remove", "danger"))) return; + mutateLines((lines) => lines.splice(node.start, node.end - node.start + 1), node.parent?.start ?? null); + }; + $("fx-add-field").onclick = async () => { + const node = nodeAt(FXE.parsed, FXE.selected); + if (!node) return; + const key = await prompt("Add field", "Script key (for example: texture)", ""); + if (!key || !/^[a-z][\w-]*$/i.test(key)) return; + mutateLines((lines) => lines.splice(node.end, 0, `${key} `), node.start); + }; + $("fx-play").onclick = () => { + FXE.playing = !FXE.playing; + $("fx-play").classList.toggle("on", FXE.playing); + if (!FXE.playing) view?.setParticles([]); + }; + $("fx-restart").onclick = refreshPreview; + $("fx-grid").onclick = () => { $("fx-grid").classList.toggle("on"); drawGrid(); }; + $("fx-reset").onclick = () => view?.reset(); + document.querySelector('[data-view="fx"]').addEventListener("click", () => { + ensureView(); + view?.resize(); + if (!FXE.src) { const src = profileSource(); if (src) { FXE.src = src; $("fx-src-label").textContent = "Profile packs"; loadList(); } } + }); + async function save(as = false) { + if (!FXE.text) return; + if ([...FXE.text].some((c) => c.charCodeAt(0) > 255)) return toast("This script contains characters outside its original 8-bit encoding. Use ASCII for new fields.", true); + let where = FXE.where; + if (as || !where) { + const file = await call("dialog:saveAs", FXE.name || "new_effect.txt", "txt"); + if (!file) return; + where = { kind: "file", file }; + } + try { + await call("mdl:writeBack", where, rawToBytes(FXE.text)); + FXE.where = where; FXE.saved = FXE.text; + if (where.kind === "file") FXE.name = where.file.replace(/^.*[\\/]/, ""); + $("fx-title").textContent = FXE.name; + $("fx-dirty").hidden = true; $("fx-save").disabled = true; + toast(`Saved ${FXE.name}`); + if (FXE.src) { await call("mdl:reload", FXE.src); await loadList(); } + } catch (err) { toast(err.message, true); } + } + $("fx-save").onclick = () => save(); + $("fx-save-as").onclick = () => save(true); + document.addEventListener("keydown", (e) => { + if (!$("view-fx").classList.contains("on") || !e.ctrlKey || e.key.toLowerCase() !== "s") return; + e.preventDefault(); save(); + }); + FXE.previewAsset = async (src, name) => { + const changed = FXE.src?.kind !== src.kind || FXE.src?.folder !== src.folder || FXE.src?.sessionId !== src.sessionId; + FXE.src = src; + $("fx-src-label").textContent = "Hero source"; + document.querySelector('[data-view="fx"]').click(); + if (changed) await loadList(); + await openHit(() => call("mdl:read", src, name, "effect", true), name); + }; +})(); diff --git a/app/public/legacy/js/effects.js b/app/public/legacy/js/effects.js new file mode 100644 index 0000000..d41a9be --- /dev/null +++ b/app/public/legacy/js/effects.js @@ -0,0 +1,454 @@ +"use strict"; + +// Effect scripts (resource/effect/*.txt) and their particle systems, following io3DEngine +// (ioEffectFactory::ParseScript, ioParticleSystem::Update, ioParticleEmitter, the emit point +// generators and affectors). Used by the map editor to play the map's effects. +// +// Units: times in ms, speeds / forces per second, angles in degrees (TexRotation speed: rad/s). +// Everything is simulated in game space (map effects have no rotation or scale). + +const Effects = (() => { + // ---------- parser ---------- + /** → { info, particles, models } with editable attributes and nested emitters/affectors. */ + function parse(text) { + const lines = text.split(/\r\n|\r|\n/).map((l) => l.replace(/\s+$/, "").replace(/^\s+/, "")); + let i = 0; + const out = { info: {}, particles: [], models: [], light: false }; + const skipToBrace = () => { + while (i < lines.length && !lines[i].startsWith("{")) i++; + i++; + }; + const readBlock = (onNested) => { + const attrs = {}; + while (i < lines.length) { + const l = lines[i]; + if (!l || l.startsWith("//")) { + i++; + continue; + } + if (l.startsWith("}")) { + i++; + break; + } + const sp = l.search(/\s/); + const key = (sp < 0 ? l : l.slice(0, sp)).toLowerCase(); + const rest = sp < 0 ? "" : l.slice(sp + 1).trim(); + if (onNested && (key === "emitter" || key === "affector")) { + i++; + skipToBrace(); + onNested(key, rest, readBlock(null)); + continue; + } + attrs[key] = rest; + i++; + } + return attrs; + }; + while (i < lines.length) { + const l = lines[i]; + if (!l || l.startsWith("//") || l.startsWith("{") || l.startsWith("}")) { + i++; + continue; + } + const tag = l.split(/\s/)[0].toLowerCase(); + i++; + skipToBrace(); + if (tag === "particle" || tag === "model") { + const ps = { emitter: null, affectors: [] }; + ps.attrs = readBlock((kind, type, attrs) => { + if (kind === "emitter") ps.emitter = { type: type.toLowerCase(), attrs }; + else ps.affectors.push({ type: type.toLowerCase(), attrs }); + }); + out[tag === "model" ? "models" : "particles"].push(ps); + } else { + const attrs = readBlock(null); + if (tag === "info") out.info = attrs; + else if (tag === "light") out.light = true; + } + } + return out; + } + + // ---------- small helpers ---------- + const nums = (s) => (s || "").trim().split(/\s+/).filter(Boolean).map(Number); + const pair = (s, d) => { + const n = nums(s); + return n.length >= 2 ? n : n.length === 1 ? [n[0], n[0]] : d; + }; + const vec = (s, d) => { + const n = nums(s); + return n.length >= 3 ? n.slice(0, 3) : d; + }; + const rnd = (a, b) => a + Math.random() * (b - a); + const norm = (v) => { + const l = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / l, v[1] / l, v[2] / l]; + }; + const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const quatMul = (a, b) => [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + ]; + const quatRotate = (q, v) => { + const u = q.slice(0, 3), w = q[3]; + const t = cross(u, v).map((n) => n * 2); + const c = cross(u, t); + return [v[0] + w * t[0] + c[0], v[1] + w * t[1] + c[1], v[2] + w * t[2] + c[2]]; + }; + /** Keyed curve "t v1 v2 …" (k values per key) → sampler(t01) → [v…] (linear, clamped) */ + function curve(s, k, dflt) { + const n = nums(s); + const keys = []; + for (let j = 0; j + k < n.length + 1; j += k + 1) { + const vals = n.slice(j + 1, j + 1 + k); + while (vals.length < k) vals.push(0); + keys.push([n[j], vals]); + } + if (!keys.length) keys.push([0, dflt]); + return (t) => { + let i = 0; + while (i < keys.length && keys[i][0] <= t) i++; + if (i === 0) return keys[0][1]; + if (i === keys.length) return keys[keys.length - 1][1]; + const [t0, a] = keys[i - 1], [t1, b] = keys[i]; + const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0; + return a.map((v, j) => v + (b[j] - v) * f); + }; + } + + // ---------- one particle system ---------- + function makeSystem(ps, model = false) { + const a = ps.attrs; + const e = ps.emitter?.attrs || {}; + const type = ps.emitter?.type || "point"; + const sys = { + model, + mesh: model ? (a.mesh_file || "").trim() : "", + quota: Number(a.quota) || 0, + width: pair(a.default_width, [100, 100]), + height: pair(a.default_height, [100, 100]), + render: (a.render_type || "add").toLowerCase(), + depth: a.depth_check !== "false", + texture: (a.texture || "").split(/[\\/]/).pop(), + linkBone: (a.link_biped_name || "").trim(), + linkOffset: vec(a.pos_offset, [0, 0, 0]), + followParent: (a.follow_parent || "none").toLowerCase(), + linkRotation: nums(a.rot_offset).length === 4 ? nums(a.rot_offset) : [0, 0, 0, 1], + billboard: (a.billboard_type || "point").toLowerCase(), + endLimit: (a.end_condition || "").toLowerCase() === "time_limit", + limit: Number(a.limit_time) || 0, + commonDir: norm(vec(a.common_dir, [0, 1, 0])), + commonFixed: a.common_dir_none_billboard === "true", + interval: Number(a.iteration_interval) || 0, + burst: Number(a.start_create_particles) || 0, + // In the standalone editor the owner/bone offset is not applied to an + // unattached model. The emitter position is its visible local origin. + offset: model ? [0, 0, 0] : vec(a.pos_offset, [0, 0, 0]), + rotOffset: model ? nums(a.rot_offset) : [], + mod2x: a.modulate2x === "true", + // emitter + shape: type, + emitPos: vec(e.emit_pos, [0, 0, 0]), + emitDir: vec(e.emit_dir, [0, 1, 0]), + angle: ((Number(e.emit_angle) || 0) * Math.PI) / 180, + rate: e.emit_rate !== undefined ? Number(e.emit_rate) : 10, + speed: pair(e.emit_speed, [1, 1]), + life: pair(e.emit_live_time, [5000, 5000]), + wait: Number(e.emit_wait_time) || 0, + texRot: pair(e.emit_start_tex_rotation, [0, 0]).map((d) => (d * Math.PI) / 180), + startRotAxis: norm(vec(e.start_rot_axis, [0, 1, 0])), + startRotAngle: pair(e.start_rot_angle, [0, 0]), + area: vec(e.area_size, [100, 100, 100]), + inner: nums(e.inner_size), + color: curve(e.color_rate, 3, [255, 255, 255]), + alpha: curve(e.alpha_rate, 1, [1]), + affectors: ps.affectors.map((f) => ({ type: f.type, a: f.attrs, wait: Number(f.attrs.wait_time) || 0 })), + }; + for (const f of sys.affectors) { + if (f.type === "linear") { + f.force = vec(f.a.force_vector, [0, -100, 0]); + f.rate = curve(f.a.force_rate, 1, [1]); + } else if (f.type === "scale") f.scale = curve(f.a.scale_rate, model ? 3 : 2, model ? [1, 1, 1] : [1, 1]); + else if (f.type === "texrotation") { + f.range = pair(f.a.rot_range, [0, 0]).map((d) => (d * Math.PI) / 180); + f.speed = pair(f.a.rot_speed, [0, 0]); // rad/s as the client uses it + } else if (f.type === "emit") { + f.power = Number(f.a.power) || 0; + f.rate = curve(f.a.force_rate, 1, [1]); + } + } + // emitter local axes (GenAreaAxis) + const d = norm(sys.emitDir); + let up = Math.abs(dot(d, [1, 0, 0])) > 0.99 ? [0, -1, 0] : [1, 0, 0]; + const side = cross(up, d); + up = cross(d, side); + sys.axes = { d, up, side }; + sys.parts = []; + sys.time = 0; + sys.acc = 0; + sys.started = false; + sys.stopped = false; // time_limit reached (no more emission) + sys.ended = false; + return sys; + } + + function spawnPoint(s) { + const { d, up, side } = s.axes; + const [sx, sy, sz] = s.area; + const RX = up.map((v) => (v * sx) / 2), RY = d.map((v) => (v * sy) / 2), RZ = side.map((v) => (v * sz) / 2); + const c = s.emitPos; + const at = (a, b, cc) => [c[0] + a * RX[0] + b * RY[0] + cc * RZ[0], c[1] + a * RX[1] + b * RY[1] + cc * RZ[1], c[2] + a * RX[2] + b * RY[2] + cc * RZ[2]]; + const r = () => Math.random() * 2 - 1; + switch (s.shape) { + case "box": + return at(r(), r(), r()); + case "cylinder": { + let x = r(), z = r(); + const l = Math.hypot(x, z); + if (l > 1) (x /= l), (z /= l); + return at(x, r(), z); + } + case "ellipse": { + const ang = Math.random() * Math.PI * 2; + const ix = s.inner[0] ?? 0.5, iz = s.inner[1] ?? 0.5; + return at(ix * Math.sin(ang), Math.random(), iz * Math.cos(ang)); + } + case "ellipsoid": { + let x = r(), y = r(), z = r(); + const l = Math.hypot(x, y, z); + if (l > 1) (x /= l), (y /= l), (z /= l); + return at(x, y, z); + } + case "hollowellipsoid": { + const al = Math.random() * Math.PI * 2, be = Math.random() * Math.PI; + const inner = s.inner.map((v) => Math.max(0, Math.min(1, v))); + const rx = rnd(inner[0] ?? 0.5, 1), ry = rnd(inner[1] ?? 0.5, 1), rz = rnd(inner[2] ?? 0.5, 1); + return at(rx * Math.cos(al) * Math.sin(be), ry * Math.cos(be), rz * Math.sin(al) * Math.sin(be)); + } + default: + return c.slice(); + } + } + /** emit_dir turned by a random angle within the cone, around a random azimuth. */ + function coneDir(s) { + const { d, up, side } = s.axes; + if (!s.angle) return d.slice(); + const th = Math.random() * s.angle, ph = Math.random() * Math.PI * 2; + const u = norm(up), v = norm(side); + const k = Math.sin(th); + return norm([d[0] * Math.cos(th) + (u[0] * Math.cos(ph) + v[0] * Math.sin(ph)) * k, d[1] * Math.cos(th) + (u[1] * Math.cos(ph) + v[1] * Math.sin(ph)) * k, d[2] * Math.cos(th) + (u[2] * Math.cos(ph) + v[2] * Math.sin(ph)) * k]); + } + + function spawn(s, origin, lead) { + const rotation = s.ownerRotation && s.followParent !== "pos_only" ? s.ownerRotation : null; + const p = rotation ? quatRotate(rotation, spawnPoint(s)) : spawnPoint(s); + const dir = rotation ? quatRotate(rotation, coneDir(s)) : coneDir(s); + const speed = rnd(s.speed[0], s.speed[1]); + const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : s.offset; + const part = { + pos: [p[0] + origin[0] + offset[0] + dir[0] * speed * lead, p[1] + origin[1] + offset[1] + dir[1] * speed * lead, p[2] + origin[2] + offset[2] + dir[2] * speed * lead], + dir, + speed, + age: 0, + life: Math.max(1, rnd(s.life[0], s.life[1])), + w0: rnd(s.width[0], s.width[1]), + h0: rnd(s.height[0], s.height[1]), + rot: rnd(s.texRot[0], s.texRot[1]), + rotSpeed: 0, + }; + if (s.model) { + // Effect scripts use Direct3D's left-handed axis-angle convention. + const angle = -rnd(s.startRotAngle[0], s.startRotAngle[1]) * Math.PI / 360; + const axis = s.startRotAxis; + const q = [axis[0] * Math.sin(angle), axis[1] * Math.sin(angle), axis[2] * Math.sin(angle), Math.cos(angle)]; + part.modelRotation = quatMul(rotation || [0, 0, 0, 1], quatMul(s.rotOffset.length === 4 ? s.rotOffset : [0, 0, 0, 1], q)); + part.modelScale = s.affectors.find((f) => f.type === "scale")?.scale(0) || [1, 1, 1]; + } + for (const f of s.affectors) { + if (f.type === "texrotation") { + part.rot = rnd(f.range[0], f.range[1]); + part.rotSpeed = rnd(f.speed[0], f.speed[1]); + } + } + part.w = part.w0; + part.h = part.h0; + s.parts.push(part); + } + + function step(s, dt, origin) { + const dts = dt / 1000; + if (!s.started) { + s.started = true; + for (let k = 0; k < Math.min(s.burst, s.quota || s.burst); k++) spawn(s, origin, 0); + } + s.time += dt; + // expire + age + s.parts = s.parts.filter((p) => p.age < p.life); + for (const p of s.parts) p.age += dt; + // affectors + for (const f of s.affectors) { + if (s.time < f.wait) continue; + for (const p of s.parts) { + const t = Math.min(1, p.age / p.life); + if (f.type === "linear") { + const r = f.rate(t)[0]; + const v = [p.dir[0] * p.speed + f.force[0] * dts * r, p.dir[1] * p.speed + f.force[1] * dts * r, p.dir[2] * p.speed + f.force[2] * dts * r]; + p.speed = Math.hypot(v[0], v[1], v[2]); + if (p.speed > 1e-6) p.dir = [v[0] / p.speed, v[1] / p.speed, v[2] / p.speed]; + } else if (f.type === "scale") { + if (s.model) p.modelScale = f.scale(t); + else { + const [sx, sy] = f.scale(t); + p.w = p.w0 * sx; + p.h = p.h0 * sy; + } + } else if (f.type === "texrotation") p.rot += p.rotSpeed * dts; + else if (f.type === "emit") { + const emitPos = s.ownerRotation && s.followParent !== "pos_only" ? quatRotate(s.ownerRotation, s.emitPos) : s.emitPos; + const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : [0, 0, 0]; + const c = [origin[0] + offset[0] + emitPos[0], origin[1] + offset[1] + emitPos[1], origin[2] + offset[2] + emitPos[2]]; + const d = norm([p.pos[0] - c[0], p.pos[1] - c[1], p.pos[2] - c[2]]); + const k = f.power * dts * f.rate(t)[0]; + p.pos[0] += d[0] * k; + p.pos[1] += d[1] * k; + p.pos[2] += d[2] * k; + } + } + } + // integrate + for (const p of s.parts) { + p.pos[0] += p.dir[0] * p.speed * dts; + p.pos[1] += p.dir[1] * p.speed * dts; + p.pos[2] += p.dir[2] * p.speed * dts; + } + // emit + if (s.endLimit && s.time >= s.limit) s.stopped = true; + if (!s.stopped && s.time >= s.wait && s.quota > 0) { + s.acc += (s.rate * dt) / 1000; + let n = Math.floor(s.acc); + s.acc -= n; + n = Math.min(n, s.quota - s.parts.length); + for (let k = 0; k < n; k++) spawn(s, origin, (k * dts) / Math.max(1, n)); + } + if ((s.stopped || (!s.endLimit && s.rate < 0.1)) && !s.parts.length && s.time > 0) s.ended = true; + } + + // ---------- an effect instance ---------- + function instance(def, origin) { + return { def, origin, systems: def.particles.map((p) => makeSystem(p)), modelSystems: def.models.map((m) => makeSystem(m, true)), restartAt: 0 }; + } + function update(inst, dt) { + let live = false; + for (const s of [...inst.systems, ...inst.modelSystems]) { + if (s.ended) continue; + if (s.heroAttached && s.followParent !== "none") { + const base = s.ownerOrigin || inst.origin, offset = s.ownerOffset || [0, 0, 0]; + const current = [base[0] + offset[0], base[1] + offset[1], base[2] + offset[2]]; + const rotation = s.followParent === "all" ? (s.ownerRotation || [0, 0, 0, 1]) : [0, 0, 0, 1]; + if (s.previousParent) { + const inverse = [-s.previousRotation[0], -s.previousRotation[1], -s.previousRotation[2], s.previousRotation[3]]; + for (const p of s.parts) { + const local = quatRotate(inverse, [p.pos[0] - s.previousParent[0], p.pos[1] - s.previousParent[1], p.pos[2] - s.previousParent[2]]); + const next = quatRotate(rotation, local); + p.pos = [current[0] + next[0], current[1] + next[1], current[2] + next[2]]; + p.dir = quatRotate(rotation, quatRotate(inverse, p.dir)); + if (p.modelRotation) p.modelRotation = quatMul(rotation, quatMul(inverse, p.modelRotation)); + } + } + s.previousParent = current; + s.previousRotation = rotation; + } + if (s.interval > 0) { + s.left = (s.left || 0) + dt; + while (s.left >= s.interval) { + step(s, s.interval, s.ownerOrigin || inst.origin); + s.left -= s.interval; + } + } else step(s, Math.min(dt, 100), s.ownerOrigin || inst.origin); + if (!s.ended) live = true; + } + // map effects play while their systems live; one-shot ones restart in the preview after a pause + if (!live && !inst.heroPreview) { + inst.restartAt += dt; + if (inst.restartAt > 1200) { + inst.systems = inst.def.particles.map(makeSystem); + inst.modelSystems = inst.def.models.map((m) => makeSystem(m, true)); + inst.restartAt = 0; + } + } + } + + const BLEND = { + add: { op: "add", src: "src_alpha", dst: "one" }, + src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" }, + screen: { op: "add", src: "inv_dest_color", dst: "one" }, + lighten: { op: "max", src: "one", dst: "one" }, + darken: { op: "min", src: "one", dst: "one" }, + rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" }, + subtract: { op: "subtract", src: "src_alpha", dst: "one" }, + }; + + /** + * Quads of every live particle (6 vertices × [x y z u v r g b a]) grouped per system. + * cam: { right, up, dir } in game space. + */ + function build(inst, cam) { + const out = []; + for (const s of inst.systems) { + if (!s.parts.length) continue; + const data = new Float32Array(s.parts.length * 6 * 9); + let o = 0; + for (const p of s.parts) { + const t = Math.min(1, p.age / p.life); + const c = s.color(t); + const al = Math.max(0, Math.min(1, s.alpha(t)[0])); + let X, Y; + if (s.billboard === "common") { + Y = s.commonDir; + X = s.commonFixed ? norm(cross(Math.abs(Y[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0], Y)) : norm(cross(Y, cam.dir)); + } else if (s.billboard === "direction") { + X = p.dir; + Y = cross(X, cam.dir); + } else { + X = cam.right; + Y = cam.up; + } + const hw = p.w / 2, hh = p.h / 2; + const cs = Math.cos(p.rot), sn = Math.sin(p.rot); + const uv = (u, v) => { + // rotation about (0.5, 0.5) + const x = u - 0.5, y = v - 0.5; + return [x * cs - y * sn + 0.5, x * sn + y * cs + 0.5]; + }; + const corner = (sx, sy) => [p.pos[0] + X[0] * hw * sx + Y[0] * hh * sy, p.pos[1] + X[1] * hw * sx + Y[1] * hh * sy, p.pos[2] + X[2] * hw * sx + Y[2] * hh * sy]; + const q = [ + [corner(-1, 1), uv(0, 0)], + [corner(1, 1), uv(1, 0)], + [corner(1, -1), uv(1, 1)], + [corner(-1, -1), uv(0, 1)], + ]; + for (const k of [0, 1, 2, 0, 2, 3]) { + const [pp, tt] = q[k]; + data[o++] = pp[0]; + data[o++] = pp[1]; + data[o++] = pp[2]; + data[o++] = tt[0]; + data[o++] = tt[1]; + data[o++] = c[0] / 255; + data[o++] = c[1] / 255; + data[o++] = c[2] / 255; + data[o++] = al; + } + } + out.push({ texture: s.texture, blend: BLEND[s.render] || BLEND.add, depth: s.depth, mod2x: s.mod2x, data, count: s.parts.length * 6 }); + } + return out; + } + + const count = (inst) => inst.systems.reduce((a, s) => a + s.parts.length, 0); + return { parse, instance, update, build, count }; +})(); +window.Effects = Effects; diff --git a/app/public/legacy/js/fxlab.js b/app/public/legacy/js/fxlab.js new file mode 100644 index 0000000..e23dded --- /dev/null +++ b/app/public/legacy/js/fxlab.js @@ -0,0 +1,1117 @@ +"use strict"; + +// FX Lab: an easy particle effect maker (inspired by patch-gg/pixelfx: presets, palette ramps, +// live preview, sprite-sheet export). A preset is a list of layers with friendly settings; every +// layer becomes one Lost Saga `particle` block. The generated script is the preview's input +// (Effects simulator, the same one as the FX and Map tabs), so what plays is what exports. +// Modes: free (own view), on the hero (borrowed Hero viewport, linked to a bone), hit target. +(() => { + const FXL = { src: null, preset: null, sel: 0, playing: true, mode: "free", user: [], text: "" }; + window.FXL = FXL; + + // ---------- helpers ---------- + const clone = (o) => JSON.parse(JSON.stringify(o)); + const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); + const f2 = (n) => (Number(n) || 0).toFixed(2); + const v3 = (a) => a.map(f2).join(" "); + const hexRgb = (h) => { const n = parseInt(String(h).replace("#", ""), 16) || 0; return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; + const safeName = (s) => String(s || "effect").trim().replace(/[^\w\-]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase() || "effect"; + const b64 = (bytes) => { let s = ""; for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192)); return btoa(s); }; + const latin1 = (text) => Uint8Array.from(text, (c) => c.charCodeAt(0) & 255); + function seeded(seed) { let s = seed >>> 0 || 1; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); } + + // ---------- procedural sprites (white RGB, shape in alpha: works for add and src_alpha) ---------- + const SPRITES = { + soft: "Soft glow", dot: "Dot", ring: "Ring", spark: "Spark", star: "Star", flare: "Flare", + smoke: "Smoke puff", slash: "Slash arc", square: "Square", plus: "Plus", + }; + const spriteCache = new Map(); // shape → canvas 128² + function sprite(shape) { + if (spriteCache.has(shape)) return spriteCache.get(shape); + const N = 128, c = document.createElement("canvas"); + c.width = c.height = N; + const ctx = c.getContext("2d"), img = ctx.createImageData(N, N), px = img.data; + const rnd = seeded(7); + const blobs = Array.from({ length: 14 }, () => [rnd() * 0.9 - 0.45, rnd() * 0.9 - 0.45, 0.18 + rnd() * 0.22]); + for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { + const u = (x + 0.5) / N * 2 - 1, v = (y + 0.5) / N * 2 - 1, r = Math.hypot(u, v); + let a = 0; + switch (shape) { + case "dot": a = clamp((0.85 - r) / 0.12, 0, 1); break; + case "ring": a = Math.exp(-(((r - 0.72) / 0.1) ** 2)); break; + case "spark": a = Math.exp(-((u / 0.95) ** 2) * 2.2 - ((v / 0.09) ** 2)); break; + case "star": { + const arm = Math.exp(-Math.abs(u) * 30) + Math.exp(-Math.abs(v) * 30); + a = clamp(arm * Math.max(0, 1 - r) * 1.4 + Math.exp(-r * r * 30), 0, 1); break; + } + case "flare": a = clamp(Math.exp(-r * r * 6) * 0.9 + (Math.exp(-Math.abs(v) * 40) + Math.exp(-Math.abs(u) * 40)) * Math.max(0, 1 - r) * 0.6, 0, 1); break; + case "smoke": { + let d = 0; + for (const [bx, by, br] of blobs) d += Math.exp(-((u - bx) ** 2 + (v - by) ** 2) / (br * br)); + a = clamp(d * 0.55, 0, 1) * clamp((1 - r) * 2.2, 0, 1); break; + } + case "slash": { + const ang = Math.atan2(v, u), band = Math.exp(-(((r - 0.7) / (0.05 + 0.12 * clamp((ang + Math.PI) / Math.PI - 0.5, 0, 1))) ** 2)); + a = ang > -Math.PI * 0.95 && ang < 0.1 ? band * clamp((ang + Math.PI * 0.95) / 1.2, 0, 1) : 0; break; + } + case "square": a = Math.max(Math.abs(u), Math.abs(v)) < 0.8 ? 1 : clamp((0.88 - Math.max(Math.abs(u), Math.abs(v))) / 0.08, 0, 1); break; + case "plus": a = (Math.abs(u) < 0.22 && Math.abs(v) < 0.8) || (Math.abs(v) < 0.22 && Math.abs(u) < 0.8) ? 1 : 0; break; + default: a = clamp(1 - r, 0, 1) ** 2; // soft + } + const o = (y * N + x) * 4; + px[o] = px[o + 1] = px[o + 2] = 255; + px[o + 3] = Math.round(clamp(a, 0, 1) * 255); + } + ctx.putImageData(img, 0, 0); + spriteCache.set(shape, c); + return c; + } + // Imported PNG → canvas (power of two, ≤ 256) kept by data URL. + const imageCache = new Map(); + function imageCanvas(dataUrl) { + if (imageCache.has(dataUrl)) return imageCache.get(dataUrl); + imageCache.set(dataUrl, null); + const img = new Image(); + img.onload = () => { + const p2 = (n) => 2 ** Math.round(Math.log2(clamp(n, 8, 256))); + const c = document.createElement("canvas"); + c.width = p2(img.width); c.height = p2(img.height); + c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); + imageCache.set(dataUrl, c); + rebuild(); + if (FXL.preset) renderLayers(); + }; + img.src = dataUrl; + return null; + } + + // ---------- model ---------- + function layer(over = {}) { + return Object.assign({ + name: "Layer", on: true, + tex: { kind: "proc", shape: "soft" }, blend: "add", billboard: "point", depth: true, mod2x: false, + quota: 100, rate: 30, burst: 0, duration: 0, delay: 0, + shape: "Point", area: [40, 40, 40], inner: [0.5, 0.5, 0.5], pos: [0, 60, 0], dir: [0, 1, 0], spread: 20, + speed: [40, 80], life: [800, 1200], gravity: [0, 0, 0], attract: 0, spin: [0, 0], rot: [0, 360], + size: [40, 60], aspect: 1, sizeCurve: [[0, 1], [1, 1]], + colors: [[0, "#ffffff"], [1, "#ffffff"]], alpha: [[0, 0], [0.15, 1], [1, 0]], + bone: "none", offset: [0, 0, 0], follow: "none", commonDir: [0, 0, 1], fixedDir: true, + }, over); + } + const P = (name, cat, layers, extra = {}) => ({ name, cat, builtin: true, layers: layers.map((l) => layer(l)), ...extra }); + const BUILTIN = [ + P("Fire", "Elements", [ + { name: "Flame", tex: { kind: "proc", shape: "soft" }, rate: 45, shape: "Cylinder", area: [30, 6, 30], pos: [0, 10, 0], spread: 12, speed: [50, 90], life: [600, 900], gravity: [0, 80, 0], size: [45, 65], sizeCurve: [[0, 0.6], [0.25, 1], [1, 0.25]], colors: [[0, "#fff2a8"], [0.35, "#ff9a1f"], [0.75, "#d42a06"], [1, "#3a0a02"]], alpha: [[0, 0], [0.1, 1], [0.7, 0.7], [1, 0]], spin: [-60, 60] }, + { name: "Embers", tex: { kind: "proc", shape: "dot" }, rate: 12, shape: "Cylinder", area: [40, 10, 40], pos: [0, 20, 0], spread: 25, speed: [60, 140], life: [900, 1500], gravity: [0, 40, 0], attract: 15, size: [5, 9], colors: [[0, "#ffd36b"], [1, "#ff4a10"]], alpha: [[0, 1], [0.8, 1], [1, 0]] }, + ]), + P("Smoke", "Elements", [ + { name: "Smoke", tex: { kind: "proc", shape: "smoke" }, blend: "src_alpha", rate: 10, shape: "Cylinder", area: [30, 5, 30], pos: [0, 20, 0], spread: 15, speed: [25, 45], life: [1800, 2600], gravity: [6, 10, 0], size: [50, 70], sizeCurve: [[0, 0.6], [1, 2.4]], colors: [[0, "#8a8a8a"], [1, "#4a4a4a"]], alpha: [[0, 0], [0.2, 0.55], [1, 0]], spin: [-25, 25] }, + ]), + P("Explosion", "Combat", [ + { name: "Flash", tex: { kind: "proc", shape: "flare" }, rate: 0, burst: 1, duration: 100, quota: 2, speed: [0, 0], life: [350, 350], size: [160, 160], sizeCurve: [[0, 0.4], [0.3, 1.6], [1, 2.2]], colors: [[0, "#fffbe0"], [1, "#ff8a20"]], alpha: [[0, 1], [1, 0]], depth: false }, + { name: "Fireball", tex: { kind: "proc", shape: "soft" }, rate: 0, burst: 24, duration: 100, shape: "Ellipsoid", area: [40, 40, 40], spread: 180, speed: [60, 160], life: [500, 800], gravity: [0, 60, 0], size: [60, 90], sizeCurve: [[0, 0.5], [0.3, 1.2], [1, 1.6]], colors: [[0, "#fff1b0"], [0.3, "#ff8c1a"], [0.7, "#a3260a"], [1, "#2a0c06"]], alpha: [[0, 1], [0.6, 0.8], [1, 0]], spin: [-90, 90] }, + { name: "Sparks", tex: { kind: "proc", shape: "spark" }, billboard: "direction", rate: 0, burst: 40, duration: 100, spread: 180, speed: [250, 480], life: [350, 650], gravity: [0, -420, 0], size: [26, 40], aspect: 0.35, colors: [[0, "#fff7c2"], [1, "#ff7a14"]], alpha: [[0, 1], [0.7, 1], [1, 0]] }, + { name: "Smoke", tex: { kind: "proc", shape: "smoke" }, blend: "src_alpha", rate: 0, burst: 10, duration: 100, delay: 0, shape: "Ellipsoid", area: [60, 30, 60], spread: 180, speed: [20, 60], life: [1200, 1800], gravity: [0, 25, 0], size: [70, 100], sizeCurve: [[0, 0.6], [1, 1.8]], colors: [[0, "#5a4a40"], [1, "#2a2624"]], alpha: [[0, 0], [0.2, 0.6], [1, 0]] }, + ]), + P("Hit spark", "Combat", [ + { name: "Flash", tex: { kind: "proc", shape: "star" }, rate: 0, burst: 1, duration: 100, quota: 2, pos: [0, 0, 0], speed: [0, 0], life: [220, 220], size: [110, 110], sizeCurve: [[0, 0.5], [0.4, 1.3], [1, 0.2]], colors: [[0, "#ffffff"], [1, "#9fe3ff"]], alpha: [[0, 1], [1, 0]], spin: [-200, 200], depth: false }, + { name: "Sparks", tex: { kind: "proc", shape: "spark" }, billboard: "direction", rate: 0, burst: 16, duration: 100, pos: [0, 0, 0], spread: 180, speed: [220, 420], life: [180, 320], gravity: [0, -200, 0], size: [30, 46], aspect: 0.3, colors: [[0, "#ffffff"], [1, "#59c8ff"]], alpha: [[0, 1], [1, 0]], depth: false }, + ], { mode: "hit" }), + P("Sword glow trail", "Weapon", [ + { name: "Trail", tex: { kind: "proc", shape: "soft" }, rate: 70, quota: 120, pos: [0, 0, 0], speed: [0, 4], life: [220, 320], size: [26, 34], sizeCurve: [[0, 1], [1, 0.1]], colors: [[0, "#b7f0ff"], [1, "#3a6bff"]], alpha: [[0, 0.9], [1, 0]], bone: "Bip01 R Hand", offset: [0, 0, 0] }, + { name: "Glints", tex: { kind: "proc", shape: "star" }, rate: 8, pos: [0, 0, 0], speed: [5, 20], spread: 180, life: [250, 400], size: [14, 22], sizeCurve: [[0, 0.2], [0.4, 1], [1, 0]], colors: [[0, "#ffffff"], [1, "#9fd8ff"]], alpha: [[0, 1], [1, 0]], spin: [-180, 180], bone: "Bip01 R Hand" }, + ], { mode: "bone" }), + P("Fire fist", "Weapon", [ + { name: "Flame", tex: { kind: "proc", shape: "soft" }, rate: 50, pos: [0, 0, 0], shape: "Ellipsoid", area: [14, 14, 14], spread: 30, speed: [20, 50], life: [300, 500], gravity: [0, 140, 0], size: [24, 36], sizeCurve: [[0, 1], [1, 0.2]], colors: [[0, "#fff0a0"], [0.4, "#ff8a1c"], [1, "#b01e04"]], alpha: [[0, 0], [0.1, 1], [1, 0]], spin: [-90, 90], bone: "Bip01 R Hand" }, + ], { mode: "bone" }), + P("Power aura", "Buff", [ + { name: "Aura", tex: { kind: "proc", shape: "soft" }, rate: 35, shape: "Cylinder", area: [60, 110, 60], pos: [0, 0, 0], speed: [30, 60], spread: 5, life: [700, 1000], gravity: [0, 40, 0], size: [30, 45], sizeCurve: [[0, 0.4], [0.4, 1], [1, 0.2]], colors: [[0, "#ffe27a"], [1, "#ff8a00"]], alpha: [[0, 0], [0.3, 0.7], [1, 0]], bone: "Bip01", offset: [0, 0, 0], follow: "pos_only" }, + { name: "Ground ring", tex: { kind: "proc", shape: "ring" }, billboard: "common", commonDir: [0, 0, 1], fixedDir: true, rate: 2, quota: 4, pos: [0, 2, 0], speed: [0, 0], life: [900, 900], size: [140, 140], sizeCurve: [[0, 0.3], [1, 1.3]], colors: [[0, "#fff1a0"], [1, "#ff9a10"]], alpha: [[0, 0], [0.2, 0.9], [1, 0]] }, + ], { mode: "bone" }), + P("Heal", "Buff", [ + { name: "Crosses", tex: { kind: "proc", shape: "plus" }, rot: [0, 0], rate: 10, shape: "Cylinder", area: [80, 100, 80], pos: [0, 60, 0], speed: [30, 50], spread: 0, life: [900, 1300], size: [14, 20], sizeCurve: [[0, 0.3], [0.2, 1], [1, 0.8]], colors: [[0, "#c8ffb8"], [1, "#3ee06a"]], alpha: [[0, 0], [0.2, 1], [1, 0]] }, + { name: "Glow", tex: { kind: "proc", shape: "soft" }, rate: 25, shape: "Cylinder", area: [70, 40, 70], pos: [0, 10, 0], speed: [40, 70], spread: 0, life: [800, 1100], size: [26, 38], sizeCurve: [[0, 1], [1, 0.2]], colors: [[0, "#9fff9a"], [1, "#16b85a"]], alpha: [[0, 0], [0.2, 0.6], [1, 0]] }, + { name: "Ground ring", tex: { kind: "proc", shape: "ring" }, billboard: "common", commonDir: [0, 0, 1], fixedDir: true, rate: 1.5, quota: 3, pos: [0, 2, 0], speed: [0, 0], life: [1200, 1200], size: [150, 150], sizeCurve: [[0, 0.4], [1, 1.2]], colors: [[0, "#c8ffb8"], [1, "#3ee06a"]], alpha: [[0, 0], [0.3, 0.8], [1, 0]] }, + ], { mode: "bone" }), + P("Magic sparkle", "Magic", [ + { name: "Sparkles", tex: { kind: "proc", shape: "star" }, rate: 30, shape: "Ellipsoid", area: [120, 120, 120], pos: [0, 80, 0], spread: 180, speed: [5, 20], life: [500, 900], size: [12, 26], sizeCurve: [[0, 0], [0.4, 1], [1, 0]], colors: [[0, "#9ff6ff"], [1, "#b46bff"]], alpha: [[0, 1], [1, 1]], spin: [-120, 120] }, + ]), + P("Vortex", "Magic", [ + { name: "Swirl", tex: { kind: "proc", shape: "dot" }, rate: 60, quota: 200, shape: "HollowEllipsoid", area: [220, 40, 220], inner: [0.9, 0.9, 0.9], pos: [0, 60, 0], spread: 0, dir: [0, 1, 0], speed: [10, 20], attract: -110, life: [1000, 1400], size: [8, 14], colors: [[0, "#c18bff"], [1, "#4a2bff"]], alpha: [[0, 0], [0.3, 1], [1, 0]] }, + { name: "Core", tex: { kind: "proc", shape: "flare" }, rate: 3, quota: 6, pos: [0, 60, 0], speed: [0, 0], life: [800, 800], size: [90, 110], sizeCurve: [[0, 0.6], [0.5, 1], [1, 0.6]], colors: [[0, "#e7c8ff"], [1, "#6b3bff"]], alpha: [[0, 0], [0.5, 0.8], [1, 0]], spin: [-60, 60] }, + ]), + P("Snow", "Weather", [ + { name: "Flakes", tex: { kind: "proc", shape: "dot" }, blend: "src_alpha", rate: 40, quota: 400, shape: "Box", area: [500, 10, 500], pos: [0, 320, 0], dir: [0, -1, 0], spread: 15, speed: [40, 70], life: [5000, 6000], gravity: [10, 0, 0], size: [5, 10], colors: [[0, "#ffffff"], [1, "#dfefff"]], alpha: [[0, 0], [0.1, 0.9], [0.9, 0.9], [1, 0]] }, + ]), + P("Slash", "Weapon", [ + { name: "Arc", tex: { kind: "proc", shape: "slash" }, rate: 0, burst: 1, duration: 100, quota: 2, pos: [0, 100, 40], speed: [0, 0], life: [260, 260], size: [180, 180], sizeCurve: [[0, 0.7], [1, 1.15]], rot: [0, 0], spin: [500, 500], colors: [[0, "#ffffff"], [1, "#7fd0ff"]], alpha: [[0, 1], [1, 0]], depth: false }, + ]), + ]; + + // ---------- preset → Lost Saga effect script ---------- + /** Texture file name the layer writes and its source → { file, src } (file = basename). */ + function texOf(L) { + const t = L.tex || {}; + if (t.kind === "pack") return { file: t.name.split(/[\\/]/).pop(), path: t.name, pack: true }; + if (t.kind === "image") return { file: `fxlab_${safeName(t.label || "image")}.dds`, path: `EffectTexture/fxlab_${safeName(t.label || "image")}.dds`, image: t.data }; + const shape = SPRITES[t.shape] ? t.shape : "soft"; + return { file: `fxlab_${shape}.dds`, path: `EffectTexture/fxlab_${shape}.dds`, shape }; + } + const SHAPES = ["Point", "Box", "Cylinder", "Ellipse", "Ellipsoid", "HollowEllipsoid"]; + // Which layer fields each script key / affector comes from. A layer read from a script keeps + // the original text of every key whose fields were not edited (exact round trip). + const KEY_FIELDS = { + quota: ["quota"], follow_parent: ["follow"], default_width: ["size"], default_height: ["size", "aspect"], render_type: ["blend"], + depth_check: ["depth"], texture: ["tex"], billboard_type: ["billboard"], end_condition: ["duration"], limit_time: ["duration", "delay"], + common_dir: ["commonDir"], common_dir_none_billboard: ["fixedDir"], start_create_particles: ["burst"], link_biped_name: ["bone"], + pos_offset: ["offset"], modulate2x: ["mod2x"], + emit_pos: ["pos"], emit_dir: ["dir"], emit_angle: ["spread"], emit_rate: ["rate"], emit_start_tex_rotation: ["rot", "rotStart"], + emit_speed: ["speed"], emit_live_time: ["life"], emit_wait_time: ["delay"], color_rate: ["colors"], alpha_rate: ["alpha"], + area_size: ["area", "shape"], inner_size: ["inner", "shape"], + }; + const AFF_FIELDS = { scale: ["sizeCurve", "scaleY"], texrotation: ["rot", "spin"], linear: ["gravity", "forceMethod", "gravityRate"], emit: ["attract", "attractRate", "attractChangeDir"] }; + const STATIC_KEYS = ["cull_each", "tex_filtering", "rot_offset", "random_sync_index"]; + const ALL_FIELDS = [...new Set([...Object.values(KEY_FIELDS).flat(), ...Object.values(AFF_FIELDS).flat(), "shape"])]; + const snapshot = (L) => Object.fromEntries(ALL_FIELDS.map((f) => [f, JSON.stringify(L[f] ?? null)])); + const unchanged = (L, fields) => !!L.orig && fields.every((f) => JSON.stringify(L[f] ?? null) === L.orig.fields[f]); + + function affectorLines(type, L) { + const rad = (d) => (d * Math.PI) / 180; + if (type === "scale") { + const sc = L.sizeCurve.slice().sort((a, b) => a[0] - b[0]); + return ["affector Scale", "{", "\twait_time\t0.00", `\tscale_rate\t${sc.flatMap(([t, s]) => [f2(t), f2(s), f2(s * (L.scaleY || 1))]).join(" ")}`, "}"]; + } + if (type === "texrotation") return ["affector TexRotation", "{", "\twait_time\t0.00", `\trot_range\t${f2(L.rot[0])} ${f2(L.rot[1])}`, `\trot_speed\t${f2(rad(L.spin[0]))} ${f2(rad(L.spin[1]))}`, "}"]; + if (type === "linear") return ["affector Linear", "{", "\twait_time\t0.00", `\tforce_vector\t${v3(L.gravity)}`, `\tforce_method\t${L.forceMethod || "add"}`, `\tforce_rate\t${L.gravityRate || "0.00 1.00"}`, "}"]; + return ["affector Emit", "{", "\twait_time\t0.00", `\tpower\t${f2(L.attract)}`, `\tforce_rate\t${L.attractRate || "0.00 1.00"}`, `\tchange_direction\t${L.attractChangeDir ? "true" : "false"}`, "}"]; + } + const affectorNeeded = (type, L) => (type === "scale" ? L.sizeCurve.some(([, s]) => Math.abs(s - 1) > 1e-3) || (L.scaleY && L.scaleY !== 1) + : type === "texrotation" ? !!(L.spin[0] || L.spin[1]) : type === "linear" ? L.gravity.some((g) => g) : !!L.attract); + + function toScript(preset) { + const out = [], ln = (s = "", d = 0) => out.push("\t".repeat(d) + s); + const raw = (lines, d) => { + const tabs = (l) => l.match(/^\t*/)[0].length; + const base = Math.min(...lines.filter((l) => l.trim()).map(tabs)); + for (const l of lines) if (l.trim()) ln(l.trim(), d + tabs(l) - base); + }; + // LS bursts (start_create_particles) fire at creation and ignore emit_wait_time, and + // limit_time counts from creation: a delayed burst becomes a 50 ms rate-based block. + // Layers read from an LS script (lsBurst) keep the script's own meaning. + const layers = []; + for (const L of preset.layers.filter((l) => l.on)) { + if (L.burst > 0 && L.delay > 0 && !L.lsBurst) { + layers.push({ ...L, rate: Math.max(1, Math.round(L.burst)) * 20, burst: 0, quota: Math.max(1, Math.round(L.burst)), duration: 50 }); + if (L.rate > 0) layers.push({ ...L, burst: 0 }); + } else layers.push(L); + } + if (preset.info?.length) { + ln("info"); ln("{"); + for (const [k, v] of preset.info) ln(v === "" ? k : `${k} ${v}`, 1); + ln("}"); + } else { + let ext = 50; + for (const L of layers) ext = Math.max(ext, ...L.area.map((a) => a / 2), L.size[1]); + ln("// Made with LS Tools FX Lab"); ln("info"); ln("{"); + ln(`collision_box ${f2(-ext)} ${f2(-ext)} ${f2(-ext)} ${f2(ext)} ${f2(ext)} ${f2(ext)}`, 1); + ln("direction 0.00 1.00 0.00", 1); ln("}"); + } + for (const L of layers) { + const tex = texOf(L), oneShot = L.duration > 0, o = L.orig; + /** Key line: original text while its fields are unchanged; keys the script did not have are added only once edited. */ + const put = (group, key, gen, d, sep) => { + const fields = KEY_FIELDS[key] || []; + if (o) { + const has = Object.prototype.hasOwnProperty.call(o[group], key); + if (has && unchanged(L, fields)) return ln(`${o.names[group][key]}${sep}${o[group][key]}`.trimEnd(), d); + if (!has && unchanged(L, fields)) return; + } + ln(`${key}${sep}${gen}`, d); + }; + const hRatio = o && unchanged(L, ["size", "aspect"]) ? null : L.aspect; + ln("particle"); ln("{"); + const sys = [["quota", Math.max(o ? 0 : 1, Math.round(L.quota))], ["follow_parent", L.follow], + ["default_width", `${f2(L.size[0])} ${f2(L.size[1])}`], ["default_height", `${f2(L.size[0] * (hRatio ?? L.aspect))} ${f2(L.size[1] * (hRatio ?? L.aspect))}`], + ["render_type", L.blend], ["depth_check", L.depth ? "true" : "false"], ["texture", tex.path], + ["billboard_type", L.billboard], ["end_condition", oneShot ? "time_limit" : "never_end"], ["limit_time", f2(oneShot ? L.delay + L.duration : 1000)], + ["common_dir", v3(L.commonDir)], ["common_dir_none_billboard", L.fixedDir ? "true" : "false"], + ["start_create_particles", Math.round(L.burst)], ["link_biped_name", L.bone && L.bone !== "none" ? L.bone : "none"], + ["pos_offset", v3(L.offset)], ["modulate2x", L.mod2x ? "true" : "false"]]; + if (!o) { + sys.splice(1, 0, ["cull_each", "false"]); + sys.splice(6, 0, ["tex_filtering", "bilinear"]); + sys.push(["rot_offset", "0.00 0.00 0.00 1.00"], ["random_sync_index", -1]); + for (const [k, v] of sys) ln(`${k} ${v}`, 1); + } else { + // the script's own key order; keys it did not have follow once edited + const gen = Object.fromEntries(sys); + for (const [k, v] of o.orderP) { + const lk = k.toLowerCase(); + if (gen[lk] !== undefined) put("p", lk, gen[lk], 1, " "); + else ln(v === "" ? k : `${k} ${v}`, 1); + } + for (const [k, v] of sys) if (!Object.prototype.hasOwnProperty.call(o.p, k)) put("p", k, v, 1, " "); + } + ln(); + ln(`emitter ${o && unchanged(L, ["shape"]) ? o.emType : SHAPES.includes(L.shape) ? L.shape : "Point"}`, 1); ln("{", 1); + const colors = L.colors.slice().sort((a, b) => a[0] - b[0]).flatMap(([t, c]) => [f2(t), ...hexRgb(c).map(f2)]).join(" "); + const alpha = L.alpha.slice().sort((a, b) => a[0] - b[0]).flatMap(([t, a]) => [f2(t), f2(a)]).join(" "); + const startRot = L.rotStart || L.rot; + const em = [["emit_pos", v3(L.pos)], ["emit_dir", v3(L.dir)], ["emit_angle", f2(L.spread)], ["emit_rate", f2(L.rate)], + ["emit_start_tex_rotation", `${f2(startRot[0])} ${f2(startRot[1])}`], ["emit_speed", `${f2(L.speed[0])} ${f2(L.speed[1])}`], + ["emit_live_time", `${f2(L.life[0])} ${f2(L.life[1])}`], ["emit_wait_time", f2(L.delay)], ["color_rate", colors], ["alpha_rate", alpha]]; + const needArea = L.shape !== "Point", needInner = L.shape === "Ellipse" || L.shape === "HollowEllipsoid"; + if (!o) { + if (needArea) em.push(["area_size", v3(L.area)]); + if (needInner) em.push(["inner_size", v3(L.inner)]); + for (const [k, v] of em) ln(`${k}\t${v}`, 2); + } else { + em.push(["area_size", v3(L.area)], ["inner_size", v3(L.inner)]); + const gen = Object.fromEntries(em); + for (const [k, v] of o.orderE) { + const lk = k.toLowerCase(); + if (gen[lk] !== undefined) put("e", lk, gen[lk], 2, "\t"); + else ln(v === "" ? k : `${k}\t${v}`, 2); + } + for (const [k, v] of em) { + if (Object.prototype.hasOwnProperty.call(o.e, k) || (k === "area_size" && !needArea) || (k === "inner_size" && !needInner)) continue; + put("e", k, v, 2, "\t"); + } + } + ln("}", 1); ln(); + const done = new Set(); + for (const entry of L.affOrder || []) { + const fields = AFF_FIELDS[entry.type]; + if (!fields || unchanged(L, fields)) raw(entry.lines, 1); + else raw(affectorLines(entry.type, L), 1); + if (fields) done.add(entry.type); + ln(); + } + for (const type of ["scale", "texrotation", "linear", "emit"]) { + if (done.has(type) || !affectorNeeded(type, L)) continue; + raw(affectorLines(type, L), 1); ln(); + } + ln("}"); + } + for (const block of preset.passthrough || []) raw(block, 0); + return out.join("\r\n") + "\r\n"; + } + + // ---------- Lost Saga effect script → preset (re-edit existing effects) ---------- + /** Blocks of a script: [{ kind, type, lines (raw, block included), attrs: [[key, value]], children }] */ + function blocksOf(text) { + const lines = text.split(/\r\n|\n|\r/); + const roots = [], stack = []; + let header = null; + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t || t.startsWith("//")) continue; + if (t === "{" || t.endsWith("{")) { + const h = t === "{" ? header?.text || "" : t.slice(0, -1).trim(); + const [kind = "", ...rest] = h.split(/\s+/); + const node = { kind: kind.toLowerCase(), type: rest.join(" "), start: t === "{" ? header?.line ?? i : i, attrs: [], children: [] }; + if (stack.length) stack.at(-1).children.push(node); else roots.push(node); + stack.push(node); + header = null; + } else if (t === "}") { + const node = stack.pop(); + if (!node) throw new Error(`Unexpected } at line ${i + 1}`); + node.end = i; + node.lines = lines.slice(node.start, i + 1); + } else { + // a line is a block header only when "{" follows it; otherwise it is an attribute + let j = i + 1; + while (j < lines.length && (!lines[j].trim() || lines[j].trim().startsWith("//"))) j++; + if (lines[j]?.trim() === "{") header = { text: t, line: i }; + else if (stack.length) { const m = /^(\S+)\s*(.*)$/.exec(t); stack.at(-1).attrs.push([m[1], m[2]]); } + } + } + if (stack.length) throw new Error(`Missing } for ${stack.at(-1).kind}`); + return roots; + } + const numsOf = (s) => String(s || "").trim().split(/\s+/).filter(Boolean).map(Number).filter((n) => !isNaN(n)); + const SHAPE_OF = Object.fromEntries(SHAPES.map((s) => [s.toLowerCase(), s])); + const rgbHex = (r, g, b) => "#" + [r, g, b].map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join(""); + const constRate = (s) => { const n = numsOf(s); for (let i = 1; i < n.length; i += 2) if (Math.abs(n[i] - 1) > 1e-3) return false; return true; }; + /** → { preset, notes } */ + function fromScript(text, name) { + const notes = []; + const preset = { name: name.replace(/\.txt$/i, ""), cat: "LS effect", layers: [], info: [], passthrough: [] }; + const roots = blocksOf(text); + let n = 0; + for (const b of roots) { + if (b.kind === "info") { preset.info = b.attrs; continue; } + if (b.kind !== "particle") { preset.passthrough.push(b.lines); notes.push(`${b.kind} block kept as is${b.kind === "model" ? " (mesh preview: hero mode or FX tab)" : ""}`); continue; } + n++; + const a = new Map(b.attrs.map(([k, v]) => [k.toLowerCase(), v])); + const emitter = b.children.find((c) => c.kind === "emitter"); + const e = new Map((emitter?.attrs || []).map(([k, v]) => [k.toLowerCase(), v])); + const pair = (s, d) => { const v = numsOf(s); return v.length >= 2 ? v.slice(0, 2) : v.length ? [v[0], v[0]] : d; }; + const vec = (s, d) => { const v = numsOf(s); return v.length >= 3 ? v.slice(0, 3) : d; }; + const w = pair(a.get("default_width"), [100, 100]), h = pair(a.get("default_height"), w); + const tex = (a.get("texture") || "").trim(); + const proc = /(?:^|[\\/])fxlab_(\w+)\.dds$/i.exec(tex); + const L = layer({ + name: `Particle ${n}`, lsBurst: true, + tex: proc && SPRITES[proc[1].toLowerCase()] ? { kind: "proc", shape: proc[1].toLowerCase() } : { kind: "pack", name: tex || "none" }, + blend: (a.get("render_type") || "add").toLowerCase(), billboard: (a.get("billboard_type") || "point").toLowerCase(), + depth: a.get("depth_check") !== "false", mod2x: a.get("modulate2x") === "true", + quota: Number(a.get("quota")) || 0, burst: Number(a.get("start_create_particles")) || 0, + follow: (a.get("follow_parent") || "none").toLowerCase(), bone: (a.get("link_biped_name") || "none").trim() || "none", + offset: vec(a.get("pos_offset"), [0, 0, 0]), commonDir: vec(a.get("common_dir"), [0, 1, 0]), fixedDir: a.get("common_dir_none_billboard") === "true", + size: w, aspect: w[1] ? Math.round((h[1] / w[1]) * 1000) / 1000 : 1, + shape: SHAPE_OF[(emitter?.type || "point").toLowerCase()] || "Point", + pos: vec(e.get("emit_pos"), [0, 0, 0]), dir: vec(e.get("emit_dir"), [0, 1, 0]), spread: Number(e.get("emit_angle")) || 0, + rate: e.has("emit_rate") ? Number(e.get("emit_rate")) || 0 : 10, speed: pair(e.get("emit_speed"), [1, 1]), life: pair(e.get("emit_live_time"), [5000, 5000]), + delay: Number(e.get("emit_wait_time")) || 0, rot: pair(e.get("emit_start_tex_rotation"), [0, 0]), + area: vec(e.get("area_size"), [100, 100, 100]), inner: vec(e.get("inner_size"), [0.5, 0.5, 0.5]), + gravity: [0, 0, 0], attract: 0, spin: [0, 0], sizeCurve: [[0, 1], [1, 1]], + }); + const limit = Number(a.get("limit_time")) || 0; + L.duration = (a.get("end_condition") || "").toLowerCase() === "time_limit" ? Math.max(1, limit - L.delay) : 0; + const curve = (s, k) => { const v = numsOf(s), out = []; for (let i = 0; i < v.length; i += k + 1) out.push([v[i], v.slice(i + 1, i + 1 + k)]); return out; }; + const cr = curve(e.get("color_rate"), 3); + L.colors = cr.length ? cr.map(([t, c]) => [t, rgbHex(c[0] ?? 255, c[1] ?? 255, c[2] ?? 255)]) : [[0, "#ffffff"]]; + const ar = curve(e.get("alpha_rate"), 1); + L.alpha = ar.length ? ar.map(([t, v]) => [t, v[0] ?? 1]) : [[0, 1]]; + // keys FX Lab does not model are written back as they are + L.extra = b.attrs.filter(([k]) => !KEY_FIELDS[k.toLowerCase()] || STATIC_KEYS.includes(k.toLowerCase())); + L.extraEmitter = (emitter?.attrs || []).filter(([k]) => !KEY_FIELDS[k.toLowerCase()]); + L.affOrder = []; + const seen = new Set(); + for (const f of b.children.filter((c) => c.kind === "affector")) { + const t = f.type.toLowerCase(), fa = new Map(f.attrs.map(([k, v]) => [k.toLowerCase(), v])); + const simple = !seen.has(t) && !(Number(fa.get("wait_time")) > 0); + let mapped = false; + if (simple && t === "scale") { + const k = curve(fa.get("scale_rate"), 2); + if (k.length) { + L.sizeCurve = k.map(([tt, s]) => [tt, s[0] ?? 1]); + const ratios = k.map(([, s]) => (s[0] ? (s[1] ?? s[0]) / s[0] : 1)); + if (ratios.some((r) => Math.abs(r - ratios[0]) > 1e-3)) notes.push(`Particle ${n}: Scale X / Y curves differ; editing the size curve makes Y follow X`); + L.scaleY = Math.round(ratios[0] * 1000) / 1000; + mapped = true; + } + } else if (simple && t === "texrotation") { + L.rotStart = L.rot; + L.rot = pair(fa.get("rot_range"), L.rot); + L.spin = pair(fa.get("rot_speed"), [0, 0]).map((r) => Math.round((r * 180) / Math.PI * 10) / 10); + mapped = true; + } else if (simple && t === "linear") { + L.gravity = vec(fa.get("force_vector"), [0, -100, 0]); + L.forceMethod = fa.get("force_method") || "add"; + L.gravityRate = constRate(fa.get("force_rate")) ? "" : fa.get("force_rate"); + mapped = true; + } else if (simple && t === "emit") { + L.attract = Number(fa.get("power")) || 0; + L.attractRate = constRate(fa.get("force_rate")) ? "" : fa.get("force_rate"); + L.attractChangeDir = fa.get("change_direction") === "true"; + mapped = true; + } + if (mapped) seen.add(t); + else notes.push(`Particle ${n}: affector ${f.type} kept as is (not editable here)`); + L.affOrder.push({ type: mapped ? t : "raw", lines: f.lines }); + } + const keyed = (attrs) => Object.fromEntries(attrs.filter(([k]) => KEY_FIELDS[k.toLowerCase()]).map(([k, v]) => [k.toLowerCase(), v])); + const names = (attrs) => Object.fromEntries(attrs.map(([k]) => [k.toLowerCase(), k])); + L.orig = { p: keyed(b.attrs), e: keyed(emitter?.attrs || []), names: { p: names(b.attrs), e: names(emitter?.attrs || []) }, emType: emitter?.type || "Point", fields: null, orderP: b.attrs, orderE: emitter?.attrs || [] }; + if (!emitter) notes.push(`Particle ${n}: no emitter block; one is written on save`); + L.orig.fields = snapshot(layer(clone(L))); + preset.layers.push(L); + } + return { preset, notes }; + } + FXL.fromScript = fromScript; + FXL.toScript = toScript; + + // ---------- views & textures ---------- + let view = null, kit = null, inst = null, last = 0, cycleAt = 0; + const viewTex = new WeakMap(); // GL3D view → Map(file → texture) + const texSources = new Map(); // lowercase file → texOf() + function customTexture(name, v) { + const src = texSources.get(String(name).toLowerCase()); + if (!src || src.pack) return null; + let m = viewTex.get(v); + if (!m) viewTex.set(v, (m = new Map())); + const key = src.image ? `img:${src.image.length}:${src.image.slice(-32)}` : `proc:${src.shape}`; + if (!m.has(key)) { + const c = src.image ? imageCanvas(src.image) : sprite(src.shape); + if (!c) return null; + const t = v.texture(c), gl = v.gl; + gl.bindTexture(gl.TEXTURE_2D, t); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + m.set(key, t); + } + return m.get(key); + } + const packTex = new Map(); + function texture(name) { + const custom = customTexture(name, view); + if (custom) return custom; + if (!name || !FXL.src || !kit) return null; + const key = name.toLowerCase(); + if (!packTex.has(key)) { + packTex.set(key, null); + kit.texture(name).then((t) => { + if (!t.tex) return; + const gl = view.gl; + gl.bindTexture(gl.TEXTURE_2D, t.tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + packTex.set(key, t.tex); + }); + } + return packTex.get(key); + } + const BG = { gray: [94 / 255, 94 / 255, 94 / 255], dark: [0.09, 0.085, 0.1], black: [0, 0, 0] }; + function ensureView() { + if (view) return view; + try { view = new GL3D.View($("fxl-canvas"), { alpha: false }); } + catch (err) { toast(`FX Lab preview unavailable: ${err.message}`, true); return null; } + view.env = { clear: BG[$("fxl-bg").value] || BG.gray }; + view.opts.lit = false; + kit = MeshKit.create(view, () => FXL.src); + view.frame([-150, -20, -150], [150, 200, 150]); + drawGrid(); + return view; + } + function drawGrid() { + if (!view) return; + if (!$("fxl-grid").classList.contains("on")) return view.setOverlay("fxl-grid", null); + const a = [], line = (x, z, xx, zz, c) => a.push(x, 0, z, ...c, xx, 0, zz, ...c); + for (let v = -300; v <= 300; v += 50) { + const c = v === 0 ? [0.82, 0.69, 0.48, 0.62] : [0.7, 0.7, 0.7, 0.35]; + line(v, -300, v, 300, c); line(-300, v, 300, v, c); + } + view.setOverlay("fxl-grid", new Float32Array(a)); + } + + // ---------- rebuild + play ---------- + let rebuildTimer = 0; + function rebuild() { + clearTimeout(rebuildTimer); + rebuildTimer = setTimeout(rebuildNow, 60); + } + function rebuildNow() { + if (!FXL.preset) return; + texSources.clear(); + for (const L of FXL.preset.layers) { const t = texOf(L); texSources.set(t.file.toLowerCase(), t); } + FXL.text = toScript(FXL.preset); + $("fxl-script").value = FXL.text; + restart(); + } + const cycleMs = () => { + const ls = FXL.preset?.layers.filter((l) => l.on) || []; + const oneShot = ls.length && ls.every((l) => l.duration > 0); + const span = Math.max(500, ...ls.map((l) => (l.duration > 0 ? l.duration : 2000) + l.delay + l.life[1])); + return oneShot ? span + 500 : 4000; + }; + function heroHost() { return { stage: $("fxl-stage"), viewId: "view-fxlab" }; } + function restart() { + if (!FXL.text) return; + const def = Effects.parse(FXL.text); + if (FXL.mode === "free") { + if (!ensureView()) return; + inst = Effects.instance(def, [0, 0, 0]); + view.setParticles([]); + } else if (window.MDL?.startHeroFx && MDL.heroPreview) { + inst = null; + const anim = $("fxl-anim").value; + if (anim) MDL.playHeroMotion(anim).catch(() => {}); + MDL.startHeroFx(def, { target: FXL.mode === "hit", duration: cycleMs(), customTexture }); + cycleAt = performance.now() + cycleMs(); + } + } + function tick(now) { + const dt = Math.min(100, Math.max(0, now - (last || now))); + last = now; + if ($("view-fxlab").classList.contains("on")) { + if (FXL.mode === "free" && view && inst && FXL.playing) { + Effects.update(inst, dt); + const parts = Effects.build(inst, view.cameraAxes()); + for (const p of parts) p.tex = texture(p.texture); + view.setParticles(parts); + $("fxl-stats").textContent = `${Effects.count(inst)} particles · ${inst.systems.length} layers`; + } else if (FXL.mode !== "free" && window.MDL?.heroPreview) { + if (FXL.playing && now >= cycleAt && cycleAt) restart(); + $("fxl-stats").textContent = `${MDL.heroFxCount()} particles · on hero`; + } + } + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + + // ---------- hero mode ---------- + async function setMode(mode) { + FXL.mode = mode; + $("fxl-mode").value = mode; + $("fxl-anim").hidden = mode === "free"; + if (mode === "free") { + if ($("mdl-canvas")?.parentElement === $("fxl-stage")) MDL.embedHero(false); + $("fxl-canvas").hidden = false; + ensureView(); view?.resize(); + cycleAt = 0; + return restart(); + } + if (!FXL.src) { toast("Choose the game packs first (hero model and animations come from them).", true); return setMode("free"); } + $("fxl-canvas").hidden = true; + try { + await MDL.previewHero(FXL.src, [], null, { embedded: true, host: heroHost() }); + MDL.setHeroTransform([0, 0, 0], 0); + MDL.setHeroPracticeTarget(mode === "hit" ? [0, 0, 130] : null); + await fillAnims(); + renderProps(); + restart(); + } catch (err) { toast(`Hero preview: ${err.message}`, true); $("fxl-canvas").hidden = false; FXL.mode = "free"; $("fxl-mode").value = "free"; } + } + let animNames = null; + async function fillAnims() { + if (!animNames) { + try { animNames = (await call("mdl:names", FXL.src, "animation")).filter((n) => /\.ani$/i.test(n) && /(^|\/)(sp2_)?human|attack|skill|delay|run|walk/i.test(n)).slice(0, 4000); } + catch { animNames = []; } + } + const sel = $("fxl-anim"), cur = sel.value; + const pick = animNames.filter((n) => /attack|skill|delay/i.test(n)).slice(0, 600); + sel.replaceChildren(el("option", { value: "", text: "No animation (stand)" }), ...pick.map((n) => el("option", { value: n.split("/").pop(), text: n.split("/").pop() }))); + sel.value = cur; + } + + // ---------- presets ---------- + function load(preset) { + FXL.preset = clone(preset); + delete FXL.preset.builtin; + FXL.preset.layers = FXL.preset.layers.map((l) => layer(l)); + FXL.sel = 0; + FXL.notes = preset.notes || []; + renderNotes(); + FXL.where = null; + updateSave(); + $("fxl-name").value = safeName(preset.name); + renderLayers(); renderProps(); renderPresets(); + rebuildNow(); + if (preset.mode && preset.mode !== FXL.mode && FXL.src) setMode(preset.mode); + } + async function loadUser() { + try { FXL.user = await call("fxlab:presets"); } catch (err) { FXL.user = []; toast(err.message, true); } + renderPresets(); + } + function renderPresets() { + const q = $("fxl-search").value.trim().toLowerCase(); + const match = (p) => !q || `${p.name} ${p.cat || ""}`.toLowerCase().includes(q); + const row = (p, user) => el("div", { class: `ims-set fxl-preset${FXL.preset?.name === p.name ? " on" : ""}`, onclick: () => load(p) }, [ + el("b", { text: p.name }), + el("span", { text: `${p.cat || "Saved"} · ${p.layers.length} layer${p.layers.length === 1 ? "" : "s"}${p.mode && p.mode !== "free" ? ` · ${p.mode === "hit" ? "hit" : "hero"}` : ""}` }), + user ? el("button", { class: "iconbtn only danger fxl-del", title: "Delete preset", onclick: async (e) => { + e.stopPropagation(); + if (!(await confirmBox("Delete preset", `Delete "${p.name}" from the FX Lab library?`, "Delete", "danger"))) return; + await call("fxlab:deletePreset", p.name); loadUser(); + } }, [icon("trash")]) : null, + ]); + const user = FXL.user.filter(match), built = BUILTIN.filter(match); + $("fxl-presets").replaceChildren( + ...(user.length ? [el("div", { class: "fxl-group", text: "My presets" }), ...user.map((p) => row(p, true))] : []), + el("div", { class: "fxl-group", text: "Built-in" }), ...built.map((p) => row(p, false))); + } + const currentPreset = () => ({ ...clone(FXL.preset), name: $("fxl-name").value.trim() || "my_effect", mode: FXL.mode, version: 1, app: "LS Tools FX Lab" }); + + // ---------- layers ---------- + function renderLayers() { + const box = $("fxl-layers"); + box.replaceChildren(...FXL.preset.layers.map((L, i) => el("div", { class: `fxl-layer${i === FXL.sel ? " on" : ""}`, onclick: () => { FXL.sel = i; renderLayers(); renderProps(); } }, [ + el("input", { type: "checkbox", checked: L.on, title: "Visible", onclick: (e) => e.stopPropagation(), onchange: (e) => { L.on = e.target.checked; rebuild(); } }), + el("canvas", { class: "fxl-swatch", width: 22, height: 22 }), + el("span", { text: L.name }), + el("em", { class: "muted", text: `${L.blend}${L.bone !== "none" ? ` · ${L.bone}` : ""}` }), + ]))); + box.querySelectorAll(".fxl-swatch").forEach((c, i) => drawSwatch(c, FXL.preset.layers[i])); + } + function drawSwatch(c, L) { + const ctx = c.getContext("2d"); + ctx.fillStyle = "#222"; ctx.fillRect(0, 0, c.width, c.height); + const t = texOf(L), src = t.image ? imageCanvas(t.image) : t.shape ? sprite(t.shape) : null; + if (!src) return; + const g = ctx.createLinearGradient(0, 0, c.width, 0); + for (const [p, col] of L.colors.slice().sort((a, b) => a[0] - b[0])) g.addColorStop(clamp(p, 0, 1), col); + const tmp = document.createElement("canvas"); tmp.width = c.width; tmp.height = c.height; + const tc = tmp.getContext("2d"); + tc.drawImage(src, 0, 0, c.width, c.height); tc.globalCompositeOperation = "source-in"; tc.fillStyle = g; tc.fillRect(0, 0, c.width, c.height); + ctx.drawImage(tmp, 0, 0); + } + const selLayer = () => FXL.preset?.layers[FXL.sel]; + + // ---------- property widgets ---------- + const changed = () => { rebuild(); const c = $("fxl-layers").children[FXL.sel]?.querySelector("canvas"); if (c) drawSwatch(c, selLayer()); }; + function numRow(label, obj, key, { min = 0, max = 100, step = 1, idx = null, hint = "" } = {}) { + const get = () => (idx === null ? obj[key] : obj[key][idx]); + const set = (v) => { if (idx === null) obj[key] = v; else obj[key][idx] = v; changed(); }; + const range = el("input", { type: "range", min, max, step, value: clamp(get(), min, max) }); + const num = el("input", { type: "number", step, value: get(), class: "fxl-num" }); + range.oninput = () => { num.value = range.value; set(Number(range.value)); }; + num.onchange = () => { const v = Number(num.value) || 0; range.value = clamp(v, min, max); set(v); }; + return [el("label", { text: label, title: hint }), el("div", { class: "fxl-slider" }, [range, num])]; + } + function pairRow(label, obj, key, { step = 1, hint = "" } = {}) { + const inp = (i) => el("input", { type: "number", step, value: obj[key][i], title: i ? "max" : "min", onchange: (e) => { obj[key][i] = Number(e.target.value) || 0; changed(); } }); + return [el("label", { text: label, title: hint }), el("div", { class: "fxl-pair" }, [inp(0), inp(1)])]; + } + function vecRow(label, obj, key, hint = "") { + return [el("label", { text: label, title: hint }), el("div", { class: "fx-vector" }, obj[key].map((v, i) => el("input", { type: "number", step: 1, value: v, title: "XYZ"[i], onchange: (e) => { obj[key][i] = Number(e.target.value) || 0; changed(); } })))]; + } + function selRow(label, obj, key, options, after) { + const s = el("select", { onchange: () => { obj[key] = s.value; changed(); after?.(); } }, options.map((o) => { const [v, t] = Array.isArray(o) ? o : [o, o]; return el("option", { value: v, text: t }); })); + s.value = obj[key]; + return [el("label", { text: label }), s]; + } + function boolRow(label, obj, key) { + return [el("label", { text: label }), el("input", { type: "checkbox", class: "fxl-check", checked: obj[key], onchange: (e) => { obj[key] = e.target.checked; changed(); } })]; + } + const group = (t) => el("div", { class: "fx-group", text: t }); + + /** Curve editor: points [[t, v]], t 0..1, v 0..vmax. Drag; double-click adds; right-click removes. */ + function curveEditor(points, vmax, color = "#e9b43c") { + const c = el("canvas", { class: "fxl-curve", width: 300, height: 80 }); + const W = () => c.width, H = () => c.height, pad = 6; + const toXY = ([t, v]) => [pad + t * (W() - 2 * pad), H() - pad - (v / vmax) * (H() - 2 * pad)]; + const fromXY = (x, y) => [clamp((x - pad) / (W() - 2 * pad), 0, 1), clamp(((H() - pad - y) / (H() - 2 * pad)) * vmax, 0, vmax)]; + const draw = () => { + const ctx = c.getContext("2d"); + ctx.clearRect(0, 0, W(), H()); + ctx.fillStyle = "rgba(255,255,255,0.04)"; ctx.fillRect(0, 0, W(), H()); + ctx.strokeStyle = "rgba(255,255,255,0.12)"; ctx.beginPath(); + for (let k = 1; k < 4; k++) { const y = pad + (k / 4) * (H() - 2 * pad); ctx.moveTo(pad, y); ctx.lineTo(W() - pad, y); } + ctx.stroke(); + points.sort((a, b) => a[0] - b[0]); + ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); + const first = toXY(points[0]); ctx.moveTo(pad, first[1]); + for (const p of points) { const [x, y] = toXY(p); ctx.lineTo(x, y); } + ctx.lineTo(W() - pad, toXY(points[points.length - 1])[1]); ctx.stroke(); + ctx.fillStyle = "#fff"; + for (const p of points) { const [x, y] = toXY(p); ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); } + ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.font = "10px sans-serif"; ctx.fillText(String(vmax), 2, 10); + }; + const local = (e) => { const r = c.getBoundingClientRect(); return [(e.clientX - r.left) * (W() / r.width), (e.clientY - r.top) * (H() / r.height)]; }; + const hitIdx = (x, y) => points.findIndex((p) => { const [px, py] = toXY(p); return Math.hypot(px - x, py - y) < 8; }); + let drag = -1; + c.onpointerdown = (e) => { if (e.button !== 0) return; drag = hitIdx(...local(e)); if (drag >= 0) c.setPointerCapture(e.pointerId); }; + c.onpointermove = (e) => { + if (drag < 0) return; + const [t, v] = fromXY(...local(e)), p = points[drag]; + p[0] = Math.round(t * 100) / 100; p[1] = Math.round(v * 100) / 100; + draw(); changed(); + }; + c.onpointerup = () => { drag = -1; }; + c.ondblclick = (e) => { points.push(fromXY(...local(e)).map((n) => Math.round(n * 100) / 100)); draw(); changed(); }; + c.oncontextmenu = (e) => { e.preventDefault(); const i = hitIdx(...local(e)); if (i >= 0 && points.length > 1) { points.splice(i, 1); draw(); changed(); } }; + draw(); + return c; + } + /** Colour ramp: stops [[t, "#hex"]]. Click a stop to recolour, drag to move, double-click the bar to add, right-click to remove. */ + function gradientEditor(stops) { + const wrap = el("div", { class: "fxl-grad" }); + const bar = el("div", { class: "fxl-grad-bar" }), marks = el("div", { class: "fxl-grad-marks" }); + const picker = el("input", { type: "color", class: "fxl-grad-picker" }); + let editing = null; + picker.oninput = () => { if (editing) { editing[1] = picker.value; paint(); changed(); } }; + const paint = () => { + stops.sort((a, b) => a[0] - b[0]); + bar.style.background = `linear-gradient(90deg, ${stops.map(([t, c]) => `${c} ${(t * 100).toFixed(1)}%`).join(", ")})`; + marks.replaceChildren(...stops.map((s) => { + const m = el("div", { class: "fxl-grad-stop", title: "Click: colour · drag: move · right-click: remove" }); + m.style.left = `${s[0] * 100}%`; m.style.background = s[1]; + let moved = false; + m.onpointerdown = (e) => { + if (e.button !== 0) return; + moved = false; m.setPointerCapture(e.pointerId); + m.onpointermove = (ev) => { const r = marks.getBoundingClientRect(); s[0] = Math.round(clamp((ev.clientX - r.left) / r.width, 0, 1) * 100) / 100; m.style.left = `${s[0] * 100}%`; moved = true; bar.style.background = `linear-gradient(90deg, ${stops.slice().sort((a, b) => a[0] - b[0]).map(([t, c]) => `${c} ${(t * 100).toFixed(1)}%`).join(", ")})`; changed(); }; + m.onpointerup = () => { m.onpointermove = null; if (!moved) { editing = s; picker.value = s[1]; picker.click(); } else paint(); }; + }; + m.oncontextmenu = (e) => { e.preventDefault(); if (stops.length > 1) { stops.splice(stops.indexOf(s), 1); paint(); changed(); } }; + return m; + })); + }; + bar.ondblclick = (e) => { + const r = bar.getBoundingClientRect(), t = Math.round(clamp((e.clientX - r.left) / r.width, 0, 1) * 100) / 100; + const near = stops.reduce((a, b) => (Math.abs(b[0] - t) < Math.abs(a[0] - t) ? b : a)); + stops.push([t, near[1]]); paint(); changed(); + }; + paint(); + wrap.append(bar, marks, picker); + return wrap; + } + /** pixelfx-style palette ramps: one click sets the colour stops. */ + const RAMPS = { + Fire: ["#fff2a8", "#ff9a1f", "#d42a06", "#3a0a02"], Ice: ["#ffffff", "#9fe8ff", "#3a8cff", "#0c2a7a"], + Poison: ["#e8ffb0", "#7cff3a", "#1f9a3a", "#0a3a1a"], Arcane: ["#ffe6ff", "#d18bff", "#7a3aff", "#1a0a5a"], + Holy: ["#ffffff", "#fff3b0", "#ffc94a", "#a86a10"], Blood: ["#ff9a9a", "#e01a2a", "#7a0612", "#2a0206"], + Electric: ["#ffffff", "#c8f4ff", "#5ac8ff", "#2a3aff"], Shadow: ["#b8a8c8", "#5a4a7a", "#2a1a3a", "#0a0610"], White: ["#ffffff", "#ffffff"], + }; + function rampButtons(L, after) { + return el("div", { class: "fxl-ramps" }, Object.entries(RAMPS).map(([name, cols]) => { + const b = el("button", { class: "fxl-ramp", title: name, onclick: () => { L.colors = cols.map((c, i) => [Math.round((i / Math.max(1, cols.length - 1)) * 100) / 100, c]); after(); changed(); } }); + b.style.background = `linear-gradient(90deg, ${cols.join(", ")})`; + return b; + })); + } + function spritePicker(L) { + const wrap = el("div", { class: "fxl-sprites" }); + for (const [shape, label] of Object.entries(SPRITES)) { + const c = el("canvas", { width: 34, height: 34, title: label, class: `fxl-sprite${L.tex.kind === "proc" && L.tex.shape === shape ? " on" : ""}`, onclick: () => { L.tex = { kind: "proc", shape }; renderProps(); changed(); } }); + const ctx = c.getContext("2d"); ctx.fillStyle = "#181818"; ctx.fillRect(0, 0, 34, 34); ctx.drawImage(sprite(shape), 1, 1, 32, 32); + wrap.append(c); + } + const packBtn = el("button", { class: `iconbtn${L.tex.kind === "pack" ? " on" : ""}`, title: "Use a texture from the game packs (resource/texture)", onclick: () => pickPackTexture(L) }, [icon("box"), el("span", { text: L.tex.kind === "pack" ? L.tex.name.split("/").pop() : "Pack…" })]); + const fileBtn = el("button", { class: `iconbtn${L.tex.kind === "image" ? " on" : ""}`, title: "Use a PNG / JPG image (exported as DDS)", onclick: () => pickImage(L) }, [icon("image"), el("span", { text: L.tex.kind === "image" ? L.tex.label : "Image…" })]); + wrap.append(packBtn, fileBtn); + return wrap; + } + async function pickPackTexture(L) { + if (!FXL.src) return toast("Choose the game packs first.", true); + let names; + try { names = (await call("mdl:names", FXL.src, "texture")).filter((n) => /\.(dds|tga|png|jpg)$/i.test(n)); } catch (err) { return toast(err.message, true); } + const search = el("input", { type: "search", placeholder: "Search textures (EffectTexture/…)", spellcheck: "false" }); + const listBox = el("div", { class: "fx-resource-list" }); + let choice = null; + const fill = () => { + const q = search.value.trim().toLowerCase(); + const rows = names.filter((n) => (q ? n.toLowerCase().includes(q) : /^effecttexture\//i.test(n))).slice(0, 300); + listBox.replaceChildren(...rows.map((n) => el("button", { class: `fx-resource${choice === n ? " on" : ""}`, text: n, onclick: () => { choice = n; fill(); }, ondblclick: () => { choice = n; $("modal-actions").lastChild.click(); } }))); + }; + search.oninput = fill; fill(); + if (!(await modal("Pack texture", [search, listBox], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }])) || !choice) return; + const name = /^effecttexture\//i.test(choice) ? `EffectTexture/${choice.slice(14)}` : choice; + L.tex = { kind: "pack", name }; + packTex.clear(); + renderProps(); changed(); + } + async function pickImage(L) { + const file = await call("dialog:file", "Sprite image (PNG / JPG)"); + if (!file) return; + try { + const hit = await call("mdl:readFile", file); + const ext = file.split(".").pop().toLowerCase(); + const blob = new Blob([hit.bytes], { type: ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png" }); + const data = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(blob); }); + L.tex = { kind: "image", label: safeName(file.split(/[\\/]/).pop().replace(/\.\w+$/, "")), data }; + renderProps(); changed(); + } catch (err) { toast(err.message, true); } + } + + function renderProps() { + const L = selLayer(), box = $("fxl-props"); + if (!L) { $("fxl-layer-name").textContent = ""; return box.replaceChildren(el("small", { class: "muted", text: "Add a layer to start." })); } + $("fxl-layer-name").textContent = L.name; + const rows = []; + const add = (...r) => rows.push(...r.flat()); + const nameIn = el("input", { type: "text", value: L.name, spellcheck: "false", onchange: (e) => { L.name = e.target.value || "Layer"; renderLayers(); $("fxl-layer-name").textContent = L.name; } }); + add(el("label", { text: "Name" }), nameIn); + add(group("Sprite")); + add(el("div", { class: "fxl-wide" }, [spritePicker(L)])); + add(selRow("Blend", L, "blend", [["add", "Add (glow)"], ["src_alpha", "Alpha (smoke)"], ["screen", "Screen"], ["lighten", "Lighten"], ["darken", "Darken"], ["subtract", "Subtract"]])); + add(selRow("Facing", L, "billboard", [["point", "Camera (billboard)"], ["direction", "Along motion (sparks)"], ["common", "Fixed direction (rings)"]], renderProps)); + if (L.billboard === "common") { add(vecRow("Sprite up axis", L, "commonDir", "The sprite top points this way. 0 0 1 + Fixed = flat on the floor")); add(boolRow("Fixed (no camera turn)", L, "fixedDir")); } + add(boolRow("Depth test", L, "depth"), boolRow("Brighter ×2", L, "mod2x")); + add(group("Emission")); + add(numRow("Per second", L, "rate", { max: 200, step: 0.5, hint: "Particles spawned per second (0 = burst only)" })); + add(numRow("Burst at start", L, "burst", { max: 200 })); + add(numRow("Max particles", L, "quota", { min: 1, max: 1000 })); + add(numRow("Duration ms", L, "duration", { max: 5000, step: 50, hint: "0 = loop forever; > 0 = one-shot that stops emitting after this time" })); + add(numRow("Delay ms", L, "delay", { max: 3000, step: 10 })); + add(selRow("Shape", L, "shape", SHAPES, renderProps)); + if (L.shape !== "Point") add(vecRow("Shape size", L, "area", "Width / height / depth of the emit area")); + if (L.shape === "Ellipse" || L.shape === "HollowEllipsoid") add(vecRow("Inner (0-1)", L, "inner")); + add(vecRow("Position", L, "pos", "Emitter position (Y up; hero is ~180 tall)")); + add(vecRow("Direction", L, "dir")); + add(numRow("Spread °", L, "spread", { max: 180, hint: "Cone angle around the direction (180 = all directions)" })); + add(group("Motion")); + add(pairRow("Speed min / max", L, "speed", { max: 800 })); + add(pairRow("Life ms min / max", L, "life", { min: 50, max: 6000, step: 10 })); + add(vecRow("Gravity / force", L, "gravity", "Constant force per second (negative Y falls)")); + add(numRow("Push / pull", L, "attract", { min: -400, max: 400, hint: "Moves particles away from (+) or toward (−) the emitter" })); + add(pairRow("Start angle °", L, "rot", { max: 360 })); + add(pairRow("Spin °/s", L, "spin", { min: -720, max: 720, step: 5 })); + add(group("Size")); + add(pairRow("Size min / max", L, "size", { min: 1, max: 400 })); + add(numRow("Height ratio", L, "aspect", { min: 0.05, max: 4, step: 0.05, hint: "Height = width × ratio (0.3 for streaks)" })); + add(el("label", { text: "Size over life" }), curveEditor(L.sizeCurve, 4, "#7fd0ff")); + add(group("Colour")); + add(el("label", { text: "Palette" }), rampButtons(L, renderProps)); + add(el("label", { text: "Colour over life" }), gradientEditor(L.colors)); + add(el("label", { text: "Alpha over life" }), curveEditor(L.alpha, 1, "#e9b43c")); + add(group("Attach (hero)")); + const bones = window.MDL?.heroBones?.() || []; + const boneIn = el("input", { type: "text", value: L.bone, list: "fxl-bones", spellcheck: "false", title: "Bone the layer follows on the hero (none = at the hero's feet / hit point)", onchange: (e) => { L.bone = e.target.value.trim() || "none"; renderLayers(); changed(); } }); + add(el("label", { text: "Bone" }), el("div", { class: "fx-resource-field" }, [boneIn, el("datalist", { id: "fxl-bones" }, ["none", "Bip01", "Bip01 R Hand", "Bip01 L Hand", "Bip01 Head", "Bip01 Spine", "Bip01 R Foot", "Bip01 L Foot", ...bones].filter((v, i, a) => a.indexOf(v) === i).map((b) => el("option", { value: b })))])); + add(selRow("Follow", L, "follow", [["none", "Leave trail (world)"], ["pos_only", "Follow position"], ["all", "Follow position + rotation"]])); + add(vecRow("Bone offset", L, "offset")); + box.replaceChildren(...rows); + } + + // ---------- actions ---------- + $("fxl-search").oninput = renderPresets; + $("fxl-new").onclick = () => load({ name: "my_effect", layers: [layer({ name: "Layer 1" })] }); + $("fxl-add-layer").onclick = () => { FXL.preset.layers.push(layer({ name: `Layer ${FXL.preset.layers.length + 1}` })); FXL.sel = FXL.preset.layers.length - 1; renderLayers(); renderProps(); rebuild(); }; + $("fxl-dup-layer").onclick = () => { const L = selLayer(); if (!L) return; FXL.preset.layers.splice(FXL.sel + 1, 0, { ...clone(L), name: `${L.name} copy` }); FXL.sel++; renderLayers(); renderProps(); rebuild(); }; + $("fxl-up-layer").onclick = () => { const i = FXL.sel; if (i < 1) return; const ls = FXL.preset.layers; [ls[i - 1], ls[i]] = [ls[i], ls[i - 1]]; FXL.sel--; renderLayers(); rebuild(); }; + $("fxl-del-layer").onclick = () => { if (!selLayer()) return; FXL.preset.layers.splice(FXL.sel, 1); FXL.sel = Math.max(0, FXL.sel - 1); renderLayers(); renderProps(); rebuild(); }; + $("fxl-play").onclick = () => { FXL.playing = !FXL.playing; $("fxl-play").classList.toggle("on", FXL.playing); if (FXL.playing) restart(); }; + $("fxl-restart").onclick = restart; + $("fxl-grid").onclick = () => { $("fxl-grid").classList.toggle("on"); drawGrid(); }; + $("fxl-reset").onclick = () => (FXL.mode === "free" ? view?.reset() : MDL.view?.reset()); + $("fxl-bg").onchange = () => { if (view) { view.env = { clear: BG[$("fxl-bg").value] }; view.dirty = true; } }; + $("fxl-mode").onchange = () => setMode($("fxl-mode").value); + $("fxl-anim").onchange = restart; + $("fxl-save-preset").onclick = async () => { + const p = currentPreset(); + try { await call("fxlab:savePreset", p); FXL.preset.name = p.name; toast(`Saved preset ${p.name}`); loadUser(); } + catch (err) { toast(err.message, true); } + }; + const importJson = async () => { + try { + const text = await call("fxlab:openJson"); + if (!text) return; + const p = JSON.parse(text); + if (!Array.isArray(p.layers)) throw new Error("Not an FX Lab preset (no layers)"); + load(p); + } catch (err) { toast(err.message, true); } + }; + function renderNotes() { + const box = $("fxl-notes"); + box.hidden = !FXL.notes?.length; + box.replaceChildren(el("summary", { class: "muted", text: `Notes (${FXL.notes?.length || 0})` }), el("ul", {}, (FXL.notes || []).map((n) => el("li", { text: n })))); + } + + // ---------- existing Lost Saga effects ---------- + const bytesToLatin1 = (bytes) => { let s = ""; for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192)); return s; }; + /** Open a Lost Saga effect script for editing. where: assets.writeBack target ({ kind: "file", file } or a pack entry). */ + function openScript(text, name, where = null) { + const { preset, notes } = fromScript(text, name); + if (!preset.layers.length && !preset.passthrough.length) throw new Error(`${name}: no particle blocks`); + preset.notes = notes; + preset.mode = preset.layers.some((l) => l.bone !== "none") ? "bone" : FXL.mode; + load(preset); + FXL.where = where; + FXL.whereName = name; + FXL.savedText = toScript(FXL.preset); + updateSave(); + toast(`Opened ${name}: ${preset.layers.length} particle layer${preset.layers.length === 1 ? "" : "s"}${notes.length ? ` · ${notes.length} note${notes.length === 1 ? "" : "s"}` : ""}`); + } + FXL.openScript = openScript; + function updateSave() { + $("fxl-save").hidden = !FXL.where; + $("fxl-save-label").textContent = FXL.where ? `Save ${FXL.whereName || ""}` : "Save"; + } + async function openFromPacks() { + if (!FXL.src) { await chooseSource(); if (!FXL.src) return; } + let rows; + try { + rows = (await call("fx:list", FXL.src)).filter(([, p]) => /(^|\/)resource\/effect\/.+\.txt$/i.test(p)).map(([key, p, pack]) => ({ key, name: p.replace(/^.*?resource\/effect\//i, ""), pack: pack || "" })); + } catch (err) { return toast(err.message, true); } + const search = el("input", { type: "search", placeholder: `Search ${rows.length.toLocaleString()} effects`, spellcheck: "false" }); + const listBox = el("div", { class: "fx-resource-list" }); + let choice = null; + const fill = () => { + const q = search.value.trim().toLowerCase(); + const hits = rows.filter((r) => !q || r.name.toLowerCase().includes(q)).slice(0, 400); + listBox.replaceChildren(...hits.map((r) => el("button", { class: `fx-resource${choice === r ? " on" : ""}`, text: r.name, title: r.pack, onclick: () => { choice = r; fill(); }, ondblclick: () => { choice = r; $("modal-actions").lastChild.click(); } }))); + }; + search.oninput = fill; fill(); + if (!(await modal("Open Lost Saga effect", [search, listBox], [{ label: "Cancel", value: false }, { label: "Open", kind: "primary", value: true }])) || !choice) return; + try { + const hit = await call("mdl:readKey", FXL.src, choice.key); + openScript(bytesToLatin1(hit.bytes), choice.name.split("/").pop(), hit.where); + } catch (err) { toast(err.message, true); } + } + async function openFile() { + const file = await call("fx:openFile"); + if (!file) return; + try { + const hit = await call("mdl:readFile", file); + openScript(bytesToLatin1(hit.bytes), file.replace(/^.*[\\/]/, ""), hit.where || { kind: "file", file }); + } catch (err) { toast(err.message, true); } + } + $("fxl-open").onclick = (e) => showMenu([ + { label: "Lost Saga effect from packs…", icon: "box", run: openFromPacks }, + { label: "Lost Saga effect file (.txt)…", icon: "folder", run: openFile }, + "-", + { label: "FX Lab preset (.json)…", icon: "import", run: importJson }, + ], { anchor: e.currentTarget }); + $("fxl-save").onclick = async () => { + if (!FXL.where || !FXL.preset) return; + const text = toScript(FXL.preset); + if ([...text].some((c) => c.charCodeAt(0) > 255)) return toast("The script has characters outside the 8-bit encoding; use ASCII names.", true); + const custom = [...new Set(FXL.preset.layers.filter((l) => l.on && l.tex.kind !== "pack").map((l) => texOf(l).file))]; + const inPack = FXL.where.kind !== "file"; + const msg = [`Overwrite ${FXL.whereName} ${inPack ? "inside its game pack" : "on disk"} with the edited script?`]; + if (custom.length) msg.push(`These textures are not in the packs and are not written by Save: ${custom.join(", ")}. Use Export LS effect for them.`); + if (!(await confirmBox("Save effect", msg, "Save"))) return; + try { + await call("mdl:writeBack", FXL.where, latin1(text)); + FXL.savedText = text; + if (inPack && FXL.src) await call("mdl:reload", FXL.src).catch(() => {}); + toast(`Saved ${FXL.whereName}`); + } catch (err) { toast(err.message, true); } + }; + $("fx-to-lab").onclick = () => { + const fx = window.FXE; + if (!fx?.text) return toast("Open an effect in the FX tab first.", true); + if (fx.src && !FXL.src) setSource(fx.src); + document.querySelector('[data-view="fxlab"]').click(); + try { openScript(fx.text, fx.name || "effect.txt", fx.where || null); } catch (err) { toast(err.message, true); } + }; + $("fxl-export-json").onclick = async () => { + const p = currentPreset(); + try { const f = await call("fxlab:saveJson", `${safeName(p.name)}.json`, JSON.stringify(p, null, 2)); if (f) toast(`Saved ${f}`); } + catch (err) { toast(err.message, true); } + }; + + /** Files of the LS export: resource/effect/.txt + resource/texture/EffectTexture/.dds (not for pack textures). */ + function exportFiles(name) { + const files = [{ path: `resource/effect/${name}.txt`, b64: b64(latin1(toScript(FXL.preset))) }]; + const done = new Set(); + for (const L of FXL.preset.layers.filter((l) => l.on)) { + const t = texOf(L); + if (t.pack || done.has(t.file)) continue; + done.add(t.file); + const c = t.image ? imageCache.get(t.image) : sprite(t.shape); + if (!c) throw new Error(`Image for ${L.name} is still loading`); + const px = c.getContext("2d").getImageData(0, 0, c.width, c.height).data; + const dds = TexKit.encodeDds(new Uint8Array(px.buffer.slice(0)), c.width, c.height, { format: "DXT5", mips: true }); + files.push({ path: `resource/texture/${t.path}`, b64: b64(dds.bytes) }); + } + return files; + } + FXL.exportFiles = exportFiles; + FXL.exportTo = async (folder) => call("fxlab:writeFiles", folder, exportFiles(safeName($("fxl-name").value))); + $("fxl-export-ls").onclick = async () => { + if (!FXL.preset?.layers.some((l) => l.on)) return toast("Nothing to export: no visible layer.", true); + const folder = await call("dialog:folder", "Export folder (resource/effect + resource/texture are created inside)"); + if (!folder) return; + try { + const written = await FXL.exportTo(folder); + toast(`Exported ${written.length} files. Add the resource folder to a pack with IOP Manager.`); + call("shell:show", written[0]).catch(() => {}); + } catch (err) { toast(err.message, true); } + }; + + // ---------- sprite sheet (pixelfx-style: PNG grid + Aseprite JSON) ---------- + let sheetView = null; + function ensureSheetView(w, h) { + if (!sheetView) { + const host = el("div", { class: "fxl-offscreen" }); + const c = el("canvas", {}); + host.append(c); document.body.append(host); + sheetView = new GL3D.View(c, { alpha: false }); + sheetView.opts.lit = false; + sheetView.host = host; + } + sheetView.host.style.width = `${w / (devicePixelRatio || 1)}px`; + sheetView.host.style.height = `${h / (devicePixelRatio || 1)}px`; + sheetView.canvas.width = w; sheetView.canvas.height = h; + return sheetView; + } + async function renderSheet({ frames = 24, fps = 24, size = 256, cols = 6, transparent = true, prewarm = true } = {}) { + if (!ensureView()) throw new Error("No preview"); + const sv = ensureSheetView(size, size); + sv.cam = { ...view.cam, target: view.cam.target.slice() }; + sv.env = { clear: [0, 0, 0] }; + const sheetKit = MeshKit.create(sv, () => FXL.src); + const def = Effects.parse(toScript(FXL.preset)); + const names = [...new Set(def.particles.map((p) => (p.attrs.texture || "").split(/[\\/]/).pop()))]; + const texMap = new Map(); + for (const n of names) { + const custom = customTexture(n, sv); + if (custom) texMap.set(n, custom); + else if (FXL.src) { const t = await sheetKit.texture(n); if (t.tex) texMap.set(n, t.tex); } + } + const one = FXL.preset.layers.filter((l) => l.on).every((l) => l.duration > 0); + const it = Effects.instance(def, [0, 0, 0]); + it.heroPreview = true; // no auto restart while rendering + const stepMs = (ms) => { while (ms > 0) { const d = Math.min(16, ms); Effects.update(it, d); ms -= d; } }; + stepMs(prewarm && !one ? 1500 : 1000 / fps); + const rows = Math.ceil(frames / cols); + const sheet = el("canvas", { width: cols * size, height: rows * size }); + const ctx = sheet.getContext("2d"); + const json = { frames: {}, meta: { app: "LS Tools FX Lab", version: "1", image: "", format: "RGBA8888", size: { w: sheet.width, h: sheet.height }, scale: "1", frameTags: [{ name: FXL.preset.name, from: 0, to: frames - 1, direction: "forward" }], fxlab: { fps, loop: !one, columns: cols, pivot: { x: 0.5, y: 0.5 } } } }; + for (let f = 0; f < frames; f++) { + if (f) stepMs(1000 / fps); + const parts = Effects.build(it, sv.cameraAxes()); + for (const p of parts) p.tex = texMap.get(p.texture) || null; + sv.setParticles(parts); + sv.draw(); + const x = (f % cols) * size, y = Math.floor(f / cols) * size; + ctx.drawImage(sv.canvas, 0, 0, size, size, x, y, size, size); + json.frames[`${safeName(FXL.preset.name)} ${f}.png`] = { frame: { x, y, w: size, h: size }, rotated: false, trimmed: false, spriteSourceSize: { x: 0, y: 0, w: size, h: size }, sourceSize: { w: size, h: size }, duration: Math.round(1000 / fps) }; + } + if (transparent) { + // rendered on black: brightness becomes alpha (exact for additive layers) + const img = ctx.getImageData(0, 0, sheet.width, sheet.height), d = img.data; + for (let i = 0; i < d.length; i += 4) { + const a = Math.max(d[i], d[i + 1], d[i + 2]); + if (a) { d[i] = Math.min(255, (d[i] * 255) / a); d[i + 1] = Math.min(255, (d[i + 1] * 255) / a); d[i + 2] = Math.min(255, (d[i + 2] * 255) / a); } + d[i + 3] = a; + } + ctx.putImageData(img, 0, 0); + } + return { canvas: sheet, json }; + } + FXL.renderSheet = renderSheet; + $("fxl-export-sheet").onclick = async () => { + if (!FXL.preset) return; + const inputs = { frames: el("input", { type: "number", value: 24, min: 1, max: 120 }), fps: el("input", { type: "number", value: 24, min: 1, max: 60 }), size: el("select", {}, [64, 128, 192, 256, 384, 512].map((n) => el("option", { value: n, text: `${n} px`, selected: n === 256 }))), cols: el("input", { type: "number", value: 6, min: 1, max: 32 }), transparent: el("input", { type: "checkbox", checked: true }) }; + const body = el("div", { class: "fxl-form" }, [ + el("p", { class: "muted", text: "Renders the free preview from the current camera angle." }), + el("label", {}, ["Frames ", inputs.frames]), el("label", {}, ["FPS ", inputs.fps]), el("label", {}, ["Frame size ", inputs.size]), + el("label", {}, ["Columns ", inputs.cols]), el("label", {}, [inputs.transparent, " Transparent background (glow → alpha)"]), + ]); + if (!(await modal("Sprite sheet", body, [{ label: "Cancel", value: false }, { label: "Render…", kind: "primary", value: true }]))) return; + const file = await call("dialog:savePng", `${safeName($("fxl-name").value)}_sheet.png`); + if (!file) return; + try { + const { canvas, json } = await renderSheet({ frames: Number(inputs.frames.value) || 24, fps: Number(inputs.fps.value) || 24, size: Number(inputs.size.value) || 256, cols: Number(inputs.cols.value) || 6, transparent: inputs.transparent.checked }); + json.meta.image = file.split(/[\\/]/).pop(); + await call("file:writeBase64", file, canvas.toDataURL("image/png").split(",")[1]); + await call("file:writeBase64", file.replace(/\.png$/i, "") + ".json", btoa(unescape(encodeURIComponent(JSON.stringify(json, null, 2))))); + toast(`Saved ${json.meta.image} + .json`); + } catch (err) { toast(err.message, true); } + }; + + // ---------- source ---------- + async function chooseSource() { + const options = [...profileSourceOption(), { value: "packs", title: "Folder with .iop packs…", sub: "Game client packs" }, { value: "dir", title: "Extracted resource folder…", sub: "Folder containing resource/" }]; + let choice = options[0]?.value; + const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [ + el("input", { type: "radio", name: "fxlsrc", checked: i === 0, onchange: () => { choice = o.value; } }), + el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), + ]))); + if (!(await modal("FX Lab source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with .iop packs" : "Extracted resources"); + if (!folder) return; + choice = { kind: choice, folder }; + } + await setSource(choice); + } + async function setSource(src) { + try { + const d = await call("mdl:describe", src); + FXL.src = src; animNames = null; packTex.clear(); kit?.clearTextures?.(); + $("fxl-src-label").textContent = src.kind === "dir" ? "Extracted folder" : "Packs"; + $("fxl-src").title = d.label; + rebuild(); + } catch (err) { toast(err.message, true); } + } + FXL.setSource = setSource; + FXL.setMode = setMode; + FXL.load = load; + FXL.builtin = BUILTIN; + $("fxl-src").onclick = chooseSource; + document.querySelector('[data-view="fxlab"]').addEventListener("click", () => { + if (!FXL.src && profileSource()) setSource(profileSource()); + if (FXL.mode === "free") { ensureView(); view?.resize(); restart(); } + else setMode(FXL.mode); // re-borrow the hero viewport (the Hero tab may have taken it back) + }); + + load(BUILTIN[0]); + loadUser(); +})(); diff --git a/app/public/legacy/js/gl3d.js b/app/public/legacy/js/gl3d.js new file mode 100644 index 0000000..d076ea1 --- /dev/null +++ b/app/public/legacy/js/gl3d.js @@ -0,0 +1,666 @@ +"use strict"; + +// Small WebGL2 viewer for the model and map tools: textured meshes (positions can be updated +// every frame for CPU skinning), per-mesh position/scale, flat-coloured meshes (collision), +// wireframe, line / point overlays (grid, bones, boxes, markers), map lighting and fog, an orbit +// camera and picking rays. +// The game data is Direct3D left-handed (Y up); the scene is drawn through a Z mirror so it looks +// the same as in the client. Culling is off (many game materials are two-sided). + +const GL3D = (() => { + // ---------- column-major mat4 (GL convention) ---------- + const M = { + mul(a, b) { + const o = new Float32Array(16); + for (let c = 0; c < 4; c++) + for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]; + return o; + }, + persp(fovy, aspect, near, far) { + const f = 1 / Math.tan(fovy / 2); + const o = new Float32Array(16); + o[0] = f / aspect; + o[5] = f; + o[10] = (far + near) / (near - far); + o[11] = -1; + o[14] = (2 * far * near) / (near - far); + return o; + }, + lookAt(eye, at, up) { + const z = norm(sub(eye, at)); + const x = norm(cross(up, z)); + const y = cross(z, x); + return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]); + }, + }; + const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + const norm = (a) => { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; + }; + const MIRROR = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1]); // D3D LH → GL RH + const IDENT3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + /** Mirror · translate · rotate · scale, for a mesh placed at pos (game coordinates). */ + const placed = (pos, scale, rotation = [0, 0, 0]) => { + const [x, y, z] = rotation.map((v) => (v * Math.PI) / 180); + const cx = Math.cos(x), sx = Math.sin(x), cy = Math.cos(y), sy = Math.sin(y), cz = Math.cos(z), sz = Math.sin(z); + const rx = new Float32Array([1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1]); + const ry = new Float32Array([cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1]); + const rz = new Float32Array([cz, sz, 0, 0, -sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + const r = M.mul(ry, M.mul(rx, rz)); + const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]); + const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]); + return M.mul(t, M.mul(MIRROR, M.mul(r, s))); + }; + const placedQuaternion = (pos, scale, rotation) => { + const length = Math.hypot(...rotation) || 1; + const [x, y, z, w] = rotation.map((v) => v / length); + const r = new Float32Array([ + 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0, + 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0, + 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0, + 0, 0, 0, 1, + ]); + const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]); + const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]); + return M.mul(t, M.mul(MIRROR, M.mul(r, s))); + }; + + const MESH_VS = `#version 300 es + layout(location=0) in vec3 aPos; layout(location=1) in vec3 aNrm; + layout(location=2) in vec2 aUv0; layout(location=3) in vec2 aUv1; + uniform mat4 uVP; uniform mat4 uModel; + out vec2 vUv0; out vec2 vUv1; out vec3 vN; out vec3 vW; + void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vN = mat3(uModel) * aNrm; vUv0 = aUv0; vUv1 = aUv1; gl_Position = uVP * w; }`; + const MESH_FS = `#version 300 es + precision highp float; + in vec2 vUv0; in vec2 vUv1; in vec3 vN; in vec3 vW; + uniform sampler2D uTex; uniform sampler2D uLm; uniform int uHasTex; uniform int uHasLm; uniform int uUvSet; uniform int uLmSet; + uniform int uLit; uniform vec3 uEye; uniform vec4 uTint; uniform float uAlphaCut; uniform int uHasNrm; uniform int uOpaque; + uniform int uFlat; uniform vec4 uColor; uniform mat3 uTexM; uniform mat3 uLmM; uniform float uColorMul; uniform float uEmit; + uniform int uLmAddDiffuse; uniform vec3 uDiffuse; + uniform int uEnv; uniform vec3 uAmbient; uniform vec3 uLightCol; uniform vec3 uLightDir; + uniform int uFog; uniform vec3 uFogCol; uniform vec2 uFogRange; + out vec4 o; + vec3 fogged(vec3 c){ + if (uFog==0) return c; + float f = clamp((distance(vW, uEye) - uFogRange.x) / max(1.0, uFogRange.y - uFogRange.x), 0.0, 1.0); + return mix(c, uFogCol, f); + } + void main(){ + vec3 n = uHasNrm==1 ? normalize(vN) : normalize(cross(dFdx(vW), dFdy(vW))); + if (uFlat==1) { + float d = abs(dot(n, normalize(vec3(0.3, 1.0, 0.2)))); + o = vec4(uColor.rgb * (0.55 + 0.45 * d), uColor.a); + return; + } + vec2 tuv = (vec3(uUvSet==1 ? vUv1 : vUv0, 1.0) * uTexM).xy; + vec4 c = uHasTex==1 ? texture(uTex, tuv) : vec4(0.72,0.70,0.76,1.0); + if (c.a < uAlphaCut) discard; + float a = uOpaque==1 ? 1.0 : c.a; + vec3 emit = c.rgb * uEmit; + c.rgb *= uColorMul; + if (uHasLm==1) { + vec3 lm = texture(uLm, (vec3(uLmSet==1 ? vUv1 : vUv0, 1.0) * uLmM).xy).rgb; + c.rgb *= uLmAddDiffuse==1 ? min(vec3(1.0), lm + uDiffuse) : lm; + o = vec4(fogged(c.rgb + emit), a) * uTint; return; + } + vec3 light; + if (uEnv==1) { + float d = max(dot(n, -uLightDir), 0.0); + light = uAmbient + uLightCol * (0.35 + 0.65 * d); + } else { + vec3 l = normalize(uEye - vW + vec3(0.0, 60.0, 0.0)); + light = vec3(uLit==1 ? 0.45 + 0.65 * abs(dot(n, l)) : 1.0); + } + o = vec4(fogged(c.rgb * light + emit), a) * uTint; + }`; + const LINE_VS = `#version 300 es + layout(location=0) in vec3 aPos; layout(location=1) in vec4 aCol; + uniform mat4 uVP; uniform mat4 uModel; uniform float uPoint; + out vec4 vCol; + void main(){ vCol = aCol; gl_Position = uVP * uModel * vec4(aPos,1.0); gl_PointSize = uPoint; }`; + const LINE_FS = `#version 300 es + precision mediump float; in vec4 vCol; uniform int uRound; out vec4 o; + void main(){ + if (uRound==1) { float r = length(gl_PointCoord-vec2(0.5)); if (r>0.5) discard; o = vec4(r > 0.36 ? vCol.rgb*0.35 : vCol.rgb, vCol.a); return; } + o = vCol; + }`; + + const PART_VS = `#version 300 es + layout(location=0) in vec3 aPos; layout(location=1) in vec2 aUv; layout(location=2) in vec4 aCol; + uniform mat4 uVP; uniform mat4 uModel; + out vec2 vUv; out vec4 vCol; out vec3 vW; + void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vUv = aUv; vCol = aCol; gl_Position = uVP * w; }`; + const PART_FS = `#version 300 es + precision mediump float; + in vec2 vUv; in vec4 vCol; in vec3 vW; + uniform sampler2D uTex; uniform int uHasTex; uniform float uMod; + out vec4 o; + void main(){ + vec4 c = (uHasTex==1 ? texture(uTex, vUv) : vec4(1.0)) * vec4(vCol.rgb * uMod, vCol.a); + if (c.a <= 1.0/255.0) discard; + o = c; + }`; + + function program(gl, vs, fs) { + const p = gl.createProgram(); + for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) { + const s = gl.createShader(type); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); + gl.attachShader(p, s); + } + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p)); + const u = {}; + const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); + for (let i = 0; i < n; i++) { + const name = gl.getActiveUniform(p, i).name; + u[name] = gl.getUniformLocation(p, name); + } + return { p, u }; + } + + class View { + constructor(canvas, options = {}) { + this.canvas = canvas; + const gl = (this.gl = canvas.getContext("webgl2", { antialias: true, preserveDrawingBuffer: true, alpha: options.alpha !== false, premultipliedAlpha: !!options.premultipliedAlpha })); + if (!gl) throw new Error("WebGL2 isn't available"); + this.meshProg = program(gl, MESH_VS, MESH_FS); + this.lineProg = program(gl, LINE_VS, LINE_FS); + this.partProg = program(gl, PART_VS, PART_FS); + this.particles = []; // [{ tex, blend, depth, mod2x, data, count }] + this.partVao = gl.createVertexArray(); + this.partBuf = gl.createBuffer(); + gl.bindVertexArray(this.partVao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 36, 0); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 36, 12); + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 36, 20); + gl.bindVertexArray(null); + this.meshes = new Map(); // id → gpu mesh + this.overlays = new Map(); // name → { vao, count, mode, depth, point } + this.opts = { textures: true, wire: false, lit: true, solid: true }; + /** Map lighting: { ambient:[r,g,b], lightColor:[r,g,b], lightDir:[x,y,z] (game coords), fog:{color,start,end}|null, clear:[r,g,b]|null } */ + this.env = null; + this.cam = { yaw: 0.6, pitch: 0.35, dist: 300, target: [0, 80, 0], fov: 0.8 }; + this.white = this.texture(new ImageData(new Uint8ClampedArray([255, 255, 255, 255]), 1, 1)); + this.onPointerDown = null; // (e) → true when the tool takes the drag (no orbit) + this.beforeDraw = null; + this.onCamera = null; + this.dirty = true; + this.controls(); + new ResizeObserver(() => this.resize()).observe(canvas.parentElement); + const loop = () => { + if (this.dirty && this.canvas.offsetParent) this.draw(); + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + } + + resize() { + const r = this.canvas.parentElement.getBoundingClientRect(); + const dpr = devicePixelRatio || 1; + this.canvas.width = Math.max(1, Math.round(r.width * dpr)); + this.canvas.height = Math.max(1, Math.round(r.height * dpr)); + this.canvas.style.width = `${r.width}px`; + this.canvas.style.height = `${r.height}px`; + this.dirty = true; + } + + // ---------- resources ---------- + texture(img) { + const gl = this.gl; + const t = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, t); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + const pow2 = (n) => (n & (n - 1)) === 0; + if (pow2(img.width) && pow2(img.height)) { + gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + } else gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); + return t; + } + + /** + * mesh: { pos, normal?, uv0?, uv1?, indices, groups: [{ start, count, tex?, lmTex?, uvSet, lmSet, alphaCut, blend, color? }] } + * opts: { pos, scale } places it (map objects); { wireColor } draws its wireframe always. + */ + addMesh(id, m, opts = {}) { + const gl = this.gl; + this.removeMesh(id); + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + const bufs = []; + const buf = (loc, data, size, usage = gl.STATIC_DRAW) => { + const b = gl.createBuffer(); + bufs.push(b); + gl.bindBuffer(gl.ARRAY_BUFFER, b); + gl.bufferData(gl.ARRAY_BUFFER, data, usage); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0); + return b; + }; + const V = m.pos.length / 3; + const g = { vao, bufs, groups: m.groups, visible: true, hasNormal: !!m.normal, model: MIRROR, wireColor: opts.wireColor || null }; + if (opts.pos || opts.scale || opts.rotation) g.model = placed(opts.pos || [0, 0, 0], opts.scale || [1, 1, 1], opts.rotation || [0, 0, 0]); + g.pos = buf(0, m.pos, 3, gl.DYNAMIC_DRAW); + g.nrm = buf(1, m.normal || new Float32Array(V * 3), 3, gl.DYNAMIC_DRAW); + buf(2, m.uv0 || new Float32Array(V * 2), 2); + buf(3, m.uv1 || m.uv0 || new Float32Array(V * 2), 2); + const idx = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices); + g.ibo = gl.createBuffer(); + bufs.push(g.ibo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.ibo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW); + gl.bindVertexArray(null); + // wireframe: every triangle edge once + const seen = new Set(); + const edges = []; + for (let i = 0; i + 2 < idx.length; i += 3) { + for (const [a, b] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) { + const k = a < b ? a * 4194304 + b : b * 4194304 + a; + if (!seen.has(k)) { + seen.add(k); + edges.push(a, b); + } + } + } + g.wvao = gl.createVertexArray(); + gl.bindVertexArray(g.wvao); + gl.bindBuffer(gl.ARRAY_BUFFER, g.pos); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); + gl.disableVertexAttribArray(1); + g.wibo = gl.createBuffer(); + bufs.push(g.wibo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.wibo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(edges), gl.STATIC_DRAW); + g.wcount = edges.length; + gl.bindVertexArray(null); + this.meshes.set(id, g); + this.dirty = true; + } + + setTransform(id, pos, scale = [1, 1, 1], rotation = [0, 0, 0]) { + const g = this.meshes.get(id); + if (g) g.model = placed(pos, scale, rotation); + this.dirty = true; + } + setTransformQuaternion(id, pos, scale, rotation) { + const g = this.meshes.get(id); + if (g) g.model = placedQuaternion(pos, scale, rotation); + this.dirty = true; + } + + updateMesh(id, pos, normal) { + const g = this.meshes.get(id); + if (!g) return; + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, g.pos); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, pos); + if (normal) { + gl.bindBuffer(gl.ARRAY_BUFFER, g.nrm); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, normal); + } + this.dirty = true; + } + + setGroupTexture(id, gi, tex, slot = "tex") { + const g = this.meshes.get(id); + if (g?.groups[gi]) g.groups[gi][slot] = tex; + this.dirty = true; + } + setVisible(id, v) { + const g = this.meshes.get(id); + if (g) g.visible = v; + this.dirty = true; + } + setTint(id, tint) { + const g = this.meshes.get(id); + if (g) g.tint = tint; + this.dirty = true; + } + setHighlight(id, on) { + const g = this.meshes.get(id); + if (g) g.highlight = on; + this.dirty = true; + } + + removeMesh(id) { + const g = this.meshes.get(id); + if (!g) return; + const gl = this.gl; + gl.deleteVertexArray(g.vao); + gl.deleteVertexArray(g.wvao); + for (const b of g.bufs) gl.deleteBuffer(b); + this.meshes.delete(id); + this.dirty = true; + } + clear() { + for (const id of [...this.meshes.keys()]) this.removeMesh(id); + for (const n of [...this.overlays.keys()]) this.setOverlay(n, null); + } + + /** Line / point overlay: data = Float32Array of x,y,z,r,g,b,a per vertex. */ + setOverlay(name, data, { mode = "lines", depth = true, point = 6 } = {}) { + const gl = this.gl; + const old = this.overlays.get(name); + const visible = old ? old.visible : true; + if (old) { + gl.deleteVertexArray(old.vao); + gl.deleteBuffer(old.buf); + this.overlays.delete(name); + } + this.dirty = true; + if (!data || !data.length) return; + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 28, 0); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 28, 12); + gl.bindVertexArray(null); + this.overlays.set(name, { vao, buf, count: data.length / 7, mode, depth, point, visible }); + } + showOverlay(name, v) { + const o = this.overlays.get(name); + if (o) o.visible = v; + this.dirty = true; + } + + // ---------- camera ---------- + frame(min, max) { + const c = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]; + const r = Math.max(1, Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2); + this.cam.target = [c[0], c[1], -c[2]]; + this.cam.dist = (r / Math.sin(this.cam.fov / 2)) * 1.05; + this.cam.radius = r; + this.home = { ...this.cam, target: this.cam.target.slice() }; + this.moved(); + } + reset() { + if (this.home) this.cam = { ...this.home, target: this.home.target.slice() }; + this.moved(); + } + /** Straight down (for placing things on the floor plan). */ + topView() { + this.cam.pitch = 1.5; + this.cam.yaw = 0; + this.moved(); + } + moved() { + this.dirty = true; + this.onCamera?.(); + } + eye() { + const c = this.cam; + return [c.target[0] + c.dist * Math.cos(c.pitch) * Math.sin(c.yaw), c.target[1] + c.dist * Math.sin(c.pitch), c.target[2] + c.dist * Math.cos(c.pitch) * Math.cos(c.yaw)]; + } + viewProj() { + const aspect = this.canvas.width / Math.max(1, this.canvas.height); + const r = this.cam.radius || 100; + const near = Math.max(0.05, this.cam.dist - r * 20) * 0.02 + 0.05; + const far = this.cam.dist + r * 40 + 1000; + return M.mul(M.persp(this.cam.fov, aspect, near, far), M.lookAt(this.eye(), this.cam.target, [0, 1, 0])); + } + + /** Camera right / up / forward in game coordinates (for billboards). */ + cameraAxes() { + const eye = this.eye(); + const f = norm(sub(this.cam.target, eye)); + const r = norm(cross(f, [0, 1, 0])); + const u = cross(r, f); + const g = (v) => [v[0], v[1], -v[2]]; + return { right: g(r), up: g(u), dir: g(f) }; + } + /** Particle quads to draw this frame (effects). */ + setParticles(list) { + this.particles = list || []; + this.dirty = true; + } + + /** Picking ray through a screen point (client px), in game coordinates. → { o, d } */ + ray(clientX, clientY) { + const b = this.canvas.getBoundingClientRect(); + const nx = ((clientX - b.left) / b.width) * 2 - 1; + const ny = 1 - ((clientY - b.top) / b.height) * 2; + const eye = this.eye(); + const f = norm(sub(this.cam.target, eye)); + const r = norm(cross(f, [0, 1, 0])); + const u = cross(r, f); + const t = Math.tan(this.cam.fov / 2); + const aspect = b.width / Math.max(1, b.height); + const d = norm([f[0] + nx * t * aspect * r[0] + ny * t * u[0], f[1] + nx * t * aspect * r[1] + ny * t * u[1], f[2] + nx * t * aspect * r[2] + ny * t * u[2]]); + return { o: [eye[0], eye[1], -eye[2]], d: [d[0], d[1], -d[2]] }; + } + + controls() { + const c = this.canvas; + let drag = null; + c.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (this.onPointerDown?.(e)) return; + drag = { x: e.clientX, y: e.clientY, pan: e.button !== 0 || e.shiftKey }; + }); + c.addEventListener("contextmenu", (e) => e.preventDefault()); + window.addEventListener("mouseup", () => (drag = null)); + window.addEventListener("mousemove", (e) => { + if (!drag) return; + const dx = e.clientX - drag.x; + const dy = e.clientY - drag.y; + drag.x = e.clientX; + drag.y = e.clientY; + if (drag.pan) { + const s = (this.cam.dist * Math.tan(this.cam.fov / 2) * 2) / c.clientHeight; + const yaw = this.cam.yaw; + const right = [Math.cos(yaw), 0, -Math.sin(yaw)]; + const up = [-Math.sin(this.cam.pitch) * Math.sin(yaw), Math.cos(this.cam.pitch), -Math.sin(this.cam.pitch) * Math.cos(yaw)]; + for (let i = 0; i < 3; i++) this.cam.target[i] += -dx * s * right[i] + dy * s * up[i]; + } else { + this.cam.yaw -= dx * 0.008; + this.cam.pitch = Math.max(-1.55, Math.min(1.55, this.cam.pitch + dy * 0.008)); + } + this.moved(); + }); + c.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + this.cam.dist = Math.max(0.5, this.cam.dist * (e.deltaY < 0 ? 0.88 : 1 / 0.88)); + this.moved(); + }, + { passive: false }, + ); + c.addEventListener("dblclick", (e) => !this.onPointerDown && this.reset()); + } + + // ---------- drawing ---------- + draw() { + const gl = this.gl; + this.dirty = false; + this.beforeDraw?.(); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + const env = this.env; + if (env?.clear) gl.clearColor(env.clear[0], env.clear[1], env.clear[2], 1); + else gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.enable(gl.DEPTH_TEST); + gl.disable(gl.CULL_FACE); + const vp = this.viewProj(); + const eye = this.eye(); + // meshes: opaque first, then blended + const mp = this.meshProg; + gl.useProgram(mp.p); + gl.uniformMatrix4fv(mp.u.uVP, false, vp); + gl.uniform3fv(mp.u.uEye, eye); + gl.uniform1i(mp.u.uLit, this.opts.lit ? 1 : 0); + gl.uniform1i(mp.u.uTex, 0); + gl.uniform1i(mp.u.uLm, 1); + gl.uniform1i(mp.u.uEnv, env?.ambient ? 1 : 0); + if (env?.ambient) { + gl.uniform3fv(mp.u.uAmbient, env.ambient); + gl.uniform3fv(mp.u.uLightCol, env.lightColor || [0.6, 0.6, 0.6]); + const d = norm(env.lightDir || [0, -1, 0]); + gl.uniform3fv(mp.u.uLightDir, [d[0], d[1], -d[2]]); + } + gl.uniform1i(mp.u.uFog, env?.fog ? 1 : 0); + if (env?.fog) { + gl.uniform3fv(mp.u.uFogCol, env.fog.color); + gl.uniform2fv(mp.u.uFogRange, [env.fog.start, env.fog.end]); + } + if (this.opts.solid) { + for (const pass of [0, 1]) { + if (pass) { + // blended sub-meshes (decals, glows, shadows): after the opaque ones, no depth writes, + // pulled slightly forward so coplanar layers don't flicker + gl.enable(gl.BLEND); + gl.depthMask(false); + gl.depthFunc(gl.LEQUAL); + gl.enable(gl.POLYGON_OFFSET_FILL); + gl.polygonOffset(-1, -2); + } + for (const g of this.meshes.values()) { + if (!g.visible) continue; + gl.bindVertexArray(g.vao); + gl.uniformMatrix4fv(mp.u.uModel, false, g.model); + gl.uniform1i(mp.u.uHasNrm, g.hasNormal ? 1 : 0); + for (const grp of g.groups) { + const blended = grp.blend || (grp.color && grp.color[3] < 1); + if (!!blended !== !!pass || !grp.count) continue; + if (pass) this.blendMode(grp.blendFunc); + if (grp.color) { + gl.uniform1i(mp.u.uFlat, 1); + gl.uniform4fv(mp.u.uColor, grp.color); + gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4); + continue; + } + gl.uniform1i(mp.u.uFlat, 0); + const tex = this.opts.textures && grp.tex; + const lm = this.opts.textures && grp.lmTex; + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, lm || this.white); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex || this.white); + gl.uniform1i(mp.u.uHasTex, tex ? 1 : 0); + gl.uniform1i(mp.u.uHasLm, lm ? 1 : 0); + gl.uniform1i(mp.u.uUvSet, grp.uvSet || 0); + gl.uniform1i(mp.u.uLmSet, grp.lmSet || 0); + gl.uniform1i(mp.u.uLmAddDiffuse, grp.lmAddDiffuse ? 1 : 0); + gl.uniform3fv(mp.u.uDiffuse, grp.diffuseColor || [1, 1, 1]); + gl.uniform1i(mp.u.uOpaque, pass ? 0 : 1); + gl.uniformMatrix3fv(mp.u.uTexM, false, grp.texM || IDENT3); + gl.uniformMatrix3fv(mp.u.uLmM, false, grp.lmM || IDENT3); + gl.uniform1f(mp.u.uColorMul, grp.colorMul ?? 1); + gl.uniform1f(mp.u.uEmit, grp.emissive || 0); + gl.uniform1f(mp.u.uAlphaCut, grp.blend ? grp.alphaCut ?? 0 : tex ? grp.alphaCut ?? 0.35 : 0); + gl.uniform4fv(mp.u.uTint, grp.highlight || g.highlight ? [1.3, 1.15, 0.7, 1] : g.tint || [1, 1, 1, 1]); + gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4); + } + } + gl.disable(gl.BLEND); + gl.depthMask(true); + gl.depthFunc(gl.LESS); + gl.disable(gl.POLYGON_OFFSET_FILL); + gl.blendEquation(gl.FUNC_ADD); + } + } + // particles: after the meshes, blended, no depth writes + if (this.particles.length) { + const pp = this.partProg; + gl.useProgram(pp.p); + gl.uniformMatrix4fv(pp.u.uVP, false, vp); + gl.uniformMatrix4fv(pp.u.uModel, false, MIRROR); + gl.uniform1i(pp.u.uTex, 0); + gl.bindVertexArray(this.partVao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf); + gl.enable(gl.BLEND); + gl.depthMask(false); + gl.activeTexture(gl.TEXTURE0); + for (const b of this.particles) { + if (!b.count) continue; + if (b.depth) gl.enable(gl.DEPTH_TEST); + else gl.disable(gl.DEPTH_TEST); + this.blendMode(b.blend); + gl.bindTexture(gl.TEXTURE_2D, b.tex || this.white); + gl.uniform1i(pp.u.uHasTex, b.tex ? 1 : 0); + gl.uniform1f(pp.u.uMod, b.mod2x ? 2 : 1); + gl.bufferData(gl.ARRAY_BUFFER, b.data, gl.STREAM_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, b.count); + } + gl.depthMask(true); + gl.enable(gl.DEPTH_TEST); + gl.disable(gl.BLEND); + gl.blendEquation(gl.FUNC_ADD); + gl.bindVertexArray(null); + } + // wireframe + overlays + const lp = this.lineProg; + gl.useProgram(lp.p); + gl.uniformMatrix4fv(lp.u.uVP, false, vp); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.uniform1i(lp.u.uRound, 0); + for (const g of this.meshes.values()) { + if (!g.visible || (!this.opts.wire && !g.wireColor)) continue; + const w = g.wireColor || [0.55, 0.85, 1, this.opts.solid ? 0.45 : 0.8]; + gl.vertexAttrib4f(1, w[0], w[1], w[2], w[3]); // constant colour (attribute 1 is off in the wire VAO) + gl.uniformMatrix4fv(lp.u.uModel, false, g.model); + gl.bindVertexArray(g.wvao); + gl.drawElements(gl.LINES, g.wcount, gl.UNSIGNED_INT, 0); + } + gl.uniformMatrix4fv(lp.u.uModel, false, MIRROR); + for (const o of this.overlays.values()) { + if (!o.visible) continue; + if (o.depth) gl.enable(gl.DEPTH_TEST); + else gl.disable(gl.DEPTH_TEST); + gl.uniform1f(lp.u.uPoint, o.point * (devicePixelRatio || 1)); + gl.uniform1i(lp.u.uRound, o.mode === "points" ? 1 : 0); + gl.bindVertexArray(o.vao); + gl.drawArrays(o.mode === "points" ? gl.POINTS : gl.LINES, 0, o.count); + } + gl.disable(gl.BLEND); + gl.enable(gl.DEPTH_TEST); + gl.bindVertexArray(null); + } + + /** Material scene_blend { op, src, dst } → GL state. Default: alpha blending. */ + blendMode(b) { + const gl = this.gl; + const F = { zero: gl.ZERO, one: gl.ONE, src_color: gl.SRC_COLOR, inv_src_color: gl.ONE_MINUS_SRC_COLOR, src_alpha: gl.SRC_ALPHA, inv_src_alpha: gl.ONE_MINUS_SRC_ALPHA, dest_alpha: gl.DST_ALPHA, inv_dest_alpha: gl.ONE_MINUS_DST_ALPHA, dest_color: gl.DST_COLOR, inv_dest_color: gl.ONE_MINUS_DST_COLOR }; + const O = { add: gl.FUNC_ADD, subtract: gl.FUNC_SUBTRACT, rev_subtract: gl.FUNC_REVERSE_SUBTRACT, min: gl.MIN, max: gl.MAX }; + gl.blendEquationSeparate(O[b?.op] ?? gl.FUNC_ADD, gl.FUNC_ADD); + // Transparent views need accumulated alpha coverage. Effect preview uses + // an opaque clear target and keeps the native RGB blend factors above. + gl.blendFuncSeparate(F[b?.src] ?? gl.SRC_ALPHA, F[b?.dst] ?? gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + } + + /** Screen position (CSS px) of a game-space point, or null behind the camera. */ + project(p) { + const vp = this.viewProj(); + const x = p[0], y = p[1], z = -p[2]; + const cx = vp[0] * x + vp[4] * y + vp[8] * z + vp[12]; + const cy = vp[1] * x + vp[5] * y + vp[9] * z + vp[13]; + const cw = vp[3] * x + vp[7] * y + vp[11] * z + vp[15]; + if (cw <= 0) return null; + return [((cx / cw + 1) / 2) * this.canvas.clientWidth, ((1 - cy / cw) / 2) * this.canvas.clientHeight]; + } + } + + return { View }; +})(); +window.GL3D = GL3D; diff --git a/app/public/legacy/js/hero-ini.js b/app/public/legacy/js/hero-ini.js new file mode 100644 index 0000000..4fa531a --- /dev/null +++ b/app/public/legacy/js/hero-ini.js @@ -0,0 +1,43 @@ +"use strict"; + +// Edit one INI value without rewriting comments, unrelated keys, or line endings. +const HeroIni = (() => { + const split = (raw) => raw.split(/\r\n|\n|\r/); + const ending = (raw) => raw.includes("\r\n") ? "\r\n" : raw.includes("\r") ? "\r" : "\n"; + function parse(raw) { + const lines = split(raw), sections = []; + let current = null; + for (let i = 0; i < lines.length; i++) { + const header = /^\s*\[([^\]]+)\]\s*(?:;.*)?$/.exec(lines[i]); + if (header) { + if (current) current.end = i; + current = { name: header[1].trim(), start: i, end: lines.length, fields: [] }; + sections.push(current); + continue; + } + const m = /^\s*([^\s=;#]+)\s*=\s*(.*)$/.exec(lines[i]); + if (m && current) current.fields.push({ key: m[1], value: m[2].trim(), line: i }); + } + return { raw, lines, sections }; + } + function section(doc, name) { return doc.sections.find((s) => s.name.toLowerCase() === name.toLowerCase()); } + function get(doc, name, key) { + const s = section(doc, name); + return s?.fields.findLast((f) => f.key.toLowerCase() === key.toLowerCase())?.value ?? ""; + } + function set(raw, name, key, value) { + const doc = parse(raw), s = section(doc, name); + if (!s) throw new Error(`Section [${name}] is missing`); + const field = s.fields.findLast((f) => f.key.toLowerCase() === key.toLowerCase()); + const lines = doc.lines; + if (field) { + const old = lines[field.line], prefix = /^\s*[^\s=;#]+\s*=\s*/.exec(old)?.[0] || `${key} = `; + lines[field.line] = prefix + value; + } else { + lines.splice(s.end, 0, `${key} = ${value}`); + } + return lines.join(ending(raw)); + } + return { parse, section, get, set }; +})(); +if (typeof module !== "undefined") module.exports = HeroIni; diff --git a/app/public/legacy/js/hero.js b/app/public/legacy/js/hero.js new file mode 100644 index 0000000..db23a5a --- /dev/null +++ b/app/public/legacy/js/hero.js @@ -0,0 +1,847 @@ +"use strict"; + +// Experimental editor for the existing mercenary INIs. Edits are exported as loose files. +(() => { + const HERO = { src: null, rows: [], id: "", name: "", docs: {}, tab: "gear", assetRows: {}, previewReady: false, actionToken: 0, + practice: { active: false, keys: new Set(), pos: [0, 0, 0], yaw: 0, vy: 0, grounded: true, dashUntil: 0, hp: 100, target: [0, 0, 220], combo: 0, comboAt: 0, directions: [], lastTap: {}, lastMotion: "", busy: false, chord: false } }; + window.HERO = HERO; + HERO.openTab = (t) => document.querySelector(`[data-hero-tab="${t}"]`)?.click(); + const kinds = ["item", "skill", "attack", "buff"]; + const raw = (bytes) => { + let out = ""; + for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192)); + return out; + }; + const doc = (kind) => HERO.docs[kind] && HeroIni.parse(HERO.docs[kind].text); + const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : ""; }; + // web fix: STR(n) → the text the game shows (text table key ini_::
_) + const shown = (kind, section, v) => { + const m = /^\s*STR\(\s*(\d+)/i.exec(v || ""); + return m ? HERO.strs?.[`ini_${HERO.id}_${kind}::${section}_${m[1]}`.toLowerCase()] || v : v; + }; + const strText = (kind, section, key) => shown(kind, section, value(kind, section, key)); + const dirty = () => kinds.filter((kind) => HERO.docs[kind]?.text !== HERO.docs[kind]?.saved); + const opt = (value, title, sub = "") => ({ value, title, sub }); + + function updateStatus() { + const changed = dirty(); + $("hero-dirty").hidden = !changed.length; + $("hero-export").disabled = !changed.length; + $("hero-export-status").textContent = changed.length + ? `Ready to export: ${changed.map((x) => `${HERO.id}_${x}.ini`).join(", ")}` + : "Only changed INI files will be exported. The game packs remain untouched."; + } + async function setSource(src) { + setPractice(false); + const d = await call("mdl:describe", src); + HERO.src = { ...src, label: d.label }; + HERO.rows = await call("hero:list", HERO.src); + HERO.docs = {}; HERO.id = ""; HERO.name = ""; + HERO.previewReady = false; + $("hero-src-label").textContent = HERO.src.kind === "dir" ? "Extracted folder" : HERO.src.kind === "packs" ? "Pack folder" : "Profile packs"; + $("hero-title").textContent = "Hero Creator · experimental"; + $("hero-work").hidden = true; $("hero-empty").hidden = false; + $("hero-preview").disabled = true; + HERO.assetRows = {}; + renderList(); updateStatus(); + } + async function chooseSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const options = [ + ...profileSourceOption(), + ...sessions.map((s) => opt({ kind: "session", sessionId: s.id, name: s.name }, `Packs next to ${s.name}`, s.file)), + opt("packs", "Folder with .iop packs…", "Client folder with rs.iop and resource packs"), + opt("dir", "Extracted folder…", "Folder containing config/mercenary"), + ]; + if (!options.length) return toast("No asset source found.", true); + let choice = options[0].value; + const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [ + el("input", { type: "radio", name: "herosrc", checked: i === 0, onchange: () => { choice = o.value; } }), + el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), + ]))); + if (!(await modal("Hero source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", "Choose the game resource folder"); + if (!folder) return; + choice = { kind: choice, folder }; + } + try { await setSource(choice); } catch (err) { toast(err.message, true); } + } + $("hero-src").onclick = chooseSource; + + function renderList() { + const q = $("hero-search").value.trim().toLowerCase(); + const rows = HERO.rows.filter(([id, name]) => !q || id.includes(q) || name.toLowerCase().includes(q)); + $("hero-count").textContent = String(rows.length); + $("hero-list").replaceChildren(...rows.map(([id, name]) => el("button", { + class: `hero-row${id === HERO.id ? " on" : ""}`, onclick: () => openHero(id, name), + }, [el("span", { class: "hero-id", text: id }), el("span", { class: "hero-row-name", text: name })]))); + } + $("hero-search").oninput = renderList; + HERO.setSource = (src) => setSource(src); + HERO.open = (id, name) => openHero(id, name || id); + + async function openHero(id, name) { + if (dirty().length && !(await confirmBox("Unsaved Hero Creator changes", "Discard your current edits?", "Discard", "danger"))) return; + try { + setPractice(false); + const docs = {}; + for (const kind of kinds) { + const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_${kind}.ini`); + if (hit) docs[kind] = { text: raw(hit.bytes), saved: raw(hit.bytes) }; + } + if (!docs.item) throw new Error(`No ${id}_item.ini in this source`); + try { HERO.strs = await call("text:forStems", HERO.src, Object.keys(docs).map((k) => `${id}_${k}`)); } catch { HERO.strs = {}; } + HERO.id = id; HERO.name = name; HERO.docs = docs; HERO.tab = "gear"; + HERO.previewReady = false; + $("hero-title").textContent = `${id} · ${name}`; + $("hero-name").textContent = `${id} · ${name}`; + $("hero-summary").textContent = "Edit gear appearance, move animations, skills and effect references. Export writes loose INI files for testing."; + $("hero-work").hidden = false; $("hero-empty").hidden = true; + $("hero-preview").disabled = false; + $("hero-practice").disabled = false; + for (const b of document.querySelectorAll("[data-hero-tab]")) b.classList.toggle("on", b.dataset.heroTab === "gear"); + renderList(); renderContent(); renderActions(); updateStatus(); + buildPreview(); + } catch (err) { toast(err.message, true); } + } + for (const b of document.querySelectorAll("[data-hero-tab]")) b.onclick = () => { + HERO.tab = b.dataset.heroTab; + for (const x of document.querySelectorAll("[data-hero-tab]")) x.classList.toggle("on", x === b); + renderContent(); + }; + + function edit(kind, section, key, newValue) { + if (/[\r\n]/.test(newValue) || [...newValue].some((c) => c.charCodeAt(0) > 255)) return toast("Use a single-line 8-bit INI value.", true); + const d = HERO.docs[kind]; + d.text = HeroIni.set(d.text, section, key, newValue); + if (kind === "item" || kind === "skill" || kind === "attack") renderActions(); + if (kind === "item" && isMesh(key) && /^(?:equip|body)_mesh/i.test(key)) { + HERO.previewReady = false; + clearTimeout(HERO.previewTimer); + HERO.previewTimer = setTimeout(buildPreview, 250); + } + updateStatus(); + } + const isMesh = (key) => /^(equip_mesh\d*|body_mesh\d*|field_mesh\d*|collision_mesh|mesh_file)$/i.test(key); + const isMove = (_key, val) => /\.ani(?:\s|$)/i.test(val); + const isFx = (key, val) => /effect/i.test(key) && /\.txt(?:\s|$)/i.test(val); + const assetType = (key, val) => isMesh(key) ? "mesh" : /\.ani(?:\s|$)/i.test(val) ? "animation" : /\.txt(?:\s|$)/i.test(val) ? "effect" : ""; + + function fieldRow(kind, section, f) { + const input = el("input", { type: "text", value: f.value, spellcheck: "false", title: `${kind}.ini [${section}] ${f.key}` }); + input.addEventListener("change", () => edit(kind, section, f.key, input.value)); + input.addEventListener("focus", () => { + $("hero-field-detail").textContent = `${HERO.id}_${kind}.ini · [${section}] · ${f.key}`; + }); + const type = assetType(f.key, f.value); + const buttons = []; + if (type) buttons.push(el("button", { class: "iconbtn only", title: `Choose ${type}`, text: "…", onclick: async () => { + const picked = await pickAsset(type, input.value); + if (picked) { input.value = picked; edit(kind, section, f.key, picked); } + }})); + if (type) buttons.push(el("button", { class: "iconbtn only", title: `Preview ${type}`, text: "View", onclick: () => previewAsset(type, input.value) })); + return el("div", { class: "hero-field" }, [el("label", { text: f.key }), input, el("span", {}, buttons)]); + } + function card(title, fields, opts = {}) { + const head = el("div", { class: "hero-card-head" }, [el("h3", { text: title }), el("div", { class: "spacer" }), ...(opts.actions || [])]); + return el("section", { class: "hero-card" }, [head, el("div", { class: "hero-fields" }, fields)]); + } + const itemSections = () => doc("item")?.sections.filter((s) => /^item\d+$/i.test(s.name)) || []; + + function renderGear() { + return itemSections().map((s) => { + const name = strText("item", s.name, "name"); + const slot = value("item", s.name, "item_type") || s.name; + const fields = s.fields.filter((f) => isMesh(f.key)); + const donor = el("button", { class: "iconbtn", text: "Copy appearance…", onclick: () => copyAppearance(s.name) }); + return card(`${slot} · ${name}`, fields.map((f) => fieldRow("item", s.name, f)), { actions: [donor] }); + }); + } + function renderMoves() { + const cards = []; + for (const s of itemSections()) { + const fields = s.fields.filter((f) => isMove(f.key, f.value)); + if (fields.length) cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${strText("item", s.name, "name")}`, fields.map((f) => fieldRow("item", s.name, f)))); + } + return cards; + } + function renderSkills() { + const d = doc("skill"); + if (!d) return []; + return d.sections.filter((s) => /^skill\d+$/i.test(s.name)).map((s) => { + const fields = s.fields.filter((f) => isMove(f.key, f.value) || isFx(f.key, f.value) || /^(name|desc_name|need_gauge|type|sub_type)$/i.test(f.key)); + return card(`${s.name} · ${strText("skill", s.name, "desc_name") || strText("skill", s.name, "name")}`, fields.map((f) => fieldRow("skill", s.name, f))); + }); + } + function renderEffects() { + const cards = []; + for (const kind of kinds) { + const d = doc(kind); + if (!d) continue; + for (const s of d.sections) { + const fields = s.fields.filter((f) => isFx(f.key, f.value)); + if (fields.length) cards.push(card(`${kind.toUpperCase()} · [${s.name}]`, fields.map((f) => fieldRow(kind, s.name, f)))); + } + } + return cards; + } + // ===================================================================== + // Flow: input → animation (.ani events) → hits (fire_s n ↔ attack_type n) → [attributeN] → effects / + // sounds / buffs, with the checks the client would fail on (ioBaseChar::RefreshFireTimeListWithBase, + // ioPlayStage::AddWeaponExplosionEffect, ioClientAniEventHandler) + // ===================================================================== + const FLOW_LABELS = { normal_attack: "D combo", dash_attack: "Dash + D", extra_dash_attack: "Dash + D (extra)", extend_dash_attack: "Dash + D (extended)", jump_attack: "Air + D", extend_jump_attack: "Air + D (extended)", land_jump_attack: "Landing + D", counter_attack: "Counter", extend_attack: "Hold D", normal_charged: "Charged D", jump_charged: "Charged air D", dash_jump_attack: "Dash jump + D", retreat_attack: "Retreat attack", attack: "Skill", jump_attack_skill: "Skill in the air" }; + const ATTR_TYPES = { 0: "dummy", 1: "contact (hitbox)", 2: "missile", 3: "throw", 4: "bomb", 5: "mine" }; + const flowNames = new Map(); + function flowList(folder) { + const key = `${folder}|${HERO.src?.folder || HERO.src?.sessionId || ""}`; + if (!flowNames.has(key)) flowNames.set(key, call("mdl:names", HERO.src, folder).then((l) => new Set(l.map((n) => n.toLowerCase().split("/").pop()))).catch(() => new Set())); + return flowNames.get(key); + } + const fname = (v) => String(v || "").replace(/\s*;.*$/, "").trim().split(/\s+/)[0]; + /** Every attacking action of the hero: { kind, section, label, ani, rate, types: [{ id, wounded }] } */ + function flowActions() { + const out = []; + for (const kind of ["item", "skill"]) { + const d = doc(kind); + if (!d) continue; + for (const s of d.sections) { + const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value); + const title = kind === "skill" ? `${s.name} · ${get("desc_name") && !/^STR\(/.test(get("desc_name")) ? get("desc_name") : get("name") || ""}` : `${get("item_type") || s.name} · ${s.name}`; + for (const f of s.fields) { + const m = /^(.*)_type_count$/i.exec(f.key); + if (!m) continue; + const p = m[1], count = Number.parseInt(fname(f.value), 10) || 0; + const aniKey = kind === "skill" && /^attack$/i.test(p) ? "skill_motion" : kind === "skill" && /^jump_attack$/i.test(p) ? "jump_skill_motion" : p; + const ani = get(aniKey); + if (!/\.ani$/i.test(ani)) continue; + const rate = Number.parseFloat(get(`${p}_time_rate`) || (kind === "skill" ? get(aniKey === "jump_skill_motion" ? "jump_animation_time_rate" : "animation_time_rate") : "") || "1") || 1; + const types = Array.from({ length: count }, (_, i) => ({ id: get(`${p}_type${i + 1}`), wounded: get(`${p}_type${i + 1}_wounded`) })); + const base = p.replace(/\d+$/, ""), num = (/(\d+)$/.exec(p) || [])[1]; + const label = kind === "skill" ? `${title}${/^jump_attack$/i.test(p) ? " (in the air)" : /^attack$/i.test(p) ? "" : ` (${p})`}` : `${FLOW_LABELS[base] || p}${num ? ` ${Number(num)}` : ""}`; + out.push({ kind, section: s.name, title, label, key: p, aniKey, ani, rate, types, preDelay: kind === "skill" ? get("pre_delay_animation") : "", preEffect: kind === "skill" ? get("pre_delay_effect") : "", gauge: kind === "skill" ? get("need_gauge") : "" }); + } + } + } + return out; + } + function flowAttr(id) { + const d = doc("attack"); + const s = d && HeroIni.section(d, `attribute${id}`); + if (!s) return null; + const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value).replace(/^(-?[0-9.]+)f$/i, "$1"); + const buffs = Array.from({ length: Number.parseInt(get("target_buff_cnt"), 10) || 0 }, (_, i) => get(`target_buff${i + 1}`)).filter(Boolean); + return { id, type: Number(get("type") || 0), damage: get("damage_rate"), push: get("push_power"), blow: get("blow_power"), airBlow: get("air_blow_power"), + explosion: get("explosion_effect"), extra: get("extra_explosion_effect"), weaponEffect: get("weapon_effect"), wounded: get("wounded_sound"), defensed: get("defensed_sound"), + buffs, breakBuff: get("defense_break_buff"), shake: get("shake_camera") }; + } + const aniCache = new Map(); + async function flowAni(name) { + const key = name.toLowerCase(); + if (!aniCache.has(key)) aniCache.set(key, call("mdl:read", HERO.src, name, "animation", true).then((h) => (h ? LSF.parseAni(h.bytes) : null)).catch(() => null)); + return aniCache.get(key); + } + let flowAudio = null; + async function flowPlay(path) { + try { + const h = await call("mdl:readPath", HERO.src, `wave/${path}`) || await call("mdl:readPath", HERO.src, `wave/${path.replace(/(\.\w+)$/, "_1$1")}`); + if (!h) return toast(`${path} is not in the packs`, true); + flowAudio ??= new AudioContext(); + const b = await flowAudio.decodeAudioData(h.bytes.slice().buffer); + const s = flowAudio.createBufferSource(); s.buffer = b; s.connect(flowAudio.destination); s.start(); + } catch (err) { toast(err.message, true); } + } + async function flowOpenAni(name) { + document.querySelector('[data-view="ans"]').click(); + if (!window.ANS?.setSource) return; + if (ANS.src?.folder !== HERO.src.folder || ANS.src?.kind !== HERO.src.kind) await ANS.setSource(HERO.src, { quiet: true }); + for (let i = 0; i < 50 && !(ANS.rows && ANS.rows.length); i++) await new Promise((r) => setTimeout(r, 100)); + const row = ANS.rows.find((r) => r[3] === "ani" && `${r[1]}.ani`.toLowerCase() === name.toLowerCase()); + if (!row) return toast(`${name} is not in the Anim Studio list`, true); + await ANS.openRow(row); + if (ANS.setHit) ANS.setHit({ hero: HERO.id, weapon: "" }); + } + async function flowOpenFx(name) { + try { + const h = await call("mdl:read", HERO.src, name, "effect", true); + if (!h) return toast(`${name} is not in the packs`, true); + let text = ""; + for (let i = 0; i < h.bytes.length; i += 8192) text += String.fromCharCode(...h.bytes.subarray(i, i + 8192)); + document.querySelector('[data-view="fxlab"]').click(); + FXL.openScript(text, name, h.where); + } catch (err) { toast(err.message, true); } + } + const chip = (text, cls = "", attrs = {}) => el("span", { class: `flow-chip ${cls}`, text, ...attrs }); + function fileChip(name, kind, exists) { + if (!name || /^none$/i.test(name)) return null; + const missing = exists && !exists.has(name.toLowerCase().split("/").pop()) && !(kind === "sound" && exists.has(name.toLowerCase().split("/").pop().replace(/(\.\w+)$/, "_1$1"))); + const kids = [el("span", { text: name })]; + if (kind === "sound") kids.push(el("button", { class: "flow-mini", title: "Listen", onclick: () => flowPlay(name) }, [icon("play")])); + if (kind === "effect") kids.push(el("button", { class: "flow-mini", title: "Open in FX Lab", onclick: () => flowOpenFx(name) }, [icon("brush")])); + return el("span", { class: `flow-chip file ${kind}${missing ? " bad" : ""}`, title: missing ? `${name}: not in the packs` : name }, kids); + } + function renderFlow() { + const box = el("div", { class: "flow" }, [el("div", { class: "muted", text: "Reading the animations…" })]); + const seq = (HERO.flowSeq = (HERO.flowSeq || 0) + 1); + (async () => { + const [effects, waves] = await Promise.all([flowList("effect"), flowList("wave")]); + const acts = flowActions(); + const cards = []; + let problems = 0; + for (const a of acts) { + const data = await flowAni(a.ani); + if (seq !== HERO.flowSeq) return; + const ev = (data?.events || []).map((e) => ({ t: e.time, type: e.type.text.toLowerCase(), info: e.sub.text.trim() })).sort((x, y) => x.t - y.t); + const starts = ev.filter((e) => e.type === "fire_s"), ends = ev.filter((e) => e.type === "fire_e"); + const ok = data && starts.length === a.types.length; + if (!ok) problems++; + const rateT = (t) => Math.round(t * a.rate); + const others = ev.filter((e) => !/^fire_[se]$/.test(e.type) && /sound|effect|face_change|trail_s|auto_shake/.test(e.type)); + const hits = a.types.map((ty, i) => { + const s = starts[i], e = ends[i], at = flowAttr(ty.id); + const box2 = s ? (s.info.split(/\s+/)[0] || "front of the body") : "—"; + return el("div", { class: `flow-hit${!s ? " bad" : ""}` }, [ + el("div", { class: "flow-hit-head" }, [ + chip(`hit ${i + 1}`, "n"), + chip(s ? `${rateT(s.t)}–${rateT(e ? e.t : s.t)} ms` : "no fire_s", s ? "" : "bad"), + chip(box2, "box"), + el("span", { class: "flow-arrow", text: "→" }), + chip(`attribute${ty.id}`, at ? "attr" : "bad"), + at ? chip(ATTR_TYPES[at.type] || `type ${at.type}`) : null, + at?.damage ? chip(`damage ×${at.damage}`) : null, + at && (at.push || at.blow) ? chip(`push ${at.push || 0} · blow ${at.blow || 0}${at.airBlow ? ` · air ${at.airBlow}` : ""}`) : null, + ]), + at ? el("div", { class: "flow-hit-body" }, [ + at.explosion ? el("span", { class: "flow-l" }, [el("em", { text: "on target" }), fileChip(at.explosion, "effect", effects)]) : null, + at.extra ? el("span", { class: "flow-l" }, [el("em", { text: "attacker only" }), fileChip(at.extra, "effect", effects)]) : null, + at.weaponEffect ? el("span", { class: "flow-l" }, [el("em", { text: "projectile" }), fileChip(at.weaponEffect, "effect", effects)]) : null, + at.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "hit sound" }), fileChip(at.wounded, "sound", waves)]) : null, + at.defensed ? el("span", { class: "flow-l" }, [el("em", { text: "blocked sound" }), fileChip(at.defensed, "sound", waves)]) : null, + ...at.buffs.map((b) => el("span", { class: "flow-l" }, [el("em", { text: "buff on target" }), chip(b, "buff")])), + at.breakBuff ? el("span", { class: "flow-l" }, [el("em", { text: "guard break" }), chip(at.breakBuff, "buff")]) : null, + ty.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "target plays" }), chip(ty.wounded)]) : null, + ]) : el("div", { class: "flow-hit-body bad", text: `[attribute${ty.id}] is not in ${HERO.id}_attack.ini` }), + ]); + }); + if (starts.length > a.types.length) hits.push(el("div", { class: "flow-hit bad" }, [chip(`${starts.length - a.types.length} extra fire_s without an attack type`, "bad")])); + const evChips = others.map((e) => { + const kind = /sound/.test(e.type) ? "sound" : /effect/.test(e.type) ? "effect" : ""; + return el("span", { class: "flow-l" }, [el("em", { text: `${rateT(e.t)} ms ${e.type}` }), kind ? fileChip(e.info.split(/\s+/)[0], kind, kind === "sound" ? waves : effects) : chip(e.info || "—")]); + }); + cards.push(el("section", { class: `flow-card${ok ? "" : " bad"}` }, [ + el("div", { class: "flow-head" }, [ + el("b", { text: a.label }), + el("span", { class: "muted", text: `${a.title} · ${a.kind}.ini ${a.key}` }), + el("div", { class: "spacer" }), + el("span", { class: `flow-check ${ok ? "ok" : "bad"}`, text: !data ? "✗ animation not in the packs" : ok ? `✓ ${starts.length} fire_s = ${a.types.length} attack types` : `✗ ${starts.length} fire_s ≠ ${a.types.length} attack types (the attack never fires)` }), + ]), + el("div", { class: "flow-row" }, [ + a.preDelay ? el("div", { class: "flow-step" }, [el("em", { text: "ready" }), chip(a.preDelay), a.preEffect ? fileChip(a.preEffect, "effect", effects) : null]) : null, + a.preDelay ? el("span", { class: "flow-arrow", text: "→" }) : null, + el("div", { class: "flow-step" }, [ + el("em", { text: `animation${a.rate !== 1 ? ` ×${a.rate}` : ""}${data ? ` · ${rateT(data.total || 0)} ms` : ""}` }), + el("span", { class: `flow-chip ani${data ? "" : " bad"}` }, [el("span", { text: a.ani }), el("button", { class: "flow-mini", title: "Open in Anim Studio (with the hitbox check)", onclick: () => flowOpenAni(a.ani) }, [icon("bone")])]), + ]), + evChips.length ? el("span", { class: "flow-arrow", text: "·" }) : null, + evChips.length ? el("div", { class: "flow-step flow-events" }, evChips) : null, + ]), + el("div", { class: "flow-hits" }, hits.length ? hits : [el("div", { class: "muted", text: "No attack types." })]), + ])); + } + if (seq !== HERO.flowSeq) return; + box.replaceChildren( + el("div", { class: `flow-summary ${problems ? "bad" : "ok"}`, text: `${acts.length} attacking actions · ${problems ? `${problems} with a fire_s / attack type mismatch or a missing animation` : "all fire_s counts match"}. Red files are not in the packs.` }), + ...cards, + ); + })().catch((err) => box.replaceChildren(el("div", { class: "bad", text: err.message }))); + return [box]; + } + function renderContent() { + if (!HERO.id) return; + const cards = HERO.tab === "gear" ? renderGear() : HERO.tab === "moves" ? renderMoves() : HERO.tab === "skills" ? renderSkills() : HERO.tab === "flow" ? renderFlow() : renderEffects(); + $("hero-content").replaceChildren(...(cards.length ? cards : [el("div", { class: "hero-empty-note", text: "No matching fields in this hero's config." })])); + } + + async function copyAppearance(section) { + const idInput = el("input", { type: "text", placeholder: "Hero ID, e.g. 052", spellcheck: "false" }); + if (!(await modal("Copy gear appearance", [ + el("p", { text: "Copy mesh references from the same gear slot of another hero. Item codes and combat data stay with the base hero." }), + el("label", { text: `Source hero for ${section}` }), idInput, + ], [{ label: "Cancel", value: false }, { label: "Copy meshes", kind: "primary", value: true }]))) return; + const id = idInput.value.trim().padStart(3, "0"); + if (!/^\d{3}$/.test(id)) return toast("Enter a 3-digit hero ID.", true); + try { + const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_item.ini`); + if (!hit) throw new Error(`Hero ${id} gear config was not found`); + const donor = HeroIni.parse(raw(hit.bytes)), s = HeroIni.section(donor, section); + if (!s) throw new Error(`Donor has no [${section}]`); + const meshFields = s.fields.filter((f) => isMesh(f.key)); + if (!meshFields.length) throw new Error(`Donor [${section}] has no mesh references`); + for (const f of meshFields) edit("item", section, f.key, f.value); + renderContent(); toast(`Copied ${meshFields.length} mesh fields from hero ${id}.`); + } catch (err) { toast(err.message, true); } + } + + async function assetsFor(type) { + if (HERO.assetRows[type]) return HERO.assetRows[type]; + const rows = type === "effect" ? await call("fx:list", HERO.src) : await call("mdl:list", HERO.src); + HERO.assetRows[type] = rows.map((r) => r[1].split("/").pop()).filter((name) => + type === "effect" ? /\.txt$/i.test(name) : type === "animation" ? /\.ani$/i.test(name) : /\.msh$/i.test(name)); + return HERO.assetRows[type]; + } + async function pickAsset(type, current) { + try { + const rows = await assetsFor(type); + const search = el("input", { type: "search", value: current.replace(/\.[^.]+$/, ""), placeholder: `Search ${type}s`, spellcheck: "false" }); + const list = el("div", { class: "hero-picker-list" }); + let chosen = ""; + const render = () => { + const q = search.value.trim().toLowerCase(); + list.replaceChildren(...rows.filter((x) => !q || x.toLowerCase().includes(q)).slice(0, 100).map((name) => el("button", { + class: `hero-picker-row${name === chosen ? " on" : ""}`, text: name, + onclick: () => { chosen = name; render(); }, ondblclick: () => { chosen = name; $("modal-actions").lastChild.click(); }, + }))); + }; + search.oninput = render; render(); + const ok = await modal(`Choose ${type}`, [search, list], [{ label: "Cancel", value: false }, { label: "Use selected", kind: "primary", value: true }]); + return ok ? chosen : ""; + } catch (err) { toast(err.message, true); return ""; } + } + async function previewAsset(type, name) { + if (!name) return; + try { + if (type === "effect") { + await FXE.previewAsset(HERO.src, name); + } else if (type === "animation") { + await MDL.previewHero(HERO.src, gearMeshes(), name, { hair: helmetHair() }); + } else { + await MDL.previewAsset(HERO.src, name, type); + } + } catch (err) { toast(err.message, true); } + } + // web fix: the helmet section (item_type HELMET) and its hair rule for the man body (default 0 = half hair) + function helmetHair() { + const s = itemSections().find((x) => /^helmet$/i.test(value("item", x.name, "item_type")) && value("item", x.name, "equip_mesh0")); + if (!s) return null; + const v = value("item", s.name, "hair_change_all0_hm"); + return v === "" ? 0 : Number(v); + } + const gearMeshes = () => itemSections().flatMap((s) => s.fields.filter((f) => /^(?:equip|body)_mesh\d*$/i.test(f.key)).map((f) => f.value)).filter(Boolean); + async function buildPreview() { + if (!HERO.id) return; + const meshes = gearMeshes(); + const animation = value("item", "item1", "delay_animation") || value("item", "item1", "normal_attack01"); + $("hero-preview-status").textContent = "Loading body, gear and animation…"; + $("hero-play-status").textContent = "Loading hero…"; + try { + await MDL.previewHero(HERO.src, meshes, animation, { embedded: true, hair: helmetHair() }); + if (HERO.practice.active) { MDL.setHeroTransform(HERO.practice.pos, HERO.practice.yaw); MDL.setHeroPracticeTarget(HERO.practice.target); } + HERO.previewReady = true; + $("hero-preview-status").textContent = `${meshes.length} gear meshes · ${animation || "default pose"}`; + $("hero-play-status").textContent = "Ready · drag to rotate, wheel to zoom"; + } catch (err) { HERO.previewReady = false; $("hero-preview-status").textContent = err.message; $("hero-play-status").textContent = "Preview unavailable"; toast(err.message, true); } + } + $("hero-preview").onclick = buildPreview; + const ani = (s) => /\.ani$/i.test(String(s || "").trim()) ? String(s).trim() : ""; + const fx = (s) => /\.txt$/i.test(String(s || "").trim()) ? String(s).trim() : ""; + function attributeSummary(index) { + if (!index || !doc("attack")) return ""; + const section = `attribute${index}`; + if (!HeroIni.section(doc("attack"), section)) return `attack ${index}`; + const damage = value("attack", section, "damage_rate"); + const effect = fx(value("attack", section, "explosion_effect")) || fx(value("attack", section, "extra_explosion_effect")); + return `attack ${index}${damage ? ` · damage ${damage}` : ""}${effect ? ` · ${effect}` : ""}`; + } + function attackRefs(kind, section, prefix) { + const count = Math.min(20, Number.parseInt(value(kind, section, `${prefix}_type_count`) || value(kind, section, "attack_type_count"), 10) || 0); + return Array.from({ length: count }, (_, i) => value(kind, section, `${prefix}_type${i + 1}`) || value(kind, section, `attack_type${i + 1}`)).filter(Boolean); + } + function actionEffect(kind, section, refs, preferred = []) { + for (const key of preferred) { + const name = fx(value(kind, section, key)); + if (name) return name; + } + for (const index of refs) { + const name = fx(value("attack", `attribute${index}`, "explosion_effect")) || fx(value("attack", `attribute${index}`, "extra_explosion_effect")); + if (name) return name; + } + return ""; + } + function iconCanvas(name) { + const canvas = el("canvas", { class: "hero-skill-icon", width: 40, height: 40, title: name || "No icon" }); + if (!name) return canvas; + (async () => { + try { + await HERO.iconReady; + if (!canvas.isConnected) return; + const i = name.indexOf("#"); + const image = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : await UIRes.image(null, null, name); + if (!image?.bmp || !canvas.isConnected) return; + const scale = Math.min(40 / image.w, 40 / image.h); + const w = image.w * scale, h = image.h * scale; + canvas.getContext("2d").drawImage(image.bmp, image.x, image.y, image.w, image.h, (40 - w) / 2, (40 - h) / 2, w, h); + } catch (err) { console.warn(`Hero icon ${name}: ${err.message}`); } + })(); + return canvas; + } + function makeAction(label, subtitle, action, iconName = "") { + const children = iconName ? [iconCanvas(iconName)] : []; + children.push(el("span", {}, [el("b", { text: label }), el("small", { text: subtitle || "" })])); + const button = el("button", { class: "hero-action", onclick: (e) => runAction(action, e.currentTarget), title: action.description }, children); + button.heroAction = action; + return button; + } + function skillSpec(section, label) { + const title = strText("skill", section, "desc_name") || strText("skill", section, "name"); + const animationKeys = ["pre_delay_animation", "skill_motion", "skill_loop_motion", "loop_animation", "skill_end_attack", "end_animation"]; + const animations = animationKeys.map((key) => ani(value("skill", section, key))).filter(Boolean); + const refs = attackRefs("skill", section, "attack"); + const effect = actionEffect("skill", section, refs, ["pre_delay_all_effect", "skill_charging_effect", "pre_delay_effect", "skill_map_effect", "range_effect"]); + const buffs = Array.from({ length: Math.min(12, Number.parseInt(value("skill", section, "owner_buff_cnt"), 10) || 0) }, (_, i) => value("skill", section, `owner_buff${i + 1}`)).filter(Boolean); + const description = [title, value("skill", section, "desc"), animations.length ? "" : "Passive / no animation", ...animations, ...buffs.map((b) => `buff ${b}`), ...refs.map(attributeSummary)].filter(Boolean).join("\n"); + return { title, action: { label, animations, effect, refs, description } }; + } + function renderActions() { + if (HERO.iconSource !== HERO.src) { + HERO.iconSource = HERO.src; + HERO.iconReady = UIRes.load(HERO.src).catch((err) => console.warn(`Hero icons: ${err.message}`)); + } + const weapon = "item1", max = Math.min(12, Number.parseInt(value("item", weapon, "max_combo"), 10) || 0); + const normal = Array.from({ length: max }, (_, i) => { + const key = `normal_attack${String(i + 1).padStart(2, "0")}`; + return { key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) }; + }).filter((step) => step.name); + const combos = normal.map((_, i) => { + const steps = normal.slice(0, i + 1); + const refs = steps.flatMap((s) => s.refs); + const label = Array(i + 1).fill("D").join(" "); + return makeAction(label, `${i + 1} hit${i ? "s" : ""}`, { + label, animations: steps.map((s) => s.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs, + description: `${steps.map((s) => s.name).join(" → ")}\n${refs.map(attributeSummary).join("\n")}`, + }); + }); + // The client has many item subclasses with their own attack families. Use + // the animation-bearing config fields so no subclass needs a hardcoded list. + const fields = HeroIni.section(doc("item"), weapon)?.fields || []; + const used = new Set(normal.map((s) => s.key.toLowerCase())); + const addMove = (label, keys) => { + const steps = keys.map((key) => ({ key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) })).filter((x) => x.name); + if (!steps.length) return; + steps.forEach((x) => used.add(x.key.toLowerCase())); + const refs = steps.flatMap((x) => x.refs); + combos.push(makeAction(label, steps.map((x) => x.name).join(" → "), { + label, animations: steps.map((x) => x.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs, + description: steps.map((x) => `[${x.key}] ${x.name}`).concat(refs.map(attributeSummary)).join("\n"), + })); + }; + const commandIds = [...new Set(fields.map((f) => /^((?:jump_)?cmd_info)(\d+)$/.exec(f.key.toLowerCase())).filter(Boolean).map((m) => `${m[1]}${m[2]}`))]; + for (const id of commandIds) { + const m = /^((?:jump_)?cmd_info)(\d+)$/.exec(id), base = `${m[1]}${m[2]}`; + const code = value("item", weapon, base); + const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" }; + const label = `${m[1].startsWith("jump") ? "Air " : ""}${[...code].map((c) => arrows[c] || c).join("")}`; + const keys = fields.filter((f) => new RegExp(`^${base}_combo\\d+$`, "i").test(f.key) && ani(f.value)).map((f) => f.key); + if (keys.length) addMove(label, keys); + if (!keys.length && value("item", weapon, `${base}_skill_use`) === "1") { + const slot = Number(value("item", weapon, `${base}_skill_slot`)); + const spec = skillSpec(`skill${slot + 1}`, label); + combos.push(makeAction(label, spec.title || `Skill slot ${slot + 1}`, spec.action)); + } + for (const suffix of ["d", "s"]) { + const branches = fields.filter((f) => new RegExp(`^${base}_combo\\d+_${suffix}$`, "i").test(f.key) && ani(f.value)).map((f) => f.key); + for (const branch of branches) addMove(`${label} + ${suffix.toUpperCase()}`, [...keys.slice(0, -1), branch]); + } + } + const labels = { dash_attack: "Dash + D", assault_dash_attack: "Assault dash + D", jump_attack: "Air + D", extend_jump_attack: "Extended air + D", land_jump_attack: "Landing + D", counter_attack: "Counter", run_animation: "Run", dash_animation: "Dash", jumpping_animation: "Jump", jump_end_animation: "Land", defense_animation: "Defense" }; + const isSelfMove = (key) => !/(?:_wounded(?:_|$)|^wound|^shop_|^award_|^win_|^lose_|^special_motion|_camera$)/i.test(key); + for (const f of fields) { + const key = f.key.toLowerCase(); + if (used.has(key) || !ani(f.value) || !isSelfMove(key)) continue; + const label = labels[key] || (/half.*attack/i.test(key) ? `${key.startsWith("dash") ? "Dash " : key.startsWith("jump") ? "Air " : ""}Half D · ${key}` : key.replace(/_/g, " ")); + addMove(label, [f.key]); + } + $("hero-combos").replaceChildren(...combos); + const slots = [["SD", "skill1"], ["ASD", "skill2"], ["AD", "skill3"], ["AS", "skill4"]]; + $("hero-skills").replaceChildren(...slots.map(([label, section]) => { + if (!doc("skill") || !HeroIni.section(doc("skill"), section)) return makeAction(label, "No skill", { label, animations: [], description: "No skill definition" }); + const spec = skillSpec(section, label); + return makeAction(label, spec.title, spec.action, value("skill", section, "icon") || value("skill", section, "skill_emoticon")); + })); + } + async function runAction(action, button) { + const token = ++HERO.actionToken; + const practice = HERO.practice; + if (practice.active) { practice.busy = true; practice.lastMotion = ""; } + MDL.clearHeroEffect(); + $("hero-action-label").textContent = action.label; + $("hero-action-info").textContent = action.description || "No config details for this action."; + for (const b of document.querySelectorAll(".hero-action")) b.classList.toggle("on", b === button); + if (!action.animations.length) { + MDL.stopHeroSequence(); + if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`)); + if (practice.active) { practiceHit(action); practice.busy = false; } + $("hero-play-status").textContent = "Passive / no animation in config"; + return; + } + if (!HERO.previewReady) await buildPreview(); + if (!HERO.previewReady || token !== HERO.actionToken) return; + $("hero-play-status").textContent = `${action.animations.length} animation step(s)`; + try { + if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`)); + let hitDone = false; + const hit = () => { if (!hitDone && token === HERO.actionToken && practice.active) { hitDone = true; practiceHit(action); } }; + await MDL.playSequence(action.animations, { + onStep: (name) => { if (token === HERO.actionToken) $("hero-play-status").textContent = `${action.label} · ${name}`; }, + onEvent: (event) => { + if (token !== HERO.actionToken) return; + if (/^fire_(?:s|e|sliding)$/i.test(event.type.text)) hit(); + if (event.type.text.toLowerCase() !== "auto_effect") return; + const name = fx(event.sub.text); + if (name) MDL.playHeroEffect(HERO.src, name).catch((err) => console.warn(`Animation effect: ${err.message}`)); + }, + onDone: () => { if (token === HERO.actionToken) { hit(); $("hero-play-status").textContent = `${action.label} complete`; if (practice.active) { practice.busy = false; practice.lastMotion = ""; updatePracticeMotion(); } else MDL.playNamed(value("item", "item1", "delay_animation")); } }, + }); + if (practice.active) setTimeout(hit, 400); + } catch (err) { practice.busy = false; toast(err.message, true); $("hero-play-status").textContent = err.message; } + } + const practiceButtons = () => [...document.querySelectorAll("#hero-combos .hero-action")]; + const actionLabel = (button) => button?.querySelector("b")?.textContent || ""; + const actionButton = (label) => practiceButtons().find((button) => actionLabel(button) === label); + const practiceVisible = () => HERO.practice.active && HERO.id && $("view-hero").classList.contains("on"); + function practiceHit(action) { + const p = HERO.practice; + if (!p.active || (!action.refs?.length && !action.effectTarget)) return; + const dx = p.target[0] - p.pos[0], dz = p.target[2] - p.pos[2]; + const distance = Math.hypot(dx, dz); + const facing = (Math.sin(p.yaw * Math.PI / 180) * dx + Math.cos(p.yaw * Math.PI / 180) * dz) / (distance || 1); + if (distance > 155 || facing < 0.2) { $("hero-play-status").textContent = `${action.label} · miss`; return; } + const rates = (action.refs || []).map((ref) => Number.parseFloat(value("attack", `attribute${ref}`, "damage_rate")) || 1); + const damage = Math.max(5, Math.min(35, Math.round(rates.reduce((sum, rate) => sum + rate, 0) * 2))); + p.hp = Math.max(0, p.hp - damage); + $("hero-dummy-health").textContent = `Dummy ${p.hp} / 100 · −${damage}`; + $("hero-play-status").textContent = `${action.label} · hit ${damage}`; + if (action.effectTarget && action.effect) MDL.playHeroEffect(HERO.src, action.effect, { target: true }).catch((err) => console.warn(`Practice hit effect: ${err.message}`)); + if (p.hp === 0) setTimeout(() => { if (p.active && p.hp === 0) { p.hp = 100; $("hero-dummy-health").textContent = "Dummy 100 / 100"; } }, 1800); + } + function updatePracticeMotion() { + const p = HERO.practice; + if (!practiceVisible() || !HERO.previewReady || p.busy) return; + const moving = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].some((key) => p.keys.has(key)); + const dashing = p.keys.has("Shift") || performance.now() < p.dashUntil; + const key = !p.grounded ? "jumpping_animation" : p.keys.has("s") ? "defense_animation" : moving ? dashing ? "dash_animation" : "run_animation" : "delay_animation"; + const name = ani(value("item", "item1", key)) || ani(value("item", "item1", "delay_animation")); + if (!name || name === p.lastMotion) return; + p.lastMotion = name; + MDL.playHeroMotion(name).catch((err) => console.warn(`Practice motion ${name}: ${err.message}`)); + } + async function setPractice(on) { + const p = HERO.practice; + if (on && !HERO.previewReady) await buildPreview(); + if (on && !HERO.previewReady) return; + p.active = !!on; + p.keys.clear(); p.directions = []; p.combo = 0; p.busy = false; p.chord = false; + p.pos = [0, 0, 0]; p.yaw = 0; p.vy = 0; p.grounded = true; p.dashUntil = 0; p.hp = 100; p.lastMotion = ""; + clearTimeout(p.attackTimer); + $("hero-practice").classList.toggle("on", p.active); + $("hero-practice").textContent = p.active ? "Exit training" : "Training mode"; + $("hero-practice-hud").hidden = !p.active; + $("hero-practice-help").hidden = !p.active; + $("hero-dummy-health").textContent = "Dummy 100 / 100"; + MDL.clearHeroEffect(); + MDL.stopHeroSequence(); + MDL.setHeroTransform(p.pos, p.yaw); + MDL.setHeroPracticeTarget(p.active ? p.target : null); + MDL.setPracticeFloor?.(p.active ? PRACTICE_ARENA : null); + if (p.active) { + $("hero-stage").focus({ preventScroll: true }); + if (MDL.view) { MDL.view.cam.target = [0, 75, -110]; MDL.view.cam.dist = Math.max(450, MDL.view.cam.dist); MDL.view.dirty = true; } + $("hero-play-status").textContent = "Training ready · move toward the dummy"; + updatePracticeMotion(); + } else { + MDL.view?.reset(); + $("hero-play-status").textContent = "Training stopped"; + } + } + $("hero-practice").onclick = () => setPractice(!HERO.practice.active); + + function commandButton() { + const p = HERO.practice, now = performance.now(); + const maxTime = Number.parseInt(value("item", "item1", "input_max_time"), 10) || 800; + p.directions = p.directions.filter((x) => now - x.time <= maxTime); + const input = p.directions.map((x) => x.code).join(""); + const fields = HeroIni.section(doc("item"), "item1")?.fields || []; + const commands = fields.filter((f) => /^(?:jump_)?cmd_info\d+$/i.test(f.key) && /^\d+$/.test(f.value)) + .filter((f) => f.key.startsWith("jump_") === !p.grounded && input.endsWith(f.value)) + .sort((a, b) => b.value.length - a.value.length); + const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" }; + for (const cmd of commands) { + const label = `${cmd.key.startsWith("jump_") ? "Air " : ""}${[...cmd.value].map((c) => arrows[c]).join("")}`; + const button = actionButton(label); + if (button) { p.directions = []; return button; } + } + return null; + } + function practiceAttack(held = 0) { + const p = HERO.practice; + if (!practiceVisible() || !HERO.previewReady) return; + let button = null; + if (held >= Math.max(250, Number.parseInt(value("item", "item1", "half_charge_time"), 10) || Number.parseInt(value("item", "item1", "half_charging_time"), 10) || 450)) + button = practiceButtons().find((b) => /^Half D\b/.test(actionLabel(b))); + if (!button) button = commandButton(); + if (!button && !p.grounded) button = practiceButtons().find((b) => /^(Air \+ D|jump attack\d+|Extended air \+ D)/i.test(actionLabel(b))); + if (!button && (p.keys.has("Shift") || performance.now() < p.dashUntil)) button = actionButton("Assault dash + D") || actionButton("Dash + D"); + if (button) { p.combo = 0; runAction(button.heroAction, button); return; } + const max = Math.min(12, Number.parseInt(value("item", "item1", "max_combo"), 10) || 1); + p.combo = performance.now() - p.comboAt < 850 ? p.combo % max + 1 : 1; + p.comboAt = performance.now(); + button = actionButton(Array(p.combo).fill("D").join(" ")) || actionButton("D"); + if (!button) return; + const key = `normal_attack${String(p.combo).padStart(2, "0")}`; + const name = ani(value("item", "item1", key)); + const refs = attackRefs("item", "item1", key); + const action = { label: actionLabel(button), animations: name ? [name] : button.heroAction.animations, + effect: actionEffect("item", "item1", refs), effectTarget: true, refs, + description: `[${key}] ${name}\n${refs.map(attributeSummary).join("\n")}` }; + runAction(action, button); + } + function practiceSkill(slot) { + const button = [...document.querySelectorAll("#hero-skills .hero-action")][slot]; + if (button) { HERO.practice.combo = 0; runAction(button.heroAction, button); } + } + const practiceKey = (event) => event.key.length === 1 ? event.key.toLowerCase() : event.key; + document.addEventListener("keydown", (event) => { + if (!practiceVisible() || event.target.matches?.("input, textarea, select, [contenteditable]") || event.ctrlKey || event.metaKey || event.altKey) return; + const p = HERO.practice, key = practiceKey(event), now = performance.now(); + if (key === "Escape") { event.preventDefault(); setPractice(false); return; } + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d", "1", "2", "3", "4"].includes(key)) return; + event.preventDefault(); + const wasDown = p.keys.has(key); + p.keys.add(key); + if (/^Arrow/.test(key)) { + if (!wasDown) { + const codes = { ArrowUp: "1", ArrowRight: "2", ArrowDown: "3", ArrowLeft: "4" }; + p.directions.push({ code: codes[key], time: now }); + p.directions = p.directions.slice(-12); + if (now - (p.lastTap[key] || 0) < 280) p.dashUntil = now + 500; + p.lastTap[key] = now; + } + updatePracticeMotion(); + return; + } + if (key === "Shift") { updatePracticeMotion(); return; } + if (wasDown) return; + if (/^[1-4]$/.test(key)) { practiceSkill(Number(key) - 1); return; } + const a = p.keys.has("a"), s = p.keys.has("s"), d = p.keys.has("d"); + const slot = a && s && d ? 1 : s && d ? 0 : a && d ? 2 : a && s ? 3 : -1; + if (slot >= 0 && !p.chord) { + p.chord = true; p.attackPending = false; clearTimeout(p.attackTimer); + practiceSkill(slot); + return; + } + if (key === "a" && p.grounded) { + p.grounded = false; + p.vy = 275 * (Number.parseFloat(value("item", "item1", "jump_power_rate")) || 1); + p.lastMotion = ""; + updatePracticeMotion(); + } else if (key === "s") updatePracticeMotion(); + else if (key === "d") { + p.attackDownAt = now; p.attackPending = true; + const hasHalf = practiceButtons().some((button) => /^Half D\b/.test(actionLabel(button))); + if (!hasHalf) p.attackTimer = setTimeout(() => { + if (p.attackPending && p.keys.has("d") && !p.chord) { p.attackPending = false; practiceAttack(); } + }, 75); + } + }); + document.addEventListener("keyup", (event) => { + if (!HERO.practice.active) return; + const p = HERO.practice, key = practiceKey(event); + if (!p.keys.has(key)) return; + if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d"].includes(key)) event.preventDefault(); + p.keys.delete(key); + if (key === "d") { + clearTimeout(p.attackTimer); + if (p.attackPending && !p.chord) practiceAttack(performance.now() - p.attackDownAt); + p.attackPending = false; + } + if (!["a", "s", "d"].some((k) => p.keys.has(k))) p.chord = false; + if (/^Arrow/.test(key) || key === "Shift" || key === "s") updatePracticeMotion(); + }); + window.addEventListener("blur", () => { if (HERO.practice.active) { HERO.practice.keys.clear(); HERO.practice.attackPending = false; updatePracticeMotion(); } }); + let practiceLastFrame = 0; + const PRACTICE_ARENA = 450; // half size of the training floor, game units + function practiceTick(now) { + const dt = Math.min(0.05, Math.max(0, (now - (practiceLastFrame || now)) / 1000)); + practiceLastFrame = now; + const p = HERO.practice; + if (practiceVisible() && HERO.previewReady) { + const dx = Number(p.keys.has("ArrowRight")) - Number(p.keys.has("ArrowLeft")); + const dz = Number(p.keys.has("ArrowUp")) - Number(p.keys.has("ArrowDown")); + const length = Math.hypot(dx, dz) || 1; + const moving = !!(dx || dz); + const dashing = p.keys.has("Shift") || now < p.dashUntil; + const dashRate = Number.parseFloat(value("item", "item1", "dash_run_max_speed_rate")) || 1; + const speed = (dashing ? 240 * dashRate : 125) * (p.busy ? 0.35 : 1); + if (moving) { + const edge = PRACTICE_ARENA - 20; // keep the whole body on the floor grid + p.pos[0] = Math.max(-edge, Math.min(edge, p.pos[0] + dx / length * speed * dt)); + p.pos[2] = Math.max(-edge, Math.min(edge, p.pos[2] + dz / length * speed * dt)); + p.yaw = Math.atan2(dx, dz) * 180 / Math.PI; + } + if (!p.grounded) { + p.vy -= 780 * dt; + p.pos[1] += p.vy * dt; + if (p.pos[1] <= 0) { p.pos[1] = 0; p.vy = 0; p.grounded = true; p.lastMotion = ""; updatePracticeMotion(); } + } + if (moving || !p.grounded) MDL.setHeroTransform(p.pos, p.yaw); + // Camera follows the hero (looking a little toward the dummy) and backs off when the two are far + // apart, so the hero never walks out of the frame. It never zooms in: a manual zoom-out stays. + // The view's z axis is the game's z flipped (see the start target [0, 75, -110]). + if (MDL.view) { + const cam = MDL.view.cam, k = Math.min(1, 4 * dt); + const fx = p.pos[0] + (p.target[0] - p.pos[0]) * 0.35, fz = p.pos[2] + (p.target[2] - p.pos[2]) * 0.35; + const need = Math.min(1000, 380 + Math.hypot(p.target[0] - p.pos[0], p.target[2] - p.pos[2]) * 0.9); + const before = cam.target[0] + cam.target[2] + cam.dist; + cam.target[0] += (fx - cam.target[0]) * k; + cam.target[2] += (-fz - cam.target[2]) * k; + if (cam.dist < need) cam.dist += (need - cam.dist) * k; + if (Math.abs(cam.target[0] + cam.target[2] + cam.dist - before) > 0.01) MDL.view.dirty = true; + } + updatePracticeMotion(); + } + requestAnimationFrame(practiceTick); + } + requestAnimationFrame(practiceTick); + $("hero-idle").onclick = async () => { + if (HERO.practice.active) await setPractice(false); + HERO.actionToken++; + MDL.stopHeroSequence(); + const name = value("item", "item1", "delay_animation"); + if (name) await MDL.playNamed(name); + $("hero-action-label").textContent = "Idle"; + $("hero-play-status").textContent = name || "Idle pose"; + }; + $("hero-stop").onclick = () => { if (HERO.practice.active) setPractice(false); HERO.actionToken++; MDL.stopHeroSequence(); $("hero-play-status").textContent = "Stopped"; }; + $("hero-export").onclick = async () => { + const files = dirty().map((kind) => ({ kind, text: HERO.docs[kind].text })); + if (!files.length) return; + const folder = await call("dialog:folder", "Choose a folder for the exported hero configs"); + if (!folder) return; + try { + const out = await call("hero:export", folder, HERO.id, files); + for (const f of files) HERO.docs[f.kind].saved = f.text; + updateStatus(); + $("hero-export-status").textContent = `Exported to ${out.folder}`; + toast(`Exported ${out.files.length} config file(s).`); + } catch (err) { toast(err.message, true); } + }; + document.querySelector('[data-view="hero"]').addEventListener("click", async () => { + if (HERO.src) { + if (HERO.id && MDL.scene?.model?.name === "Hero preview") MDL.embedHero(true); + else if (HERO.id) buildPreview(); + return; + } + const src = profileSource(); + if (src) try { await setSource(src); } catch (err) { toast(err.message, true); } + }); +})(); diff --git a/app/public/legacy/js/imageset.js b/app/public/legacy/js/imageset.js new file mode 100644 index 0000000..62c1540 --- /dev/null +++ b/app/public/legacy/js/imageset.js @@ -0,0 +1,866 @@ +"use strict"; + +// UI Image Set editor (uiimageset.xml): browse imagesets, see every image as a box on its texture, +// move/resize boxes, draw new ones, add/duplicate/delete images and imagesets, then save the XML +// into the pack or as a file. Lines that weren't edited are written back exactly as they were. + +const IMS = { + doc: null, // { eol, head: [lines], tail: [lines], sets: [set] } + source: null, // { kind: "pack", sessionId, entry, file } | { kind: "file", file } + texSource: null, // where textures come from (see setTexSource) + dirty: false, + set: -1, + img: -1, + undo: [], + redo: [], + zoom: 1, + panX: 20, + panY: 20, + showAll: true, + draw: false, + tex: new Map(), // file → Promise<{ bitmap, w, h, source } | { error }> +}; + +// ---------- XML model ---------- +const parseAttrs = (s) => [...s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)].map((m) => [m[1], m[2]]); +const attr = (o, k) => o.attrs.find((a) => a[0] === k)?.[1] ?? ""; +const num = (o, k) => Number(attr(o, k)) || 0; +function setAttr(o, k, v) { + const a = o.attrs.find((x) => x[0] === k); + if (a) a[1] = String(v); + else o.attrs.push([k, String(v)]); + o.raw = null; // this line is written again +} + +// Lines keep their own line break: the file mixes CRLF with bare CR in places, and untouched lines +// must come back byte for byte. `raw` = line text + its break; edited lines get the file's usual one. +function splitLines(text) { + const out = []; + const re = /([^\r\n]*)(\r\n|\r|\n|$)/g; + let m; + while ((m = re.exec(text)) && m.index < text.length) { + out.push({ text: m[1], raw: m[0] }); + if (!m[2]) break; + } + return out; +} + +function parseDoc(text) { + const crlf = (text.match(/\r\n/g) || []).length; + const eol = crlf || !text.includes("\n") ? "\r\n" : "\n"; + const doc = { eol, head: [], tail: [], sets: [] }; + let cur = null; + let pending = []; // lines between two imagesets (blank lines): they stay after the previous one + for (const { text: line, raw } of splitLines(text)) { + let m; + if ((m = /^\s*\s*$/.exec(line))) { + if (doc.sets.length) doc.sets[doc.sets.length - 1].after = pending; + pending = []; + const set = { attrs: parseAttrs(m[1]), raw, images: [], rawClose: null, selfClosing: m[2] === "/", after: [] }; + doc.sets.push(set); + cur = set.selfClosing ? null : set; + } else if (cur && (m = /^\s*\s*$/.exec(line))) { + cur.images.push({ attrs: parseAttrs(m[1]), raw }); + } else if (cur && /^\s*<\/Imageset>\s*$/.test(line)) { + cur.rawClose = raw; + cur = null; + } else if (cur) { + cur.images.push({ keep: true, raw }); // blank line etc. inside a set, kept as is + } else if (!doc.sets.length) doc.head.push(raw); + else pending.push(raw); + } + doc.tail = pending; // whatever follows the last imageset () + if (!doc.head.length) doc.head = [`${eol}`, `${eol}`]; + if (!doc.tail.some((l) => /<\/ImagesetLayout>/.test(l))) doc.tail.unshift(`${eol}`); + return doc; +} + +const attrText = (attrs) => attrs.map(([k, v]) => `${k}="${v}"`).join(" "); +function serialize(doc) { + const e = doc.eol; + const out = [...doc.head]; + // a line before a generated one must end with a break + const push = (s) => { + const last = out.length - 1; + if (last >= 0 && !/[\r\n]$/.test(out[last])) out[last] += e; + out.push(s); + }; + for (const s of doc.sets) { + const selfClose = s.selfClosing && !s.images.length; + push(s.raw ?? ` ${e}`); + if (!selfClose) { + for (const i of s.images) push(i.raw ?? ` ${e}`); + push(s.rawClose ?? ` ${e}`); + } + for (const a of s.after || []) push(a); + } + for (const t of doc.tail) push(t); + return out.join(""); +} + +const images = (set) => set.images.filter((i) => !i.keep); +const curSet = () => IMS.doc?.sets[IMS.set] || null; +const curImg = () => (curSet() ? images(curSet())[IMS.img] || null : null); +const rectOf = (i) => ({ x: num(i, "X"), y: num(i, "Y"), w: num(i, "Width"), h: num(i, "Height") }); +const clone = (o) => JSON.parse(JSON.stringify(o)); +const validName = (s) => /^[\x21-\x7e]+$/.test(s) && !/["<>&]/.test(s); + +// ---------- undo ---------- +function snapshot() { + IMS.undo.push({ sets: IMS.doc.sets.map((s) => s), setCopy: IMS.set >= 0 ? { i: IMS.set, data: clone(curSet()) } : null, set: IMS.set, img: IMS.img }); + if (IMS.undo.length > 200) IMS.undo.shift(); + IMS.redo = []; + markDirty(); +} +function restore(from, to) { + const s = from.pop(); + if (!s) return; + to.push({ sets: IMS.doc.sets.map((x) => x), setCopy: s.setCopy ? { i: s.setCopy.i, data: clone(IMS.doc.sets[s.setCopy.i] || s.setCopy.data) } : null, set: IMS.set, img: IMS.img }); + IMS.doc.sets = s.sets.slice(); + if (s.setCopy) IMS.doc.sets[s.setCopy.i] = clone(s.setCopy.data); + IMS.set = s.set; + IMS.img = s.img; + markDirty(); + renderAll(); +} +const undo = () => restore(IMS.undo, IMS.redo); +const redo = () => restore(IMS.redo, IMS.undo); + +function markDirty() { + IMS.dirty = true; + $("ims-dirty").hidden = false; +} + +// ---------- loading ---------- +async function imsSessions() { + const list = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const sel = $("ims-session"); + sel.replaceChildren(...(list.length ? list.map((s) => el("option", { value: s.id, text: s.name, title: s.file })) : [el("option", { value: "", text: "Open rs.iop in IOP Manager first" })])); + const rs = list.find((s) => s.name.toLowerCase() === "rs.iop"); + if (rs) sel.value = rs.id; + sel.disabled = !list.length; + $("ims-load").disabled = !list.length; + return list; +} + +async function confirmDiscard() { + if (!IMS.dirty) return true; + return confirmBox("Unsaved changes", "The current imageset file has changes that aren't saved. Discard them?", "Discard", "danger"); +} + +// ---------- texture source ---------- +// session = the packs next to a file open in IOP Manager; packs = a folder of .iop files; +// dir = a folder of extracted files (e.g. map.iop extracted) +async function setTexSource(src) { + try { + const d = await call("ims:describeSource", src); + IMS.texSource = { ...src, label: d.label }; + } catch (err) { + return toast(err.message, true); + } + $("ims-texsrc-label").textContent = IMS.texSource.kind === "dir" ? "Extracted folder" : IMS.texSource.kind === "packs" ? "Pack folder" : "Packs next to " + IMS.texSource.name; + $("ims-texsrc").title = `Textures: ${IMS.texSource.label}`; + IMS.tex.clear(); + if (curSet()) { + await loadTexture(); + drawCanvas(); + drawCrop(); + } +} + +/** Asks where the textures are; resolves true when a source was set. */ +async function chooseTexSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const pick = (value) => (choice = value); + let choice = null; + const options = [ + ...profileSourceOption(), + ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), + { value: "packs", title: "A folder with .iop packs…", sub: "map.iop, map2.iop, sakura.iop… — passwords found from the profiles" }, + { value: "dir", title: "A folder of extracted files…", sub: "e.g. map.iop extracted: resource/texture/*.dds" }, + ]; + const list = el( + "div", + { class: "choice-list" }, + options.map((o, i) => + el("label", { class: "choice" }, [ + el("input", { type: "radio", name: "texsrc", checked: i === 0, onchange: () => pick(o.value) }), + el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), + ]), + ), + ); + choice = options[0].value; + const ok = await modal("Where are the textures?", ["The imageset XML only names its textures (ui_icon_event1.dds). Pick where to read them from so the boxes match the right pictures.", list], [ + { label: "Cancel", value: false }, + { label: "Use this", kind: "primary", value: true }, + ]); + if (!ok) return false; + let src = choice; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); + if (!folder) return false; + src = { kind: choice, folder }; + } + await setTexSource(src); + return true; +} +$("ims-texsrc").onclick = () => chooseTexSource(); + +async function loadDoc(result) { + IMS.doc = parseDoc(result.text); + IMS.source = result.source; + IMS.dirty = false; + IMS.undo = []; + IMS.redo = []; + IMS.set = -1; + IMS.img = -1; + IMS.tex.clear(); + $("ims-dirty").hidden = true; + $("ims-title").textContent = result.source.kind === "pack" ? `${result.source.file.split(/[\\/]/).pop()} › ${result.source.entry}` : result.source.file; + $("ims-empty").hidden = true; + renderAll(); + if (IMS.doc.sets.length) selectSet(0); + const n = IMS.doc.sets.reduce((a, s) => a + images(s).length, 0); + toast(`${IMS.doc.sets.length.toLocaleString()} imagesets, ${n.toLocaleString()} images.`); +} + +$("ims-load").onclick = async () => { + if (!(await confirmDiscard())) return; + const id = Number($("ims-session").value); + try { + const entry = $("ims-entry").value.trim() || "xml/uiimageset.xml"; + const r = await call("ims:loadPack", id, entry); + // Textures default to the packs next to this one (can be changed with the Textures button) + const s = (await call("iop:sessions")).find((x) => x.id === id); + await setTexSource({ kind: "session", sessionId: id, name: s?.name || "pack" }); + await loadDoc(r); + } catch (err) { + toast(err.message, true); + } +}; +$("ims-open-file").onclick = async () => { + if (!(await confirmDiscard())) return; + const file = await call("dialog:openXml"); + if (!file) return; + try { + const r = await call("ims:loadFile", file); + // A loose XML says nothing about where its textures are: ask + if (!(await chooseTexSource())) toast("No texture source: the boxes show without pictures. Use the Textures button to pick one.", true); + await loadDoc(r); + } catch (err) { + toast(err.message, true); + } +}; + +// ---------- saving ---------- +async function saveToPack() { + if (IMS.source?.kind !== "pack") return toast("This XML came from a file. Use “Save as file…”, or load it from a pack.", true); + try { + await call("ims:savePack", IMS.source.sessionId, IMS.source.entry, serialize(IMS.doc)); + IMS.dirty = false; + $("ims-dirty").hidden = true; + toast(`Saved into ${IMS.source.file.split(/[\\/]/).pop()} (${IMS.source.entry}).`); + } catch (err) { + toast(err.message, true); + } +} +async function saveToFile() { + const name = (IMS.source?.entry || IMS.source?.file || "uiimageset.xml").split(/[\\/]/).pop(); + const file = await call("dialog:saveXml", name); + if (!file) return; + try { + await call("ims:saveFile", file, serialize(IMS.doc)); + if (IMS.source?.kind === "file") { + IMS.source.file = file; + IMS.dirty = false; + $("ims-dirty").hidden = true; + } + toast(`Saved ${file}.`); + } catch (err) { + toast(err.message, true); + } +} +$("ims-save").onclick = (e) => + showMenu( + [ + { label: "Save into the pack", icon: "box", disabled: IMS.source?.kind !== "pack", hint: "Ctrl+S", run: saveToPack }, + { label: "Save as file…", icon: "download", disabled: !IMS.doc, run: saveToFile }, + "-", + { label: "Export this image as PNG…", icon: "external", disabled: !curImg(), run: exportPng }, + { + label: "Reload textures", + icon: "refresh", + disabled: !IMS.texSource, + run: async () => { + await call("ims:reloadTextures", IMS.texSource); + IMS.tex.clear(); + await loadTexture(); + drawCanvas(); + drawCrop(); + }, + }, + ], + { anchor: e.currentTarget }, + ); +$("ims-undo").onclick = undo; +$("ims-redo").onclick = redo; + +// ---------- imageset list ---------- +function renderSets() { + const q = $("ims-search").value.trim().toLowerCase(); + const list = $("ims-sets"); + if (!IMS.doc) return list.replaceChildren(); + const frag = document.createDocumentFragment(); + IMS.doc.sets.forEach((s, i) => { + const name = attr(s, "Name"); + const file = attr(s, "File"); + if (q && !name.toLowerCase().includes(q) && !file.toLowerCase().includes(q)) return; + frag.append( + el("div", { class: `ims-set${i === IMS.set ? " on" : ""}`, "data-i": i, onclick: () => selectSet(i) }, [ + el("b", { text: name || "(no name)" }), + el("span", { text: `${file} · ${images(s).length}` }), + ]), + ); + }); + list.replaceChildren(frag); + $("ims-set-count").textContent = `${IMS.doc.sets.length.toLocaleString()}`; +} +$("ims-search").addEventListener("input", renderSets); + +async function selectSet(i, { keepView = false } = {}) { + IMS.set = i; + IMS.img = images(curSet()).length ? 0 : -1; + for (const n of $("ims-sets").children) n.classList.toggle("on", Number(n.dataset.i) === i); + renderSide(); + await loadTexture(); + if (!keepView) fitView(); + drawCanvas(); +} + +function selectImg(i) { + IMS.img = i; + renderImages(); + renderImgForm(); + drawCanvas(); +} + +// ---------- textures ---------- +function textureFor(file) { + const key = file.toLowerCase(); + if (!IMS.tex.has(key)) { + IMS.tex.set( + key, + (async () => { + if (!IMS.texSource) return { error: "No texture source — use the Textures button" }; + const t = await call("ims:texture", IMS.texSource, file); + if (!t) return { error: `${file} isn't in ${IMS.texSource.label}` }; + try { + const bitmap = await TexDecode.decode(t.bytes, t.ext); + return { bitmap, w: bitmap.width, h: bitmap.height, source: t.source }; + } catch (err) { + return { error: `${file}: ${err.message}` }; + } + })(), + ); + } + return IMS.tex.get(key); +} + +async function loadTexture() { + const s = curSet(); + IMS.cur = null; + $("ims-texinfo").textContent = "Loading texture…"; + if (!s) return; + const t = await textureFor(attr(s, "File")); + if (curSet() !== s) return; + IMS.cur = t.error ? null : t; + $("ims-texinfo").textContent = t.error ? t.error : `${t.w} × ${t.h} · ${t.source}`; + $("ims-texinfo").classList.toggle("bad", !!t.error); +} + +// ---------- side panel ---------- +function field(label, value, onCommit, { type = "text", width } = {}) { + const input = el("input", { type, value, spellcheck: "false" }); + if (width) input.style.width = width; + input.addEventListener("change", () => onCommit(input.value.trim(), input)); + input.addEventListener("keydown", (e) => e.key === "Enter" && input.blur()); + return el("label", { class: "fld" }, [el("span", { text: label }), input]); +} + +function renderSide() { + const s = curSet(); + $("ims-side").hidden = !s; + if (!s) return; + $("ims-setform").replaceChildren( + field("Name", attr(s, "Name"), (v, inp) => { + if (!validName(v)) return ((inp.value = attr(s, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true)); + snapshot(); + setAttr(s, "Name", v); + renderSets(); + }), + field("Texture", attr(s, "File"), async (v, inp) => { + if (!validName(v)) return ((inp.value = attr(s, "File")), toast("Use a plain file name, e.g. ui_icon_event1.dds.", true)); + snapshot(); + setAttr(s, "File", v); + renderSets(); + await loadTexture(); + drawCanvas(); + }), + ...s.attrs.filter(([k]) => k !== "Name" && k !== "File").map(([k, v]) => + field(k, v, (nv) => { + snapshot(); + setAttr(s, k, nv); + }), + ), + ); + renderImages(); + renderImgForm(); +} + +function renderImages() { + const s = curSet(); + if (!s) return; + const q = $("ims-img-search").value.trim().toLowerCase(); + const frag = document.createDocumentFragment(); + images(s).forEach((im, i) => { + const name = attr(im, "Name"); + if (q && !name.toLowerCase().includes(q)) return; + const r = rectOf(im); + frag.append(el("div", { class: `ims-img${i === IMS.img ? " on" : ""}`, onclick: () => selectImg(i) }, [el("span", { text: name }), el("em", { text: `${r.w}×${r.h}` })])); + }); + $("ims-images").replaceChildren(frag); + $("ims-img-count").textContent = images(s).length.toLocaleString(); + $("ims-images").querySelector(".on")?.scrollIntoView({ block: "nearest" }); +} +$("ims-img-search").addEventListener("input", renderImages); + +function renderImgForm() { + const im = curImg(); + $("ims-imgbox").hidden = !im; + if (!im) return; + const intField = (k) => + field(k, attr(im, k), (v, inp) => { + if (v !== "" && !/^-?\d+$/.test(v)) return ((inp.value = attr(im, k)), toast("Whole numbers only.", true)); + snapshot(); + if (v === "" && k.startsWith("Offset")) { + im.attrs = im.attrs.filter((a) => a[0] !== k); + im.raw = null; + } else setAttr(im, k, v || "0"); + renderImages(); + drawCanvas(); + drawCrop(); + }, { type: "text", width: "100%" }); + $("ims-imgform").replaceChildren( + field("Name", attr(im, "Name"), (v, inp) => { + if (!validName(v)) return ((inp.value = attr(im, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true)); + if (images(curSet()).some((x) => x !== im && attr(x, "Name") === v)) return ((inp.value = attr(im, "Name")), toast("Another image in this imageset has that name.", true)); + snapshot(); + setAttr(im, "Name", v); + renderImages(); + }), + el("div", { class: "fld-grid" }, ["X", "Y", "Width", "Height", "OffsetX", "OffsetY"].map(intField)), + ); + drawCrop(); +} + +/** The selected image at its real size (2× when small). */ +function drawCrop() { + const im = curImg(); + const c = $("ims-crop"); + const ctx = c.getContext("2d"); + if (!im || !IMS.cur) { + c.width = c.height = 0; + return; + } + const r = rectOf(im); + const scale = Math.max(1, Math.min(4, Math.floor(160 / Math.max(r.w, r.h, 1)))); + c.width = Math.max(1, r.w * scale); + c.height = Math.max(1, r.h * scale); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w * scale, r.h * scale); +} + +async function exportPng() { + const im = curImg(); + if (!im || !IMS.cur) return; + const r = rectOf(im); + const c = document.createElement("canvas"); + c.width = r.w; + c.height = r.h; + c.getContext("2d").drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h); + const file = await call("dialog:savePng", `${attr(im, "Name")}.png`); + if (!file) return; + await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]); + toast(`Saved ${file}.`); +} + +// ---------- add / duplicate / delete ---------- +function uniqueName(set, base) { + const names = new Set(images(set).map((i) => attr(i, "Name"))); + if (!names.has(base)) return base; + for (let n = 2; ; n++) if (!names.has(`${base}_${n}`)) return `${base}_${n}`; +} + +function addImage(rect) { + const s = curSet(); + if (!s) return; + snapshot(); + const r = rect || { x: 0, y: 0, w: 32, h: 32 }; + const im = { attrs: [["Name", uniqueName(s, "new_image")], ["X", r.x], ["Y", r.y], ["Width", r.w], ["Height", r.h]].map(([k, v]) => [k, String(v)]), raw: null }; + s.images.push(im); + s.selfClosing = false; + if (!s.rawClose) s.raw = null; + IMS.img = images(s).length - 1; + renderSets(); + renderImages(); + renderImgForm(); + drawCanvas(); +} +$("ims-add-img").onclick = () => addImage(); +$("ims-dup-img").onclick = () => { + const im = curImg(); + if (!im) return; + const r = rectOf(im); + addImage({ x: r.x + 4, y: r.y + 4, w: r.w, h: r.h }); + const copy = curImg(); + for (const [k, v] of im.attrs) if (!["Name", "X", "Y"].includes(k)) setAttr(copy, k, v); + setAttr(copy, "Name", uniqueName(curSet(), attr(im, "Name") + "_copy")); + renderImages(); + renderImgForm(); +}; +function deleteImage() { + const s = curSet(); + const im = curImg(); + if (!im) return; + snapshot(); + s.images.splice(s.images.indexOf(im), 1); + IMS.img = Math.min(IMS.img, images(s).length - 1); + renderSets(); + renderImages(); + renderImgForm(); + drawCanvas(); +} +$("ims-del-img").onclick = deleteImage; + +$("ims-add-set").onclick = async () => { + if (!IMS.doc) return; + const name = await prompt("New imageset", "Imageset name (letters, digits, _)", "NewImageset"); + if (!name) return; + if (!validName(name)) return toast("Use letters, digits and _ (no spaces or quotes).", true); + if (IMS.doc.sets.some((s) => attr(s, "Name") === name)) return toast("An imageset with that name already exists.", true); + const file = await prompt("Texture", "Texture file name (in resource/texture of the packs)", curSet() ? attr(curSet(), "File") : "ui_new.dds"); + if (!file) return; + snapshot(); + IMS.doc.sets.push({ attrs: [["Name", name], ["File", file]], raw: null, images: [], rawClose: null, selfClosing: false }); + renderSets(); + await selectSet(IMS.doc.sets.length - 1); + $("ims-sets").lastChild?.scrollIntoView({ block: "nearest" }); +}; +$("ims-del-set").onclick = async () => { + const s = curSet(); + if (!s) return; + if (!(await confirmBox("Delete imageset", `Delete the imageset “${attr(s, "Name")}” and its ${images(s).length} images?`, "Delete", "danger"))) return; + snapshot(); + IMS.doc.sets.splice(IMS.set, 1); + IMS.set = Math.min(IMS.set, IMS.doc.sets.length - 1); + renderSets(); + if (IMS.set >= 0) selectSet(IMS.set); + else renderAll(); +}; + +// ---------- canvas ---------- +const canvas = $("ims-canvas"); +const ctx = canvas.getContext("2d"); +let checker = null; + +function resizeCanvas() { + const r = canvas.parentElement.getBoundingClientRect(); + const dpr = devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(r.width * dpr)); + canvas.height = Math.max(1, Math.round(r.height * dpr)); + canvas.style.width = `${r.width}px`; + canvas.style.height = `${r.height}px`; + drawCanvas(); +} +new ResizeObserver(resizeCanvas).observe(canvas.parentElement); + +function fitView() { + const t = IMS.cur; + const r = canvas.parentElement.getBoundingClientRect(); + if (!t) { + IMS.zoom = 1; + IMS.panX = IMS.panY = 20; + return updateZoomLabel(); + } + IMS.zoom = Math.max(0.05, Math.min(8, Math.min((r.width - 40) / t.w, (r.height - 40) / t.h))); + IMS.panX = (r.width - t.w * IMS.zoom) / 2; + IMS.panY = (r.height - t.h * IMS.zoom) / 2; + updateZoomLabel(); +} +function setZoom(z, cx, cy) { + const r = canvas.getBoundingClientRect(); + const x = cx ?? r.width / 2; + const y = cy ?? r.height / 2; + const nz = Math.max(0.05, Math.min(16, z)); + IMS.panX = x - ((x - IMS.panX) * nz) / IMS.zoom; + IMS.panY = y - ((y - IMS.panY) * nz) / IMS.zoom; + IMS.zoom = nz; + updateZoomLabel(); + drawCanvas(); +} +const updateZoomLabel = () => ($("ims-zoom").textContent = `${Math.round(IMS.zoom * 100)}%`); +$("ims-zoom-in").onclick = () => setZoom(IMS.zoom * 1.25); +$("ims-zoom-out").onclick = () => setZoom(IMS.zoom / 1.25); +$("ims-zoom").onclick = () => setZoom(1); +$("ims-fit").onclick = () => (fitView(), drawCanvas()); +$("ims-showall").onclick = () => { + IMS.showAll = !IMS.showAll; + $("ims-showall").classList.toggle("on", IMS.showAll); + drawCanvas(); +}; +$("ims-drawmode").onclick = () => { + IMS.draw = !IMS.draw; + $("ims-drawmode").classList.toggle("on", IMS.draw); + canvas.classList.toggle("drawing", IMS.draw); +}; + +function drawCanvas() { + const dpr = devicePixelRatio || 1; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.setTransform(dpr * IMS.zoom, 0, 0, dpr * IMS.zoom, dpr * IMS.panX, dpr * IMS.panY); + const t = IMS.cur; + const s = curSet(); + if (t) { + if (!checker) { + const c = document.createElement("canvas"); + c.width = c.height = 16; + const g = c.getContext("2d"); + g.fillStyle = "#2a2830"; + g.fillRect(0, 0, 16, 16); + g.fillStyle = "#34313b"; + g.fillRect(0, 0, 8, 8); + g.fillRect(8, 8, 8, 8); + checker = ctx.createPattern(c, "repeat"); + } + ctx.fillStyle = checker; + ctx.fillRect(0, 0, t.w, t.h); + ctx.imageSmoothingEnabled = IMS.zoom < 1; + ctx.drawImage(t.bitmap, 0, 0); + } + if (!s) return; + const px = 1 / IMS.zoom; + const list = images(s); + list.forEach((im, i) => { + if (i === IMS.img || (!IMS.showAll && i !== IMS.hover)) return; + const r = rectOf(im); + ctx.lineWidth = px; + ctx.strokeStyle = i === IMS.hover ? "rgba(255,255,255,.95)" : "rgba(233,180,60,.55)"; + ctx.strokeRect(r.x + px / 2, r.y + px / 2, r.w - px, r.h - px); + }); + const sel = curImg(); + if (sel) { + const r = rectOf(sel); + ctx.fillStyle = "rgba(233,180,60,.18)"; + ctx.fillRect(r.x, r.y, r.w, r.h); + ctx.lineWidth = 2 * px; + ctx.strokeStyle = "#f4c95a"; + ctx.strokeRect(r.x, r.y, r.w, r.h); + // offset point (OffsetX/Y: the image's pivot) + if (attr(sel, "OffsetX") !== "" || attr(sel, "OffsetY") !== "") { + const ox = r.x + num(sel, "OffsetX"); + const oy = r.y + num(sel, "OffsetY"); + ctx.strokeStyle = "#ff6b5e"; + ctx.beginPath(); + ctx.moveTo(ox - 5 * px, oy); + ctx.lineTo(ox + 5 * px, oy); + ctx.moveTo(ox, oy - 5 * px); + ctx.lineTo(ox, oy + 5 * px); + ctx.stroke(); + } + ctx.fillStyle = "#f4c95a"; + for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px); + } + if (IMS.drawRect) { + const r = IMS.drawRect; + ctx.lineWidth = px; + ctx.setLineDash([4 * px, 3 * px]); + ctx.strokeStyle = "#3ec07a"; + ctx.strokeRect(r.x, r.y, r.w, r.h); + ctx.setLineDash([]); + } +} + +const handles = (r) => [ + [r.x, r.y, "nw"], + [r.x + r.w / 2, r.y, "n"], + [r.x + r.w, r.y, "ne"], + [r.x + r.w, r.y + r.h / 2, "e"], + [r.x + r.w, r.y + r.h, "se"], + [r.x + r.w / 2, r.y + r.h, "s"], + [r.x, r.y + r.h, "sw"], + [r.x, r.y + r.h / 2, "w"], +]; +const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" }; + +function toTex(e) { + const b = canvas.getBoundingClientRect(); + return { x: (e.clientX - b.left - IMS.panX) / IMS.zoom, y: (e.clientY - b.top - IMS.panY) / IMS.zoom }; +} +function hitHandle(p) { + const sel = curImg(); + if (!sel) return null; + const tol = 6 / IMS.zoom; + return handles(rectOf(sel)).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null; +} +/** Smallest box under the point (so small icons inside bigger ones can be picked). */ +function hitImage(p) { + const s = curSet(); + if (!s) return -1; + let best = -1; + let area = Infinity; + images(s).forEach((im, i) => { + const r = rectOf(im); + if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h < area) { + best = i; + area = r.w * r.h; + } + }); + return best; +} + +let drag = null; +canvas.addEventListener("mousedown", (e) => { + if (!curSet()) return; + canvas.focus(); + const p = toTex(e); + if (e.button === 1 || e.button === 2 || (e.button === 0 && e.altKey)) { + drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY }; + } else if (IMS.draw) { + drag = { mode: "draw", start: { x: Math.round(p.x), y: Math.round(p.y) } }; + } else { + const h = hitHandle(p); + if (h) { + snapshot(); + drag = { mode: "resize", h, start: p, r: rectOf(curImg()) }; + } else { + const i = hitImage(p); + if (i >= 0) { + if (i !== IMS.img) selectImg(i); + snapshot(); + drag = { mode: "move", start: p, r: rectOf(curImg()), moved: false }; + } else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY }; + } + } + e.preventDefault(); +}); +canvas.addEventListener("contextmenu", (e) => e.preventDefault()); + +window.addEventListener("mousemove", (e) => { + if (!$("view-ui").classList.contains("on")) return; + if (!drag) { + if (e.target !== canvas || !curSet()) return; + const p = toTex(e); + const h = !IMS.draw && hitHandle(p); + canvas.style.cursor = IMS.draw ? "crosshair" : h ? CURSOR[h] : hitImage(p) >= 0 ? "move" : "grab"; + const hov = hitImage(p); + if (hov !== IMS.hover) { + IMS.hover = hov; + $("ims-hover").textContent = hov >= 0 ? `${attr(images(curSet())[hov], "Name")} ${Math.floor(p.x)}, ${Math.floor(p.y)}` : `${Math.floor(p.x)}, ${Math.floor(p.y)}`; + drawCanvas(); + } else $("ims-hover").textContent = `${hov >= 0 ? attr(images(curSet())[hov], "Name") + " " : ""}${Math.floor(p.x)}, ${Math.floor(p.y)}`; + return; + } + if (drag.mode === "pan") { + IMS.panX = drag.px + e.clientX - drag.sx; + IMS.panY = drag.py + e.clientY - drag.sy; + canvas.style.cursor = "grabbing"; + return drawCanvas(); + } + const p = toTex(e); + if (drag.mode === "draw") { + const x = Math.round(p.x); + const y = Math.round(p.y); + IMS.drawRect = { x: Math.min(x, drag.start.x), y: Math.min(y, drag.start.y), w: Math.abs(x - drag.start.x), h: Math.abs(y - drag.start.y) }; + return drawCanvas(); + } + const im = curImg(); + const dx = Math.round(p.x - drag.start.x); + const dy = Math.round(p.y - drag.start.y); + let { x, y, w, h } = drag.r; + if (drag.mode === "move") { + x += dx; + y += dy; + drag.moved = drag.moved || dx || dy; + } else { + if (drag.h.includes("w")) (x += dx), (w -= dx); + if (drag.h.includes("e")) w += dx; + if (drag.h.includes("n")) (y += dy), (h -= dy); + if (drag.h.includes("s")) h += dy; + if (w < 1) (x += w - 1), (w = 1); + if (h < 1) (y += h - 1), (h = 1); + } + setAttr(im, "X", x); + setAttr(im, "Y", y); + setAttr(im, "Width", w); + setAttr(im, "Height", h); + drawCanvas(); + $("ims-hover").textContent = `${attr(im, "Name")} X ${x} Y ${y} ${w} × ${h}`; +}); + +window.addEventListener("mouseup", () => { + if (!drag) return; + const d = drag; + drag = null; + canvas.style.cursor = ""; + if (d.mode === "draw" && IMS.drawRect) { + const r = IMS.drawRect; + IMS.drawRect = null; + if (r.w >= 2 && r.h >= 2) addImage(r); + else drawCanvas(); + } else if (d.mode === "move" || d.mode === "resize") { + if (d.mode === "move" && !d.moved) IMS.undo.pop(); // just a click: nothing changed + renderImages(); + renderImgForm(); + } +}); + +canvas.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + const b = canvas.getBoundingClientRect(); + setZoom(IMS.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15), e.clientX - b.left, e.clientY - b.top); + }, + { passive: false }, +); + +// Keyboard: arrows nudge (Shift = 10 px), Delete removes, Ctrl+Z / Ctrl+Y, Ctrl+S saves +document.addEventListener("keydown", (e) => { + if (!$("view-ui").classList.contains("on") || !IMS.doc) return; + if (e.target.matches?.("input, textarea, select")) return; + if (e.ctrlKey && e.key.toLowerCase() === "z") return e.preventDefault(), undo(); + if (e.ctrlKey && e.key.toLowerCase() === "y") return e.preventDefault(), redo(); + if (e.ctrlKey && e.key.toLowerCase() === "s") return e.preventDefault(), IMS.source?.kind === "pack" ? saveToPack() : saveToFile(); + const im = curImg(); + if (!im) return; + const step = e.shiftKey ? 10 : 1; + const move = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key]; + if (move) { + e.preventDefault(); + if (!e.repeat) snapshot(); + setAttr(im, "X", num(im, "X") + move[0]); + setAttr(im, "Y", num(im, "Y") + move[1]); + drawCanvas(); + renderImgForm(); + } else if (e.key === "Delete") deleteImage(); +}); + +function renderAll() { + renderSets(); + renderSide(); + drawCanvas(); +} + +// Refresh the pack list whenever the view opens +document.querySelector('[data-view="ui"]').addEventListener("click", () => imsSessions()); diff --git a/app/public/legacy/js/items.js b/app/public/legacy/js/items.js new file mode 100644 index 0000000..a3ada7b --- /dev/null +++ b/app/public/legacy/js/items.js @@ -0,0 +1,232 @@ +"use strict"; + +// Items tab: every item of the game, read from its packs by src/main/items.js (names from the text +// table, icons from xml/uiimageset.xml through UIRes, prices, box contents, descriptions). It reads +// the packs as they are, so it always shows the current rs.iop / map.iop; Reload reads them again. + +(() => { + const IT = { src: null, data: null, cat: "etc", text: null, table: null, loading: false, detailSeq: 0 }; + window.IT = IT; // (dev probes) + const n = (v) => Number(v || 0).toLocaleString(); + const PRICE = (s) => (s.cash ? `${n(s.cash)} cash` : s.peso ? `${n(s.peso)} peso` : ""); + + // ---------- icons (UIRes: imageset → texture, same drawing as the window editor) ---------- + function iconCanvas(name, px) { + const c = el("canvas", { class: "it-icon", width: px, height: px }); + c.style.width = `${px}px`; + c.style.height = `${px}px`; + if (name) drawIcon(c, name, px); + return c; + } + async function drawIcon(c, name, px) { + const i = name.indexOf("#"); + const im = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : await UIRes.image(null, null, name); + if (!im?.bmp) { + c.classList.add("missing"); + c.title = `${name} (not found)`; + return; + } + const ctx = c.getContext("2d"); + const scale = Math.min(px / im.w, px / im.h); + const w = Math.round(im.w * scale); + const h = Math.round(im.h * scale); + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, (px - w) / 2, (px - h) / 2, w, h); + c.title = name; + } + + // ---------- list ---------- + function table() { + if (IT.table) return IT.table; + IT.table = new VTable( + $("it-table"), + [ + { key: "icon", label: "", width: 40, format: (r) => iconCanvas(r.icon, 30) }, + { key: "id", label: "ID", width: 90, cls: "num mono" }, + { key: "name", label: "Name", grow: true, width: 200, format: (r) => r.name || "(no name)", sort: (r) => r.name || "￿", title: true }, + { key: "kind", label: "Type", width: 110, title: true }, + { key: "price", label: "Price", width: 120, cls: "num", format: (r) => (r.shop?.length ? PRICE(r.shop.find((s) => s.cash || s.peso) || r.shop[0]) : ""), sort: (r) => (r.shop?.[0]?.cash || 0) * 1e6 + (r.shop?.[0]?.peso || 0) }, + ], + { multi: false, rowHeight: 36, id: "items" }, + ); + IT.table.keyOf = (r) => r.index; + IT.table.onSelect = (_sel, r) => r && showDetail(r); + return IT.table; + } + + function renderCats() { + const nav = $("it-cats"); + nav.replaceChildren( + ...IT.data.categories.map((c) => + el("button", { class: c.id === IT.cat ? "on" : "", title: c.error || "", onclick: () => pickCat(c.id) }, [ + el("span", { text: c.label }), + el("b", { text: c.error ? "!" : n(c.items.filter(shown).length) }), + ]), + ), + ); + } + + const shown = (it) => !$("it-hide").checked || (it.name && !/^STR\(/i.test(it.name)); + + function pickCat(id) { + IT.cat = id; + renderCats(); + const c = IT.data.categories.find((x) => x.id === id); + table().setRows(c.items.map((it, index) => ({ ...it, index })), { animate: "fade" }); + filter(); + $("it-detail").replaceChildren(el("div", { class: "it-empty muted", text: c.error ? `Couldn't read this category: ${c.error}` : "Pick an item." })); + } + + function filter() { + const q = $("it-search").value.trim().toLowerCase(); + table().setView((r) => shown(r) && (!q || String(r.id).includes(q) || (r.name || "").toLowerCase().includes(q) || (r.icon || "").toLowerCase().includes(q) || (r.section || "").toLowerCase().includes(q))); + $("it-count").textContent = `${n(table().view.length)} of ${n(table().rows.length)}`; + } + + // ---------- detail ---------- + function row(label, value) { + return value === undefined || value === null || value === "" ? null : el("div", { class: "it-kv" }, [el("span", { class: "muted", text: label }), el("span", { text: String(value) })]); + } + + async function showDetail(r) { + const seq = ++IT.detailSeq; + const box = $("it-detail"); + const shop = (r.shop || []).filter((s) => s.cash || s.peso || s.value || s.period); + box.replaceChildren( + el("div", { class: "it-head" }, [ + iconCanvas(r.icon, 72), + el("div", {}, [ + el("h3", { text: r.name || "(no name)" }), + el("div", { class: "muted", text: `${IT.data.categories.find((c) => c.id === IT.cat)?.label} · ID ${r.id}` }), + el("code", { class: "muted", text: r.icon || "no icon" }), + ]), + ]), + el("div", { class: "it-kvs" }, [ + row("Original", r.originalName), + row("Type", r.kind), + row("Group", r.group), + row("Grade", r.grade || null), + row("Classes", r.enableClass), + row("Level", r.needLevel || null), + row("Sell price", r.sellPeso ? `${n(r.sellPeso)} peso` : r.canSell === false ? "can't be sold" : null), + row("Active", r.active === null || r.active === undefined ? null : r.active ? "yes" : "no"), + row("Source", `${r.file} [${r.section}]`), + ].filter(Boolean)), + ...(shop.length + ? [ + el("h4", { text: "Shop" }), + el("table", { class: "it-shop" }, [ + el("tr", {}, ["Item", "Count / days", "Peso", "Cash", "Bonus peso", "On sale"].map((h) => el("th", { text: h }))), + ...shop.map((s) => + el("tr", { class: s.active === false ? "off" : "" }, [ + el("td", { text: s.label || "" }), + el("td", { text: s.period ? `${n(s.period)} h` : s.value ? n(s.value) : "" }), + el("td", { class: "num", text: s.peso ? n(s.peso) : "" }), + el("td", { class: "num", text: s.cash ? n(s.cash) : "" }), + el("td", { class: "num", text: s.bonusPeso ? n(s.bonusPeso) : "" }), + el("td", { text: s.active === null || s.active === undefined ? "" : s.active ? "yes" : "no" }), + ]), + ), + ]), + ] + : []), + ...(r.contents?.length + ? [ + el("h4", { text: `Contents (${r.contents.length})` }), + el( + "div", + { class: "it-contents" }, + r.contents.map((c) => + el("div", { class: "it-content" }, [ + iconCanvas(c.icon, 26), + el("span", { text: c.name && !/^STR\(/i.test(c.name) ? c.name : `#${c.code}` }), + el("span", { class: "muted num", text: c.percent !== null && c.percent !== undefined ? `${c.percent.toFixed(c.percent < 1 ? 2 : 1)}%` : "" }), + ]), + ), + ), + ] + : []), + el("div", { class: "it-manuals muted", text: r.manuals?.length ? "Loading the description…" : "" }), + ); + let d = null; + try { + d = await call("items:detail", IT.src, IT.text, IT.cat, r.index); + } catch (err) { + toast(err.message, true); + } + if (seq !== IT.detailSeq || !d) return; + const manuals = box.querySelector(".it-manuals"); + manuals.classList.remove("muted"); + manuals.replaceChildren( + ...d.manuals.map((m) => + el("div", { class: "it-manual" }, [ + el("h4", { text: `${m.label} description` }, [el("span", { class: "muted", text: ` ${m.file} [Manual${m.n}]` })]), + ...(m.manual + ? [ + ...(m.manual.title.length ? [el("div", { class: "it-mtitle" }, m.manual.title.map((x) => el("span", { text: x.text, style: x.color ? `color:${x.color}` : "" })))] : []), + ...m.manual.lines.map((l) => el("div", { class: "it-mline" }, l.runs.map((x) => el("span", { text: x.text, style: x.color ? `color:${x.color}` : "" })))), + ] + : [el("div", { class: "muted", text: "(missing)" })]), + ]), + ), + el("details", { class: "it-raw" }, [ + el("summary", { text: `All fields (${Object.keys(d.fields).length})` }), + el("table", {}, Object.entries(d.fields).map(([k, v]) => el("tr", {}, [el("td", { class: "mono", text: k }), el("td", { class: "mono", text: v })]))), + ]), + ); + } + + // ---------- loading ---------- + async function load({ reload = false } = {}) { + if (IT.loading) return; + if (!IT.src) IT.src = profileSource(); + if (!IT.src) { + $("it-source").textContent = "This profile has no game folder: set one in the profile, or pick a folder."; + return; + } + IT.loading = true; + $("it-source").textContent = `Reading ${IT.src.folder}…`; + try { + const [data] = await Promise.all([call("items:load", IT.src, { textFile: IT.text, reload }), UIRes.load(IT.src)]); + IT.data = data; + IT.text = data.text; + $("it-text").replaceChildren(...data.texts.map((t) => el("option", { value: t.file, text: t.label, selected: t.file === data.text }))); + $("it-source").textContent = `${IT.src.folder} · ${n(data.strings)} texts from ${data.text || "no text table"}${data.missing.length ? ` · missing: ${data.missing.join(", ")}` : ""}`; + if (!data.categories.some((c) => c.id === IT.cat)) IT.cat = data.categories[0]?.id; + pickCat(IT.cat); + } catch (err) { + $("it-source").textContent = err.message; + toast(err.message, true); + } finally { + IT.loading = false; + } + } + + $("it-search").addEventListener("input", filter); + $("it-hide").addEventListener("change", () => (renderCats(), filter())); + $("it-text").addEventListener("change", () => { + IT.text = $("it-text").value; + load(); + }); + $("it-reload").addEventListener("click", () => { + UIRes.reset(); + load({ reload: true }); + }); + $("it-folder").addEventListener("click", async () => { + const dir = await call("dialog:folder", "Game folder (with rs.iop / map.iop)"); + if (!dir) return; + IT.src = { kind: "packs", folder: dir, name: dir }; + IT.data = null; + UIRes.reset(); + load(); + }); + // first time the tab opens: the profile's game folder + document.querySelector('#tabs [data-view="items"]').addEventListener("click", () => { + if (!IT.data) load(); + }); + window.addEventListener("profile-changed", () => { + IT.src = null; + IT.data = null; + if ($("view-items").classList.contains("on")) load(); + }); +})(); diff --git a/app/public/legacy/js/lsformats.js b/app/public/legacy/js/lsformats.js new file mode 100644 index 0000000..6ca3159 --- /dev/null +++ b/app/public/legacy/js/lsformats.js @@ -0,0 +1,710 @@ +"use strict"; + +// Lost Saga 3D formats (io3DEngine): .msh meshes, .skl skeletons, .ani animations, material +// scripts and model configs. Little-endian, no compression; strings are int32 length + raw bytes +// (CP949, no terminator). Readers keep the raw bytes of everything they don't understand or the +// editor doesn't touch, so a save changes only what was edited. + +const LSF = (() => { + const cp949 = new TextDecoder("euc-kr"); + + class Reader { + constructor(bytes) { + this.b = bytes; + this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + this.o = 0; + } + need(n) { + if (this.o + n > this.b.length) throw new Error(`File ends early (offset ${this.o}, needs ${n} bytes)`); + } + i32() { + this.need(4); + const v = this.dv.getInt32(this.o, true); + this.o += 4; + return v; + } + u32() { + this.need(4); + const v = this.dv.getUint32(this.o, true); + this.o += 4; + return v; + } + f32() { + this.need(4); + const v = this.dv.getFloat32(this.o, true); + this.o += 4; + return v; + } + /** n floats as a Float32Array (copied, so it is aligned). */ + f32s(n) { + this.need(n * 4); + const out = new Float32Array(this.b.slice(this.o, this.o + n * 4).buffer); + this.o += n * 4; + return out; + } + u32s(n) { + this.need(n * 4); + const out = new Uint32Array(this.b.slice(this.o, this.o + n * 4).buffer); + this.o += n * 4; + return out; + } + str() { + const n = this.i32(); + if (n < 0 || n > 4096) throw new Error(`Bad string length ${n} at offset ${this.o - 4}`); + this.need(n); + const raw = this.b.slice(this.o, this.o + n); + this.o += n; + return { raw, text: cp949.decode(raw) }; + } + magic(m) { + this.need(4); + const s = String.fromCharCode(this.b[this.o], this.b[this.o + 1], this.b[this.o + 2]); + if (s !== m || this.b[this.o + 3] !== 0) throw new Error(`Not a .${m.toLowerCase()} file`); + this.o += 4; + } + } + + class Writer { + constructor(size = 1 << 16) { + this.b = new Uint8Array(size); + this.dv = new DataView(this.b.buffer); + this.o = 0; + } + grow(n) { + if (this.o + n <= this.b.length) return; + let size = this.b.length * 2; + while (size < this.o + n) size *= 2; + const nb = new Uint8Array(size); + nb.set(this.b); + this.b = nb; + this.dv = new DataView(nb.buffer); + } + i32(v) { + this.grow(4); + this.dv.setInt32(this.o, v, true); + this.o += 4; + } + u32(v) { + this.grow(4); + this.dv.setUint32(this.o, v >>> 0, true); + this.o += 4; + } + f32(v) { + this.grow(4); + this.dv.setFloat32(this.o, v, true); + this.o += 4; + } + bytes(u8) { + this.grow(u8.length); + this.b.set(u8, this.o); + this.o += u8.length; + } + /** A string: its original bytes when the text wasn't changed, else ASCII. */ + str(s) { + const raw = s.raw && cp949.decode(s.raw) === s.text ? s.raw : ascii(s.text); + this.i32(raw.length); + this.bytes(raw); + } + done() { + return this.b.slice(0, this.o); + } + } + + function ascii(text) { + const out = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c > 0x7e || c < 0x20) throw new Error(`“${text}”: only plain ASCII text can be written here.`); + out[i] = c; + } + return out; + } + const S = (text) => ({ raw: null, text }); // a new string (written as ASCII) + + // ---------- .msh ---------- + const MESH_TYPES = ["Static", "Animated", "Lightmap", "Billboard", "Normal billboard", "Animate effect", "Static vertex colour"]; + const FVF = { POSITION: 0x1, POSITION2: 0x2, WEIGHTS: 0x8, INDICES: 0x10, NORMAL: 0x20, COLOR0: 0x40, UV0: 0x100, UV1: 0x200, TANGENT: 0x1000, BINORMAL: 0x2000 }; + + function parseMsh(bytes) { + const r = new Reader(bytes); + r.magic("MSH"); + const m = { version: r.i32(), type: r.i32(), mask: r.u32() }; + m.aabbMin = [r.f32(), r.f32(), r.f32()]; + m.aabbMax = [r.f32(), r.f32(), r.f32()]; + m.radius = r.f32(); + const subCount = r.i32(); + if (subCount < 0 || subCount > 4096) throw new Error("Bad sub-mesh count"); + m.subs = []; + for (let i = 0; i < subCount; i++) m.subs.push({ minIndex: r.i32(), vertexCount: r.i32(), indexStart: r.i32(), faceCount: r.i32() }); + const V = (m.vertexCount = r.i32()); + if (V < 0 || V > 4e6) throw new Error("Bad vertex count"); + const k = m.mask; + if (k & FVF.POSITION) m.pos = r.f32s(V * 3); + if (k & FVF.NORMAL) m.normal = r.f32s(V * 3); + if (k & FVF.TANGENT) m.tangent = r.f32s(V * 3); + if (k & FVF.BINORMAL) m.binormal = r.f32s(V * 3); + if (k & FVF.COLOR0) m.color = r.u32s(V); + if (k & FVF.UV0) m.uv0 = r.f32s(V * 2); + if (k & FVF.UV1) m.uv1 = r.f32s(V * 2); + if (k & FVF.WEIGHTS) { + const n = r.i32(); + if (n < 0 || n > 1024) throw new Error("Bad bone count"); + m.boneNames = []; + for (let i = 0; i < n; i++) m.boneNames.push(r.str()); + // per vertex: weight[4], boneIndex[4] (indices stored as floats) + const blk = r.f32s(V * 8); + m.weights = new Float32Array(V * 4); + m.boneIdx = new Uint8Array(V * 4); + for (let v = 0; v < V; v++) { + for (let j = 0; j < 4; j++) { + m.weights[v * 4 + j] = blk[v * 8 + j]; + m.boneIdx[v * 4 + j] = Math.max(0, Math.min(255, Math.round(blk[v * 8 + 4 + j]))); + } + } + } + if (k & FVF.POSITION2) m.pos2 = r.f32s(V * 3); + const F = (m.faceCount = r.i32()); + if (F < 0 || F > 8e6) throw new Error("Bad face count"); + const at = r.o; + // Index width: 16-bit in the client files we checked; some newer engines read 32-bit. + // Pick the one whose end lines up with the file (after the control points for v2002). + const tryWidth = (w) => { + const end = at + F * 3 * w; + if (end > bytes.length) return null; + if (m.version !== 2002) return end === bytes.length ? { end, cps: [] } : null; + try { + const rr = new Reader(bytes); + rr.o = end; + const cps = readControlPoints(rr); + return rr.o === bytes.length ? { end, cps } : null; + } catch { + return null; + } + }; + let hit = tryWidth(2); + m.index32 = false; + if (!hit && (hit = tryWidth(4))) m.index32 = true; + if (!hit) { + // unexpected trailing data: read 16-bit and keep the rest as is + hit = { end: at + F * 6, cps: [], tail: true }; + if (hit.end > bytes.length) throw new Error("File ends inside the index list"); + } + const ib = bytes.slice(at, hit.end).buffer; + m.indices = m.index32 ? new Uint32Array(ib) : new Uint16Array(ib); + m.bodyEnd = hit.end; // everything before the control points + m.controlPoints = hit.cps; + m.trailing = hit.tail ? bytes.slice(hit.end) : null; + m.bytes = bytes; + return m; + } + + function readControlPoints(r) { + const n = r.i32(); + if (n < 0 || n > 10000) throw new Error("Bad control point count"); + const out = []; + for (let i = 0; i < n; i++) out.push({ name: r.str(), bone: r.str(), extra: r.str(), point: [r.f32(), r.f32(), r.f32()] }); + return out; + } + + /** The mesh with its control points rewritten (geometry bytes kept); v2000/2001 become 2002. */ + function writeMsh(m) { + const w = new Writer(m.bodyEnd + 256); + w.bytes(m.bytes.subarray(0, m.bodyEnd)); + if (m.trailing) { + w.bytes(m.trailing); + return w.done(); + } + const cps = m.controlPoints || []; + if (m.version !== 2002 && !cps.length) return w.done(); + w.dv.setInt32(4, 2002, true); + w.i32(cps.length); + for (const c of cps) { + w.str(c.name); + w.str(c.bone); + w.str(c.extra); + for (const v of c.point) w.f32(v); + } + return w.done(); + } + + /** + * A whole new mesh from its fields (the Costume tab's imports): { type, subs, pos, normal, uv0, + * boneNames: [text], weights (4 per vertex, biggest first), boneIdx, indices }. Writes v2000 with + * 16-bit indices like every client mesh; the box and radius are computed. + */ + function buildMsh(m) { + const V = m.pos.length / 3; + if (m.indices.length && Math.max(...m.indices) >= 65536) throw new Error("More than 65 536 vertices: split the mesh."); + let mask = FVF.POSITION | FVF.UV0; + if (m.normal) mask |= FVF.NORMAL; + if (m.boneNames?.length) mask |= FVF.WEIGHTS | FVF.INDICES; + const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; + for (let v = 0; v < V; v++) for (let k = 0; k < 3; k++) { + min[k] = Math.min(min[k], m.pos[v * 3 + k]); + max[k] = Math.max(max[k], m.pos[v * 3 + k]); + } + if (!V) min.fill(0), max.fill(0); + const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; // half the box diagonal, like the exporter + const w = new Writer(64 + V * 80 + m.indices.length * 2); + w.bytes(Uint8Array.of(0x4d, 0x53, 0x48, 0)); // "MSH\0" + w.i32(2000); + w.i32(m.type ?? 1); + w.u32(mask); + for (const v of [...min, ...max, radius]) w.f32(v); + w.i32(m.subs.length); + for (const s of m.subs) for (const v of [s.minIndex, s.vertexCount, s.indexStart, s.faceCount]) w.i32(v); + w.i32(V); + for (const v of m.pos) w.f32(v); + if (m.normal) for (const v of m.normal) w.f32(v); + for (const v of m.uv0) w.f32(v); + if (mask & FVF.WEIGHTS) { + w.i32(m.boneNames.length); + for (const b of m.boneNames) w.str(typeof b === "string" ? S(b) : b); // text, or a read / built { raw, text } + // per vertex: weight[4], boneIndex[4] as floats; the engine stops at the first zero weight + for (let v = 0; v < V; v++) { + for (let j = 0; j < 4; j++) w.f32(m.weights[v * 4 + j]); + for (let j = 0; j < 4; j++) w.f32(m.boneIdx[v * 4 + j]); + } + } + w.i32(m.indices.length / 3); + w.grow(m.indices.length * 2); + for (const i of m.indices) { + w.dv.setUint16(w.o, i, true); + w.o += 2; + } + return w.done(); + } + + // ---------- .skl ---------- + function parseSkl(bytes) { + const r = new Reader(bytes); + r.magic("SKL"); + const s = { version: r.i32(), bones: [] }; + const n = r.i32(); + if (n < 0 || n > 4096) throw new Error("Bad bone count"); + for (let i = 0; i < n; i++) { + const b = { name: r.str() }; + b.localPos = [r.f32(), r.f32(), r.f32()]; + b.localRot = [r.f32(), r.f32(), r.f32(), r.f32()]; + b.invPos = [r.f32(), r.f32(), r.f32()]; + b.invRot = [r.f32(), r.f32(), r.f32(), r.f32()]; + b.objectTM = Array.from(r.f32s(16)); + b.parent = r.str(); + const c = r.i32(); + b.children = []; + for (let j = 0; j < c; j++) b.children.push(r.str().text); + s.bones.push(b); + } + s.linked = r.o < bytes.length ? r.str().text : ""; + return s; + } + + // ---------- .ani ---------- + const ANI_VERSIONS = { 4000: "full rotations (16 bytes)", 4001: "small-three rotations (4 bytes)", 4002: "8-byte rotations" }; + + /** 4001: index of the largest component in bits 31-30, the other three as 10-bit fields. */ + function smallThree(dw, out, o) { + const max = dw >>> 30; + const a = 2 * (((dw >>> 20) & 1023) / 1023) - 1; + const b = 2 * (((dw >>> 10) & 1023) / 1023) - 1; + const c = 2 * ((dw & 1023) / 1023) - 1; + const extra = Math.sqrt(Math.max(0, 1 - (a * a + b * b + c * c))); + const vals = [a, b, c]; + for (let j = 0, k = 0; j < 4; j++) out[o + j] = j === max ? extra : vals[k++]; + } + function eightBytes(hi, lo, out, o) { + out[o] = ((hi >>> 16) / 65535) * 2 - 1; + out[o + 1] = ((hi & 0xffff) / 65535) * 2 - 1; + out[o + 2] = ((lo >>> 16) / 65535) * 2 - 1; + out[o + 3] = (lo & 0xffff) / 65535; + } + + /** QComp::CompSmallThree / Comp8Bytes (QuaternionCompression.cpp), the inverse of the two above. */ + function compSmallThree(q) { + let mx = 0; + for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[mx])) mx = i; + const s = q[mx] < 0 ? -1 : 1; + let dw = mx << 30; + for (let i = 0, k = 0; i < 4; i++) { + if (i === mx) continue; + const v = Math.trunc(((s * q[i] + 1) * 0.5) * 1023); + dw |= Math.max(0, Math.min(v, 1023)) << (10 * (2 - k++)); + } + return dw >>> 0; + } + function compEight(q) { + const s = q[3] < 0 ? -1 : 1; + const c = (v) => Math.max(0, Math.min(Math.trunc(((s * v + 1) * 0.5) * 65535), 65535)); + return [((c(q[0]) << 16) | c(q[1])) >>> 0, ((c(q[2]) << 16) | Math.max(0, Math.min(Math.trunc(s * q[3] * 65535), 65535))) >>> 0]; + } + /** Stored rotation words of a (normalised) quaternion for an .ani version. */ + function encodeRot(version, q) { + const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1; + const n = [q[0] / len, q[1] / len, q[2] / len, q[3] / len]; + if (version === 4001) return [compSmallThree(n)]; + if (version === 4002) return compEight(n); + const dv = new DataView(new ArrayBuffer(16)); + n.forEach((v, j) => dv.setFloat32(j * 4, v, true)); + return [0, 1, 2, 3].map((j) => dv.getUint32(j * 4, true)); + } + /** What a stored rotation reads back as (to show edits with the precision they'll have in the game). */ + function decodeRot(version, words) { + const out = [0, 0, 0, 1]; + if (version === 4001) smallThree(words[0], out, 0); + else if (version === 4002) eightBytes(words[0], words[1], out, 0); + else { + const dv = new DataView(new ArrayBuffer(4)); + for (let j = 0; j < 4; j++) { + dv.setUint32(0, words[j], true); + out[j] = dv.getFloat32(0, true); + } + } + const len = Math.hypot(...out) || 1; + return out.map((v) => v / len); + } + + function parseAni(bytes) { + const r = new Reader(bytes); + r.magic("ANI"); + const a = { version: r.i32(), events: [], tracks: [] }; + if (!ANI_VERSIONS[a.version]) throw new Error(`Unknown .ani version ${a.version}`); + const ne = r.i32(); + if (ne < 0 || ne > 100000) throw new Error("Bad event count"); + for (let i = 0; i < ne; i++) a.events.push({ type: r.str(), sub: r.str(), time: r.f32() }); + a.total = r.i32(); + const nt = r.i32(); + if (nt < 0 || nt > 4096) throw new Error("Bad track count"); + const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4; // 4000: four raw floats + const bits = new DataView(new ArrayBuffer(4)); + for (let i = 0; i < nt; i++) { + const t = { name: r.str(), weight: r.f32() }; + const n = r.i32(); + if (n < 0 || n > 1e6) throw new Error("Bad key count"); + t.times = new Int32Array(n); + t.trans = new Float32Array(n * 3); + t.rot = new Float32Array(n * 4); + t.rawRot = new Uint32Array(n * words); // stored rotation words, written back untouched + for (let k = 0; k < n; k++) { + if (words === 4) + for (let j = 0; j < 4; j++) { + bits.setUint32(0, (t.rawRot[k * 4 + j] = r.u32()), true); + t.rot[k * 4 + j] = bits.getFloat32(0, true); + } + else if (words === 1) smallThree((t.rawRot[k] = r.u32()), t.rot, k * 4); + else eightBytes((t.rawRot[k * 2] = r.u32()), (t.rawRot[k * 2 + 1] = r.u32()), t.rot, k * 4); + const q = t.rot.subarray(k * 4, k * 4 + 4); + const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1; + for (let j = 0; j < 4; j++) q[j] /= len; + t.trans[k * 3] = r.f32(); + t.trans[k * 3 + 1] = r.f32(); + t.trans[k * 3 + 2] = r.f32(); + t.times[k] = r.i32(); + } + a.tracks.push(t); + } + if (r.o !== bytes.length) a.trailing = bytes.slice(r.o); + return a; + } + + /** Writes an animation; rotations keep their stored bytes (no re-compression). */ + function writeAni(a) { + const w = new Writer(1 << 16); + w.bytes(new Uint8Array([0x41, 0x4e, 0x49, 0])); + w.i32(a.version); + const events = a.events.slice().sort((x, y) => x.time - y.time); + w.i32(events.length); + for (const e of events) { + w.str(e.type); + w.str(e.sub); + w.f32(e.time); + } + w.i32(Math.round(a.total)); + w.i32(a.tracks.length); + const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4; + for (const t of a.tracks) { + w.str(t.name); + w.f32(t.weight); + const n = t.times.length; + w.i32(n); + for (let k = 0; k < n; k++) { + for (let j = 0; j < words; j++) w.u32(t.rawRot[k * words + j]); + w.f32(t.trans[k * 3]); + w.f32(t.trans[k * 3 + 1]); + w.f32(t.trans[k * 3 + 2]); + w.i32(t.times[k]); + } + } + if (a.trailing) w.bytes(a.trailing); + return w.done(); + } + + /** Sampled local transform of a track at time t (ms): lerp position, slerp rotation. */ + function sampleTrack(t, time, pos, rot) { + const n = t.times.length; + if (!n) return false; + let i = 0; + while (i < n && t.times[i] <= time) i++; + if (i === 0 || i === n) { + const k = i === 0 ? 0 : n - 1; + pos[0] = t.trans[k * 3]; + pos[1] = t.trans[k * 3 + 1]; + pos[2] = t.trans[k * 3 + 2]; + for (let j = 0; j < 4; j++) rot[j] = t.rot[k * 4 + j]; + return true; + } + const a = i - 1; + const b = i; + const span = t.times[b] - t.times[a]; + const f = span > 0 ? (time - t.times[a]) / span : 0; + for (let j = 0; j < 3; j++) pos[j] = t.trans[a * 3 + j] + (t.trans[b * 3 + j] - t.trans[a * 3 + j]) * f; + slerp(t.rot, a * 4, t.rot, b * 4, f, rot); + return true; + } + + function slerp(qa, oa, qb, ob, f, out) { + let ax = qa[oa], ay = qa[oa + 1], az = qa[oa + 2], aw = qa[oa + 3]; + let bx = qb[ob], by = qb[ob + 1], bz = qb[ob + 2], bw = qb[ob + 3]; + let cos = ax * bx + ay * by + az * bz + aw * bw; + if (cos < 0) { + cos = -cos; + bx = -bx; + by = -by; + bz = -bz; + bw = -bw; + } + let s0, s1; + if (cos > 0.9995) { + s0 = 1 - f; + s1 = f; + } else { + const th = Math.acos(cos); + const sin = Math.sin(th); + s0 = Math.sin((1 - f) * th) / sin; + s1 = Math.sin(f * th) / sin; + } + out[0] = s0 * ax + s1 * bx; + out[1] = s0 * ay + s1 * by; + out[2] = s0 * az + s1 * bz; + out[3] = s0 * aw + s1 * bw; + const len = Math.hypot(out[0], out[1], out[2], out[3]) || 1; + for (let j = 0; j < 4; j++) out[j] /= len; + } + + // ---------- material script (OGRE-like text) ---------- + /** → [{ passes: [{ shader, units: [{ texture, uvSet, light }], sceneBlend, alphaReject, cull, lighting }] }] */ + function parseMaterial(text) { + const mats = []; + const stack = []; + let pendingBlock = null; + let mat = null; + let pass = null; + let unit = null; + for (let line of text.split(/\r\n|\r|\n/)) { + line = line.replace(/\/\/.*$/, "").trim(); + if (!line) continue; + if (line === "{") { + stack.push(pendingBlock); + if (pendingBlock === "material") mats.push((mat = { passes: [] })); + else if (pendingBlock === "pass" && mat) mat.passes.push((pass = { units: [] })); + else if (pendingBlock === "texture_unit" && pass) pass.units.push((unit = { uvSet: 0 })); + pendingBlock = null; + continue; + } + if (line === "}") { + const top = stack.pop(); + if (top === "texture_unit") unit = null; + else if (top === "pass") pass = null; + else if (top === "material") mat = null; + continue; + } + const [key, ...rest] = line.split(/\s+/); + if (["material", "technique", "pass", "texture_unit"].includes(key) && !rest.length) { + pendingBlock = key; + continue; + } + pendingBlock = key; // unknown block names ("technique x") still nest + if (unit) { + const wave = () => ({ type: (rest[0] || "").toLowerCase(), wave: (rest[1] || "sine").toLowerCase(), base: Number(rest[2]) || 0, freq: Number(rest[3]) || 0, phase: Number(rest[4]) || 0, amp: Number(rest[5]) || 0, once: rest[6] === "true" }); + if (key === "texture") unit.texture = rest[0]; + else if (key === "color_blend") unit.colorBlend = rest.map((x) => x.toLowerCase()); + else if (key === "tex_coord_set") unit.uvSet = Number(rest[0]) || 0; + else if (key === "use_light_texture") unit.light = rest[0] === "true"; + // texture animation (ioTextureUnitState): frames, scroll, rotate, wave transforms, colour waves + else if (key === "anim_texture") unit.frames = { count: Math.max(1, Number(rest[0]) || 1), duration: Number(rest[1]) || 1000, once: rest[2] === "true" }; + else if (key === "scroll_anim") unit.scroll = { u: Number(rest[0]) || 0, v: Number(rest[1]) || 0, once: rest[2] === "true" }; + else if (key === "rotate_anim") unit.rotate = { speed: Number(rest[0]) || 0, once: rest[1] === "true" }; + else if (key === "wave_xform") (unit.waves ||= []).push(wave()); + else if (key === "color_xform") (unit.colorWaves ||= []).push(wave()); + else if (key === "scale_uv_on_center") unit.scaleCenter = rest[0] === "true"; + } else if (pass) { + if (key === "shader_group") pass.shader = rest[0]; + else if (key === "scene_blend") pass.sceneBlend = rest.join(" "); + else if (key === "alpha_rejection") pass.alphaReject = rest.join(" "); + else if (key === "cull") pass.cull = rest[0]; + else if (key === "lighting") pass.lighting = rest[0] !== "false"; + else if (key === "depth_write") pass.depthWrite = rest[0] !== "false"; + } else if (mat && key === "diffuse") { + mat.diffuse = rest.slice(0, 3).map(Number); + } + } + return mats; + } + + /** + * What the viewer draws for one material: the first real texture of the first non-outline pass. + * LightMap passes have the lightmap first (UV set 0) and the diffuse after it (usually set 1). + */ + function diffuseOf(mat) { + for (const p of mat?.passes || []) { + if (/outline/i.test(p.shader || "")) continue; + const units = p.units.filter((x) => x.texture && !x.light); + if (!units.length) continue; + const lightmap = /lightmap/i.test(p.shader || "") && units.length > 1; + const u = lightmap ? units[units.length - 1] : units[0]; + const animOf = (x) => (x && (x.frames || x.scroll || x.rotate || x.waves || x.colorWaves) ? { frames: x.frames || null, scroll: x.scroll || null, rotate: x.rotate || null, waves: x.waves || [], colorWaves: x.colorWaves || [], scaleCenter: !!x.scaleCenter } : null); + return { + texture: u.texture, + uvSet: u.uvSet, + anim: animOf(u), + lightmap: lightmap ? units[0].texture : null, + lightmapSet: lightmap ? units[0].uvSet : 0, + lightmapAnim: lightmap ? animOf(units[0]) : null, + lmAddDiffuse: lightmap && units[0].colorBlend?.join(" ") === "add texture diffuse", + diffuseColor: mat.diffuse || [1, 1, 1], + blend: p.sceneBlend || "", + alphaReject: p.alphaReject || "", + }; + } + return null; + } + + // ---------- model config (#MODEL_CFG) ---------- + function parseModelCfg(text) { + const cfg = { skeleton: "", animations: [], meshes: [] }; + const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim()); + let list = null; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (!l || l.startsWith(";") || l.startsWith("//")) continue; // commented-out entries + if (l === "#SKELETON") cfg.skeleton = lines[++i] || ""; + else if (l === "#ANIMATION_LIST") list = cfg.animations; + else if (l === "#MESH_LIST") list = cfg.meshes; + else if (l === "#ANIMATION_LIST_END" || l === "#MESH_LIST_END") list = null; + else if (l === "#MODEL_COLOR") cfg.color = (lines[++i] || "").split(/\s+/).map(Number); + else if (l === "#AABOX") cfg.aabb = [(lines[++i] || "").split(/\s+/).map(Number), (lines[++i] || "").split(/\s+/).map(Number)]; + else if (l === "#COLLISION_MESH") cfg.collision = lines[++i] || ""; + else if (l === "#OUTLINE") { + // file, on/off, colour (r g b), thickness + cfg.outline = { file: lines[++i] || "", on: (lines[++i] || "") !== "0", color: (lines[++i] || "").split(/\s+/).map(Number), size: Number(lines[++i]) || 0 }; + } + else if (l.startsWith("#")) list = null; + else if (list) list.push(l); + } + return cfg; + } + + // ---------- .cms collision mesh (ioOpcodeManagerImpl::LoadOpcodeShapeMesh) ---------- + /** "CMS\0", version, vertex count, float3[], face count, uint16[3 * faces]. */ + function parseCms(bytes) { + const r = new Reader(bytes); + r.magic("CMS"); + const c = { version: r.i32() }; + // 5000 = world / object collision; 5500 / 5700 are character attack / hit box groups + if (c.version !== 5000) throw new Error(`Not a collision mesh (version ${c.version}: a character box group)`); + const V = r.i32(); + if (V < 0 || V > 4e6) throw new Error("Bad collision vertex count"); + c.pos = r.f32s(V * 3); + const F = r.i32(); + if (F < 0 || F > 8e6) throw new Error("Bad collision face count"); + r.need(F * 6); + c.indices = new Uint16Array(bytes.slice(r.o, r.o + F * 6).buffer); + return c; + } + + // ---------- .edg outline edges (ioEdgeRender::LoadFileInternal) ---------- + /** vertices + normals, face normals, edges { a, b (vertices), fa, fb (faces, 0xFFFF = open) }. */ + function parseEdg(bytes) { + const r = new Reader(bytes); + r.magic("EDG"); + const e = { version: r.i32() }; + const V = r.i32(); + if (V < 0 || V > 4e6) throw new Error("Bad edge vertex count"); + e.pos = r.f32s(V * 3); + e.normal = r.f32s(V * 3); + const F = r.i32(); + if (F < 0 || F > 8e6) throw new Error("Bad edge face count"); + e.faceNormals = r.f32s(F * 3); + const E = r.i32(); + if (E < 0 || E > 8e6) throw new Error("Bad edge count"); + r.need(E * 8); + e.edges = new Uint16Array(bytes.slice(r.o, r.o + E * 8).buffer); // a, b, fa, fb per edge + return e; + } + + // ---------- texture animation at time t (seconds), like ioTexture*Effect + RecalcTextureMatrix ---------- + const waveValue = (w, t) => { + let x = t * w.freq; + x = w.once && x >= 1 ? 1 : x - Math.floor(x); + let o = 0; + if (w.wave === "sine") o = Math.sin(x * Math.PI * 2); + else if (w.wave === "triangle") o = x < 0.25 ? x * 4 : x <= 0.75 ? 1 - (x - 0.25) * 4 : (x - 0.75) * 4 - 1; + else if (w.wave === "square") o = x <= 0.5 ? 1 : -1; + else if (w.wave === "sawtooth") o = x * 2 - 1; + else if (w.wave === "inverse_sawtooth") o = 1 - x * 2; + return w.base + (o + 1) * 0.5 * w.amp; + }; + const wrap = (v, once) => (once ? Math.max(-1, Math.min(1, v)) : v - Math.trunc(v)); + + /** → { m: Float32Array(9) for vec3(uv,1) * m (GLSL), frame, colorMul, emissive } */ + function texAnimAt(a, t) { + let uMod = 0, vMod = 0, rot = 0, uScale = 1, vScale = 1; + if (a.scroll) { + uMod = wrap(-a.scroll.u * t, a.scroll.once); + vMod = wrap(-a.scroll.v * t, a.scroll.once); + } + if (a.rotate) rot = (-a.rotate.speed * t) % 360; + let colorMul = 1, emissive = 0; + for (const w of a.waves) { + const r = waveValue(w, t); + if (w.type === "translate_u") uMod = r; + else if (w.type === "translate_v") vMod = r; + else if (w.type === "rotate") rot = r * 360; + else if (w.type === "scale_u") uScale = r >= 0 ? 1 + r : 1 / -r; + else if (w.type === "scale_v") vScale = r >= 0 ? 1 + r : 1 / -r; + } + for (const w of a.colorWaves) { + const r = waveValue(w, t); + if (w.type === "diffuse" || w.type === "ambient") colorMul *= w.type === "diffuse" ? r : 0.5 + 0.5 * r; // approximation of the pass colour rates + else if (w.type === "emissive") emissive += r; + } + // D3D row-vector 3×3 (uv·M): scale (optionally on the centre), then translate, then rotate about (0.5, 0.5) + const mul = (A, B) => { + const o = new Array(9); + for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) o[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c]; + return o; + }; + let X = [1 / uScale, 0, 0, 0, 1 / vScale, 0, 0, 0, 1]; + if (a.scaleCenter) { + X[6] = -0.5 * X[0] + 0.5; + X[7] = -0.5 * X[4] + 0.5; + } + if (uMod || vMod) X = mul([1, 0, 0, 0, 1, 0, uMod, vMod, 1], X); + if (rot) { + const th = (rot * Math.PI) / 180, c = Math.cos(th), s = Math.sin(th); + X = mul([c, s, 0, -s, c, 0, 0.5 + (-0.5 * c - -0.5 * s), 0.5 + (-0.5 * s + -0.5 * c), 1], X); + } + // GLSL mat3 columns = the D3D matrix's columns, used as vec3(uv, 1) * m + const m = new Float32Array([X[0], X[3], X[6], X[1], X[4], X[7], X[2], X[5], X[8]]); + let frame = 0; + if (a.frames && a.frames.count > 1) { + let r = (t * 1000) / a.frames.duration; + r = a.frames.once && r >= 1 ? 0.999 : r - Math.floor(r); + frame = Math.min(a.frames.count - 1, Math.floor(r * a.frames.count)); + } + return { m, frame, colorMul, emissive }; + } + + const decode = (bytes) => cp949.decode(bytes); + return { texAnimAt, parseMsh, writeMsh, buildMsh, parseCms, parseEdg, parseSkl, parseAni, writeAni, encodeRot, decodeRot, sampleTrack, slerp, parseMaterial, diffuseOf, parseModelCfg, decode, S, MESH_TYPES, FVF, ANI_VERSIONS }; +})(); +window.LSF = LSF; diff --git a/app/public/legacy/js/mapedit.js b/app/public/legacy/js/mapedit.js new file mode 100644 index 0000000..d7fcfd0 --- /dev/null +++ b/app/public/legacy/js/mapedit.js @@ -0,0 +1,2217 @@ +"use strict"; + +// Map editor (Map tab): the mode map configs (rs.iop config/mode//*.ini). Shows the map +// model, its collision and outline, the objects, and every position in the file as a marker that +// can be dragged in 3D: any key pair _x / _z (+ _y) is a point, any min / +// max pair a box — so start / revival points, item spawns, objects, effects, zones, flags, +// camera and boost limits of every mode are covered without a list per mode. Every section can be +// edited as key = value; numbered sections (object1, effect2…) and numbered points (pos3_x…) +// can be added, copied and removed with their counts kept right. Lines that weren't edited are +// written back byte for byte (CP949 comments included). + +(() => { + const MAP = { + src: null, + rows: [], // [key, name, mode, mesh, path, pack] + mode: "", + doc: null, // { lines: [raw], eol, sections: [...], byName } + where: null, + title: "", + dirty: false, + undo: [], + redo: [], + world: null, // { name, cfg, meshes, col, edg } + objects: new Map(), // section lname → { model, meshes, pending } + generatedModels: new Map(), // generated one-mesh staticmodel configs for this editor session + previewRot: new Map(), // editor-only rotation persisted in an ignored INI comment + transformMode: "position", + moveAxis: null, + markers: [], + boxes: [], + sel: null, // { sec, prefix } of the selected marker + secSel: null, // selected section lname + hover: null, + hidden: new Set(), // hidden layers + snap: 0, + }; + window.MAP = MAP; + let view = null; + let kit = null; + + // ---------- small helpers ---------- + const toLatin1 = (u8) => { + let s = ""; + for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192)); + return s; + }; + const fromLatin1 = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255); + const cp949 = (s) => LSF.decode(fromLatin1(s)); // latin1 string → readable text + const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const baseName = (p) => p.replace(/\\/g, "/").split("/").pop(); + const toggled = (id) => $(id).classList.contains("on"); + + function ensureView() { + if (view) return view; + try { + view = new GL3D.View($("map-canvas")); + } catch (err) { + toast(`3D view unavailable: ${err.message}`, true); + return null; + } + kit = MeshKit.create(view, () => MAP.src); + view.onPointerDown = pointerDown; + view.onCamera = () => { + edgeDirty = true; + requestAnimationFrame(drawLabels); + }; + view.beforeDraw = () => { + if (edgeDirty) updateOutline(); + }; + MAP.view = view; + return view; + } + + // ===================================================================== + // INI document (line-preserving) + // ===================================================================== + function parseIni(text) { + const lines = []; + const re = /[^\r\n]*(\r\n|\r|\n|$)/g; + let m; + while ((m = re.exec(text)) && m.index < text.length) { + lines.push(m[0]); + if (!m[1]) break; + } + const crlf = (text.match(/\r\n/g) || []).length; + const doc = { lines, eol: crlf || !text.includes("\n") ? "\r\n" : "\n" }; + return reindex(doc); + } + + function reindex(doc = MAP.doc) { + doc.sections = []; + let cur = null; + doc.lines.forEach((raw, i) => { + const text = raw.replace(/[\r\n]+$/, ""); + const t = text.trim(); + const h = /^\[([^\]]*)\]/.exec(t); + if (h) { + cur = { name: h[1].trim(), lname: h[1].trim().toLowerCase(), head: i, keys: [] }; + doc.sections.push(cur); + return; + } + if (!cur || !t || t.startsWith(";") || t.startsWith("//") || t.startsWith("#")) return; + const eq = text.indexOf("="); + if (eq < 0) return; + const key = text.slice(0, eq).trim(); + if (!key) return; + let vs = eq + 1; + while (vs < text.length && /\s/.test(text[vs])) vs++; + let ve = text.length; + while (ve > vs && /\s/.test(text[ve - 1])) ve--; + cur.keys.push({ key, lkey: key.toLowerCase(), line: i, vs, ve, value: text.slice(vs, ve) }); + }); + doc.byName = new Map(); + for (const s of doc.sections) if (!doc.byName.has(s.lname)) doc.byName.set(s.lname, s); // the first one wins, like the client + return doc; + } + const serialize = () => MAP.doc.lines.join(""); + // undo snapshots hold the map config and (PvE maps) its navigation box graph + const NAVI_SEP = "\u0001\u0001NAVI\u0001\u0001"; + const state = () => serialize() + (MAP.navi ? NAVI_SEP + MAP.navi.doc.src : ""); + + const sec = (name) => MAP.doc?.byName.get(name.toLowerCase()) || null; + const keyIn = (s, k) => s?.keys.find((x) => x.lkey === k.toLowerCase()) || null; + const val = (s, k) => keyIn(s, k)?.value; + const num = (s, k, def = 0) => { + const v = parseFloat(val(s, k)); + return isFinite(v) ? v : def; + }; + const lastLine = (s) => (s.keys.length ? s.keys[s.keys.length - 1].line : s.head); + + const EDITOR_ROTATION = /^\s*;\s*LS_TOOL_ROTATION\s*=\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*$/i; + function loadPreviewRotations() { + MAP.previewRot.clear(); + if (!MAP.doc) return; + for (const s of MAP.doc.sections) { + if (!/^(?:object|costom_object|function_object|object_camera_fixed)\d+$/i.test(s.name)) continue; + const end = MAP.doc.sections.find((next) => next.head > s.head)?.head ?? MAP.doc.lines.length; + for (let i = s.head + 1; i < end; i++) { + const match = EDITOR_ROTATION.exec(MAP.doc.lines[i].replace(/[\r\n]+$/, "")); + if (match) { + MAP.previewRot.set(s.lname, match.slice(1).map(Number)); + break; + } + } + } + } + function writePreviewRotation(section, rotation) { + const s = sec(section); + if (!s) return false; + const line = `; LS_TOOL_ROTATION=${rotation.map((v) => Number(v.toFixed(3))).join(",")}`; + const end = MAP.doc.sections.find((next) => next.head > s.head)?.head ?? MAP.doc.lines.length; + for (let i = s.head + 1; i < end; i++) { + if (EDITOR_ROTATION.test(MAP.doc.lines[i].replace(/[\r\n]+$/, ""))) { + if (MAP.doc.lines[i].replace(/[\r\n]+$/, "") === line) return false; + const eol = /[\r\n]*$/.exec(MAP.doc.lines[i])[0]; + MAP.doc.lines[i] = line + eol; + reindex(); + return true; + } + } + MAP.doc.lines.splice(s.head + 1, 0, line + MAP.doc.eol); + reindex(); + return true; + } + + /** A number written like the value it replaces (decimals, trailing "f"). */ + function fmtNum(old, v) { + const m = old != null ? /^\s*-?\d*(?:\.(\d+))?(f?)\s*$/i.exec(old) : null; + if (m) { + const dec = m[1] ? m[1].length : Number.isInteger(v) ? 0 : 1; + return v.toFixed(dec) + (m[2] || ""); + } + return (Number.isInteger(v) ? v.toFixed(1) : String(+v.toFixed(2))) + "f"; + } + + function insertLines(at, texts) { + const L = MAP.doc.lines; + if (at > 0 && !/[\r\n]$/.test(L[at - 1])) L[at - 1] += MAP.doc.eol; + L.splice(at, 0, ...texts.map((t) => t + MAP.doc.eol)); + } + + /** Sets (or adds) key = value in a section; the section is created when missing. */ + function setVal(secName, k, str) { + let s = sec(secName); + if (!s) { + insertLines(MAP.doc.lines.length, ["", `[${secName}]`]); + reindex(); + s = sec(secName); + } + const kk = keyIn(s, k); + if (kk) { + const raw = MAP.doc.lines[kk.line]; + const eol = /[\r\n]*$/.exec(raw)[0]; + const text = raw.slice(0, raw.length - eol.length); + MAP.doc.lines[kk.line] = text.slice(0, kk.vs) + str + text.slice(kk.ve) + eol; + } else insertLines(lastLine(s) + 1, [`${k} = ${str}`]); + reindex(); + } + const setNum = (secName, k, v) => setVal(secName, k, fmtNum(val(sec(secName), k), v)); + + function removeKey(secName, k) { + const kk = keyIn(sec(secName), k); + if (!kk) return; + MAP.doc.lines.splice(kk.line, 1); + reindex(); + } + function renameKey(secName, from, to) { + const kk = keyIn(sec(secName), from); + if (!kk) return; + const raw = MAP.doc.lines[kk.line]; + const i = raw.indexOf(kk.key); + MAP.doc.lines[kk.line] = raw.slice(0, i) + to + raw.slice(i + kk.key.length); + reindex(); + } + function removeSection(s) { + const L = MAP.doc.lines; + let end = lastLine(s) + 1; + while (end < L.length && !L[end].trim()) end++; + L.splice(s.head, end - s.head); + reindex(); + } + function renameSection(s, name) { + const raw = MAP.doc.lines[s.head]; + MAP.doc.lines[s.head] = raw.replace(/\[[^\]]*\]/, `[${name}]`); + reindex(); + } + + // ---------- numbered sections: object1…N with object_common.total_count ---------- + const FAMILIES = { + object: ["object_common", "total_count"], + effect: ["effect_common", "total_count"], + costom_object: ["costom_object_common", "total_count"], + costom_effect: ["costom_effect_common", "total_count"], + object_camera_fixed: ["object_camera_fixed", "total_count"], + function_object: ["function_object_info", "count"], + symbol: ["symbol_common_info", "symbol_cnt"], + }; + function familyOf(lname) { + const m = /^(.*?)(\d+)$/.exec(lname); + if (!m || !FAMILIES[m[1]]) return null; + return { base: m[1], n: Number(m[2]), common: FAMILIES[m[1]][0], countKey: FAMILIES[m[1]][1] }; + } + const familyCount = (f) => num(sec(f.common), f.countKey, 0); + + /** Adds [base N+1] after the last one (count + 1). → new section name */ + function addNumbered(base, keyvals) { + const [common, countKey] = FAMILIES[base]; + const n = num(sec(common), countKey, 0); + const name = `${base}${n + 1}`; + const after = sec(`${base}${n}`) || sec(common); + const at = after ? lastLine(after) + 1 : MAP.doc.lines.length; + insertLines(at, ["", `[${name}]`, ...keyvals.map(([k, v]) => `${k} = ${v}`)]); + reindex(); + setVal(common, countKey, String(n + 1)); + return name; + } + /** Removes [base n] and renumbers the ones after it (count − 1). */ + function removeNumbered(lname) { + const f = familyOf(lname); + const count = familyCount(f); + removeSection(sec(lname)); + for (let k = f.n + 1; k <= count; k++) { + const s = sec(`${f.base}${k}`); + if (s) renameSection(s, s.name.replace(/\d+$/, String(k - 1))); + } + setVal(f.common, f.countKey, String(Math.max(0, count - 1))); + } + + // ---------- numbered points inside a section: pos1_x…, zone2_pos_x…, with a count key ---------- + function pointFamily(s, prefix) { + const m = /^(.*?)(\d+)(.*)$/.exec(prefix); + if (!m) return null; + const [, head, idxS] = m; + const idx = Number(idxS); + const re = new RegExp(`^${esc(head)}(\\d+)(_|$)`); + let max = 0; + for (const k of s.keys) { + const km = re.exec(k.lkey); + if (km) max = Math.max(max, Number(km[1])); + } + // the count key: a cnt / count / max key holding the highest index, preferably naming the list + const cands = s.keys.filter((k) => !re.test(k.lkey) && /(cnt|count|max)/.test(k.lkey) && Number(k.value) === max); + const count = cands.find((k) => k.lkey.includes(head.replace(/_$/, ""))) || cands[0] || null; + return { head, idx, max, count: count?.key || null }; + } + const memberKeys = (s, head, idx) => s.keys.filter((k) => new RegExp(`^${esc(head)}${idx}(_|$)`).test(k.lkey)); + + function addPointMember(secName, prefix) { + const s = sec(secName); + const pf = pointFamily(s, prefix); + if (!pf) return null; + const n = pf.max + 1; + const src = memberKeys(s, pf.head, pf.idx); + const last = Math.max(...s.keys.filter((k) => new RegExp(`^${esc(pf.head)}\\d+(_|$)`).test(k.lkey)).map((k) => k.line)); + const lines = src.map((k) => { + const nk = pf.head + n + k.key.slice(pf.head.length + String(pf.idx).length); + let v = k.value; + if (/_x$/i.test(k.key)) v = fmtNum(v, (parseFloat(v) || 0) + 150); + return `${nk} = ${v}`; + }); + insertLines(last + 1, lines); + reindex(); + if (pf.count) setVal(secName, pf.count, String(n)); + return prefix.replace(new RegExp(`^${esc(pf.head)}${pf.idx}`), pf.head + n); + } + function removePointMember(secName, prefix) { + const pf = pointFamily(sec(secName), prefix); + if (!pf) return false; + for (const k of memberKeys(sec(secName), pf.head, pf.idx).reverse()) removeKey(secName, k.key); + for (let j = pf.idx + 1; j <= pf.max; j++) { + for (const k of memberKeys(sec(secName), pf.head, j)) renameKey(secName, k.key, pf.head + (j - 1) + k.key.slice(pf.head.length + String(j).length)); + } + if (pf.count) setVal(secName, pf.count, String(Math.max(0, pf.max - 1))); + return true; + } + + // ===================================================================== + // markers (every _x / _z in the file) + // ===================================================================== + const NOT_POSITIONS = /^(main_light|shadow|minimap|minimapextra|glow|outline|clear_color|ambient|fog|omni_light|alarm_ment|highturn\d*|symbol_common_info)$/; + const NOT_POINT_PREFIX = /^(scale|move_dir|target|timeicon|maptitle|mapdifficulty|timenum|timecolon|timedot)/; + const COLORS = { + blue: [0.35, 0.6, 1, 1], + red: [1, 0.35, 0.3, 1], + white: [0.92, 0.92, 0.95, 1], + item: [1, 0.85, 0.25, 1], + object: [1, 0.6, 0.2, 1], + effect: [0.95, 0.4, 0.95, 1], + camera: [0.35, 0.9, 1, 1], + boost: [0.4, 1, 0.5, 1], + other: [0.6, 1, 0.75, 1], + axis: [0.7, 0.7, 0.75, 1], + navi: [0.3, 0.95, 0.85, 1], + }; + const layerOf = (lname) => lname.replace(/\d+$/, ""); + function colorOf(lname, p) { + const L = layerOf(lname); + if (/red/.test(p) || /^red/.test(L) || (L === "team_start" && p === "start2")) return "red"; + if (/blue/.test(p) || /^blue/.test(L) || (L === "team_start" && p === "start1")) return "blue"; + if (/(start|revival|waiting)$/.test(L)) return "white"; + if (/generate/.test(L)) return "item"; + if (/object/.test(L)) return "object"; + if (/effect/.test(L)) return "effect"; + if (L === "map_camera" || /event_scene|camera/.test(L)) return "camera"; + if (L === "boost_limit") return "boost"; + if (L === "axis_limit") return "axis"; + return "other"; + } + + function buildMarkers() { + MAP.markers = []; + MAP.boxes = []; + if (!MAP.doc) return; + for (const b of naviBoxes()) { + const cx = (b.min[0] + b.max[0]) / 2, cz = (b.min[2] + b.max[2]) / 2; + MAP.markers.push({ sec: "navigation", secName: "navigation", prefix: b.name, hasY: true, x: cx, y: b.min[1], z: cz, gy: b.min[1], layer: "navigation", color: "navi", navi: b }); + } + for (const s of MAP.doc.sections) { + if (NOT_POSITIONS.test(s.lname)) continue; + const keys = new Set(s.keys.map((k) => k.lkey)); + const prefixes = []; + for (const k of s.keys) { + const m = /^(.*)_x$/.exec(k.lkey); + if (m && keys.has(`${m[1]}_z`) && !NOT_POINT_PREFIX.test(m[1]) && !prefixes.includes(m[1])) prefixes.push(m[1]); + } + for (const p of prefixes) { + const hasY = keys.has(`${p}_y`); + const x = num(s, `${p}_x`); + const z = num(s, `${p}_z`); + const gy = groundAt(x, z) ?? 0; + MAP.markers.push({ sec: s.lname, secName: s.name, prefix: p, hasY, x, z, y: hasY ? num(s, `${p}_y`) : gy, gy, layer: layerOf(s.lname), color: colorOf(s.lname, p) }); + } + for (const p of prefixes) { + if (!/(^|_)min$/.test(p)) continue; + const q = p.replace(/min$/, "max"); + if (!prefixes.includes(q)) continue; + MAP.boxes.push({ sec: s.lname, min: p, max: q, layer: layerOf(s.lname), color: colorOf(s.lname, p), y0: keys.has(`${p}_y`) ? num(s, `${p}_y`) : null, y1: keys.has(`${q}_y`) ? num(s, `${q}_y`) : null }); + } + } + } + const markerOf = (sel) => sel && MAP.markers.find((m) => m.sec === sel.sec && m.prefix === sel.prefix); + const layerVisible = (layer) => !MAP.hidden.has(layer); + + function drawMarkers() { + if (!view) return; + const pts = []; + const sel = []; + const lines = []; + const push = (a, p, c) => a.push(p[0], p[1], p[2], c[0], c[1], c[2], c[3]); + const s = markerOf(MAP.sel); + for (const m of MAP.markers) { + if (!layerVisible(m.layer)) continue; + const c = COLORS[m.color]; + push(pts, [m.x, m.y + 4, m.z], c); + // stem down to the floor for points in the air + if (m.hasY && Math.abs(m.y - m.gy) > 20) { + push(lines, [m.x, m.gy, m.z], [c[0], c[1], c[2], 0.35]); + push(lines, [m.x, m.y, m.z], [c[0], c[1], c[2], 0.35]); + } + } + if (s) push(sel, [s.x, s.y + 4, s.z], [1, 1, 1, 1]); + for (const b of MAP.boxes) { + if (!layerVisible(b.layer)) continue; + const S = sec(b.sec); + const x0 = num(S, `${b.min}_x`), z0 = num(S, `${b.min}_z`), x1 = num(S, `${b.max}_x`), z1 = num(S, `${b.max}_z`); + const g = groundAt((x0 + x1) / 2, (z0 + z1) / 2) ?? 0; + const y0 = b.y0 ?? g, y1 = b.y1 ?? g + 400; + const lo = [Math.min(x0, x1), Math.min(y0, y1), Math.min(z0, z1)]; + const hi = [Math.max(x0, x1), Math.max(y0, y1), Math.max(z0, z1)]; + const selected = MAP.secSel === b.sec; + const c = COLORS[b.color]; + const col = [c[0], c[1], c[2], selected ? 0.95 : 0.45]; + const k = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => [i & 1 ? hi[0] : lo[0], i & 2 ? hi[1] : lo[1], i & 4 ? hi[2] : lo[2]]); + for (const [a, e] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) { + push(lines, k[a], col); + push(lines, k[e], col); + } + } + // PvE navigation: boxes (walkable areas for the monsters' A*) and their links + if (layerVisible("navigation")) { + const boxes = naviBoxes(); + const byName = new Map(boxes.map((b) => [b.name, b])); + for (const b of boxes) { + const sel = MAP.sel?.sec === "navigation" && MAP.sel.prefix === b.name; + const col = [0.3, 0.95, 0.85, sel ? 1 : 0.5]; + const lo = b.min.map((v, i) => Math.min(v, b.max[i])), hi = b.min.map((v, i) => Math.max(v, b.max[i])); + const k = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => [i & 1 ? hi[0] : lo[0], i & 2 ? hi[1] : lo[1], i & 4 ? hi[2] : lo[2]]); + for (const [a, e] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) { + push(lines, k[a], col); + push(lines, k[e], col); + } + } + const ctr = (b) => [(b.min[0] + b.max[0]) / 2, Math.max(b.min[1], b.max[1]), (b.min[2] + b.max[2]) / 2]; + for (const l of naviLinks()) { + const a = byName.get(l.from), b = byName.get(l.to); + if (!a || !b) continue; + push(lines, ctr(a), [1, 0.9, 0.4, 0.7]); + push(lines, ctr(b), [1, 0.9, 0.4, 0.7]); + } + } + // elevator paths (function_object entity_type 6: pos → end_pos) + for (const s of MAP.doc.sections) { + if (!/^function_object\d+$/.test(s.lname) || num(s, "entity_type", -1) !== 6 || !keyIn(s, "end_pos_x") || !layerVisible(layerOf(s.lname))) continue; + const c = [1, 0.75, 0.2, MAP.secSel === s.lname ? 1 : 0.6]; + push(lines, [num(s, "pos_x"), num(s, "pos_y"), num(s, "pos_z")], c); + push(lines, [num(s, "end_pos_x"), num(s, "end_pos_y"), num(s, "end_pos_z")], c); + } + view.setOverlay("markers", pts.length ? new Float32Array(pts) : null, { mode: "points", depth: false, point: 11 }); + view.setOverlay("sel", sel.length ? new Float32Array(sel) : null, { mode: "points", depth: false, point: 18 }); + view.setOverlay("boxes", lines.length ? new Float32Array(lines) : null, { depth: true }); + drawLabels(); + } + + function markerLabel(m) { + const s = sec(m.sec); + const file = val(s, "model_file") || val(s, "effect_file"); + const name = m.prefix === "pos" ? m.secName : `${m.secName} · ${m.prefix}`; + return file && m.prefix === "pos" ? `${name} · ${baseName(file)}` : name; + } + + function drawLabels() { + const box = $("map-labels"); + if (!view || !MAP.doc) return box.replaceChildren(); + const all = toggled("map-t-labels"); + const show = MAP.markers.filter((m) => layerVisible(m.layer) && (all || (MAP.sel && m.sec === MAP.sel.sec && m.prefix === MAP.sel.prefix) || m === MAP.hover)); + const out = []; + for (const m of show.slice(0, 400)) { + const p = view.project([m.x, m.y + 4, m.z]); + if (!p) continue; + const c = COLORS[m.color]; + out.push(el("span", { class: `map-tag${m === markerOf(MAP.sel) ? " on" : ""}`, text: markerLabel(m), style: `left:${p[0]}px;top:${p[1]}px;border-color:rgb(${c.slice(0, 3).map((v) => Math.round(v * 255)).join(",")})` })); + } + box.replaceChildren(...out); + } + + // ===================================================================== + // collision: floor height and picking + // ===================================================================== + let ground = null; // { pos, idx, grid, cell, minX, minZ, nx, nz } + function buildGround(c) { + const P = c.pos; + const I = c.indices; + let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity; + for (let i = 0; i < P.length; i += 3) { + minX = Math.min(minX, P[i]); + maxX = Math.max(maxX, P[i]); + minZ = Math.min(minZ, P[i + 2]); + maxZ = Math.max(maxZ, P[i + 2]); + } + const cell = Math.max(50, Math.max(maxX - minX, maxZ - minZ) / 128); + const nx = Math.ceil((maxX - minX) / cell) + 1; + const nz = Math.ceil((maxZ - minZ) / cell) + 1; + const grid = new Map(); + for (let t = 0; t < I.length; t += 3) { + const xs = [P[I[t] * 3], P[I[t + 1] * 3], P[I[t + 2] * 3]]; + const zs = [P[I[t] * 3 + 2], P[I[t + 1] * 3 + 2], P[I[t + 2] * 3 + 2]]; + const x0 = Math.floor((Math.min(...xs) - minX) / cell), x1 = Math.floor((Math.max(...xs) - minX) / cell); + const z0 = Math.floor((Math.min(...zs) - minZ) / cell), z1 = Math.floor((Math.max(...zs) - minZ) / cell); + for (let gx = x0; gx <= x1; gx++) + for (let gz = z0; gz <= z1; gz++) { + const k = gx * nz + gz; + if (!grid.has(k)) grid.set(k, []); + grid.get(k).push(t); + } + } + ground = { pos: P, idx: I, grid, cell, minX, minZ, nz }; + } + + /** Highest collision surface at (x, z), or null. */ + function groundAt(x, z) { + if (!ground) return null; + const { pos: P, idx: I, grid, cell, minX, minZ, nz } = ground; + const list = grid.get(Math.floor((x - minX) / cell) * nz + Math.floor((z - minZ) / cell)); + if (!list) return null; + let best = null; + for (const t of list) { + const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3; + const d = (P[b + 2] - P[c + 2]) * (P[a] - P[c]) + (P[c] - P[b]) * (P[a + 2] - P[c + 2]); + if (Math.abs(d) < 1e-9) continue; + const l1 = ((P[b + 2] - P[c + 2]) * (x - P[c]) + (P[c] - P[b]) * (z - P[c + 2])) / d; + const l2 = ((P[c + 2] - P[a + 2]) * (x - P[c]) + (P[a] - P[c]) * (z - P[c + 2])) / d; + const l3 = 1 - l1 - l2; + if (l1 < -1e-4 || l2 < -1e-4 || l3 < -1e-4) continue; + const y = l1 * P[a + 1] + l2 * P[b + 1] + l3 * P[c + 1]; + if (best === null || y > best) best = y; + } + return best; + } + + /** Nearest collision hit of a ray (Möller–Trumbore), or null. */ + function raycast(o, d) { + if (!ground) return null; + const { pos: P, idx: I } = ground; + let best = Infinity; + for (let t = 0; t < I.length; t += 3) { + const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3; + const e1 = [P[b] - P[a], P[b + 1] - P[a + 1], P[b + 2] - P[a + 2]]; + const e2 = [P[c] - P[a], P[c + 1] - P[a + 1], P[c + 2] - P[a + 2]]; + const p = [d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]]; + const det = e1[0] * p[0] + e1[1] * p[1] + e1[2] * p[2]; + if (Math.abs(det) < 1e-9) continue; + const inv = 1 / det; + const s = [o[0] - P[a], o[1] - P[a + 1], o[2] - P[a + 2]]; + const u = (s[0] * p[0] + s[1] * p[1] + s[2] * p[2]) * inv; + if (u < 0 || u > 1) continue; + const q = [s[1] * e1[2] - s[2] * e1[1], s[2] * e1[0] - s[0] * e1[2], s[0] * e1[1] - s[1] * e1[0]]; + const v = (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]) * inv; + if (v < 0 || u + v > 1) continue; + const dist = (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]) * inv; + if (dist > 0.01 && dist < best) best = dist; + } + return best < Infinity ? [o[0] + d[0] * best, o[1] + d[1] * best, o[2] + d[2] * best] : null; + } + + // ---------- outline (.edg): silhouette + open edges, re-found when the camera moves ---------- + let edgeDirty = true; + function updateOutline() { + edgeDirty = false; + const w = MAP.world; + if (!w?.edg || !toggled("map-t-outline")) return view.setOverlay("outline", null); + const e = w.edg; + const eyeGl = view.eye(); + const eye = [eyeGl[0], eyeGl[1], -eyeGl[2]]; + const P = e.pos, FN = e.faceNormals, E = e.edges; + const c = w.cfg.outline?.color?.length === 3 ? w.cfg.outline.color.map((v) => v / 255) : [0, 0, 0]; + const out = []; + const front = (f, a) => FN[f * 3] * (eye[0] - P[a]) + FN[f * 3 + 1] * (eye[1] - P[a + 1]) + FN[f * 3 + 2] * (eye[2] - P[a + 2]) > 0; + for (let i = 0; i < E.length; i += 4) { + const a = E[i] * 3, b = E[i + 1] * 3, fa = E[i + 2], fb = E[i + 3]; + const open = fb === 0xffff || fa === 0xffff; + if (!open && front(fa, a) === front(fb, a)) continue; + for (const v of [a, b]) { + // pulled a little toward the camera so the line isn't hidden by its own surface + const dx = eye[0] - P[v], dy = eye[1] - P[v + 1], dz = eye[2] - P[v + 2]; + const l = Math.hypot(dx, dy, dz) || 1; + const k = Math.min(3, l * 0.002) / l; + out.push(P[v] + dx * k, P[v + 1] + dy * k, P[v + 2] + dz * k, c[0], c[1], c[2], 0.9); + } + } + view.setOverlay("outline", out.length ? new Float32Array(out) : null, { depth: true }); + } + + // ===================================================================== + // loading a map + // ===================================================================== + async function setSource(src, { quiet = false } = {}) { + try { + const d = await call("mdl:describe", src); + MAP.src = { ...src, label: d.label }; + } catch (err) { + return toast(err.message, true); + } + $("map-src-label").textContent = MAP.src.kind === "dir" ? "Extracted folder" : MAP.src.kind === "packs" ? "Pack folder" : `Packs next to ${MAP.src.name}`; + $("map-src").title = `Reading from ${MAP.src.label}`; + kit?.clearTextures(); + modelBrowser?.kit?.clearTextures(); + nameLists = {}; + imagesetCache = null; + await loadList(); + if (!quiet) toast(`Reading maps from ${MAP.src.label}.`); + } + + async function chooseSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + let choice = null; + const options = [ + ...profileSourceOption(), + ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), + { value: "packs", title: "A folder with .iop packs…", sub: "the client folder: rs.iop (map configs), map.iop / map2.iop (meshes, textures, collision)" }, + { value: "dir", title: "A folder of extracted files…", sub: "config/mode/…, resource/mesh, resource/texture… extracted from the packs" }, + ]; + const list = el( + "div", + { class: "choice-list" }, + options.map((o, i) => + el("label", { class: "choice" }, [ + el("input", { type: "radio", name: "mapsrc", checked: i === 0, onchange: () => (choice = o.value) }), + el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), + ]), + ), + ); + choice = options[0].value; + if (!(await modal("Where are the maps?", ["Map configs are in rs.iop (config/mode//…); the map meshes, textures and collision in map.iop / map2.iop. Pick the folder with all of them.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false; + let src = choice; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); + if (!folder) return false; + src = { kind: choice, folder }; + } + await setSource(src); + return true; + } + $("map-src").onclick = () => chooseSource(); + + const list = new VTable( + $("map-list"), + [ + { key: 1, label: "Map", width: 190, grow: true, title: true }, + { key: 2, label: "Mode", width: 110, cls: "mono dim" }, + ], + { multi: false, id: "map" }, + ); + list.keyOf = (r) => r[0]; + list.onOpen = (r) => openRow(r); + + async function loadList() { + if (!MAP.src) return; + loading(true, "Reading the map configs…"); + try { + const rows = await call("map:list", MAP.src); + MAP.rows = rows.map(([key, path, mode, mesh, pack]) => [key, baseName(path).replace(/\.ini$/i, "").replace(/^sp2_/, ""), mode, mesh, path, pack]); + } catch (err) { + toast(err.message, true); + MAP.rows = []; + } finally { + loading(false); + } + const modes = [...new Set(MAP.rows.map((r) => r[2]))].sort(); + $("map-mode").replaceChildren(el("option", { value: "", text: `All modes (${MAP.rows.length})` }), ...modes.map((m) => el("option", { value: m, text: `${m} (${MAP.rows.filter((r) => r[2] === m).length})` }))); + filterList(); + } + function filterList() { + const q = $("map-search").value.trim().toLowerCase(); + const mode = $("map-mode").value; + const rows = MAP.rows.filter((r) => (!mode || r[2] === mode) && (!q || r[1].toLowerCase().includes(q) || r[3].toLowerCase().includes(q))); + list.setRows(rows); + $("map-count").textContent = rows.length.toLocaleString(); + } + $("map-search").addEventListener("input", filterList); + $("map-mode").addEventListener("change", filterList); + + function loading(on, text = "Loading…") { + $("map-loading").hidden = !on; + $("map-loading").querySelector("span").textContent = text; + } + + async function confirmDiscard() { + if (!MAP.dirty) return true; + return confirmBox("Unsaved changes", "This map has changes that aren't saved. Discard them?", "Discard", "danger"); + } + + async function openRow(r) { + if (!r || !(await confirmDiscard())) return; + try { + const hit = await call("mdl:readKey", MAP.src, r[0]); + if (!hit) throw new Error("Not found in the packs any more."); + await openDoc(toLatin1(hit.bytes), hit.where, r[1], hit.source); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + + async function importMapFromDisk() { + if (!(await confirmDiscard())) return; + const file = await call("dialog:file", "Map config (.ini)"); + if (!file) return; + try { + const hit = await call("mdl:readFile", file); + await openDoc(toLatin1(hit.bytes), hit.where, baseName(file), file); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + $("map-open-file").onclick = importMapFromDisk; + + // ---------- PvE navigation ([common] navigation_map = xml/pve_navi_*.xml: a box graph) ---------- + async function loadNavi() { + MAP.navi = null; + const path = val(sec("common"), "navigation_map"); + if (!path || !MAP.src) return; + const h = await call("mdl:readPath", MAP.src, path).catch(() => null); + if (!h) return toast(`Navigation ${path} isn't in these packs.`, true); + try { + MAP.navi = { path, where: h.where, doc: XmlDoc.parse(toLatin1(h.bytes)), dirty: false }; + } catch (err) { + toast(`${path}: ${err.message}`, true); + } + } + /** Boxes in game coordinates (the file stores x, z, y — ioTowerDefMode swaps y / z). */ + function naviBoxes() { + if (!MAP.navi) return []; + const v = (s) => (s || "0,0,0").split(",").map(Number); + return MAP.navi.doc.elements + .filter((e) => e.tag.toLowerCase() === "box") + .map((e) => { + const a = v(XmlDoc.attr(e, "min")), b = v(XmlDoc.attr(e, "max")); + return { el: e, name: XmlDoc.attr(e, "name") || "", min: [a[0], a[2], a[1]], max: [b[0], b[2], b[1]] }; + }); + } + const naviLinks = () => (MAP.navi ? MAP.navi.doc.elements.filter((e) => e.tag.toLowerCase() === "link").map((e) => ({ from: XmlDoc.attr(e, "from"), to: XmlDoc.attr(e, "to"), type: XmlDoc.attr(e, "type") })) : []); + function moveNaviBox(name, dx, dy, dz) { + const box = naviBoxes().find((b) => b.name === name); + if (!box) return; + const fmt = (p) => `${+p[0].toFixed(3)},${+p[2].toFixed(3)},${+p[1].toFixed(3)}`; + const mn = [box.min[0] + dx, box.min[1] + dy, box.min[2] + dz]; + const mx = [box.max[0] + dx, box.max[1] + dy, box.max[2] + dz]; + let d = MAP.navi.doc; + let src = XmlDoc.setAttr(d, box.el, "min", fmt(mn)); + d = XmlDoc.parse(src); + src = XmlDoc.setAttr(d, d.elements.find((e) => e.tag.toLowerCase() === "box" && XmlDoc.attr(e, "name") === name), "max", fmt(mx)); + MAP.navi.doc = XmlDoc.parse(src); + MAP.navi.dirty = true; + } + + async function openDoc(text, where, title, source) { + if (!ensureView()) return; + MAP.doc = parseIni(text); + if (!sec("map")) throw new Error("This file has no [map] section — not a map config."); + MAP.where = where; + MAP.title = title; + MAP.source = source; + MAP.dirty = false; + MAP.undo = []; + MAP.redo = []; + loadPreviewRotations(); + MAP.transformMode = "position"; + MAP.sel = null; + MAP.secSel = "map"; + $("map-title").textContent = source || title; + $("map-dirty").hidden = true; + $("map-empty").hidden = true; + $("map-side").hidden = false; + await loadWorld(); + await loadNavi(); + buildMarkers(); + frameMap(); + applyEnv(); + drawMarkers(); + renderSide(); + syncObjects(); + FX.inst.clear(); + view.setParticles([]); + syncEffects(); + updateSave(); + } + + /** The map model ([map] mesh_name): its meshes, collision (#COLLISION_MESH) and outline (#OUTLINE). */ + async function loadWorld() { + view.clear(); + for (const o of MAP.objects.values()) o.dead = true; + MAP.objects.clear(); + ground = null; + const name = val(sec("map"), "mesh_name") || val(sec("map"), "mesh_name1") || ""; + MAP.world = { name, cfg: null, meshes: [], missing: [] }; + if (!name || !MAP.src) return; + loading(true, "Loading the map model…"); + const model = await kit.readModel(name); + if (!model) { + MAP.world.missing.push(name); + return toast(`${name} isn't in these packs.`, true); + } + MAP.world.cfg = model.cfg; + for (const n of model.cfg.meshes) { + const h = await kit.readNamed(`${n}.msh`, "mesh"); + if (!h) { + MAP.world.missing.push(`${n}.msh`); + continue; + } + try { + const m = await kit.loadMesh(h, n); + MAP.world.meshes.push(m); + kit.loadTextures(m); + } catch (err) { + MAP.world.missing.push(`${n}.msh (${err.message})`); + } + } + if (model.cfg.collision) { + const h = await kit.readNamed(model.cfg.collision, "mesh"); + try { + if (!h) throw new Error("not found"); + const c = LSF.parseCms(h.bytes); + MAP.world.col = c; + buildGround(c); + view.addMesh("col", { pos: c.pos, indices: new Uint32Array(c.indices), groups: [{ start: 0, count: c.indices.length, color: [0.25, 0.95, 0.55, 0.2] }] }, { wireColor: [0.35, 1, 0.6, 0.4] }); + view.setVisible("col", toggled("map-t-col")); + } catch (err) { + MAP.world.missing.push(`${model.cfg.collision} (${err.message})`); + } + } + if (model.cfg.outline?.file) { + const h = await kit.readNamed(model.cfg.outline.file, "mesh"); + try { + if (h) MAP.world.edg = LSF.parseEdg(h.bytes); + } catch (err) { + MAP.world.missing.push(`${model.cfg.outline.file} (${err.message})`); + } + edgeDirty = true; + } + drawGrid(); + loading(false); + } + + function worldBounds() { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + const add = (p) => { + for (let i = 0; i < 3; i++) { + min[i] = Math.min(min[i], p[i]); + max[i] = Math.max(max[i], p[i]); + } + }; + const aabb = MAP.world?.cfg?.aabb; + if (aabb?.[0]?.length === 3) { + add(aabb[0]); + add(aabb[1]); + } else for (const m of MAP.world?.meshes || []) (add(m.data.aabbMin), add(m.data.aabbMax)); + if (!isFinite(min[0])) for (const m of MAP.markers) add([m.x, m.y, m.z]); + if (!isFinite(min[0])) return [[-1000, 0, -1000], [1000, 500, 1000]]; + return [min, max]; + } + function frameMap() { + const [min, max] = worldBounds(); + view.cam.pitch = 0.62; + view.cam.yaw = 0; + view.frame(min, max); + view.cam.dist *= 0.8; + view.home = { ...view.cam, target: view.cam.target.slice() }; + } + function drawGrid() { + const [min, max] = worldBounds(); + const g = []; + const r = Math.max(max[0] - min[0], max[2] - min[2], 1000); + const step = 10 ** Math.floor(Math.log10(r / 6)); + const cx = Math.round((min[0] + max[0]) / 2 / step) * step, cz = Math.round((min[2] + max[2]) / 2 / step) * step; + const half = Math.ceil(r / 2 / step + 1) * step; + for (let v = -half; v <= half + 1e-6; v += step) { + const c = Math.abs(v) < step / 2 ? [1, 1, 1, 0.22] : [1, 1, 1, 0.07]; + g.push(cx + v, 0, cz - half, ...c, cx + v, 0, cz + half, ...c, cx - half, 0, cz + v, ...c, cx + half, 0, cz + v, ...c); + } + view.setOverlay("grid", new Float32Array(g)); + view.showOverlay("grid", toggled("map-t-grid")); + } + + // ---------- objects placed by the config ---------- + const OBJECT_SECTIONS = /^(object|costom_object|function_object|object_camera_fixed)\d+$/; + function syncObjects() { + if (!MAP.doc || !view) return; + const want = new Map(); + if (toggled("map-t-objects")) { + for (const s of MAP.doc.sections) { + if (!OBJECT_SECTIONS.test(s.lname) || !layerVisible(layerOf(s.lname))) continue; + const model = val(s, "model_file"); + if (!model || model.toLowerCase() === "none") continue; + const elev = + num(s, "entity_type", -1) === 6 && keyIn(s, "end_pos_x") + ? { end: [num(s, "end_pos_x"), num(s, "end_pos_y"), num(s, "end_pos_z")], speed: num(s, "move_speed", 0), wait: num(s, "wait_time", 0), first: num(s, "first_wait_add_time", 0) } + : null; + want.set(s.lname, { model, pos: [num(s, "pos_x"), num(s, "pos_y"), num(s, "pos_z")], scale: [num(s, "scale_x", 1) || 1, num(s, "scale_y", 1) || 1, num(s, "scale_z", 1) || 1], rotation: MAP.previewRot.get(s.lname) || [0, 0, 0], animIdx: num(s, "animation_idx", -1), elev }); + } + } + for (const [k, o] of MAP.objects) { + const w = want.get(k); + if (!w || w.model.toLowerCase() !== o.model.toLowerCase()) { + o.dead = true; + for (const m of o.meshes) view.removeMesh(m.id); + MAP.objects.delete(k); + } + } + for (const [k, w] of want) { + const o = MAP.objects.get(k); + if (o) { + o.pos = w.pos; + o.scale = w.scale; + o.rotation = w.rotation; + o.elev = w.elev; + if (o.animIdx !== w.animIdx) { + o.animIdx = w.animIdx; + if (o.skin) pickObjectAnimation(o); + } + if (!ANIM.on || !o.elev) for (const m of o.meshes) view.setTransform(m.id, w.pos, w.scale, w.rotation); + continue; + } + const obj = { model: w.model, pos: w.pos, scale: w.scale, rotation: w.rotation, animIdx: w.animIdx, elev: w.elev, meshes: [], minY: Infinity, dead: false }; + MAP.objects.set(k, obj); + loadObject(obj); + } + for (const [k, o] of MAP.objects) for (const m of o.meshes) view.setHighlight(m.id, MAP.secSel === k); + } + async function loadObject(obj) { + const model = MAP.generatedModels.get(obj.model.toLowerCase()) || await kit.readModel(obj.model); + if (!model || obj.dead) return; + obj.cfg = model.cfg; + // animated models (animodel/…): skeleton + the animation_idx-th animation of the config + if (model.cfg.skeleton) { + const h = await kit.readNamed(model.cfg.skeleton, "skeleton"); + if (h) { + try { + const sk = SkinKit.prepare(LSF.parseSkl(h.bytes)); + if (sk.linked) { + const l = await kit.readNamed(sk.linked, "skeleton"); + if (l) SkinKit.link(sk, LSF.parseSkl(l.bytes)); + } + obj.skin = { sk, ani: null, map: null }; + await pickObjectAnimation(obj); + } catch (err) { + console.warn("[map] skeleton", model.cfg.skeleton, err); + } + } + } + for (const n of model.cfg.meshes) { + const h = await kit.readNamed(`${n}.msh`, "mesh"); + if (!h || obj.dead) continue; + try { + const m = await kit.loadMesh(h, n, { pos: obj.pos, scale: obj.scale }); + if (obj.dead) { + view.removeMesh(m.id); + continue; + } + for (let i = 1; i < m.data.pos.length; i += 3) obj.minY = Math.min(obj.minY, m.data.pos[i]); + view.setTransform(m.id, obj.pos, obj.scale, obj.rotation); // may have moved meanwhile + if (obj.skin && m.data.boneNames?.length) m.pal = SkinKit.palette(obj.skin.sk, m.data); + obj.meshes.push(m); + kit.loadTextures(m); + } catch {} + } + } + + // ---------- map animation: texture effects, animated objects, elevators ---------- + const ANIM = { on: true, raf: 0, t0: performance.now() }; + + async function pickObjectAnimation(obj) { + const name = obj.animIdx >= 0 ? obj.cfg?.animations?.[obj.animIdx] : null; + obj.skin.ani = null; + obj.skin.map = null; + if (!name) return; + const h = await kit.readNamed(name, "animation"); + if (!h) return; + try { + obj.skin.ani = LSF.parseAni(h.bytes); + obj.skin.map = SkinKit.mapTracks(obj.skin.sk, obj.skin.ani); + obj.skin.aniName = name; + } catch (err) { + console.warn("[map] animation", name, err); + } + } + + // ---------- effects ([effectN] / [costom_effectN] effect_file at pos; "none" = effect_common's common_file) ---------- + const FX = { defs: new Map(), inst: new Map(), tex: new Map() }; + function syncEffects() { + if (!MAP.doc) return; + const want = new Map(); + const common = val(sec("effect_common"), "common_file"); + for (const s of MAP.doc.sections) { + if (!/^(effect|costom_effect)\d+$/.test(s.lname) || !layerVisible(layerOf(s.lname))) continue; + let file = val(s, "effect_file") || "none"; + if (file.toLowerCase() === "none") file = common || ""; + if (!file || file.toLowerCase() === "none") continue; + want.set(s.lname, { file, pos: [num(s, "pos_x"), num(s, "pos_y"), num(s, "pos_z")] }); + } + for (const k of [...FX.inst.keys()]) if (!want.has(k) || want.get(k).file.toLowerCase() !== FX.inst.get(k).file.toLowerCase()) FX.inst.delete(k); + for (const [k, w] of want) { + const cur = FX.inst.get(k); + if (cur) { + cur.pos = w.pos; + if (cur.inst) cur.inst.origin = w.pos; + continue; + } + const entry = { file: w.file, pos: w.pos, inst: null }; + FX.inst.set(k, entry); + effectDef(w.file).then((def) => { + if (def && FX.inst.get(k) === entry) entry.inst = Effects.instance(def, entry.pos); + }); + } + } + function effectDef(file) { + const key = file.toLowerCase(); + if (!FX.defs.has(key)) { + FX.defs.set( + key, + (async () => { + const h = await kit.readNamed(file, "effect"); + if (!h) return null; + try { + return Effects.parse(toLatin1(h.bytes)); + } catch (err) { + console.warn("[map] effect", file, err); + return null; + } + })(), + ); + } + return FX.defs.get(key); + } + /** A particle texture (clamped like the client); null until loaded. */ + function fxTexture(name) { + if (!name) return null; + const key = name.toLowerCase(); + if (!FX.tex.has(key)) { + FX.tex.set(key, null); + kit.texture(name).then((t) => { + if (!t.tex) return; + const gl = view.gl; + gl.bindTexture(gl.TEXTURE_2D, t.tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + FX.tex.set(key, t.tex); + }); + } + return FX.tex.get(key); + } + function stepEffects(dt) { + const cam = view.cameraAxes(); + const out = []; + for (const e of FX.inst.values()) { + if (!e.inst) continue; + Effects.update(e.inst, dt); + for (const b of Effects.build(e.inst, cam)) { + b.tex = fxTexture(b.texture); + out.push(b); + } + } + view.setParticles(out); + return out.length > 0; + } + + /** Elevator (function_object entity_type 6): wait, pos → end_pos at move_speed, wait, back. */ + function elevatorAt(o, tMs) { + const e = o.elev; + const s = o.pos; + const d = e.end; + const dist = Math.hypot(d[0] - s[0], d[1] - s[1], d[2] - s[2]); + if (!dist || e.speed <= 0) return s; + const move = (dist / e.speed) * 1000; + const w = Math.max(0, e.wait); + let tt = tMs - Math.max(0, e.first); + if (tt < 0) return s; + const p = tt % (2 * (w + move)); + const f = p < w ? 0 : p < w + move ? (p - w) / move : p < 2 * w + move ? 1 : 1 - (p - 2 * w - move) / move; + return [s[0] + (d[0] - s[0]) * f, s[1] + (d[1] - s[1]) * f, s[2] + (d[2] - s[2]) * f]; + } + + function animTick(now) { + if (!ANIM.on) return; + if (view && MAP.doc && $("view-map").classList.contains("on")) { + const t = (now - ANIM.t0) / 1000; + const dt = Math.min(100, Math.max(0, now - (ANIM.last || now))); + ANIM.last = now; + let any = FX.inst.size > 0 && (stepEffects(dt) || true); + for (const m of MAP.world?.meshes || []) any = kit.animate(m, t) || any; + for (const o of MAP.objects.values()) { + for (const m of o.meshes) any = kit.animate(m, t) || any; + if (o.skin?.ani) { + const ps = SkinKit.pose(o.skin.sk, o.skin.ani, o.skin.map, (t * 1000) % Math.max(1, o.skin.ani.total)); + for (const m of o.meshes) { + if (!m.pal || !m.skinPos) continue; + SkinKit.skin(m, m.pal, ps); + view.updateMesh(m.id, m.skinPos, m.skinNrm); + } + any = true; + } + if (o.elev) { + const p = elevatorAt(o, t * 1000); + for (const m of o.meshes) view.setTransform(m.id, p, o.scale, o.rotation); + any = true; + } + } + if (any) view.dirty = true; + } + ANIM.raf = requestAnimationFrame(animTick); + } + + /** Animation off: everything back to its rest state (the "static" look of the map). */ + function resetAnim() { + if (!view) return; + view.setParticles([]); + ANIM.last = 0; + // effects start again from nothing next time + for (const e of FX.inst.values()) if (e.inst) e.inst = Effects.instance(e.inst.def, e.pos); + for (const m of MAP.world?.meshes || []) kit.animate(m, null); + for (const o of MAP.objects.values()) { + for (const m of o.meshes) { + kit.animate(m, null); + if (m.pal && m.skinPos) view.updateMesh(m.id, m.data.pos, m.data.normal); + view.setTransform(m.id, o.pos, o.scale, o.rotation); + } + } + view.dirty = true; + } + + function setAnim(on) { + ANIM.on = on; + cancelAnimationFrame(ANIM.raf); + if (on) { + ANIM.t0 = performance.now(); + ANIM.raf = requestAnimationFrame(animTick); + } else resetAnim(); + } + ANIM.raf = requestAnimationFrame(animTick); + + // ---------- lighting preview ---------- + function applyEnv() { + if (!view) return; + if (!MAP.doc || !toggled("map-t-light")) { + view.env = null; + view.dirty = true; + return; + } + const rgb = (s, def = 127, keys = ["red", "green", "blue"]) => keys.map((k) => num(s, k, def) / 255); + const light = sec("main_light"); + const fog = sec("fog"); + view.env = { + ambient: rgb(sec("ambient"), 127), + lightColor: rgb(light, 160), + lightDir: [num(light, "target_x") - num(light, "pos_x", 10), num(light, "target_y") - num(light, "pos_y", 10), num(light, "target_z") - num(light, "pos_z", -5)], + fog: fog && num(fog, "enable") ? { color: rgb(fog), start: num(fog, "start", 1000), end: num(fog, "end", 8000) } : null, + clear: sec("clear_color") ? rgb(sec("clear_color"), 0) : null, + }; + view.dirty = true; + } + + // ===================================================================== + // picking + dragging + // ===================================================================== + function pickMarker(cx, cy) { + const b = $("map-canvas").getBoundingClientRect(); + let best = null; + let bd = 13; + for (const m of MAP.markers) { + if (!layerVisible(m.layer)) continue; + const p = view.project([m.x, m.y + 4, m.z]); + if (!p) continue; + const d = Math.hypot(p[0] + b.left - cx, p[1] + b.top - cy); + if (d < bd) { + bd = d; + best = m; + } + } + return best; + } + + let drag = null; + let down = null; + function setTransformMode(mode) { + MAP.transformMode = mode; + for (const [id, name] of [["map-tool-position", "position"], ["map-tool-rotate", "rotate"], ["map-tool-scale", "scale"]]) $(id)?.classList.toggle("on", mode === name); + const labels = { position: `G · Move${MAP.moveAxis ? ` ${MAP.moveAxis.toUpperCase()} axis` : ""}`, rotate: "R · Rotate preview", scale: "S · Scale" }; + const status = $("map-tool-status"); + if (status) status.textContent = labels[mode] || labels.position; + } + function setMoveAxis(axis) { + MAP.moveAxis = axis; + setTransformMode("position"); + } + const isObjectSection = (name) => OBJECT_SECTIONS.test(name || ""); + function pointerDown(e) { + if (!MAP.doc) return false; + down = { x: e.clientX, y: e.clientY }; + if (e.button !== 0 || e.shiftKey) return false; + const m = pickMarker(e.clientX, e.clientY); + if (!m) return false; + select({ sec: m.sec, prefix: m.prefix }); + const object = isObjectSection(m.sec); + const s = sec(m.sec); + drag = { + m, before: state(), moved: false, alt: e.altKey, + mode: object ? MAP.transformMode : "position", + startX: e.clientX, startY: e.clientY, + startScale: [num(s, "scale_x", 1) || 1, num(s, "scale_y", 1) || 1, num(s, "scale_z", 1) || 1], + startRotation: [...(MAP.previewRot.get(m.sec) || [0, 0, 0])], + }; + return true; + } + + window.addEventListener("mousemove", (e) => { + if (!$("view-map").classList.contains("on") || !MAP.doc || !view) return; + if (!drag) { + if (e.target !== $("map-canvas") || e.buttons) return; + const h = pickMarker(e.clientX, e.clientY); + if (h !== MAP.hover) { + MAP.hover = h; + $("map-canvas").style.cursor = h ? "move" : ""; + drawLabels(); + } + return; + } + const m = drag.m; + if (drag.mode === "scale" && isObjectSection(m.sec)) { + const factor = Math.max(0.05, Math.min(20, Math.pow(2, (drag.startY - e.clientY) / 150))); + if (Math.abs(factor - 1) < 0.001) return; + const keys = ["scale_x", "scale_y", "scale_z"]; + for (let i = 0; i < 3; i++) setVal(m.sec, keys[i], `${Math.max(0.05, Math.min(100, drag.startScale[i] * factor)).toFixed(3)}f`); + drag.moved = true; + refreshScene(); + $("map-hover").textContent = `${m.sec} · scale ${factor.toFixed(2)}×`; + return; + } + if (drag.mode === "rotate" && isObjectSection(m.sec)) { + const sensitivity = e.shiftKey ? 0.2 : 0.6; + MAP.previewRot.set(m.sec, [drag.startRotation[0] + (drag.startY - e.clientY) * sensitivity, drag.startRotation[1] + (e.clientX - drag.startX) * sensitivity, drag.startRotation[2]]); + drag.moved = true; + refreshScene(); + const r = MAP.previewRot.get(m.sec); + $("map-hover").textContent = `${m.sec} · preview rotation X ${Math.round(r[0])}° Y ${Math.round(r[1])}°`; + return; + } + const { o, d } = view.ray(e.clientX, e.clientY); + let x = m.x, y = m.y, z = m.z; + if (drag.alt && m.hasY) { + // Alt: straight up / down, on the plane facing the camera + const n = [d[0], 0, d[2]]; + const l = Math.hypot(n[0], n[2]) || 1; + n[0] /= l; + n[2] /= l; + const den = d[0] * n[0] + d[2] * n[2]; + if (Math.abs(den) > 1e-6) { + const t = ((m.x - o[0]) * n[0] + (m.z - o[2]) * n[2]) / den; + y = o[1] + d[1] * t; + } + } else { + let p = !m.hasY ? raycast(o, d) : null; + if (!p) { + const y0 = m.y; + const t = Math.abs(d[1]) > 1e-6 ? (y0 - o[1]) / d[1] : -1; + if (t > 0) p = [o[0] + d[0] * t, y0, o[2] + d[2] * t]; + } + if (!p) return; + [x, , z] = p; + if (MAP.snap) { + x = Math.round(x / MAP.snap) * MAP.snap; + z = Math.round(z / MAP.snap) * MAP.snap; + } else { + x = Math.round(x); + z = Math.round(z); + } + } + if (MAP.snap && drag.alt) y = Math.round(y / MAP.snap) * MAP.snap; + else y = Math.round(y); + if (x === m.x && y === m.y && z === m.z) return; + drag.moved = true; + if (m.navi) moveNaviBox(m.prefix, x - m.x, m.hasY && drag.alt ? y - m.y : 0, z - m.z); + else { + setNum(m.sec, `${m.prefix}_x`, x); + setNum(m.sec, `${m.prefix}_z`, z); + if (m.hasY && drag.alt) setNum(m.sec, `${m.prefix}_y`, y); + } + refreshScene(); + drag.m = markerOf({ sec: m.sec, prefix: m.prefix }) || m; + $("map-hover").textContent = `${markerLabel(drag.m)} X ${x} Y ${Math.round(drag.m.y)} Z ${z}`; + }); + + window.addEventListener("mouseup", (e) => { + if (drag) { + const d = drag; + drag = null; + if (d.moved && d.mode !== "rotate") { + pushUndo(d.before); + markDirty(); + renderSide(); + } else if (d.moved) { + const rotation = MAP.previewRot.get(d.m.sec) || [0, 0, 0]; + if (writePreviewRotation(d.m.sec, rotation)) { + pushUndo(d.before); + markDirty(); + refreshScene(); + } + renderSide(); + } + } else if (down && e.target === $("map-canvas") && Math.hypot(e.clientX - down.x, e.clientY - down.y) < 3 && e.button === 0) { + // plain click on empty space: deselect the point + if (MAP.sel) { + MAP.sel = null; + drawMarkers(); + renderSide(); + } + } + down = null; + }); + + function select(s) { + MAP.sel = s; + if (s) { + MAP.secSel = s.sec; + if (isObjectSection(s.sec)) setTransformMode(MAP.transformMode || "position"); + } + drawMarkers(); + syncObjects(); + renderSide(); + const row = $("map-sections").querySelector(".on"); + row?.scrollIntoView({ block: "nearest" }); + } + + function focusSel() { + const m = markerOf(MAP.sel); + if (!m) return; + view.cam.target = [m.x, m.y, -m.z]; + view.cam.dist = Math.min(view.cam.dist, 1800); + view.moved(); + } + + // ===================================================================== + // edits, undo, save + // ===================================================================== + function refreshScene() { + buildMarkers(); + drawMarkers(); + syncObjects(); + syncEffects(); + applyEnv(); + } + function markDirty() { + MAP.dirty = true; + $("map-dirty").hidden = false; + updateSave(); + } + function pushUndo(before = state()) { + MAP.undo.push(before); + if (MAP.undo.length > 200) MAP.undo.shift(); + MAP.redo = []; + } + /** Runs an edit with an undo step. */ + function edit(fn) { + const before = state(); + fn(); + if (state() === before) return; + pushUndo(before); + markDirty(); + refreshScene(); + renderSide(); + } + async function restore(from, to) { + const text = from.pop(); + if (text === undefined) return; + to.push(state()); + const meshBefore = val(sec("map"), "mesh_name"); + const [ini, navi] = text.split(NAVI_SEP); + MAP.doc = parseIni(ini); + loadPreviewRotations(); + if (MAP.navi && navi !== undefined && navi !== MAP.navi.doc.src) { + MAP.navi.doc = XmlDoc.parse(navi); + MAP.navi.dirty = true; + } + if (!markerOf(MAP.sel)) MAP.sel = null; + if (!sec(MAP.secSel || "")) MAP.secSel = "map"; + markDirty(); + if (val(sec("map"), "mesh_name") !== meshBefore) await loadWorld(); + refreshScene(); + renderSide(); + } + function updateSave() { + $("map-save").disabled = false; + $("map-undo").disabled = !MAP.undo.length; + $("map-redo").disabled = !MAP.redo.length; + } + $("map-undo").onclick = () => restore(MAP.undo, MAP.redo); + $("map-redo").onclick = () => restore(MAP.redo, MAP.undo); + + async function save(as = false) { + if (!MAP.doc) return; + let where = MAP.where; + if (as || !where) { + const file = await call("dialog:saveAs", baseName(MAP.source || MAP.title || "map.ini").replace(/^(?!.*\.ini$)(.*)$/i, "$1.ini"), "ini"); + if (!file) return; + where = { kind: "file", file }; + } else if (where.kind === "pack" && !(await confirmBox("Save into the pack", `Write ${where.entry} into ${baseName(where.file)}? The new copy replaces the old one (IOP Manager's Optimize frees the old space).`, "Save"))) return; + try { + await call("mdl:writeBack", where, fromLatin1(serialize())); + if (MAP.navi?.dirty) { + const nw = where.kind === "file" ? { kind: "file", file: where.file.replace(/[^\\/]+$/, "") + baseName(MAP.navi.path) } : MAP.navi.where; + await call("mdl:writeBack", nw, fromLatin1(MAP.navi.doc.src)); + MAP.navi.dirty = false; + toast(`Saved the navigation ${baseName(MAP.navi.path)} too.`); + } + MAP.where = where; + MAP.dirty = false; + $("map-dirty").hidden = true; + if (where.kind === "pack") kit.clearTextures(); + toast(`Saved ${where.kind === "pack" ? `${where.entry} into ${baseName(where.file)}` : where.file}.`); + } catch (err) { + toast(err.message, true); + } + } + $("map-save").onclick = (e) => + showMenu( + [ + { label: MAP.where?.kind === "pack" ? `Save into ${baseName(MAP.where.file)}` : "Save", icon: MAP.where?.kind === "pack" ? "box" : "save", hint: "Ctrl+S", disabled: !MAP.doc, run: () => save() }, + { label: "Save as file…", icon: "download", disabled: !MAP.doc, run: () => save(true) }, + { label: "Import map from disk…", icon: "import", run: importMapFromDisk }, + "-", + { label: "Screenshot (PNG)…", icon: "camera", disabled: !MAP.doc, run: screenshot }, + ], + { anchor: e.currentTarget }, + ); + + async function screenshot() { + const file = await call("dialog:savePng", `${MAP.title || "map"}.png`); + if (!file) return; + view.draw(); + const src = $("map-canvas"); + const c = document.createElement("canvas"); + c.width = src.width; + c.height = src.height; + const g = c.getContext("2d"); + g.fillStyle = "#101014"; + g.fillRect(0, 0, c.width, c.height); + g.drawImage(src, 0, 0); + await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]); + toast(`Saved ${file}.`); + } + + // ---------- adding things ---------- + let nameLists = {}; + async function names(folder) { + if (!nameLists[folder] && MAP.src) nameLists[folder] = await call("mdl:names", MAP.src, folder).catch(() => []); + return nameLists[folder] || []; + } + async function askName(title, label, folder, value = "") { + const id = `map-dl-${folder}`; + const dl = el("datalist", { id }, (await names(folder)).map((n) => el("option", { value: n }))); + const input = el("input", { type: "text", value, list: id, spellcheck: "false" }); + input.addEventListener("keydown", (e) => e.key === "Enter" && $("modal-actions").lastChild.click()); + const ok = await modal(title, [el("label", { text: label }), input, dl], [{ label: "Cancel", value: false }, { label: "Add", kind: "primary", value: true }]); + return ok ? input.value.trim() : null; + } + let modelBrowser = null; + let meshBrowser = null; + async function chooseStaticModel() { + const all = (await names("model")).filter((n) => /^(?:staticmodel|animodel)\/.+\.txt$/i.test(n)); + if (!all.length) return toast("No static or animated model configs were found in the selected source.", true), null; + if (!modelBrowser) { + const search = el("input", { type: "search", placeholder: "Search static and animated models…", spellcheck: "false" }); + const list = el("div", { class: "map-model-list" }); + const canvas = el("canvas", { class: "map-model-canvas" }); + const status = el("div", { class: "map-model-status", text: "Choose a model to preview it." }); + const preview = el("div", { class: "map-model-preview" }, [canvas, status]); + const body = el("div", { class: "map-model-browser" }, [el("div", { class: "map-model-browser-list" }, [search, list]), preview]); + modelBrowser = { body, search, list, canvas, status, preview, view: null, kit: null, selected: "", request: 0, meshes: [] }; + } + const b = modelBrowser; + b.selected = ""; + b.search.value = ""; + b.status.textContent = "Choose a model to preview it."; + if (!b.view) { + try { + b.view = new GL3D.View(b.canvas); + b.kit = MeshKit.create(b.view, () => MAP.src); + } catch (err) { + return toast(`3D preview unavailable: ${err.message}`, true), null; + } + } + const render = () => { + const q = b.search.value.trim().toLowerCase(); + const hits = all.filter((n) => n.toLowerCase().includes(q)); + b.list.replaceChildren(...hits.map((n) => el("button", { + class: `map-model-option${b.selected === n ? " on" : ""}`, + text: n, + title: n, + onclick: () => { b.selected = n; render(); previewModel(n); }, + }))); + }; + const previewModel = async (name) => { + const request = ++b.request; + b.view.clear(); + b.status.textContent = `Loading ${name}…`; + const cfg = await b.kit.readModel(name).catch(() => null); + if (request !== b.request) return; + if (!cfg) return (b.status.textContent = "Model config was not found."); + const loaded = []; + for (const n of cfg.cfg.meshes) { + const hit = await b.kit.readNamed(`${n}.msh`, "mesh").catch(() => null); + if (request !== b.request) return; + if (!hit) continue; + try { + const m = await b.kit.loadMesh(hit, n); + loaded.push(m); + } catch {} + } + if (request !== b.request) return; + if (!loaded.length) return (b.status.textContent = "This model has no readable meshes."); + b.meshes = loaded; + await Promise.all(loaded.map((m) => b.kit.loadTextures(m))); + if (request !== b.request) return; + const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; + for (const m of loaded) for (let i = 0; i < m.data.pos.length; i += 3) for (let a = 0; a < 3; a++) { + lo[a] = Math.min(lo[a], m.data.pos[i + a]); + hi[a] = Math.max(hi[a], m.data.pos[i + a]); + } + const center = lo.map((v, i) => (v + hi[i]) / 2); + const radius = Math.max(1, ...hi.map((v, i) => v - lo[i])); + b.view.cam.target = [center[0], center[1], -center[2]]; + b.view.cam.dist = radius * 2.1; + b.view.cam.radius = radius; + b.view.cam.pitch = 0.38; + b.view.cam.yaw = 0.72; + b.view.moved(); + b.status.textContent = `${name} · ${loaded.length} mesh(es)`; + }; + b.search.oninput = () => { + const hits = all.filter((n) => n.toLowerCase().includes(b.search.value.trim().toLowerCase())); + if (!hits.includes(b.selected)) { + b.selected = hits[0] || ""; + render(); + if (b.selected) previewModel(b.selected); + else { b.request++; b.view.clear(); b.status.textContent = "No models match this search."; } + } else render(); + }; + b.search.onkeydown = (e) => { + if (e.key === "Enter" && b.selected) $("modal-actions").lastChild.click(); + }; + render(); + b.selected = all[0]; + render(); + previewModel(b.selected); + const chosen = await modal("Model library", [b.body], [ + { label: "Cancel", value: false }, + { label: "Add selected", kind: "primary", value: true }, + ]); + const result = chosen ? b.selected : null; + b.request++; + b.view.clear(); + b.status.textContent = "Choose a model to preview it."; + return result; + } + async function chooseMeshFromLibrary() { + const all = (await names("mesh")).filter((n) => /\.msh$/i.test(n)); + if (!all.length) return toast("No mesh files were found in the selected resource source.", true), null; + if (!meshBrowser) { + const search = el("input", { type: "search", placeholder: "Search meshes…", spellcheck: "false" }); + const list = el("div", { class: "map-model-list" }); + const canvas = el("canvas", { class: "map-model-canvas" }); + const status = el("div", { class: "map-model-status", text: "Choose a mesh to preview it." }); + const preview = el("div", { class: "map-model-preview" }, [canvas, status]); + const body = el("div", { class: "map-model-browser" }, [el("div", { class: "map-model-browser-list" }, [search, list]), preview]); + meshBrowser = { body, search, list, canvas, status, view: null, kit: null, selected: "", request: 0 }; + } + const b = meshBrowser; + b.selected = ""; + b.search.value = ""; + b.status.textContent = "Choose a mesh to preview it."; + if (!b.view) { + try { + b.view = new GL3D.View(b.canvas); + b.kit = MeshKit.create(b.view, () => MAP.src); + } catch (err) { + return toast(`3D preview unavailable: ${err.message}`, true), null; + } + } + const render = () => { + const q = b.search.value.trim().toLowerCase(); + const hits = all.filter((n) => n.toLowerCase().includes(q)); + b.list.replaceChildren(...hits.map((n) => el("button", { + class: `map-model-option${b.selected === n ? " on" : ""}`, + text: n, + title: n, + onclick: () => { b.selected = n; render(); previewMesh(n); }, + }))); + }; + const previewMesh = async (file) => { + const request = ++b.request; + b.view.clear(); + b.status.textContent = `Loading ${file}…`; + const stem = file.replace(/\.msh$/i, ""); + const hit = await b.kit.readNamed(file, "mesh", true).catch(() => null); + if (request !== b.request) return; + if (!hit) return (b.status.textContent = "Mesh file was not found in the selected source."); + let mesh; + try { mesh = await b.kit.loadMesh(hit, stem); } + catch (err) { return (b.status.textContent = `Couldn't load mesh: ${err.message}`); } + if (request !== b.request) return; + await b.kit.loadTextures(mesh); + if (request !== b.request) return; + const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; + for (let i = 0; i < mesh.data.pos.length; i += 3) for (let a = 0; a < 3; a++) { + lo[a] = Math.min(lo[a], mesh.data.pos[i + a]); + hi[a] = Math.max(hi[a], mesh.data.pos[i + a]); + } + const center = lo.map((v, i) => (v + hi[i]) / 2); + const radius = Math.max(1, ...hi.map((v, i) => v - lo[i])); + b.view.cam.target = [center[0], center[1], -center[2]]; + b.view.cam.dist = radius * 2.1; + b.view.cam.radius = radius; + b.view.cam.pitch = 0.38; + b.view.cam.yaw = 0.72; + b.view.moved(); + b.status.textContent = `${file} · ${mesh.data.faceCount} triangle(s)`; + }; + b.search.oninput = () => { + const hits = all.filter((n) => n.toLowerCase().includes(b.search.value.trim().toLowerCase())); + if (!hits.includes(b.selected)) { + b.selected = hits[0] || ""; + render(); + if (b.selected) previewMesh(b.selected); + else { b.request++; b.view.clear(); b.status.textContent = "No meshes match this search."; } + } else render(); + }; + b.search.onkeydown = (e) => { + if (e.key === "Enter" && b.selected) $("modal-actions").lastChild.click(); + }; + render(); + b.selected = all[0]; + render(); + previewMesh(b.selected); + const chosen = await modal("Mesh library", [b.body], [ + { label: "Cancel", value: false }, + { label: "Add selected", kind: "primary", value: true }, + ]); + const result = chosen ? b.selected : null; + b.request++; + b.view.clear(); + b.status.textContent = "Choose a mesh to preview it."; + return result; + } + /** Where new things go: the selected point, else the floor under the view centre. */ + function dropPoint() { + const m = markerOf(MAP.sel); + if (m) return [m.x + 150, m.y, m.z]; + const t = view.cam.target; + const x = Math.round(t[0]), z = Math.round(-t[2]); + return [x, groundAt(x, z) ?? 0, z]; + } + const f1 = (v) => fmtNum(null, Math.round(v)); + + function snapObjectToFloor(section) { + const m = markerOf({ sec: section, prefix: "pos" }); + const o = MAP.objects.get(section); + if (!m) return toast("This object has no position marker.", true); + const floor = groundAt(m.x, m.z); + if (floor === null) return toast("The map has no collision surface under this object.", true); + if (!o || !Number.isFinite(o.minY)) return toast("Wait for the object preview to finish loading, then snap it again.", true); + const bottom = o.minY * (num(sec(section), "scale_y", 1) || 1); + edit(() => setNum(section, "pos_y", floor - bottom)); + } + + function objectInput(section, key, label, fallback = 0) { + const input = el("input", { type: "number", step: "any", value: val(sec(section), key) ?? String(fallback) }); + input.addEventListener("change", () => { + const n = Number(input.value); + if (!Number.isFinite(n)) return; + edit(() => setVal(section, key, `${n}${/[f]$/i.test(val(sec(section), key) || "") ? "f" : ""}`)); + }); + return el("label", { class: "fld" }, [el("span", { text: label }), input]); + } + + function objectTransformCard(section) { + const rot = MAP.previewRot.get(section) || [0, 0, 0]; + const modeButtons = [ + ["map-tool-position", "G", "Move (G, then X/Y/Z to constrain axis)", "position"], + ["map-tool-rotate", "R", "Rotate preview (R)", "rotate"], + ["map-tool-scale", "S", "Scale (S)", "scale"], + ].map(([id, text, title, mode]) => el("button", { id, class: `iconbtn${MAP.transformMode === mode ? " on" : ""}`, title, text, onclick: () => setTransformMode(mode) })); + return el("div", { class: "map-object-transform" }, [ + el("div", { class: "map-object-mode" }, [...modeButtons, el("span", { id: "map-tool-status", class: "muted small", text: `${MAP.transformMode === "rotate" ? "R · Rotate preview" : MAP.transformMode === "scale" ? "S · Scale" : `G · Move${MAP.moveAxis ? ` ${MAP.moveAxis.toUpperCase()} axis` : ""}`}` })]), + el("div", { class: "map-object-fields" }, [ + objectInput(section, "pos_x", "X"), objectInput(section, "pos_y", "Y"), objectInput(section, "pos_z", "Z"), + objectInput(section, "scale_x", "Scale X", 1), objectInput(section, "scale_y", "Scale Y", 1), objectInput(section, "scale_z", "Scale Z", 1), + ]), + el("div", { class: "map-object-mode" }, [ + el("button", { class: "btn", text: "Snap base to map floor", onclick: () => snapObjectToFloor(section) }), + el("span", { class: "muted small", text: `Editor rotation: ${Math.round(rot[0])}°, ${Math.round(rot[1])}°, ${Math.round(rot[2])}° · saved as editor metadata; game ignores it` }), + ]), + ]); + } + + async function addObject(base = "object") { + const model = await chooseStaticModel(); + if (!model || !/^(?:staticmodel|animodel)\/.+\.txt$/i.test(model)) return model && toast("Pick a model config from staticmodel/ or animodel/.", true); + const [x, y, z] = dropPoint(); + let name; + edit(() => { + const entries = [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)], ["model_file", model], ["shadow_cast", "0"], ["object_quality_level", "0"]]; + if (/^animodel\//i.test(model)) entries.push(["animation_idx", "0"]); + name = addNumbered(base, entries); + }); + select({ sec: name.toLowerCase(), prefix: "pos" }); + } + async function chooseSourceMapDoc(description) { + const sourceSelect = el("select", {}, [ + el("option", { value: "", text: "Choose a source map…" }), + el("option", { value: "__disk__", text: "Browse map file from disk…" }), + ...MAP.rows.map((row) => el("option", { value: row[0], text: `${row[1]} · ${row[2]}` })), + ]); + if (!(await modal("Choose the source map", [ + el("label", { class: "fld" }, [el("span", { text: description }), sourceSelect]), + ], [{ label: "Cancel", value: false }, { label: "Continue", kind: "primary", value: true }]))) return null; + if (!sourceSelect.value) return toast("Choose a source map first.", true), null; + let hit, title; + if (sourceSelect.value === "__disk__") { + const file = await call("dialog:file", "Source map config (.ini)"); + if (!file) return null; + hit = await call("mdl:readFile", file); + title = baseName(file); + } else { + const row = MAP.rows.find((r) => r[0] === sourceSelect.value); + hit = await call("mdl:readKey", MAP.src, sourceSelect.value); + title = row?.[1] || sourceSelect.value; + } + if (!hit) throw new Error("Couldn't read the selected source map."); + return { doc: parseIni(toLatin1(hit.bytes)), title }; + } + + async function importMapMesh() { + if (!MAP.doc) return; + try { + const source = await chooseSourceMapDoc("Map containing the mesh list"); + if (!source) return; + const sourceDoc = source.doc; + const mapSection = sourceDoc.byName.get("map"); + const modelName = keyIn(mapSection, "mesh_name")?.value || keyIn(mapSection, "mesh_name1")?.value; + if (!modelName) throw new Error("The selected file has no map mesh_name."); + const mapModel = await kit.readModel(modelName); + if (!mapModel?.cfg.meshes?.length) throw new Error(`Map model ${modelName} wasn't found in the selected resource source.`); + const meshSelect = el("select", {}, mapModel.cfg.meshes.map((mesh, index) => el("option", { value: mesh, text: `${index + 1}. ${mesh}` }))); + if (!(await modal("Choose a mesh", [ + el("div", { class: "muted small", text: `${source.title} · ${mapModel.cfg.meshes.length} meshes` }), + el("label", { class: "fld" }, [el("span", { text: "Mesh to import" }), meshSelect]), + el("div", { class: "muted small", text: "A one-mesh staticmodel config will be created for this object." }), + ], [{ label: "Cancel", value: false }, { label: "Next", kind: "primary", value: true }]))) return; + await addMeshAsObject(meshSelect.value); + } catch (err) { + toast(err.message, true); + } + } + async function addMeshAsObject(mesh) { + const folder = await call("dialog:folder", "Choose resource/model/staticmodel output folder"); + if (!folder) return; + const safe = mesh.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 44) || "mesh"; + const fileName = `map_import_${safe}_${Date.now().toString(36)}.txt`; + const modelFile = `staticmodel/${fileName}`; + const config = `#MODEL_CFG\r\n#MESH_LIST\r\n${mesh}\r\n#MESH_LIST_END\r\n`; + await call("mdl:writeFiles", folder, [{ name: fileName, data: config }]); + MAP.generatedModels.set(modelFile.toLowerCase(), { cfg: { meshes: [mesh], skeleton: "", animations: [] } }); + const [x, y, z] = dropPoint(); + let name; + edit(() => { + name = addNumbered("object", [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)], ["model_file", modelFile], ["shadow_cast", "0"], ["object_quality_level", "0"]]); + }); + select({ sec: name.toLowerCase(), prefix: "pos" }); + toast(`Added mesh ${mesh} as ${name}. Add ${fileName} to the game's resource pack for it to load in-game.`); + } + async function addMeshFromLibrary() { + if (!MAP.doc) return; + const selected = await chooseMeshFromLibrary(); + if (!selected) return; + await addMeshAsObject(selected.replace(/\\/g, "/").replace(/\.msh$/i, "")); + } + async function importMapObject() { + if (!MAP.doc) return; + try { + const source = await chooseSourceMapDoc("Map containing the object to copy"); + if (!source) return; + const objects = source.doc.sections.filter((s) => + /^(?:object|costom_object)\d+$/i.test(s.name) && + keyIn(s, "model_file")?.value && keyIn(s, "model_file").value.toLowerCase() !== "none", + ); + if (!objects.length) return toast("No placed objects were found in that map.", true); + const objectSelect = el("select", {}, objects.map((s, i) => { + const model = keyIn(s, "model_file").value; + return el("option", { value: String(i), text: `${s.name} · ${model}` }); + })); + if (!(await modal("Choose an object", [ + el("div", { class: "muted small", text: `${source.title} · ${objects.length} placed objects` }), + el("label", { class: "fld" }, [el("span", { text: "Object to copy" }), objectSelect]), + el("div", { class: "muted small", text: "The copied object will be placed at the current map view position." }), + ], [{ label: "Cancel", value: false }, { label: "Add object", kind: "primary", value: true }]))) return; + const src = objects[Number(objectSelect.value)]; + const model = keyIn(src, "model_file").value; + const [x, y, z] = dropPoint(); + const entries = [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)]]; + for (const k of src.keys) if (!/^pos_[xyz]$/i.test(k.key)) entries.push([k.key, k.value]); + const nextSection = source.doc.sections.find((s) => s.head > src.head); + const sourceEnd = nextSection?.head ?? source.doc.lines.length; + let rotation = null; + for (let i = src.head + 1; i < sourceEnd; i++) { + const match = EDITOR_ROTATION.exec(source.doc.lines[i].replace(/[\r\n]+$/, "")); + if (match) { rotation = match.slice(1).map(Number); break; } + } + let name; + edit(() => { + name = addNumbered("object", entries); + if (rotation) { + MAP.previewRot.set(name.toLowerCase(), rotation); + writePreviewRotation(name.toLowerCase(), rotation); + } + }); + select({ sec: name.toLowerCase(), prefix: "pos" }); + toast(`Copied ${src.name} (${model}) into ${name}.`); + } catch (err) { + toast(err.message, true); + } + } + async function addEffect() { + const file = await askName("New effect", "Effect file (resource/effect/…)", "effect"); + if (!file) return; + const [x, y, z] = dropPoint(); + let name; + edit(() => { + name = addNumbered("effect", [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)], ["effect_file", file]]); + }); + select({ sec: name.toLowerCase(), prefix: "pos" }); + } + function addItemPoint() { + const s = sec("item_generate"); + const [x, , z] = dropPoint(); + let prefix = "pos1"; + edit(() => { + if (s && keyIn(s, "pos1_x")) prefix = addPointMember("item_generate", "pos1"); + else { + setVal("item_generate", "pos_cnt", "1"); + setVal("item_generate", "pos1_x", f1(x)); + setVal("item_generate", "pos1_z", f1(z)); + } + }); + select({ sec: "item_generate", prefix }); + } + function duplicateSel() { + const m = markerOf(MAP.sel); + if (!m) return; + const s = sec(m.sec); + const fam = familyOf(m.sec); + if (fam) { + let name; + edit(() => { + name = addNumbered(fam.base, s.keys.map((k) => [k.key, /^pos_x$/i.test(k.key) ? fmtNum(k.value, (parseFloat(k.value) || 0) + 150) : k.value])); + }); + return select({ sec: name.toLowerCase(), prefix: m.prefix }); + } + if (pointFamily(s, m.prefix)) { + let p; + edit(() => (p = addPointMember(m.sec, m.prefix))); + if (p) select({ sec: m.sec, prefix: p }); + return; + } + toast("This point is a fixed part of its section; it can't be copied.", true); + } + function deleteSel() { + const m = markerOf(MAP.sel); + if (!m) return; + if (familyOf(m.sec) && m.prefix === "pos") { + edit(() => removeNumbered(m.sec)); + MAP.sel = null; + MAP.secSel = null; + return (refreshScene(), renderSide(), toast(`Removed [${m.secName}]; the ones after it were renumbered.`)); + } + if (pointFamily(sec(m.sec), m.prefix)) { + edit(() => removePointMember(m.sec, m.prefix)); + MAP.sel = null; + return (refreshScene(), renderSide(), toast(`Removed ${m.prefix}; the count and the numbers after it were updated.`)); + } + toast("This point is a fixed part of its section (edit its values instead).", true); + } + + $("map-add").onclick = (e) => + showMenu( + [ + { label: "Object…", icon: "cube", disabled: !MAP.doc, run: () => addObject() }, + { label: "Object from another map…", icon: "import", disabled: !MAP.doc, run: importMapObject }, + { label: "Mesh from library…", icon: "cube", disabled: !MAP.doc, run: addMeshFromLibrary }, + { label: "Mesh from map file…", icon: "import", disabled: !MAP.doc, run: importMapMesh }, + { label: "Effect…", icon: "zap", disabled: !MAP.doc, run: addEffect }, + { label: "Item spawn point", icon: "target", disabled: !MAP.doc, run: addItemPoint }, + "-", + { label: "Copy of the selected point", icon: "copy", hint: "Ctrl+D", disabled: !MAP.sel, run: duplicateSel }, + { label: "Remove the selected point", icon: "trash", hint: "Del", danger: true, disabled: !MAP.sel, run: deleteSel }, + ], + { anchor: e.currentTarget }, + ); + + // ===================================================================== + // side panel + // ===================================================================== + const LAYER_NAMES = { + team_start: "Team start", + team_revival: "Team revival", + single_start: "Single start", + single_revival: "Single revival", + item_generate: "Item spawns", + object: "Objects", + effect: "Effects", + function_object: "Moving objects", + object_camera_fixed: "Camera-fixed objects", + map_camera: "Camera limit", + boost_limit: "Boost limit", + event_scene: "Camera events", + map: "Map centre", + navigation: "PvE navigation boxes", + }; + + function renderSide() { + if (!MAP.doc) return; + renderMapCard(); + renderLayers(); + renderObjectList(); + renderSections(); + renderSectionCard(); + updateSave(); + } + + function renderMapCard() { + const m = sec("map"); + const w = MAP.world || {}; + const dl = el("dl", { class: "mdl-dl" }); + const add = (k, v, cls) => dl.append(el("dt", { text: k }), el("dd", { text: v, class: cls || "" })); + add("Model", w.name || "—"); + add("Meshes", `${w.meshes?.length || 0}${w.missing?.length ? ` · missing: ${w.missing.join(", ")}` : ""}`, w.missing?.length ? "bad" : ""); + add("Collision", w.col ? `${w.cfg.collision} · ${(w.col.indices.length / 3).toLocaleString()} triangles` : "none (points sit at height 0)"); + add("Outline", w.edg ? `${w.cfg.outline.file} · ${(w.edg.edges.length / 4).toLocaleString()} edges` : "none"); + if (val(m, "navmesh_name")) add("Navmesh", `${val(m, "navmesh_name")} (PathEngine, not shown)`); + const mode = MAP.rows.find((r) => r[0] === MAP.where?.entry)?.[2] || (MAP.where?.entry || "").split("/")[2] || ""; + if (/^(dungeon|monstersurvival|raid|towerdef|darkxmas|shuffle)/.test(mode)) + dl.append(el("dt", { text: "Server" }), el("dd", { class: "small", text: "The game server reads its own copy for this mode (config/mode/map/…); change spawn / monster data there too." })); + const quick = el("div", { class: "fld-grid" }, [ + numField("map", "map_friction", "Friction"), + numField("map", "map_gravity", "Gravity"), + ]); + const meshIn = el("input", { type: "text", value: val(m, "mesh_name") || "", spellcheck: "false", list: "map-dl-model" }); + meshIn.addEventListener("change", async () => { + edit(() => setVal("map", "mesh_name", meshIn.value.trim())); + await loadWorld(); + refreshScene(); + renderSide(); + }); + names("model").then((n) => { + if (!$("map-dl-model")) document.body.append(el("datalist", { id: "map-dl-model" }, n.map((x) => el("option", { value: x })))); + }); + $("map-info").replaceChildren(dl, el("label", { class: "fld" }, [el("span", { text: "Map model (mesh_name)" }), meshIn]), quick, minimapPreview()); + } + + function numField(secName, key, label) { + const s = sec(secName); + const input = el("input", { type: "text", value: val(s, key) ?? "", spellcheck: "false" }); + input.addEventListener("change", () => edit(() => setVal(secName, key, input.value.trim()))); + return el("label", { class: "fld" }, [el("span", { text: label }), input]); + } + + // minimap image: [minimap] image = # from xml/uiimageset.xml + let imagesetCache = null; + function minimapPreview() { + const wrap = el("div", { class: "map-minimap" }); + const ref = val(sec("minimap"), "image"); + if (!ref || !ref.includes("#") || !MAP.src) return wrap; + const [setName, imgName] = ref.split("#"); + (async () => { + if (!imagesetCache) { + const h = await call("mdl:readPath", MAP.src, "xml/uiimageset.xml").catch(() => null); + imagesetCache = h ? toLatin1(h.bytes) : ""; + } + const block = new RegExp(`]*Name="${esc(setName)}"[^>]*>([\\s\\S]*?)`, "i").exec(imagesetCache); + const file = block && /File="([^"]+)"/.exec(block[0].slice(0, block[0].indexOf(">")))?.[1]; + const img = block && new RegExp(`]*Name="${esc(imgName)}"[^>]*/>`, "i").exec(block[1])?.[0]; + if (!file || !img) return wrap.append(el("em", { class: "muted small", text: `Minimap ${ref} isn't in uiimageset.xml` })); + const n = (k) => Number(new RegExp(`\\b${k}="(-?\\d+)"`).exec(img)?.[1] || 0); + const t = await kit.texture(file); + if (!t.img) return wrap.append(el("em", { class: "muted small", text: `${file}: ${t.error}` })); + const c = el("canvas"); + c.width = n("Width"); + c.height = n("Height"); + const g = c.getContext("2d"); + const bmp = t.img instanceof ImageData ? await createImageBitmap(t.img) : t.img; + g.drawImage(bmp, n("X"), n("Y"), c.width, c.height, 0, 0, c.width, c.height); + wrap.append(el("span", { class: "muted small", text: `Minimap ${ref}` }), c); + })().catch(() => {}); + return wrap; + } + + function renderLayers() { + const counts = new Map(); + for (const m of MAP.markers) counts.set(m.layer, (counts.get(m.layer) || 0) + 1); + const color = new Map(MAP.markers.map((m) => [m.layer, m.color])); + $("map-layers").replaceChildren( + ...[...counts].map(([layer, n]) => { + const c = COLORS[color.get(layer)]; + const cb = el("input", { + type: "checkbox", + checked: layerVisible(layer), + onchange: () => { + if (cb.checked) MAP.hidden.delete(layer); + else MAP.hidden.add(layer); + drawMarkers(); + syncObjects(); + }, + }); + const dot = el("i", { class: "map-dot", style: `background:rgb(${c.slice(0, 3).map((v) => Math.round(v * 255)).join(",")})` }); + return el("label", { class: "map-layer", title: layer }, [cb, dot, el("span", { class: "grow", text: LAYER_NAMES[layer] || layer }), el("em", { text: String(n) })]); + }), + ); + } + + function renderSections() { + const q = $("map-sec-search").value.trim().toLowerCase(); + $("map-sections").replaceChildren( + ...MAP.doc.sections + .filter((s) => !q || s.lname.includes(q) || s.keys.some((k) => k.value.toLowerCase().includes(q))) + .map((s) => + el("div", { + class: `map-sec${s.lname === MAP.secSel ? " on" : ""}`, + onclick: () => { + MAP.secSel = s.lname; + const first = MAP.markers.find((m) => m.sec === s.lname); + MAP.sel = first ? { sec: first.sec, prefix: first.prefix } : null; + drawMarkers(); + syncObjects(); + renderSide(); + }, + }, [el("b", { text: s.name }), el("em", { text: String(s.keys.length) })]), + ), + ); + $("map-sec-count").textContent = String(MAP.doc.sections.length); + } + $("map-sec-search").addEventListener("input", () => MAP.doc && renderSections()); + + function renderObjectList() { + const q = $("map-object-search").value.trim().toLowerCase(); + const objects = MAP.doc.sections.filter((s) => isObjectSection(s.lname) && val(s, "model_file") && val(s, "model_file").toLowerCase() !== "none"); + const shown = objects.filter((s) => !q || s.lname.includes(q) || val(s, "model_file").toLowerCase().includes(q)); + $("map-object-count").textContent = String(objects.length); + $("map-object-list").replaceChildren(...shown.map((s) => { + const choose = () => { + MAP.secSel = s.lname; + MAP.sel = { sec: s.lname, prefix: "pos" }; + setTransformMode(MAP.transformMode || "position"); + drawMarkers(); + syncObjects(); + renderSide(); + }; + const row = el("button", { + class: `map-object-row${MAP.secSel === s.lname ? " on" : ""}`, + title: val(s, "model_file"), + onclick: () => { choose(); focusSel(); }, + }, [el("b", { text: s.name }), el("span", { text: val(s, "model_file") })]); + const actions = el("div", { class: "map-object-actions" }, [ + el("button", { class: "iconbtn only", title: `Duplicate ${s.name}`, onclick: (e) => { e.stopPropagation(); choose(); duplicateSel(); } }, [icon("copy")]), + el("button", { class: "iconbtn only danger", title: `Delete ${s.name}`, onclick: (e) => { e.stopPropagation(); choose(); deleteSel(); } }, [icon("trash")]), + ]); + return el("div", { class: `map-object-item${MAP.secSel === s.lname ? " on" : ""}` }, [row, actions]); + })); + if (!shown.length) $("map-object-list").append(el("div", { class: "muted small", text: objects.length ? "No objects match this search." : "No objects in this map yet." })); + } + $("map-object-search").addEventListener("input", () => MAP.doc && renderObjectList()); + + /** Comment lines just above a key (CP949, shown as help). */ + function commentsAbove(s, k) { + const out = []; + const prev = s.keys[s.keys.indexOf(k) - 1]; + const from = prev ? prev.line + 1 : s.head + 1; + for (let i = from; i < k.line; i++) { + const t = MAP.doc.lines[i].trim(); + if (/^(;|\/\/)/.test(t)) out.push(cp949(t.replace(/^(;+|\/\/+)\s*/, ""))); + } + return out.join("\n"); + } + + function renderSectionCard() { + const s = sec(MAP.secSel || ""); + $("map-sec-card").hidden = !s; + if (!s) return; + const m = markerOf(MAP.sel); + $("map-sec-name").textContent = `[${s.name}]`; + const fam = familyOf(s.lname); + const pf = m && m.sec === s.lname ? pointFamily(s, m.prefix) : null; + const tools = []; + if (m && m.sec === s.lname) + tools.push( + el("button", { class: "iconbtn", title: "Look at this point (F)", onclick: focusSel }, [icon("focus"), el("span", { text: m.prefix })]), + el("span", { class: "muted small mono", text: `${Math.round(m.x)}, ${Math.round(m.y)}, ${Math.round(m.z)}` }), + ); + tools.push(el("span", { class: "spacer" })); + if (fam || pf) tools.push(el("button", { class: "iconbtn only", title: fam ? `Copy this section as [${fam.base}${familyCount(fam) + 1}] (Ctrl+D)` : `Add ${pf.head}${pf.max + 1} (Ctrl+D)`, onclick: duplicateSel, disabled: !m }, [icon("copy")])); + if ((fam && m?.prefix === "pos") || pf) tools.push(el("button", { class: "iconbtn only danger", title: fam ? "Remove this section (the numbers after it move down)" : `Remove ${m.prefix}`, onclick: deleteSel }, [icon("trash")])); + const rows = s.keys.map((k) => { + const help = commentsAbove(s, k); + const input = el("input", { type: "text", value: k.value, spellcheck: "false" }); + input.addEventListener("change", () => { + const v = input.value; + if (/[\r\n]/.test(v)) return; + edit(() => setVal(s.name, k.key, v.trim())); + if (s.lname === "map" && k.lkey === "mesh_name") loadWorld().then(() => (refreshScene(), renderSide())); + }); + input.addEventListener("keydown", (e) => e.key === "Enter" && input.blur()); + const isPoint = m && m.sec === s.lname && k.lkey.startsWith(`${m.prefix}_`); + return el("div", { class: `map-kv${isPoint ? " on" : ""}`, title: help }, [ + el("span", { class: "k", text: k.key }), + input, + el("button", { class: "iconbtn only", title: "Remove this key", onclick: () => edit(() => removeKey(s.name, k.key)) }, [icon("minus")]), + help ? el("i", { class: "map-help", text: help }) : null, + ]); + }); + const nk = el("input", { type: "text", placeholder: "new_key", spellcheck: "false" }); + const nv = el("input", { type: "text", placeholder: "value", spellcheck: "false" }); + const addKey = () => { + const k = nk.value.trim(); + if (!/^[\w.#-]+$/.test(k)) return toast("Key names are letters, digits and _.", true); + edit(() => setVal(s.name, k, nv.value.trim())); + }; + nv.addEventListener("keydown", (e) => e.key === "Enter" && addKey()); + const content = [el("div", { class: "map-sec-tools" }, tools)]; + if (isObjectSection(s.lname)) content.push(objectTransformCard(s.lname)); + content.push( + el("div", { class: "map-kvs" }, rows), + el("div", { class: "map-kv add" }, [nk, nv, el("button", { class: "iconbtn only", title: "Add key", onclick: addKey }, [icon("plus")])]), + ); + $("map-sec").replaceChildren(...content); + $("map-sec-card").querySelector(".map-kv.on input")?.scrollIntoView({ block: "nearest" }); + } + + // ===================================================================== + // toolbar + keys + // ===================================================================== + function setToggle(id, on) { + $(id).classList.toggle("on", on); + if (!view) return; + view.opts.textures = toggled("map-t-tex"); + view.opts.wire = toggled("map-t-wire"); + view.setVisible("col", toggled("map-t-col")); + view.showOverlay("grid", toggled("map-t-grid")); + edgeDirty = true; + applyEnv(); + syncObjects(); + drawLabels(); + view.dirty = true; + } + for (const id of ["map-t-tex", "map-t-wire", "map-t-col", "map-t-outline", "map-t-light", "map-t-objects", "map-t-labels", "map-t-grid"]) $(id).onclick = () => setToggle(id, !toggled(id)); + $("map-t-anim").onclick = () => { + const on = !toggled("map-t-anim"); + $("map-t-anim").classList.toggle("on", on); + setAnim(on); + }; + $("map-top").onclick = () => view?.topView(); + $("map-reset").onclick = () => view?.reset(); + $("map-snap").onchange = () => (MAP.snap = Number($("map-snap").value) || 0); + + document.addEventListener("keydown", (e) => { + if (!$("view-map").classList.contains("on") || !MAP.doc) return; + if (e.target.matches?.("input, textarea, select")) return; + const k = e.key.toLowerCase(); + if (e.ctrlKey && k === "z") return e.preventDefault(), restore(MAP.undo, MAP.redo); + if (e.ctrlKey && k === "y") return e.preventDefault(), restore(MAP.redo, MAP.undo); + if (e.ctrlKey && k === "s") return e.preventDefault(), save(); + if (e.ctrlKey && k === "d") return e.preventDefault(), duplicateSel(); + if (e.key === "Delete") return deleteSel(); + const selected = markerOf(MAP.sel); + if (selected && isObjectSection(selected.sec) && ["g", "p", "r", "s"].includes(k)) { + e.preventDefault(); + if (k === "g" || k === "p") return setMoveAxis(null); + return setTransformMode(k === "r" ? "rotate" : "scale"); + } + if (selected && isObjectSection(selected.sec) && MAP.transformMode === "position" && ["x", "y", "z"].includes(k)) { + e.preventDefault(); + return setMoveAxis(k); + } + if (k === "f") return focusSel(); + if (k === "t") return view.topView(); + const m = selected || markerOf(MAP.sel); + const step = (e.shiftKey ? 100 : 10) * (MAP.snap ? MAP.snap / 10 : 1); + const mv = MAP.moveAxis === "x" + ? ({ arrowleft: [-step, 0, 0], arrowright: [step, 0, 0] }[k]) + : MAP.moveAxis === "y" + ? ({ arrowup: [0, step, 0], arrowdown: [0, -step, 0] }[k]) + : MAP.moveAxis === "z" + ? ({ arrowup: [0, 0, step], arrowdown: [0, 0, -step] }[k]) + : ({ arrowleft: [-step, 0, 0], arrowright: [step, 0, 0], arrowup: [0, 0, step], arrowdown: [0, 0, -step] }[k]); + if (m && mv) { + e.preventDefault(); + edit(() => { + if (m.navi) return moveNaviBox(m.prefix, mv[0], mv[1], mv[2]); + if (mv[0]) setNum(m.sec, `${m.prefix}_x`, m.x + mv[0]); + if (mv[1]) setNum(m.sec, `${m.prefix}_y`, m.y + mv[1]); + if (mv[2]) setNum(m.sec, `${m.prefix}_z`, m.z + mv[2]); + }); + } + }); + + // first visit: the packs next to a file open in IOP Manager + document.querySelector('[data-view="map"]').addEventListener("click", async () => { + ensureView(); + view?.resize(); + if (MAP.src) return; + if (profileSource()) return setSource(profileSource(), { quiet: true }); + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const s = sessions.find((x) => /^rs\.iop$/i.test(x.name)) || sessions[0]; + if (s) await setSource({ kind: "session", sessionId: s.id, name: s.name }, { quiet: true }); + }); + + Object.assign(MAP, { FX, ANIM, setAnim, elevatorAt, setSource, openRow, openDoc, serialize, sec, val, setVal, edit, restore, addNumbered, removeNumbered, addPointMember, removePointMember, select, save, groundAt, markerOf, pickMarker, rowsOf: () => list.view }); +})(); diff --git a/app/public/legacy/js/meshkit.js b/app/public/legacy/js/meshkit.js new file mode 100644 index 0000000..6098c4a --- /dev/null +++ b/app/public/legacy/js/meshkit.js @@ -0,0 +1,193 @@ +"use strict"; + +// Loading game meshes into a GL3D.View: the .msh, its material script (Material/.txt, one +// material per sub-mesh), the textures (decoded here, uploaded per view) and model configs. +// Shared by the model viewer and the map editor; each keeps its own kit (one texture cache per +// WebGL context). + +const MeshKit = (() => { + let seq = 0; + + function create(view, getSrc, options = {}) { + const tex = new Map(); // texture name → Promise<{ tex, img, w, h, source } | { error }> + + function texture(name) { + const key = name.toLowerCase(); + if (!tex.has(key)) { + tex.set( + key, + (async () => { + const src = getSrc(); + if (!src) return { error: "no source" }; + const t = await call("mdl:texture", src, name); + if (!t) return { error: "not found" }; + let img; + if (t.ext === "dds") img = TexDecode.dds(t.bytes); + else if (t.ext === "tga") img = TexDecode.tga(t.bytes); + else img = await createImageBitmap(new Blob([t.bytes]), { premultiplyAlpha: "none" }); + const prepared = options.prepareTexture ? options.prepareTexture({ img, ext: t.ext, name }) : img; + return { tex: view.texture(prepared), img: prepared, w: prepared.width, h: prepared.height, source: t.source, ext: t.ext }; + })().catch((err) => ({ error: err.message })), + ); + } + return tex.get(key); + } + + async function readNamed(name, folder, strict = false) { + const src = getSrc(); + if (!src) return null; + return call("mdl:read", src, name, folder, strict); + } + + /** + * scene_blend " " (D3D style: add / subtract / rev_subtract / min / max with + * zero, one, src_color, inv_src_color, src_alpha, inv_src_alpha, dest_alpha, inv_dest_alpha, + * dest_color, inv_dest_color) → { op, src, dst }, or null for none / opaque. + */ + function parseBlend(s) { + const t = (s || "").trim().toLowerCase().split(/\s+/).filter(Boolean); + if (!t.length) return null; + // ioMaterialSerializer::ParseSceneBlend delegates named modes to + // ioPass::SetSceneBlending. Keep its factors, especially additive alpha. + if (t.length === 1) { + const named = { + no_alpha: null, + src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" }, + color_key: { op: "add", src: "src_alpha", dst: "inv_src_alpha" }, + alpha_blend: { op: "add", src: "src_alpha", dst: "inv_src_alpha" }, + add: { op: "add", src: "src_alpha", dst: "one" }, + screen: { op: "add", src: "inv_dest_color", dst: "one" }, + lighten: { op: "max", src: "one", dst: "one" }, + darken: { op: "min", src: "one", dst: "one" }, + rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" }, + subtract: { op: "subtract", src: "src_alpha", dst: "one" }, + }; + if (Object.hasOwn(named, t[0])) return named[t[0]]; + } + if (t[0] === "modulate") return { op: "add", src: "dest_color", dst: "zero" }; + const [op, src, dst] = t.length >= 3 ? t : ["add", ...t]; + if (src === "one" && dst === "zero") return null; + return { op, src: src.replace(/s$/, ""), dst: (dst || "zero").replace(/colors$/, "color") }; + } + + /** Texture, UV set, lightmap and blending of a sub-mesh from its material. */ + function groupLook(d, data) { + const set = (n) => (n === 1 && data.uv1 ? 1 : 0); + const blendFunc = parseBlend(d?.blend); + return { + uvSet: set(d?.uvSet), + texName: d?.texture || null, + lmName: d?.lightmap || null, + lmSet: set(d?.lightmapSet), + lmAddDiffuse: !!d?.lmAddDiffuse, + diffuseColor: d?.diffuseColor || [1, 1, 1], + blend: !!blendFunc, + blendFunc, + texAnim: d?.anim || null, + lmAnim: d?.lightmapAnim || null, + alphaCut: d?.alphaReject ? 0.5 : 0, + tex: null, + lmTex: null, + texInfo: null, + }; + } + + /** Reads the material script, builds the draw groups and puts the mesh in the view. → m */ + async function loadMesh(hit, name, place = {}) { + const data = LSF.parseMsh(hit.bytes); + const m = { id: ++seq, name, data, where: hit.where, source: hit.source, visible: true, dirty: false, mat: null, groups: [] }; + const mh = await readNamed(`${name}.txt`, "material", true).catch(() => null); + if (mh) { + const text = LSF.decode(mh.bytes); + m.mat = { text, raw: mh.bytes, where: mh.where, source: mh.source, mats: LSF.parseMaterial(text), dirty: false }; + } + const subs = data.subs.length ? data.subs : [{ indexStart: 0, faceCount: data.indices.length / 3 }]; + m.groups = subs.map((sub, i) => { + const start = Math.min(sub.indexStart, data.indices.length); + return Object.assign({ start, count: Math.min(sub.faceCount * 3, data.indices.length - start) }, groupLook(LSF.diffuseOf(m.mat?.mats[i]), data)); + }); + if (data.boneNames?.length) { + m.skinPos = new Float32Array(data.pos.length); + m.skinNrm = data.normal ? new Float32Array(data.normal.length) : null; + } + view.addMesh(m.id, { pos: data.pos, normal: data.normal, uv0: data.uv0, uv1: data.uv1, indices: data.indices, groups: m.groups }, place); + return m; + } + + async function loadTextures(m) { + await Promise.all( + m.groups.map(async (g, gi) => { + if (g.texName) { + const t = await texture(g.texName); + g.texInfo = t; + if (t.tex) view.setGroupTexture(m.id, gi, t.tex); + // anim_texture N: frames 1.dds … (N-1).dds after the base one + const n = g.texAnim?.frames?.count || 0; + if (n > 1) { + const dot = g.texName.lastIndexOf("."); + const stem = dot > 0 ? g.texName.slice(0, dot) : g.texName; + const ext = dot > 0 ? g.texName.slice(dot) : ""; + const frames = [t.tex || null]; + for (let i = 1; i < n; i++) frames.push((await texture(`${stem}${i}${ext}`)).tex || null); + g.frameTex = frames; + } + } + if (g.lmName) { + const t = await texture(g.lmName); + if (t.tex) view.setGroupTexture(m.id, gi, t.tex, "lmTex"); + } + }), + ); + } + + /** Re-reads a mesh's draw groups after its material text changed. */ + function applyMaterial(m) { + m.groups.forEach((g, i) => { + Object.assign(g, groupLook(LSF.diffuseOf(m.mat.mats[i]), m.data)); + view.setGroupTexture(m.id, i, null); + view.setGroupTexture(m.id, i, null, "lmTex"); + }); + return loadTextures(m); + } + + /** A model config (resource/model/) by its path, e.g. "staticmodel/prisoner.txt". */ + async function readModel(path) { + const src = getSrc(); + if (!src) return null; + const hit = await call("mdl:readPath", src, `model/${path.replace(/\\/g, "/").replace(/^\/+/, "")}`); + return hit ? { hit, cfg: LSF.parseModelCfg(LSF.decode(hit.bytes)) } : null; + } + + /** Texture animations of a mesh at time t (s); t = null puts them back to rest. → true when something animates */ + function animate(m, t) { + let any = false; + for (const g of m.groups) { + if (g.texAnim) { + any = true; + if (t === null) { + g.texM = null; + g.colorMul = 1; + g.emissive = 0; + if (g.frameTex?.[0]) g.tex = g.frameTex[0]; + } else { + const r = LSF.texAnimAt(g.texAnim, t); + g.texM = r.m; + g.colorMul = r.colorMul; + g.emissive = r.emissive; + if (g.frameTex?.[r.frame]) g.tex = g.frameTex[r.frame]; + } + } + if (g.lmAnim) { + any = true; + g.lmM = t === null ? null : LSF.texAnimAt(g.lmAnim, t).m; + } + } + return any; + } + + return { texture, readNamed, groupLook, loadMesh, loadTextures, applyMaterial, readModel, animate, clearTextures: () => tex.clear() }; + } + + return { create }; +})(); +window.MeshKit = MeshKit; diff --git a/app/public/legacy/js/model.js b/app/public/legacy/js/model.js new file mode 100644 index 0000000..ed73f1d --- /dev/null +++ b/app/public/legacy/js/model.js @@ -0,0 +1,1735 @@ +"use strict"; + +// Model viewer (3D tab): browse the model configs, meshes, animations and skeletons of a set of +// packs, see them with their textures, play animations on the skeleton (CPU skinning, the same +// maths as io3DEngine), and make the safe edits: animation events / track weights / duration and +// mesh control points, plus the material scripts. Saves go back into the pack or to a file. + +(() => { + const MDL = { + src: null, // asset source { kind: "session"|"packs"|"dir", sessionId|folder, name, label } + rows: [], // [key, display, pack, kind, path] + kind: "model", + scene: null, + playing: false, + time: 0, + speed: 1, + loop: true, + undo: [], + redo: [], + seq: 0, + }; + window.MDL = MDL; + let view = null; + let kit = null; // meshkit.js loader for this view + let heroSequence = null, heroSequenceToken = 0, heroMotionToken = 0, heroFx = null, heroFxUntil = 0, heroFxLast = 0; + let heroFxModelSeq = 0, heroFxRequest = 0, heroFxModels = []; + let heroRoot = { pos: [0, 0, 0], yaw: 0 }, heroTarget = null; + const heroDummyIds = ["hero-practice-dummy-body", "hero-practice-dummy-head"]; + const heroTextures = new Map(); + + const stem = (p) => p.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, ""); + const kindOf = (p) => { + const l = p.toLowerCase(); + if (l.endsWith(".msh")) return "msh"; + if (l.endsWith(".ani")) return "ani"; + if (l.endsWith(".skl")) return "skl"; + if (l.endsWith(".txt") && /(^|\/)model\//.test(l)) return "model"; + return null; + }; + const fmtMs = (ms) => `${(ms / 1000).toFixed(2)} s`; + const plural = (n, w) => `${n.toLocaleString()} ${w}${n === 1 ? "" : "s"}`; + const isAscii = (s) => /^[\x20-\x7e]*$/.test(s); + + function ensureView() { + if (view) return view; + try { + view = new GL3D.View($("mdl-canvas")); + } catch (err) { + toast(`3D view unavailable: ${err.message}`, true); + return null; + } + MDL.view = view; + kit = MeshKit.create(view, () => MDL.src); + return view; + } + + // ===================================================================== + // source + browser + // ===================================================================== + async function setSource(src, { quiet = false } = {}) { + try { + const d = await call("mdl:describe", src); + MDL.src = { ...src, label: d.label }; + } catch (err) { + return toast(err.message, true); + } + $("mdl-src-label").textContent = MDL.src.kind === "dir" ? "Extracted folder" : MDL.src.kind === "packs" ? "Pack folder" : `Packs next to ${MDL.src.name}`; + $("mdl-src").title = `Reading from ${MDL.src.label}`; + kit?.clearTextures(); + await loadList(); + if (!quiet) toast(`Reading models from ${MDL.src.label}.`); + } + + async function chooseSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + let choice = null; + const options = [ + ...profileSourceOption(), + ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), + { value: "packs", title: "A folder with .iop packs…", sub: "the client folder: map.iop, map2.iop, rs.iop… — passwords found from the profiles" }, + { value: "dir", title: "A folder of extracted files…", sub: "resource/mesh, resource/animation, resource/texture… extracted from the packs" }, + ]; + const list = el( + "div", + { class: "choice-list" }, + options.map((o, i) => + el("label", { class: "choice" }, [ + el("input", { type: "radio", name: "mdlsrc", checked: i === 0, onchange: () => (choice = o.value) }), + el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), + ]), + ), + ); + choice = options[0].value; + const ok = await modal("Where are the models?", ["Meshes, textures, skeletons and animations are spread over several packs (map.iop, map2.iop, rs.iop, …). Pick the folder they are in.", list], [ + { label: "Cancel", value: false }, + { label: "Use this", kind: "primary", value: true }, + ]); + if (!ok) return false; + let src = choice; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); + if (!folder) return false; + src = { kind: choice, folder }; + } + await setSource(src); + return true; + } + $("mdl-src").onclick = () => chooseSource(); + + const list = new VTable( + $("mdl-list"), + [ + { key: 1, label: "Name", width: 200, grow: true, title: true }, + { key: 2, label: "Pack", width: 70, cls: "mono dim" }, + ], + { multi: false, id: "mdl" }, + ); + list.keyOf = (r) => r[0]; + list.sortBy(1); + + async function loadList() { + if (!MDL.src) return; + loading(true, "Reading the pack index…"); + try { + const rows = await call("mdl:list", MDL.src); + MDL.rows = rows.map(([key, path, pack]) => { + const kind = kindOf(path); + const display = kind === "model" ? path.replace(/^.*?model\//i, "").replace(/\.txt$/i, "") : stem(path); + return [key, display, pack || path.split("/").slice(0, -1).join("/"), kind, path]; + }); + } catch (err) { + toast(err.message, true); + MDL.rows = []; + } finally { + loading(false); + } + filterList(); + } + + function filterList() { + const q = $("mdl-search").value.trim().toLowerCase(); + const rows = MDL.rows.filter((r) => r[3] === MDL.kind && (!q || r[1].toLowerCase().includes(q))); + list.setRows(rows); + $("mdl-count").textContent = rows.length.toLocaleString(); + } + let searchTimer = 0; + $("mdl-search").addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(filterList, 120); + }); + for (const b of $("mdl-kinds").children) { + b.onclick = () => { + MDL.kind = b.dataset.kind; + for (const x of $("mdl-kinds").children) x.classList.toggle("on", x === b); + filterList(); + }; + } + + list.onOpen = (r) => openRow(r); + $("mdl-list").addEventListener("contextmenu", (e) => { + const rowEl = e.target.closest(".vt-row"); + if (!rowEl) return; + e.preventDefault(); + const i = Number(rowEl.dataset.i); + const r = list.view[i]; + list.click(i, {}); + const s = MDL.scene; + const items = [{ label: "Open", icon: "external", hint: "Double-click", run: () => openRow(r) }]; + if (r[3] === "msh") items.push({ label: "Add to the scene", icon: "plus", disabled: !s, run: () => addMeshRow(r) }); + if (r[3] === "ani") items.push({ label: "Play on this skeleton", icon: "play", disabled: !s?.skel, run: () => playRow(r, { force: true }) }); + if (r[3] === "skl") items.push({ label: "Use as the scene's skeleton", icon: "bone", disabled: !s?.meshes.length, run: () => useSkeletonRow(r) }); + items.push("-", { label: "Copy name", icon: "copy", run: () => navigator.clipboard.writeText(r[4].split("/").pop()) }); + showMenu(items, { x: e.clientX, y: e.clientY }); + }); + + function loading(on, text = "Loading…") { + $("mdl-loading").hidden = !on; + $("mdl-loading").querySelector("span").textContent = text; + } + + // ===================================================================== + // reading + // ===================================================================== + async function readKey(key) { + const r = await call("mdl:readKey", MDL.src, key); + if (!r) throw new Error("Not found in the packs any more."); + return r; + } + /** A resource by bare name in its usual folder (skeleton / mesh / animation / material). */ + async function readNamed(name, folder, strict = false) { + if (!MDL.src) return null; + return call("mdl:read", MDL.src, name, folder, strict); + } + + + // ===================================================================== + // scene + // ===================================================================== + function anyDirty() { + const s = MDL.scene; + return !!s && (s.anim?.dirty || s.meshes.some((m) => m.dirty || m.mat?.dirty)); + } + async function confirmDiscard() { + if (!anyDirty()) return true; + return confirmBox("Unsaved changes", "The scene has changes that aren't saved. Discard them?", "Discard", "danger"); + } + + function newScene(title) { + if (!ensureView()) return null; + stop(); + view.clear(); + MDL.scene = { title, model: null, skel: null, meshes: [], anim: null, animList: [], missing: [], sel: -1 }; + MDL.time = 0; + MDL.undo = []; + MDL.redo = []; + $("mdl-title").textContent = title; + $("mdl-empty").hidden = true; + $("mdl-side").hidden = false; + return MDL.scene; + } + + async function openRow(r) { + if (!r) return; + try { + if (r[3] === "model") return await openModel(await readKey(r[0]), r[1]); + if (r[3] === "msh") return await openMesh(await readKey(r[0])); + if (r[3] === "ani") return await playRow(r); + if (r[3] === "skl") return await openSkeleton(await readKey(r[0])); + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + } + + async function openModel(hit, name) { + if (!(await confirmDiscard())) return; + loading(true, "Loading the model…"); + const cfg = LSF.parseModelCfg(LSF.decode(hit.bytes)); + const s = newScene(name || stem(hit.source)); + if (!s) return; + s.model = { name: name || stem(hit.source), cfg, where: hit.where, source: hit.source }; + if (cfg.skeleton) { + const sk = await readNamed(cfg.skeleton, "skeleton"); + if (sk) setSkeleton(LSF.parseSkl(sk.bytes), cfg.skeleton, sk); + else s.missing.push(cfg.skeleton); + } + for (const n of cfg.meshes) { + const h = await readNamed(`${n}.msh`, "mesh"); + if (!h) { + s.missing.push(`${n}.msh`); + continue; + } + await addMesh(h, n); + } + s.animList = cfg.animations; + frameScene(); + renderSide(); + if (s.animList.length) { + const first = s.animList.find((a) => /delay/i.test(a)) || s.animList[0]; + await playNamed(first); + } + applyPose(); + if (s.missing.length) toast(`Not in these packs: ${s.missing.join(", ")}`, true); + } + + async function openMesh(hit) { + if (!(await confirmDiscard())) return; + loading(true, "Loading the mesh…"); + const s = newScene(stem(hit.source)); + if (!s) return; + const m = await addMesh(hit, stem(hit.source)); + if (m?.data.boneNames?.length) await autoSkeleton(m); + frameScene(); + renderSide(); + applyPose(); + } + + async function openSkeleton(hit) { + if (!(await confirmDiscard())) return; + const s = newScene(stem(hit.source)); + if (!s) return; + setSkeleton(LSF.parseSkl(hit.bytes), hit.source.split(/[/\\: ]/).pop(), hit); + setToggle("mdl-t-bones", true); + frameScene(); + renderSide(); + applyPose(); + } + + async function addMeshRow(r) { + try { + const m = await addMesh(await readKey(r[0]), r[1]); + if (m && MDL.scene.skel) mapPalette(m); + else if (m?.data.boneNames?.length && !MDL.scene.skel) await autoSkeleton(m); + frameScene(); + renderSide(); + applyPose(); + } catch (err) { + toast(err.message, true); + } + } + + async function useSkeletonRow(r) { + try { + const hit = await readKey(r[0]); + setSkeleton(LSF.parseSkl(hit.bytes), `${r[1]}.skl`, hit); + if (MDL.scene.anim) mapTracks(MDL.scene.anim); + renderSide(); + applyPose(); + } catch (err) { + toast(err.message, true); + } + } + + /** Picks a skeleton for a skinned mesh: from the model configs, else by bone names. */ + async function autoSkeleton(m) { + if (!MDL.src) return; + loading(true, "Finding the skeleton…"); + try { + const c = await call("mdl:findSkeleton", MDL.src, { mesh: m.name, bones: m.data.boneNames.map((b) => b.text) }); + const best = c.find((x) => x.fromModel) || c.find((x) => x.matched === x.total) || c[0]; + if (!best) return toast("No skeleton in these packs has this mesh's bones; it shows in its bind pose.", true); + const sk = await readNamed(best.name, "skeleton"); + if (sk) setSkeleton(LSF.parseSkl(sk.bytes), best.name, sk); + } finally { + loading(false); + } + } + + // ---------- skeleton ---------- + function setSkeleton(data, name, hit) { + const s = MDL.scene; + const n = data.bones.length; + const byName = new Map(); + data.bones.forEach((b, i) => byName.has(b.name.text.toLowerCase()) || byName.set(b.name.text.toLowerCase(), i)); + const parent = data.bones.map((b) => { + const p = b.parent.text.toLowerCase(); + return p === "noparent" || !byName.has(p) ? -1 : byName.get(p); + }); + // parents before children + const order = []; + const done = new Uint8Array(n); + const visit = (i, depth) => { + if (done[i] || depth > n) return; + if (parent[i] >= 0) visit(parent[i], depth + 1); + if (!done[i]) { + done[i] = 1; + order.push(i); + } + }; + for (let i = 0; i < n; i++) visit(i, 0); + const bst = data.bones.map((b) => M4.fromQT(b.invRot, b.invPos)); + const sk = { name, data, where: hit?.where, source: hit?.source, byName, parent, order, bst, rate: new Float64Array(n).fill(1), linked: data.linked || "" }; + s.skel = sk; + for (const m of s.meshes) mapPalette(m); + if (data.linked) linkSkeleton(sk); + return sk; + } + + /** Linked skeleton: the bind offset and bone-length ratio come from the skeleton it links to. */ + async function linkSkeleton(sk) { + const h = await readNamed(sk.linked, "skeleton"); + if (!h) return toast(`${sk.name} links to ${sk.linked}, which isn't in these packs; poses may look off.`, true); + const other = LSF.parseSkl(h.bytes); + if (other.bones.length !== sk.data.bones.length) return toast(`${sk.linked} has a different bone count; not linked (the client does the same).`, true); + sk.data.bones.forEach((b, i) => { + const o = other.bones[i]; + const offset = M4.mul(M4.invert(M4.rowMajor(o.objectTM)), M4.rowMajor(b.objectTM)); + sk.bst[i] = M4.mul(offset, M4.fromQT(b.invRot, b.invPos)); + const linkLen = Math.hypot(...o.localPos); + sk.rate[i] = linkLen > 1e-6 ? Math.hypot(...b.localPos) / linkLen : 1; + }); + applyPose(); + } + + function mapPalette(m) { + const sk = MDL.scene.skel; + if (!m.data.boneNames || !sk) { + m.palette = null; + m.missingBones = []; + return; + } + m.palette = Int32Array.from(m.data.boneNames, (b) => sk.byName.get(b.text.toLowerCase()) ?? -1); + m.missingBones = m.data.boneNames.filter((_, i) => m.palette[i] < 0).map((b) => b.text); + } + + // ---------- meshes ---------- + /** Mesh + material + textures (meshkit.js), added to the scene. */ + async function addMesh(hit, name) { + const s = MDL.scene; + const m = await kit.loadMesh(hit, name); + s.meshes.push(m); + mapPalette(m); + loadTextures(m); + return m; + } + + async function loadTextures(m) { + await kit.loadTextures(m); + if (MDL.scene?.meshes[MDL.scene.sel] === m) renderMeshCard(); + } + + function removeMesh(m) { + const s = MDL.scene; + view.removeMesh(m.id); + s.meshes.splice(s.meshes.indexOf(m), 1); + if (s.sel >= s.meshes.length) s.sel = s.meshes.length - 1; + renderSide(); + applyPose(); + } + + // ---------- animation ---------- + async function playRow(r, { force = false } = {}) { + const hit = await readKey(r[0]); + const data = LSF.parseAni(hit.bytes); + const s = MDL.scene; + const names = data.tracks.map((t) => t.name.text.toLowerCase()); + const fits = s?.skel && names.filter((n) => s.skel.byName.has(n)).length >= Math.max(1, names.length * 0.5); + if (!fits && !force) { + // show it on a model that uses it, else on the best skeleton + if (!(await confirmDiscard())) return; + loading(true, "Finding a model for this animation…"); + const used = MDL.src ? await call("mdl:usedBy", MDL.src, { ani: r[4].split("/").pop() }) : []; + const modelRow = used.length && MDL.rows.find((x) => x[3] === "model" && x[4].toLowerCase().endsWith("/" + used[0])); + if (modelRow) { + await openModel(await readKey(modelRow[0]), modelRow[1]); + } else { + const c = MDL.src ? await call("mdl:findSkeleton", MDL.src, { ani: r[4].split("/").pop(), bones: names }) : []; + const sk = c[0] && (await readNamed(c[0].name, "skeleton")); + const sc = newScene(r[1]); + if (!sc) return; + if (sk) setSkeleton(LSF.parseSkl(sk.bytes), c[0].name, sk); + setToggle("mdl-t-bones", true); + frameScene(); + } + } + setAnim(data, `${r[1]}.ani`, hit); + } + + async function playNamed(name) { + const h = await readNamed(name, "animation"); + if (!h) return toast(`${name} isn't in these packs.`, true); + setAnim(LSF.parseAni(h.bytes), name, h); + } + + function mapTracks(a) { + const sk = MDL.scene.skel; + a.map = Int32Array.from(a.data.tracks, (t) => (sk ? sk.byName.get(t.name.text.toLowerCase()) ?? -1 : -1)); + } + + function setAnim(data, name, hit) { + const s = MDL.scene; + if (!s) return; + s.anim = { name, data, where: hit?.where, source: hit?.source, dirty: false }; + mapTracks(s.anim); + MDL.time = 0; + MDL.undo = []; + MDL.redo = []; + $("mdl-timeline").hidden = false; + renderTimeline(); + renderSide(); + play(); + } + + // ===================================================================== + // pose + skinning (io3DEngine maths: D3D row vectors, v' = v · BipedSpaceTM · BoneWorld) + // ===================================================================== + const M4 = { + /** Rotation (quaternion x,y,z,w) + translation as a D3D row-major matrix (D3DXMatrixRotationQuaternion + _41.._43). */ + fromQT(q, p) { + const [x, y, z, w] = q; + return new Float64Array([ + 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0, + 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0, + 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0, + p[0], p[1], p[2], 1, + ]); + }, + rowMajor: (a) => Float64Array.from(a), + mul(a, b) { + const o = new Float64Array(16); + for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) o[r * 4 + c] = a[r * 4] * b[c] + a[r * 4 + 1] * b[4 + c] + a[r * 4 + 2] * b[8 + c] + a[r * 4 + 3] * b[12 + c]; + return o; + }, + invert(m) { + const inv = new Float64Array(16); + const a = m; + inv[0] = a[5] * a[10] * a[15] - a[5] * a[11] * a[14] - a[9] * a[6] * a[15] + a[9] * a[7] * a[14] + a[13] * a[6] * a[11] - a[13] * a[7] * a[10]; + inv[4] = -a[4] * a[10] * a[15] + a[4] * a[11] * a[14] + a[8] * a[6] * a[15] - a[8] * a[7] * a[14] - a[12] * a[6] * a[11] + a[12] * a[7] * a[10]; + inv[8] = a[4] * a[9] * a[15] - a[4] * a[11] * a[13] - a[8] * a[5] * a[15] + a[8] * a[7] * a[13] + a[12] * a[5] * a[11] - a[12] * a[7] * a[9]; + inv[12] = -a[4] * a[9] * a[14] + a[4] * a[10] * a[13] + a[8] * a[5] * a[14] - a[8] * a[6] * a[13] - a[12] * a[5] * a[10] + a[12] * a[6] * a[9]; + inv[1] = -a[1] * a[10] * a[15] + a[1] * a[11] * a[14] + a[9] * a[2] * a[15] - a[9] * a[3] * a[14] - a[13] * a[2] * a[11] + a[13] * a[3] * a[10]; + inv[5] = a[0] * a[10] * a[15] - a[0] * a[11] * a[14] - a[8] * a[2] * a[15] + a[8] * a[3] * a[14] + a[12] * a[2] * a[11] - a[12] * a[3] * a[10]; + inv[9] = -a[0] * a[9] * a[15] + a[0] * a[11] * a[13] + a[8] * a[1] * a[15] - a[8] * a[3] * a[13] - a[12] * a[1] * a[11] + a[12] * a[3] * a[9]; + inv[13] = a[0] * a[9] * a[14] - a[0] * a[10] * a[13] - a[8] * a[1] * a[14] + a[8] * a[2] * a[13] + a[12] * a[1] * a[10] - a[12] * a[2] * a[9]; + inv[2] = a[1] * a[6] * a[15] - a[1] * a[7] * a[14] - a[5] * a[2] * a[15] + a[5] * a[3] * a[14] + a[13] * a[2] * a[7] - a[13] * a[3] * a[6]; + inv[6] = -a[0] * a[6] * a[15] + a[0] * a[7] * a[14] + a[4] * a[2] * a[15] - a[4] * a[3] * a[14] - a[12] * a[2] * a[7] + a[12] * a[3] * a[6]; + inv[10] = a[0] * a[5] * a[15] - a[0] * a[7] * a[13] - a[4] * a[1] * a[15] + a[4] * a[3] * a[13] + a[12] * a[1] * a[7] - a[12] * a[3] * a[5]; + inv[14] = -a[0] * a[5] * a[14] + a[0] * a[6] * a[13] + a[4] * a[1] * a[14] - a[4] * a[2] * a[13] - a[12] * a[1] * a[6] + a[12] * a[2] * a[5]; + inv[3] = -a[1] * a[6] * a[11] + a[1] * a[7] * a[10] + a[5] * a[2] * a[11] - a[5] * a[3] * a[10] - a[9] * a[2] * a[7] + a[9] * a[3] * a[6]; + inv[7] = a[0] * a[6] * a[11] - a[0] * a[7] * a[10] - a[4] * a[2] * a[11] + a[4] * a[3] * a[10] + a[8] * a[2] * a[7] - a[8] * a[3] * a[6]; + inv[11] = -a[0] * a[5] * a[11] + a[0] * a[7] * a[9] + a[4] * a[1] * a[11] - a[4] * a[3] * a[9] - a[8] * a[1] * a[7] + a[8] * a[3] * a[5]; + inv[15] = a[0] * a[5] * a[10] - a[0] * a[6] * a[9] - a[4] * a[1] * a[10] + a[4] * a[2] * a[9] + a[8] * a[1] * a[6] - a[8] * a[2] * a[5]; + let det = a[0] * inv[0] + a[1] * inv[4] + a[2] * inv[8] + a[3] * inv[12]; + if (Math.abs(det) < 1e-12) return M4.fromQT([0, 0, 0, 1], [0, 0, 0]); + det = 1 / det; + for (let i = 0; i < 16; i++) inv[i] *= det; + return inv; + }, + }; + const qmul = (a, b) => [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + ]; + /** Rotates v by q (HelpFunc.cpp operator*: v + 2w(q×v) + 2q×(q×v)). */ + function qrot(q, v) { + const [x, y, z, w] = q; + const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0]; + const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux; + return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)]; + } + + /** Bone world transforms + skin matrices at time t. */ + function computePose(t) { + const sk = MDL.scene.skel; + const bones = sk.data.bones; + const n = bones.length; + const lp = bones.map((b) => b.localPos.slice()); + const lr = bones.map((b) => b.localRot.slice()); + const a = MDL.scene.anim; + if (a) { + const p = [0, 0, 0]; + const q = [0, 0, 0, 1]; + a.data.tracks.forEach((tr, ti) => { + const bi = a.map[ti]; + if (bi < 0 || tr.weight <= 0) return; // unknown bone: skipped (the client would move the root) + if (!LSF.sampleTrack(tr, t, p, q)) return; + const r = sk.rate[bi]; + lp[bi] = [p[0] * r, p[1] * r, p[2] * r]; + lr[bi] = q.slice(); + }); + } + const wp = new Array(n); + const wr = new Array(n); + for (const i of sk.order) { + const pi = sk.parent[i]; + if (pi < 0) { + wr[i] = lr[i]; + wp[i] = lp[i]; + } else { + wr[i] = qmul(wr[pi], lr[i]); + const v = qrot(wr[pi], lp[i]); + wp[i] = [v[0] + wp[pi][0], v[1] + wp[pi][1], v[2] + wp[pi][2]]; + } + } + const skin = new Array(n); + for (let i = 0; i < n; i++) skin[i] = M4.mul(sk.bst[i], M4.fromQT(wr[i] || [0, 0, 0, 1], wp[i] || [0, 0, 0])); + return { wp, wr, skin }; + } + + function skinMesh(m, pose) { + const d = m.data; + const V = d.vertexCount; + const P = d.pos; + const N = d.normal; + const out = m.skinPos; + const on = m.skinNrm; + const pal = m.palette; + for (let v = 0; v < V; v++) { + let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0, tw = 0; + const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2]; + const qx = N ? N[v * 3] : 0, qy = N ? N[v * 3 + 1] : 0, qz = N ? N[v * 3 + 2] : 0; + for (let j = 0; j < 4; j++) { + const w = d.weights[v * 4 + j]; + if (w <= 0) continue; + const bi = pal[d.boneIdx[v * 4 + j]]; + if (bi === undefined || bi < 0) continue; + const M = pose.skin[bi]; + x += w * (px * M[0] + py * M[4] + pz * M[8] + M[12]); + y += w * (px * M[1] + py * M[5] + pz * M[9] + M[13]); + z += w * (px * M[2] + py * M[6] + pz * M[10] + M[14]); + if (N) { + nx += w * (qx * M[0] + qy * M[4] + qz * M[8]); + ny += w * (qx * M[1] + qy * M[5] + qz * M[9]); + nz += w * (qx * M[2] + qy * M[6] + qz * M[10]); + } + tw += w; + } + if (tw <= 0) { + x = px; + y = py; + z = pz; + nx = qx; + ny = qy; + nz = qz; + } else if (Math.abs(tw - 1) > 1e-3) { + x /= tw; + y /= tw; + z /= tw; + } + out[v * 3] = x; + out[v * 3 + 1] = y; + out[v * 3 + 2] = z; + if (on) { + on[v * 3] = nx; + on[v * 3 + 1] = ny; + on[v * 3 + 2] = nz; + } + } + view.updateMesh(m.id, out, on); + } + + let lastPose = null; + function applyPose() { + const s = MDL.scene; + if (!s || !view) return; + lastPose = s.skel ? computePose(MDL.time) : null; + for (const m of s.meshes) if (m.palette && lastPose) skinMesh(m, lastPose); + if (MDL.heroPreview) for (const m of s.meshes) view.setTransform(m.id, heroRoot.pos, [1, 1, 1], [0, heroRoot.yaw, 0]); + drawOverlays(); + renderClock(); + } + + // ---------- overlays ---------- + const C = { bone: [0.96, 0.79, 0.35, 1], joint: [1, 0.9, 0.55, 1], root: [1, 0.42, 0.37, 1], grid: [1, 1, 1, 0.1], axis: [1, 1, 1, 0.26], box: [0.35, 0.8, 1, 0.55], point: [1, 0.42, 0.37, 1] }; + function push(arr, p, c) { + arr.push(p[0], p[1], p[2], c[0], c[1], c[2], c[3]); + } + + function sceneBounds() { + const s = MDL.scene; + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + const add = (p) => { + for (let i = 0; i < 3; i++) { + min[i] = Math.min(min[i], p[i]); + max[i] = Math.max(max[i], p[i]); + } + }; + for (const m of s.meshes) { + if (!m.visible) continue; + add(m.data.aabbMin); + add(m.data.aabbMax); + } + if (s.skel) { + const pose = computePose(0); + pose.wp.forEach((p) => p && add(p)); + } + if (!isFinite(min[0])) return [[-50, 0, -50], [50, 100, 50]]; + return [min, max]; + } + + // Floor grid: sized to the model normally, to the training arena while hero training runs. + let sceneGrid = null, practiceGrid = null; + function floorGrid(half, step) { + const g = []; + for (let v = -half; v <= half + 1e-6; v += step) { + const c = Math.abs(v) < step / 2 ? C.axis : C.grid; + push(g, [v, 0, -half], c); + push(g, [v, 0, half], c); + push(g, [-half, 0, v], c); + push(g, [half, 0, v], c); + } + view.setOverlay("grid", new Float32Array(g)); + view.showOverlay("grid", toggled("mdl-t-grid")); + } + /** Training arena floor (half size in game units), or null to go back to the model-sized grid. */ + function setPracticeFloor(half) { + practiceGrid = half ? [half, 50] : null; + if (!view) return; + const grid = practiceGrid || sceneGrid; + if (grid) floorGrid(...grid); + view.dirty = true; + } + function frameScene() { + const [min, max] = sceneBounds(); + view.frame(min, max); + // floor grid under the model + const r = Math.max(max[0] - min[0], max[2] - min[2], max[1] - min[1], 20); + const step = 10 ** Math.floor(Math.log10(r / 4)); + sceneGrid = [Math.ceil(r / step) * step, step]; + floorGrid(...(practiceGrid || sceneGrid)); + const b = []; + const corners = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => [i & 1 ? max[0] : min[0], i & 2 ? max[1] : min[1], i & 4 ? max[2] : min[2]]); + for (const [a, c] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) { + push(b, corners[a], C.box); + push(b, corners[c], C.box); + } + view.setOverlay("box", new Float32Array(b)); + view.showOverlay("box", toggled("mdl-t-box")); + const s = MDL.scene; + $("mdl-info").textContent = [ + s.meshes.length ? plural(s.meshes.reduce((a, m) => a + m.data.faceCount, 0), "triangle") : "", + s.skel ? plural(s.skel.data.bones.length, "bone") : "", + `${Math.round(max[1] - min[1])} tall`, + ] + .filter(Boolean) + .join(" · "); + } + + function drawOverlays() { + const s = MDL.scene; + const labels = $("mdl-labels"); + labels.replaceChildren(); + // bones + if (s.skel && lastPose) { + const l = []; + const pts = []; + s.skel.parent.forEach((pi, i) => { + const p = lastPose.wp[i]; + if (!p) return; + push(pts, p, pi < 0 ? C.root : C.joint); + if (pi >= 0 && lastPose.wp[pi]) { + push(l, lastPose.wp[pi], C.bone); + push(l, p, C.bone); + } + }); + view.setOverlay("bones", new Float32Array(l), { depth: false }); + view.setOverlay("joints", new Float32Array(pts), { mode: "points", depth: false, point: 5 }); + } else { + view.setOverlay("bones", null); + view.setOverlay("joints", null); + } + const showBones = toggled("mdl-t-bones"); + view.showOverlay("bones", showBones); + view.showOverlay("joints", showBones); + // control points (trail anchors), moved with their bone + const cp = []; + const tags = []; + for (const m of s.meshes) { + if (!m.visible) continue; + for (const c of m.data.controlPoints || []) { + let p = c.point; + const bi = s.skel && lastPose ? s.skel.byName.get(c.bone.text.toLowerCase()) : undefined; + if (bi !== undefined && bi >= 0) { + const M = lastPose.skin[bi]; + p = [p[0] * M[0] + p[1] * M[4] + p[2] * M[8] + M[12], p[0] * M[1] + p[1] * M[5] + p[2] * M[9] + M[13], p[0] * M[2] + p[1] * M[6] + p[2] * M[10] + M[14]]; + } + push(cp, p, C.point); + tags.push([p, c.name.text]); + } + } + const showPts = toggled("mdl-t-points"); + view.setOverlay("points", cp.length ? new Float32Array(cp) : null, { mode: "points", depth: false, point: 8 }); + view.showOverlay("points", showPts); + if (showPts && tags.length <= 40) { + for (const [p, text] of tags) { + const sp = view.project(p); + if (sp) labels.append(el("span", { class: "mdl-tag", text, style: `left:${sp[0]}px;top:${sp[1]}px` })); + } + } + } + // labels follow the camera + $("mdl-canvas").addEventListener("mousemove", (e) => e.buttons && MDL.scene && requestAnimationFrame(drawOverlays)); + $("mdl-canvas").addEventListener("wheel", () => MDL.scene && requestAnimationFrame(drawOverlays)); + + // ---------- toggles ---------- + const toggled = (id) => $(id).classList.contains("on"); + function setToggle(id, on) { + $(id).classList.toggle("on", on); + applyToggles(); + } + function applyToggles() { + if (!view) return; + view.opts.textures = toggled("mdl-t-tex"); + view.opts.wire = toggled("mdl-t-wire"); + view.showOverlay("grid", toggled("mdl-t-grid")); + view.showOverlay("box", toggled("mdl-t-box")); + if (MDL.scene) drawOverlays(); + view.dirty = true; + } + for (const id of ["mdl-t-tex", "mdl-t-wire", "mdl-t-bones", "mdl-t-points", "mdl-t-grid", "mdl-t-box"]) $(id).onclick = () => setToggle(id, !toggled(id)); + $("mdl-reset").onclick = () => view?.reset(); + + // ===================================================================== + // timeline + // ===================================================================== + let rafId = 0; + let lastT = 0; + function play() { + const a = MDL.scene?.anim; + if (!a) return; + if (MDL.time >= a.data.total && !MDL.loop) MDL.time = 0; + MDL.playing = true; + $("mdl-play").replaceChildren(icon("pause")); + lastT = performance.now(); + cancelAnimationFrame(rafId); + const tick = (now) => { + if (!MDL.playing) return; + const s = MDL.scene; + const total = Math.max(1, s?.anim?.data.total || 1); + const previousTime = MDL.time; + MDL.time += (now - lastT) * MDL.speed; + lastT = now; + if (heroSequence?.onEvent && s?.anim?.data.events) { + for (const event of s.anim.data.events) { + if (event.time >= previousTime && event.time < Math.min(MDL.time, total)) heroSequence.onEvent(event); + } + } + if (MDL.time > total) { + if (heroSequence) { + if (heroSequence.steps.length) { + const next = heroSequence.steps.shift(); + setAnim(next.data, next.name, next.hit); + heroSequence.onStep?.(next.name); + return; + } + const done = heroSequence.onDone; + heroSequence = null; + MDL.time = total; + stop(); + applyPose(); + done?.(); + return; + } + if (MDL.loop) MDL.time %= total; + else { + MDL.time = total; + stop(); + } + } + if ($("view-mdl").classList.contains("on") || heroShown()) applyPose(); + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + } + function stop() { + MDL.playing = false; + cancelAnimationFrame(rafId); + $("mdl-play").replaceChildren(icon("play")); + } + function stopHeroSequence() { + heroSequenceToken++; + heroMotionToken++; + heroSequence = null; + stop(); + } + async function playHeroMotion(name) { + if (!name) return; + const token = ++heroMotionToken; + const hit = await readNamed(name, "animation", true); + if (token !== heroMotionToken || !hit || !MDL.heroPreview) return; + stopHeroSequence(); + setAnim(LSF.parseAni(hit.bytes), name, hit); + } + async function playSequence(names, { onStep, onEvent, onDone } = {}) { + stopHeroSequence(); + const token = heroSequenceToken; + const steps = []; + for (const name of names.filter(Boolean)) { + const hit = await readNamed(name, "animation", true); + if (!hit) throw new Error(`Animation ${name} was not found`); + steps.push({ name, hit, data: LSF.parseAni(hit.bytes) }); + } + if (token !== heroSequenceToken || !steps.length) return; + heroSequence = { steps: steps.slice(1), onStep, onEvent, onDone }; + setAnim(steps[0].data, steps[0].name, steps[0].hit); + onStep?.(steps[0].name); + } + $("mdl-play").onclick = () => (MDL.playing ? stop() : play()); + $("mdl-loop").onclick = () => { + MDL.loop = !MDL.loop; + $("mdl-loop").classList.toggle("on", MDL.loop); + }; + $("mdl-speed").onchange = () => (MDL.speed = Number($("mdl-speed").value) || 1); + $("mdl-time").addEventListener("input", () => { + stop(); + MDL.time = Number($("mdl-time").value); + applyPose(); + }); + + function renderTimeline() { + const a = MDL.scene?.anim; + $("mdl-timeline").hidden = !a; + if (!a) return; + const total = Math.max(1, a.data.total); + $("mdl-time").max = String(total); + $("mdl-events").replaceChildren( + ...a.data.events.map((e) => + el("i", { + class: "mdl-ev", + title: `${fmtMs(e.time)} · ${e.type.text}${e.sub.text ? ` ${e.sub.text}` : ""}`, + style: `left:${Math.min(100, (100 * e.time) / total)}%`, + onclick: () => { + stop(); + MDL.time = e.time; + applyPose(); + }, + }), + ), + ); + renderClock(); + } + function renderClock() { + const a = MDL.scene?.anim; + if (!a) return; + $("mdl-time").value = String(Math.round(MDL.time)); + $("mdl-clock").textContent = `${(MDL.time / 1000).toFixed(2)} / ${(a.data.total / 1000).toFixed(2)} s`; + } + + // ===================================================================== + // side panel + // ===================================================================== + function renderSide() { + const s = MDL.scene; + if (!s) return; + const rows = []; + if (s.model) rows.push(infoRow("cube", "Model", s.model.name, s.model.source)); + if (s.skel) { + const linked = s.skel.linked ? ` · linked to ${s.skel.linked}` : ""; + rows.push( + infoRow("bone", "Skeleton", `${stem(s.skel.name)} (${s.skel.data.bones.length} bones${linked})`, s.skel.source, [ + el("button", { class: "iconbtn only", title: "Change the skeleton", onclick: pickSkeleton }, [icon("swap")]), + ]), + ); + } else if (s.meshes.some((m) => m.data.boneNames?.length)) { + rows.push(infoRow("bone", "Skeleton", "none — bind pose", "", [el("button", { class: "iconbtn only", title: "Choose a skeleton", onclick: pickSkeleton }, [icon("plus")])])); + } + s.meshes.forEach((m, i) => { + const vis = el("input", { type: "checkbox", checked: m.visible, title: "Show", onchange: () => ((m.visible = vis.checked), view.setVisible(m.id, m.visible), drawOverlays()) }); + rows.push( + el("div", { class: `mdl-row mesh${i === s.sel ? " on" : ""}`, onclick: (e) => e.target !== vis && !e.target.closest("button") && selectMesh(i) }, [ + vis, + el("span", { class: "grow" }, [el("b", { text: m.name + (m.dirty || m.mat?.dirty ? " •" : "") }), el("em", { text: `${plural(m.data.faceCount, "tri")}${m.missingBones?.length ? ` · ${m.missingBones.length} bones missing` : ""}` })]), + el("button", { class: "iconbtn only", title: "Remove from the scene", onclick: () => removeMesh(m) }, [icon("trash")]), + ]), + ); + }); + if (s.animList.length) { + const sel = el("select", { class: "grow", title: "Animations of this model", onchange: () => sel.value && playNamed(sel.value).then(renderSide) }, [ + el("option", { value: "", text: `${s.animList.length} animations…` }), + ...s.animList.map((a) => el("option", { value: a, text: a, selected: s.anim?.name === a })), + ]); + rows.push(el("div", { class: "mdl-row" }, [icon("play"), sel])); + } + if (s.missing.length) rows.push(el("div", { class: "mdl-note bad", text: `Missing: ${s.missing.join(", ")}` })); + $("mdl-scene").replaceChildren(...rows); + $("mdl-scene-count").textContent = s.meshes.length ? plural(s.meshes.length, "mesh").replace("meshs", "meshes") : ""; + if (s.sel < 0 && s.meshes.length) s.sel = s.meshes.reduce((best, m, i) => (m.data.vertexCount > s.meshes[best].data.vertexCount ? i : best), 0); + renderMeshCard(); + renderAniCard(); + updateSave(); + } + + function infoRow(ic, label, text, title, extra = []) { + return el("div", { class: "mdl-row", title: title || "" }, [icon(ic), el("span", { class: "grow" }, [el("em", { text: label }), el("b", { text })]), ...extra]); + } + + function selectMesh(i) { + MDL.scene.sel = i; + renderSide(); + } + + async function pickSkeleton() { + const s = MDL.scene; + const m = s.meshes[Math.max(0, s.sel)]; + const bones = m?.data.boneNames?.map((b) => b.text) || s.anim?.data.tracks.map((t) => t.name.text) || []; + loading(true, "Finding skeletons…"); + let c = []; + try { + c = MDL.src ? await call("mdl:findSkeleton", MDL.src, { mesh: m?.name, ani: s.anim?.name, bones }) : []; + } finally { + loading(false); + } + if (!c.length) return toast("No skeleton in these packs matches.", true); + let choice = c[0].name; + const listEl = el( + "div", + { class: "choice-list scroll" }, + c.map((x, i) => + el("label", { class: "choice" }, [ + el("input", { type: "radio", name: "mdlskl", checked: i === 0, onchange: () => (choice = x.name) }), + el("span", {}, [el("b", { text: x.name }), el("em", { text: `${x.fromModel ? "used by a model config · " : ""}${x.total ? `${x.matched}/${x.total} bones match` : ""} · ${x.bones} bones` })]), + ]), + ), + ); + if (!(await modal("Skeleton", [listEl], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]))) return; + const h = await readNamed(choice, "skeleton"); + if (!h) return; + setSkeleton(LSF.parseSkl(h.bytes), choice, h); + if (s.anim) mapTracks(s.anim); + renderSide(); + applyPose(); + } + + // ---------- mesh card ---------- + function renderMeshCard() { + const s = MDL.scene; + const m = s?.meshes[s.sel]; + $("mdl-mesh-card").hidden = !m; + if (!m) return; + const d = m.data; + $("mdl-mesh-name").textContent = m.name; + const dl = el("dl", { class: "mdl-dl" }); + const add = (k, v, cls) => dl.append(el("dt", { text: k }), el("dd", { text: v, class: cls || "" })); + add("Type", `${LSF.MESH_TYPES[d.type] || d.type} · v${d.version}`); + add("Geometry", `${d.vertexCount.toLocaleString()} vertices · ${d.faceCount.toLocaleString()} triangles · ${d.index32 ? "32" : "16"}-bit indices`); + if (d.boneNames?.length) add("Bones", `${d.boneNames.length} used${m.missingBones?.length ? ` · not in skeleton: ${m.missingBones.join(", ")}` : ""}`, m.missingBones?.length ? "bad" : ""); + add("Source", m.source || "", "mono small"); + const subs = el( + "div", + { class: "mdl-subs" }, + m.groups.map((g, i) => + el("div", { class: "mdl-sub", onmouseenter: () => highlight(m, i, true), onmouseleave: () => highlight(m, i, false) }, [ + el("b", { text: `${i + 1}` }), + el("span", { class: "grow mono", text: g.texName || "(no texture)", title: g.texInfo?.source || g.texInfo?.error || "" }), + el("em", { class: g.texInfo?.error ? "bad" : "", text: g.texInfo ? (g.texInfo.error ? "missing" : `${g.texInfo.w}×${g.texInfo.h}`) : g.texName ? "…" : "" }), + ]), + ), + ); + const matBtn = el("button", { class: "iconbtn", title: m.mat?.source || "", disabled: !m.mat, onclick: () => editMaterial(m) }, [icon("note"), el("span", { text: m.mat ? `Material ${m.name}.txt${m.mat.dirty ? " •" : ""}` : "No material script" })]); + $("mdl-mesh").replaceChildren(dl, el("h5", { text: `Sub-meshes (${m.groups.length})` }), subs, matBtn, controlPointsEditor(m)); + } + + function highlight(m, gi, on) { + const g = m.groups[gi]; + if (!g) return; + g.highlight = on; + view.dirty = true; + } + + function controlPointsEditor(m) { + const cps = m.data.controlPoints || (m.data.controlPoints = []); + const bones = MDL.scene.skel?.data.bones.map((b) => b.name.text) || []; + const wrap = el("div", { class: "mdl-cps" }); + wrap.append(el("h5", { text: `Control points (${cps.length})`, title: "Weapon trail anchors: trailstart_N / trailend_N, linked to a bone" })); + cps.forEach((c, i) => { + const txt = (s, ph, onSet) => { + const inp = el("input", { type: "text", value: s.text, placeholder: ph, spellcheck: "false" }); + inp.addEventListener("change", () => { + if (!isAscii(inp.value)) return ((inp.value = s.text), toast("Plain ASCII only.", true)); + pushUndo(); + onSet(inp.value); + meshChanged(m); + }); + return inp; + }; + const num = (k) => { + const inp = el("input", { type: "number", value: String(+c.point[k].toFixed(3)), step: "0.5" }); + inp.addEventListener("change", () => { + const v = Number(inp.value); + if (!isFinite(v)) return; + pushUndo(); + c.point[k] = v; + meshChanged(m, { keepCard: true }); + }); + return inp; + }; + const boneSel = bones.length + ? el("select", { title: "Linked bone", onchange: (e) => (pushUndo(), (c.bone = LSF.S(e.target.value)), meshChanged(m)) }, [ + ...(bones.includes(c.bone.text) ? [] : [el("option", { value: c.bone.text, text: c.bone.text || "(none)" })]), + ...bones.map((b) => el("option", { value: b, text: b, selected: b === c.bone.text })), + ]) + : txt(c.bone, "bone", (v) => (c.bone = LSF.S(v))); + wrap.append( + el("div", { class: "mdl-cp" }, [ + el("div", { class: "row" }, [txt(c.name, "trailstart_0", (v) => (c.name = LSF.S(v))), boneSel, el("button", { class: "iconbtn only danger", title: "Remove", onclick: () => (pushUndo(), cps.splice(i, 1), meshChanged(m)) }, [icon("trash")])]), + el("div", { class: "row xyz" }, [num(0), num(1), num(2), txt(c.extra, "extra", (v) => (c.extra = LSF.S(v)))]), + ]), + ); + }); + wrap.append( + el("button", { + class: "iconbtn", + onclick: () => { + pushUndo(); + const n = cps.filter((c) => /^trailstart_/i.test(c.name.text)).length; + cps.push({ name: LSF.S(`trailstart_${n}`), bone: LSF.S(bones[0] || ""), extra: LSF.S(""), point: [0, 0, 0] }); + meshChanged(m); + }, + }, [icon("plus"), el("span", { text: "Point" })]), + ); + return wrap; + } + + function meshChanged(m, { keepCard = false } = {}) { + m.dirty = true; + drawOverlays(); + if (!keepCard) renderSide(); + else updateSave(); + } + + async function editMaterial(m) { + const ta = el("textarea", { class: "mdl-mat", spellcheck: "false" }); + ta.value = m.mat.text; + const ok = await modal(`Material — ${m.name}.txt`, [el("p", { class: "muted", text: "One material { } block per sub-mesh. Texture names are looked up in resource/texture." }), ta], [ + { label: "Cancel", value: false }, + { label: "Apply", kind: "primary", value: true }, + ]); + if (!ok || ta.value === m.mat.text) return; + if (!isAscii(ta.value.replace(/[\r\n\t]/g, " ")) && LSF.decode(m.mat.raw) !== ta.value) { + if (!(await confirmBox("Non-ASCII text", "The script has characters outside plain ASCII; they will be written as UTF-8 and may not read back the same in the client. Apply anyway?", "Apply"))) return; + } + m.mat.text = ta.value; + m.mat.mats = LSF.parseMaterial(ta.value); + m.mat.dirty = true; + kit.applyMaterial(m).then(() => renderMeshCard()); + renderSide(); + } + + // ---------- animation card ---------- + function renderAniCard() { + const s = MDL.scene; + const a = s?.anim; + $("mdl-ani-card").hidden = !a; + if (!a) return; + const d = a.data; + $("mdl-ani-name").textContent = a.name + (a.dirty ? " •" : ""); + const missing = d.tracks.filter((_, i) => a.map[i] < 0).length; + const dl = el("dl", { class: "mdl-dl" }); + const add = (k, v, cls) => dl.append(el("dt", { text: k }), el("dd", { text: v, class: cls || "" })); + add("Format", `v${d.version} · ${LSF.ANI_VERSIONS[d.version]}`); + add("Tracks", `${d.tracks.length} · ${d.tracks.reduce((x, t) => x + t.times.length, 0).toLocaleString()} keys${missing ? ` · ${missing} not in the skeleton (skipped)` : ""}`, missing ? "bad" : ""); + if (a.source) add("Source", a.source, "mono small"); + const usedBy = el("dd", { class: "small", text: "…" }); + dl.append(el("dt", { text: "Used by" }), usedBy); + if (MDL.src) + call("mdl:usedBy", MDL.src, { ani: a.name }) + .then((u) => { + const names = u.map((x) => x.replace(/\.txt$/, "")); + usedBy.textContent = names.length ? names.slice(0, 6).join(", ") + (names.length > 6 ? ` +${names.length - 6} more` : "") : "no model config (hero / item animations are set in the .ini files)"; + usedBy.title = names.join("\n"); + }) + .catch(() => (usedBy.textContent = "")); + + // duration (stretches the keys and events with it) + const dur = el("input", { type: "number", min: "1", step: "1", value: String(d.total) }); + const stretch = el("input", { type: "checkbox", checked: true }); + dur.addEventListener("change", () => { + const v = Math.round(Number(dur.value)); + if (!(v > 0) || v === d.total) return (dur.value = String(d.total)); + pushUndo(); + if (stretch.checked) { + const f = v / d.total; + for (const t of d.tracks) for (let k = 0; k < t.times.length; k++) t.times[k] = Math.round(t.times[k] * f); + for (const e of d.events) e.time = Math.round(e.time * f * 1000) / 1000; + MDL.time *= f; + } + d.total = v; + aniChanged(); + }); + const durRow = el("div", { class: "mdl-dur" }, [el("label", { class: "fld" }, [el("span", { text: "Duration (ms)" }), dur]), el("label", { class: "chip" }, [stretch, el("span", { text: "Stretch keys & events" })])]); + + // events + const evWrap = el("div", { class: "mdl-events-list" }); + d.events.forEach((e, i) => { + const t = el("input", { type: "number", step: "1", value: String(+e.time.toFixed(3)), title: "Time (ms)" }); + t.addEventListener("change", () => { + const v = Number(t.value); + if (!isFinite(v) || v < 0) return (t.value = String(e.time)); + pushUndo(); + e.time = v; + aniChanged({ keepCard: true }); + }); + const txt = (s, ph, set) => { + const inp = el("input", { type: "text", value: s.text, placeholder: ph, spellcheck: "false" }); + inp.addEventListener("change", () => { + if (!isAscii(inp.value)) return ((inp.value = s.text), toast("Plain ASCII only (the file keeps CP949 names you don't change).", true)); + pushUndo(); + set(LSF.S(inp.value)); + aniChanged({ keepCard: true }); + }); + return inp; + }; + evWrap.append( + el("div", { class: "mdl-cp" }, [ + el("div", { class: "mdl-ev-row" }, [ + el("button", { class: "iconbtn only", title: "Go to this time", onclick: () => (stop(), (MDL.time = e.time), applyPose()) }, [icon("target")]), + t, + txt(e.type, "type", (v) => (e.type = v)), + el("button", { class: "iconbtn only danger", title: "Remove", onclick: () => (pushUndo(), d.events.splice(i, 1), aniChanged()) }, [icon("trash")]), + ]), + el("div", { class: "mdl-ev-row" }, [txt(e.sub, "info (sound file, effect, …)", (v) => (e.sub = v))]), + ]), + ); + }); + const addEv = el("button", { + class: "iconbtn", + title: "New event at the playhead", + onclick: () => { + pushUndo(); + d.events.push({ type: LSF.S("sound"), sub: LSF.S(""), time: Math.round(MDL.time) }); + d.events.sort((x, y) => x.time - y.time); + aniChanged(); + }, + }, [icon("plus"), el("span", { text: "Event at playhead" })]); + + // tracks + const q = el("input", { type: "search", placeholder: "Filter bones", spellcheck: "false" }); + const trWrap = el("div", { class: "mdl-tracks" }); + const drawTracks = () => { + const f = q.value.trim().toLowerCase(); + trWrap.replaceChildren( + ...d.tracks.flatMap((t, i) => { + if (f && !t.name.text.toLowerCase().includes(f)) return []; + const w = el("input", { type: "number", step: "0.1", min: "0", value: String(+t.weight.toFixed(3)), title: "Track weight (blending)" }); + w.addEventListener("change", () => { + const v = Number(w.value); + if (!isFinite(v) || v < 0) return (w.value = String(t.weight)); + pushUndo(); + t.weight = v; + aniChanged({ keepCard: true }); + }); + return [ + el("div", { class: `mdl-tr${a.map[i] < 0 ? " bad" : ""}`, title: a.map[i] < 0 ? "This bone isn't in the skeleton" : "" }, [ + el("span", { class: "grow", text: t.name.text }), + el("em", { text: `${t.times.length}` }), + w, + el("button", { class: "iconbtn only danger", title: "Remove this track", onclick: () => (pushUndo(), d.tracks.splice(i, 1), mapTracks(a), aniChanged()) }, [icon("trash")]), + ]), + ]; + }), + ); + }; + q.addEventListener("input", drawTracks); + drawTracks(); + + $("mdl-ani").replaceChildren( + dl, + durRow, + el("h5", { text: `Events (${d.events.length})`, title: "Sound / effect / trail triggers the client fires during the animation" }), + evWrap, + addEv, + el("h5", { text: `Tracks (${d.tracks.length})` }), + el("div", { class: "search" }, [icon("search"), q]), + trWrap, + ); + } + + function aniChanged({ keepCard = false } = {}) { + const a = MDL.scene.anim; + a.dirty = true; + renderTimeline(); + applyPose(); + if (!keepCard) renderAniCard(); + else $("mdl-ani-name").textContent = a.name + " •"; + updateSave(); + } + + // ---------- undo (whole-file snapshots of the edited animation / meshes) ---------- + function snapshot() { + const s = MDL.scene; + return { + anim: s.anim ? { bytes: LSF.writeAni(s.anim.data), dirty: s.anim.dirty } : null, + cps: s.meshes.map((m) => ({ m, cps: JSON.stringify((m.data.controlPoints || []).map((c) => [c.name.text, c.bone.text, c.extra.text, c.point])), raw: m.data.controlPoints, dirty: m.dirty })), + }; + } + function pushUndo() { + MDL.undo.push(snapshot()); + if (MDL.undo.length > 100) MDL.undo.shift(); + MDL.redo = []; + } + function restore(from, to) { + const snap = from.pop(); + if (!snap) return; + to.push(snapshot()); + const s = MDL.scene; + if (snap.anim && s.anim) { + s.anim.data = LSF.parseAni(snap.anim.bytes); + s.anim.dirty = snap.anim.dirty; + mapTracks(s.anim); + } + for (const c of snap.cps) { + if (!s.meshes.includes(c.m)) continue; + c.m.data.controlPoints = JSON.parse(c.cps).map(([name, bone, extra, point], i) => { + const old = c.raw?.[i]; + return { name: old && old.name.text === name ? old.name : LSF.S(name), bone: old && old.bone.text === bone ? old.bone : LSF.S(bone), extra: old && old.extra.text === extra ? old.extra : LSF.S(extra), point }; + }); + c.m.dirty = c.dirty; + } + renderTimeline(); + renderSide(); + applyPose(); + } + + // ===================================================================== + // save / export + // ===================================================================== + function dirtyItems() { + const s = MDL.scene; + if (!s) return []; + const out = []; + if (s.anim?.dirty) out.push({ what: "animation", name: s.anim.name, ext: "ani", obj: s.anim, bytes: () => LSF.writeAni(s.anim.data) }); + for (const m of s.meshes) { + if (m.dirty) out.push({ what: "mesh", name: `${m.name}.msh`, ext: "msh", obj: m, bytes: () => LSF.writeMsh(m.data) }); + if (m.mat?.dirty) out.push({ what: "material", name: `${m.name}.txt`, ext: "txt", obj: m.mat, bytes: () => encodeText(m.mat) }); + } + return out; + } + function encodeText(mat) { + if (LSF.decode(mat.raw) === mat.text) return mat.raw; + const t = mat.text; + if (/^[\x00-\x7e]*$/.test(t)) return Uint8Array.from(t, (c) => c.charCodeAt(0)); + return new TextEncoder().encode(t); + } + function updateSave() { + const n = dirtyItems().length; + $("mdl-save").disabled = !n; + $("mdl-dirty").hidden = !n; + } + + async function saveItem(it, as = false) { + let where = it.obj.where; + if (as || !where) { + const file = await call("dialog:saveAs", it.name, it.ext); + if (!file) return false; + where = { kind: "file", file }; + } else if (where.kind === "pack") { + const pack = where.file.split(/[\\/]/).pop(); + if (!(await confirmBox("Save into the pack", `Write ${where.entry} into ${pack}? The new copy replaces the old one (IOP Manager's Optimize frees the old space).`, "Save"))) return false; + } + try { + await call("mdl:writeBack", where, it.bytes()); + it.obj.where = where; + it.obj.dirty = false; + if (it.obj.raw !== undefined && it.what === "material") it.obj.raw = encodeText(it.obj); + if (where.kind === "pack") kit.clearTextures(); // the source's packs were reopened + toast(`Saved ${it.name} ${where.kind === "pack" ? `into ${where.file.split(/[\\/]/).pop()}` : `to ${where.file}`}.`); + renderSide(); + return true; + } catch (err) { + toast(err.message, true); + return false; + } + } + + $("mdl-save").onclick = (e) => { + const items = dirtyItems(); + if (!items.length) return; + const menu = []; + for (const it of items) { + const w = it.obj.where; + menu.push({ label: w?.kind === "pack" ? `Save ${it.name} into ${w.file.split(/[\\/]/).pop()}` : w?.kind === "file" ? `Save ${it.name}` : `Save ${it.name}…`, icon: w?.kind === "pack" ? "box" : "save", run: () => saveItem(it) }); + menu.push({ label: `Save ${it.name} as file…`, icon: "download", run: () => saveItem(it, true) }); + } + showMenu(menu, { anchor: e.currentTarget }); + }; + + $("mdl-export").onclick = (e) => + showMenu( + [ + { label: "Screenshot (PNG)…", icon: "camera", disabled: !MDL.scene, run: screenshot }, + { label: "Export OBJ + textures (current pose)…", icon: "cube", disabled: !MDL.scene?.meshes.length, run: exportObj }, + "-", + { label: "Save the original .msh / .ani / .skl as file…", icon: "download", disabled: !MDL.scene, run: exportOriginal }, + ], + { anchor: e.currentTarget }, + ); + + async function screenshot() { + const file = await call("dialog:savePng", `${MDL.scene.title}.png`); + if (!file) return; + view.draw(); + const src = $("mdl-canvas"); + const c = document.createElement("canvas"); + c.width = src.width; + c.height = src.height; + const g = c.getContext("2d"); + const bg = g.createLinearGradient(0, 0, 0, c.height); + bg.addColorStop(0, "#1a191f"); + bg.addColorStop(1, "#0b0a0d"); + g.fillStyle = bg; + g.fillRect(0, 0, c.width, c.height); + g.drawImage(src, 0, 0); + await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]); + toast(`Saved ${file}.`); + } + + async function exportOriginal() { + const s = MDL.scene; + const items = [ + ...s.meshes.map((m) => ({ label: `${m.name}.msh`, run: () => saveBytes(`${m.name}.msh`, "msh", m.data.bytes) })), + s.skel && { label: stem(s.skel.name) + ".skl", run: () => readNamed(s.skel.name, "skeleton").then((h) => h && saveBytes(stem(s.skel.name) + ".skl", "skl", h.bytes)) }, + s.anim && { label: s.anim.name, run: () => saveBytes(s.anim.name, "ani", LSF.writeAni(s.anim.data)) }, + ].filter(Boolean); + showMenu(items.map((x) => ({ ...x, icon: "download" })), { anchor: $("mdl-export") }); + } + async function saveBytes(name, ext, bytes) { + const file = await call("dialog:saveAs", name, ext); + if (!file) return; + await call("mdl:writeBack", { kind: "file", file }, bytes); + toast(`Saved ${file}.`); + } + + /** OBJ + MTL + PNG textures of the visible meshes in the current pose (right-handed, for Blender etc.). */ + async function exportObj(target) { + const s = MDL.scene; + const folder = typeof target === "string" ? target : await call("dialog:folder", "Folder for the OBJ export"); + if (!folder) return; + const base = s.title.replace(/[^\w.-]+/g, "_"); + const obj = [`# ${s.title} — exported by LS Tools`, `mtllib ${base}.mtl`]; + const mtl = []; + const files = []; + const texDone = new Set(); + let vOff = 1; + const used = new Map(); + for (const m of s.meshes) { + if (!m.visible) continue; + const n = (used.get(m.name) || 0) + 1; + used.set(m.name, n); + const oname = n > 1 ? `${m.name}_${n}` : m.name; + const d = m.data; + const P = m.palette && lastPose ? m.skinPos : d.pos; + const N = m.palette && lastPose && m.skinNrm ? m.skinNrm : d.normal; + const uvSet = m.groups[0]?.uvSet === 1 && d.uv1 ? d.uv1 : d.uv0; + obj.push(`o ${oname}`); + for (let v = 0; v < d.vertexCount; v++) obj.push(`v ${P[v * 3].toFixed(4)} ${P[v * 3 + 1].toFixed(4)} ${(-P[v * 3 + 2]).toFixed(4)}`); + if (uvSet) for (let v = 0; v < d.vertexCount; v++) obj.push(`vt ${uvSet[v * 2].toFixed(5)} ${(1 - uvSet[v * 2 + 1]).toFixed(5)}`); + if (N) for (let v = 0; v < d.vertexCount; v++) obj.push(`vn ${N[v * 3].toFixed(4)} ${N[v * 3 + 1].toFixed(4)} ${(-N[v * 3 + 2]).toFixed(4)}`); + m.groups.forEach((g, gi) => { + const mname = `${oname}_${gi + 1}`; + obj.push(`g ${mname}`, `usemtl ${mname}`); + mtl.push(`newmtl ${mname}`, "Kd 1 1 1"); + if (g.texName && g.texInfo?.img) { + const png = g.texName.replace(/\.[^.]+$/, "") + ".png"; + mtl.push(`map_Kd ${png}`); + if (!texDone.has(png)) { + texDone.add(png); + files.push({ name: png, img: g.texInfo.img }); + } + } + mtl.push(""); + const f = (i) => { + const k = d.indices[i] + vOff; + return uvSet && N ? `${k}/${k}/${k}` : uvSet ? `${k}/${k}` : N ? `${k}//${k}` : `${k}`; + }; + // mirrored Z: reverse the winding + for (let i = g.start; i + 2 < g.start + g.count; i += 3) obj.push(`f ${f(i)} ${f(i + 2)} ${f(i + 1)}`); + }); + vOff += d.vertexCount; + } + const out = [ + { name: `${base}.obj`, data: obj.join("\n") + "\n" }, + { name: `${base}.mtl`, data: mtl.join("\n") + "\n" }, + ]; + for (const t of files) { + const c = document.createElement("canvas"); + c.width = t.img.width; + c.height = t.img.height; + const g = c.getContext("2d"); + if (t.img instanceof ImageData) g.putImageData(t.img, 0, 0); + else g.drawImage(t.img, 0, 0); + const b64 = c.toDataURL("image/png").split(",")[1]; + out.push({ name: t.name, data: Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)) }); + } + try { + await call("mdl:writeFiles", folder, out); + toast(`Exported ${base}.obj with ${files.length} texture(s) to ${folder}.`); + } catch (err) { + toast(err.message, true); + } + } + + // ---------- loose files ---------- + $("mdl-open-file").onclick = async () => { + const file = await call("dialog:openModel"); + if (!file) return; + try { + const hit = await call("mdl:readFile", file); + const ext = file.split(".").pop().toLowerCase(); + if (ext === "msh") await openMesh(hit); + else if (ext === "skl") await openSkeleton(hit); + else if (ext === "ani") { + const data = LSF.parseAni(hit.bytes); + if (!MDL.scene?.skel) { + const c = MDL.src ? await call("mdl:findSkeleton", MDL.src, { ani: file.split(/[\\/]/).pop(), bones: data.tracks.map((t) => t.name.text) }) : []; + if (!newScene(stem(file))) return; + const sk = c[0] && (await readNamed(c[0].name, "skeleton")); + if (sk) setSkeleton(LSF.parseSkl(sk.bytes), c[0].name, sk); + else toast("Choose packs first so a skeleton can be found for this animation.", true); + setToggle("mdl-t-bones", true); + frameScene(); + } + setAnim(data, file.split(/[\\/]/).pop(), hit); + } else { + const text = LSF.decode(hit.bytes); + if (!/#MODEL_CFG/.test(text)) throw new Error("This .txt isn't a model config (#MODEL_CFG)."); + await openModel(hit, stem(file)); + } + } catch (err) { + toast(err.message, true); + } finally { + loading(false); + } + }; + + // ---------- keys ---------- + document.addEventListener("keydown", (e) => { + if (!$("view-mdl").classList.contains("on") || !MDL.scene) return; + if (e.target.matches?.("input, textarea, select")) return; + if (e.key === " ") return e.preventDefault(), MDL.playing ? stop() : play(); + if (e.ctrlKey && e.key.toLowerCase() === "z") return e.preventDefault(), restore(MDL.undo, MDL.redo); + if (e.ctrlKey && e.key.toLowerCase() === "y") return e.preventDefault(), restore(MDL.redo, MDL.undo); + if (e.ctrlKey && e.key.toLowerCase() === "s") { + e.preventDefault(); + const it = dirtyItems()[0]; + if (it) saveItem(it); + } + if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && MDL.scene.anim) { + e.preventDefault(); + stop(); + MDL.time = Math.max(0, Math.min(MDL.scene.anim.data.total, MDL.time + (e.key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? 100 : 33))); + applyPose(); + } + }); + + // ---------- texture animations (material scroll / rotate / wave / frames), always running ---------- + const TEXANIM = { t0: performance.now() }; + function texAnimTick(now) { + const s = MDL.scene; + if (view && kit && s && ($("view-mdl").classList.contains("on") || heroShown())) { + const on = toggled("mdl-t-texanim"); + let any = false; + for (const m of s.meshes) any = kit.animate(m, on ? (now - TEXANIM.t0) / 1000 : null) || any; + if (any && (on || !TEXANIM.rested)) view.dirty = true; + TEXANIM.rested = !on; + } + requestAnimationFrame(texAnimTick); + } + requestAnimationFrame(texAnimTick); + $("mdl-t-texanim").onclick = () => $("mdl-t-texanim").classList.toggle("on"); + + // First visit: default to the packs next to a file open in IOP Manager + const modelStage = $("mdl-canvas").parentElement; + let modelEnvBeforeHero = null; + // The hero viewport is the Hero tab stage by default; FX Lab borrows it with its own host. + let heroHost = null; + const observedStages = new WeakSet(); + function embedHero(on, host = null) { + if (on) heroHost = host || { stage: $("hero-stage"), viewId: "view-hero" }; + ensureView(); + if (on && !MDL.heroPreview) { + modelEnvBeforeHero = view.env; + // The normal model viewer clears transparent. Additive particles need an + // opaque target here or canvas alpha compositing exposes black DDS quads. + view.env = { ...(view.env || {}), clear: [0.09, 0.085, 0.10] }; + } else if (!on && MDL.heroPreview) view.env = modelEnvBeforeHero; + MDL.heroPreview = !!on; + if (!on) setHeroPracticeTarget(null); + const target = on ? heroHost.stage : modelStage; + if (on && !observedStages.has(target)) { observedStages.add(target); new ResizeObserver(() => { if (MDL.heroPreview && $("mdl-canvas").parentElement === target) view?.resize(); }).observe(target); } + if ($("mdl-canvas").parentElement !== target) target.prepend($("mdl-canvas")); + view?.resize(); + } + document.querySelector('[data-view="mdl"]').addEventListener("click", async () => { + if (MDL.heroPreview) embedHero(false); + ensureView(); + view?.resize(); + if (MDL.src) return; + if (profileSource()) return setSource(profileSource(), { quiet: true }); + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const s = sessions.find((x) => /^(rs|map)\.iop$/i.test(x.name)) || sessions[0]; + if (s) await setSource({ kind: "session", sessionId: s.id, name: s.name }, { quiet: true }); + }); + + const sameSource = (src) => MDL.src && MDL.src.kind === src.kind && MDL.src.folder === src.folder && MDL.src.sessionId === src.sessionId; + async function previewAsset(src, name, kind) { + if (!sameSource(src)) await setSource(src, { quiet: true }); + document.querySelector('[data-view="mdl"]').click(); + const file = kind === "mesh" && !/\.msh$/i.test(name) ? `${name}.msh` : name; + const row = MDL.rows.find((r) => r[3] === (kind === "mesh" ? "msh" : "ani") && r[4].toLowerCase().endsWith(`/${file.toLowerCase()}`)); + if (!row) throw new Error(`${file} was not found in the model source`); + await openRow(row); + } + async function previewHero(src, meshes, animation, { embedded = false, host = null, hair = null } = {}) { + if (!sameSource(src)) await setSource(src, { quiet: true }); + if (embedded) embedHero(true, host); + else document.querySelector('[data-view="mdl"]').click(); + stopHeroSequence(); heroFx = null; clearHeroFxModels(); view?.setParticles([]); + const hit = await readNamed("sp2_human_man.txt", "model"); + if (!hit) throw new Error("sp2_human_man.txt was not found in the model source"); + await openModel(hit, "Hero preview"); + if (MDL.scene?.model?.name !== "Hero preview") return; + if (meshes.some((name) => /_body(?:\d+)?$/i.test(name))) { + const baseBody = MDL.scene.meshes.find((m) => m.name === "human_body"); + if (baseBody) removeMesh(baseBody); + } + // web fix: the helmet's hair rule (0 half, 1 none, 2 bind, 3 full) + if (hair !== null && hair !== undefined && Number(hair) !== 3) { + const baseHair = MDL.scene.meshes.find((m) => /^hair/i.test(m.name)); + if (baseHair) { + removeMesh(baseHair); + const alt = Number(hair) === 0 ? `half${baseHair.name}` : Number(hair) === 2 ? `bind${baseHair.name}` : ""; + const h = alt ? await readNamed(`${alt}.msh`, "mesh") : null; + if (h) await addMesh(h, alt); + } + } + const missing = []; + for (const name of [...new Set(meshes)].filter(Boolean)) { + const h = await readNamed(`${name.replace(/\.msh$/i, "")}.msh`, "mesh"); + if (!h) { missing.push(name); continue; } + await addMesh(h, name); + } + frameScene(); renderSide(); + setHeroTransform(heroRoot.pos, heroRoot.yaw); + if (animation) await playNamed(animation); + applyPose(); + if (missing.length) toast(`Missing gear meshes: ${missing.join(", ")}`, true); + } + function clearHeroFxModels() { + heroFxModelSeq++; + for (const entry of heroFxModels) for (const id of entry?.slots || []) view?.removeMesh(id); + heroFxModels = []; + } + function setHeroTransform(pos, yaw) { + heroRoot = { pos: pos.slice(), yaw }; + if (!MDL.heroPreview || !MDL.scene || !view) return; + for (const m of MDL.scene.meshes) view.setTransform(m.id, heroRoot.pos, [1, 1, 1], [0, yaw, 0]); + } + function setHeroPracticeTarget(pos) { + heroTarget = pos?.slice() || null; + if (!view) return; + for (const id of heroDummyIds) view.removeMesh(id); + if (!heroTarget || !MDL.heroPreview) return; + const cube = { pos: new Float32Array([-1,-1,-1, 1,-1,-1, 1,1,-1, -1,1,-1, -1,-1,1, 1,-1,1, 1,1,1, -1,1,1]), + indices: new Uint32Array([0,2,1,0,3,2, 4,5,6,4,6,7, 0,1,5,0,5,4, 3,7,6,3,6,2, 0,4,7,0,7,3, 1,2,6,1,6,5]), + groups: [{ start: 0, count: 36, color: [0.62, 0.43, 0.28, 1] }] }; + view.addMesh(heroDummyIds[0], cube); + view.setTransform(heroDummyIds[0], [heroTarget[0], 45, heroTarget[2]], [16, 45, 14]); + view.addMesh(heroDummyIds[1], { ...cube, groups: [{ start: 0, count: 36, color: [0.85, 0.69, 0.42, 1] }] }); + view.setTransform(heroDummyIds[1], [heroTarget[0], 105, heroTarget[2]], [19, 17, 17]); + } + function clearHeroEffect() { + heroFxRequest++; + heroFx = null; + clearHeroFxModels(); + view?.setParticles([]); + } + async function loadHeroFxModel(index, spec, seq) { + const name = (spec.attrs.mesh_file || "").trim().replace(/\.msh$/i, ""); + if (!name || name.toLowerCase() === "none") return; + try { + const hit = await kit.readNamed(`${name}.msh`, "mesh", true); + if (!hit || seq !== heroFxModelSeq) return; + const mesh = await kit.loadMesh(hit, name); + if (seq !== heroFxModelSeq) return view.removeMesh(mesh.id); + view.setVisible(mesh.id, false); + heroFxModels[index] = { mesh, slots: [mesh.id] }; + await kit.loadTextures(mesh); + if (seq !== heroFxModelSeq) return view.removeMesh(mesh.id); + } catch (err) { if (seq === heroFxModelSeq) console.warn(`Hero effect model ${name}: ${err.message}`); } + } + function syncHeroFxModels() { + heroFx.modelSystems.forEach((system, index) => { + const entry = heroFxModels[index]; + if (!entry) return; + kit.animate(entry.mesh, system.time / 1000); + system.parts.forEach((part, i) => { + let id = entry.slots[i]; + if (!id) { + id = `hero-fx-model-${heroFxModelSeq}-${index}-${i}`; + const m = entry.mesh; + view.addMesh(id, { pos: m.data.pos, normal: m.data.normal, uv0: m.data.uv0, uv1: m.data.uv1, indices: m.data.indices, groups: m.groups }); + entry.slots[i] = id; + } + view.setTransformQuaternion(id, part.pos, part.modelScale, part.modelRotation); + const t = Math.min(1, part.age / part.life), color = system.color(t); + view.setTint(id, [color[0] / 255, color[1] / 255, color[2] / 255, Math.max(0, Math.min(1, system.alpha(t)[0]))]); + view.setVisible(id, true); + }); + entry.slots.forEach((id, i) => { if (i >= system.parts.length) view.setVisible(id, false); }); + }); + } + async function playHeroEffect(src, name, { target = false } = {}) { + if (!name || !view || !kit) return; + const request = ++heroFxRequest; + clearHeroFxModels(); + const hit = await call("mdl:read", src, name, "effect", true); + if (request !== heroFxRequest) return; + if (!hit) throw new Error(`Effect ${name} was not found`); + // AttackAttribute explosion_effect is a hit effect in the client. With no + // opponent in this preview, use a point in front of the hero as the hit. + const origin = target ? (heroTarget ? [heroTarget[0], 75, heroTarget[2]] : [0, 75, 130]) : heroRoot.pos; + startHeroFx(Effects.parse(LSF.decode(hit.bytes)), { target }); + } + /** Play a parsed effect on the hero (FX Lab): customTexture(name, view) → WebGL texture for textures not in the packs. */ + function startHeroFx(def, { target = false, duration = 3500, customTexture = null } = {}) { + heroFxRequest++; + clearHeroFxModels(); + const origin = target ? (heroTarget ? [heroTarget[0], 75, heroTarget[2]] : [0, 75, 130]) : heroRoot.pos; + heroFx = Effects.instance(def, origin); + heroFx.target = target; + heroFx.heroPreview = true; + heroFx.customTexture = customTexture; + for (const system of [...heroFx.systems, ...heroFx.modelSystems]) system.heroAttached = true; + const seq = heroFxModelSeq; + heroFx.def.models.forEach((spec, index) => loadHeroFxModel(index, spec, seq)); + heroFxUntil = performance.now() + duration; + heroFxLast = 0; + } + const heroShown = () => MDL.heroPreview && !!heroHost && $(heroHost.viewId).classList.contains("on"); + const heroBones = () => MDL.scene?.skel ? MDL.scene.skel.data.bones.map((b) => b.name.text) : []; + const heroFxCount = () => heroFx ? Effects.count(heroFx) : 0; + function heroFxTick(now) { + if (view && heroFx && now < heroFxUntil && heroShown()) { + const dt = Math.min(100, Math.max(0, now - (heroFxLast || now))); + heroFxLast = now; + if (!heroFx.target && lastPose && MDL.scene?.skel) for (const system of [...heroFx.systems, ...heroFx.modelSystems]) { + const index = MDL.scene.skel.byName.get(system.linkBone.toLowerCase()); + const rootRotation = [0, Math.sin(heroRoot.yaw * Math.PI / 360), 0, Math.cos(heroRoot.yaw * Math.PI / 360)]; + const localPos = index === undefined ? null : lastPose.wp[index]; + const bonePos = localPos ? qrot(rootRotation, localPos).map((v, i) => v + heroRoot.pos[i]) : heroRoot.pos; + const boneRot = index === undefined ? null : lastPose.wr[index]; + system.ownerOrigin = bonePos; + const worldRot = boneRot ? qmul(rootRotation, boneRot) : rootRotation; + system.ownerOffset = boneRot ? qrot(worldRot, system.linkOffset) : [0, 0, 0]; + system.ownerRotation = qmul(worldRot, system.linkRotation); + } + Effects.update(heroFx, dt); + syncHeroFxModels(); + const parts = Effects.build(heroFx, view.cameraAxes()); + for (const p of parts) { + const key = String(p.texture || "").toLowerCase(); + if (!key) continue; + const custom = heroFx.customTexture?.(p.texture, view); + if (custom) { p.tex = custom; continue; } + if (!heroTextures.has(key)) { + heroTextures.set(key, null); + kit.texture(p.texture).then((t) => { if (t.tex) heroTextures.set(key, t.tex); }); + } + p.tex = heroTextures.get(key); + } + view.setParticles(parts); + } else if (heroFx && now >= heroFxUntil) { + heroFx = null; + clearHeroFxModels(); + view?.setParticles([]); + } + requestAnimationFrame(heroFxTick); + } + requestAnimationFrame(heroFxTick); + Object.assign(MDL, { setSource, openRow, openModel, openMesh, playNamed, playHeroMotion, previewAsset, previewHero, setHeroTransform, setHeroPracticeTarget, setPracticeFloor, clearHeroEffect, playSequence, stopHeroSequence, playHeroEffect, startHeroFx, heroBones, heroFxCount, embedHero, applyPose, dirtyItems, saveItem, restore, pushUndo, exportObj, rowsOf: () => list.view }); +})(); diff --git a/app/public/legacy/js/quests.js b/app/public/legacy/js/quests.js new file mode 100644 index 0000000..392f37e --- /dev/null +++ b/app/public/legacy/js/quests.js @@ -0,0 +1,1253 @@ +"use strict"; + +// Quests tab: quests (sp2_quest_info.ini), rewards (sp2_quest_present.ini), daily / weekly / +// monthly missions and a check of all of them, read from the game's packs by src/main/quests.js. +// Edits are kept as a draft per section (raw INI values + player-visible texts) and saved as one +// change: only the changed keys are written, texts go to the string table as STR(n). +// Key meanings: docs/quests.md and src/main/questspec.js. + +(() => { + const Q = { src: null, text: null, data: null, spec: null, sub: "quests", loading: false, draft: null, detail: null, j: 1, presents: null, pick: null, mKind: "daily", missions: {}, mDraft: null, rDraft: null }; + window.QST = Q; // (dev probes) + const nf = (v) => Number(v || 0).toLocaleString(); + const num = (v) => { + const n = parseInt(String(v ?? "").trim(), 10); + return Number.isFinite(n) ? n : 0; + }; + + // ---------- icons ---------- + function iconCanvas(name, px) { + const c = el("canvas", { class: "it-icon", width: px, height: px }); + c.style.width = `${px}px`; + c.style.height = `${px}px`; + if (name) drawIcon(c, name, px); + return c; + } + async function drawIcon(c, name, px) { + const i = name.indexOf("#"); + const im = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : null; + if (!im?.bmp) { + c.classList.add("missing"); + c.title = `${name} (not found)`; + return; + } + const ctx = c.getContext("2d"); + const s = Math.min(px / im.w, px / im.h); + const w = Math.round(im.w * s); + const h = Math.round(im.h * s); + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, (px - w) / 2, (px - h) / 2, w, h); + c.title = name; + } + const runsEl = (runs) => runs.map((r) => el("span", { text: `${" ".repeat(r.space || 0)}${r.text}`, style: r.color ? `color:${r.color}` : "" })); + + // ---------- drafts: one INI section being edited ---------- + // values: lower key → { key (as written), raw }; texts: lower key → text (player-visible) + class Draft { + constructor(file, section, data, { create = false } = {}) { + this.file = file; + this.section = section; + this.create = create; + this.orig = new Map(); + this.origTexts = {}; + this.values = new Map(); + this.texts = {}; + for (const [k, v] of data?.entries || []) { + this.orig.set(k.toLowerCase(), { key: k, raw: v }); + this.values.set(k.toLowerCase(), { key: k, raw: v }); + } + Object.assign(this.origTexts, data?.texts || {}); + Object.assign(this.texts, data?.texts || {}); + this.onChange = () => {}; + } + get(k) { + return this.values.get(k.toLowerCase())?.raw; + } + has(k) { + return this.values.has(k.toLowerCase()); + } + set(k, raw) { + const lk = k.toLowerCase(); + if (raw === null || raw === undefined || raw === "") this.values.delete(lk); + else this.values.set(lk, { key: this.values.get(lk)?.key || this.orig.get(lk)?.key || k, raw: String(raw) }); + this.onChange(); + } + text(k) { + const lk = k.toLowerCase(); + return lk in this.texts ? this.texts[lk] : this.get(k) || ""; + } + setText(k, t) { + this.texts[k.toLowerCase()] = t; + if (!this.has(k) && t) this.set(k, "STR(0)"); // placeholder: the main process gives it a number + if (!t) this.set(k, null); + this.onChange(); + } + keys(prefix) { + return [...this.values.values()].filter((v) => v.key.toLowerCase().startsWith(prefix.toLowerCase())); + } + dirty() { + return this.create || !!this.change(); + } + /** → { file, section, create, values, texts } | null when nothing changed */ + change() { + const values = {}; + const texts = {}; + for (const [lk, v] of this.values) { + if (lk in this.texts) continue; + if (this.orig.get(lk)?.raw !== v.raw || this.create) values[v.key] = v.raw; + } + for (const [lk, v] of this.orig) if (!this.values.has(lk) && !(lk in this.texts)) values[v.key] = null; + for (const [lk, t] of Object.entries(this.texts)) { + const key = this.values.get(lk)?.key || this.orig.get(lk)?.key || lk; + if (t !== this.origTexts[lk] || this.create) { + texts[key] = t; + // a copied STR(n) stays as the key's value: the main process writes the text under this section + const raw = this.values.get(lk)?.raw; + if (raw && raw !== "STR(0)" && this.create) values[key] = raw; + } + } + for (const k of Object.keys(values)) if (values[k] === "STR(0)") delete values[k]; + if (!this.create && !Object.keys(values).length && !Object.keys(texts).length) return null; + return { file: this.file, section: this.section, create: this.create, values, texts }; + } + } + + // ---------- form fields bound to a draft ---------- + function row(label, control, hint) { + return el("div", { class: "q-field" }, [el("label", { text: label, title: hint || "" }), control]); + } + function fText(d, key, label, { multiline = false, hint } = {}) { + const input = multiline ? el("textarea", { rows: 3, spellcheck: "false" }) : el("input", { type: "text", spellcheck: "false" }); + input.value = d.text(key); + input.addEventListener("input", () => d.setText(key, input.value)); + return row(label, input, hint || key); + } + function fRaw(d, key, label, { placeholder = "", hint, onInput } = {}) { + const input = el("input", { type: "text", spellcheck: "false", placeholder: String(placeholder) }); + input.value = d.get(key) ?? ""; + input.addEventListener("input", () => { + d.set(key, input.value.trim()); + onInput?.(input.value.trim()); + }); + return row(label, input, hint || key); + } + function fBool(d, key, label, def = 0) { + const cb = el("input", { type: "checkbox" }); + cb.checked = d.has(key) ? num(d.get(key)) !== 0 : !!def; + cb.addEventListener("change", () => d.set(key, cb.checked ? "1" : "0")); + return el("label", { class: "chip q-chip", title: key }, [cb, el("span", { text: label })]); + } + function fEnum(d, key, label, map, def, { onChange } = {}) { + const sel = el("select"); + const cur = d.has(key) ? String(num(d.get(key))) : String(def); + const keys = Object.keys(map); + if (!keys.includes(cur)) sel.append(el("option", { value: cur, text: `${cur} (unknown)` })); + for (const k of keys.sort((a, b) => Number(a) - Number(b))) sel.append(el("option", { value: k, text: `${k} · ${map[k]}` })); + sel.value = cur; + sel.addEventListener("change", () => { + d.set(key, sel.value); + onChange?.(sel.value); + }); + return row(label, sel, key); + } + + // ---------- loading ---------- + async function load({ reload = false } = {}) { + if (Q.loading) return; + if (!Q.src) Q.src = profileSource(); + if (!Q.src) { + $("q-source").textContent = "This profile has no game folder: set one in the profile, or pick a folder."; + return; + } + Q.loading = true; + $("q-source").textContent = `Reading ${Q.src.folder}…`; + try { + if (!Q.spec) Q.spec = await call("q:spec"); + const [data] = await Promise.all([call("q:load", Q.src, { textFile: Q.text, reload }), UIRes.load(Q.src)]); + Q.data = data; + Q.text = data.text; + Q.presents = null; + Q.missions = {}; + $("q-text").replaceChildren(...data.texts.map((t) => el("option", { value: t.file, text: t.label, selected: t.file === data.text }))); + const errs = data.quests.filter((q) => q.errors).length; + $("q-source").textContent = `${Q.src.folder} · ${nf(data.quests.length)} quests${errs ? ` · ${errs} with errors` : ""}${data.missing.length ? ` · missing: ${data.missing.join(", ")}` : ""}`; + fillClassFilter(); + showQuests(); + if (Q.sub === "rewards") await showRewards(); + if (Q.sub === "missions") await showMissions(); + if (Q.sub === "checks") await runChecks(); + } catch (err) { + $("q-source").textContent = err.message; + toast(err.message, true); + } finally { + Q.loading = false; + } + } + + async function refreshAfterSave(select) { + Q.data = await call("q:load", Q.src, { textFile: Q.text }); + Q.presents = null; + Q.missions = {}; + showQuests({ keep: true }); + if (select) openQuest(select); + } + + async function save(changes, { what = "Saved" } = {}) { + try { + const r = await call("q:save", Q.src, Q.text, changes); + toast(`${what}: ${r.written.join(", ") || "nothing to write"}`); + for (const w of r.warnings) toast(w, true); + return true; + } catch (err) { + toast(err.message, true); + return false; + } + } + + // ---------- quest list ---------- + const flags = (q) => { + const f = []; + if (q.subs.some((s) => s.perform === 2)) f.push("event"); + if (q.subs.some((s) => s.oneday)) f.push("daily"); + if (q.subs.some((s) => s.repeat)) f.push("repeat"); + return f; + }; + function qTable() { + if (Q.table) return Q.table; + Q.table = new VTable( + $("q-table"), + [ + { key: "icon", label: "", width: 40, format: (r) => iconCanvas(r.subs[0]?.icon, 30) }, + { key: "main", label: "Main", width: 70, cls: "num mono" }, + { key: "title", label: "Title", grow: true, width: 220, format: (r) => r.subs[0]?.title || "(no title)", sort: (r) => r.subs[0]?.title || "￿", title: true }, + { key: "cls", label: "Class", width: 170, format: (r) => r.cls.replace(/^Quest/, ""), title: true }, + { key: "subs", label: "Subs", width: 50, cls: "num", format: (r) => String(r.subs.length), sort: (r) => r.subs.length }, + { + key: "flags", + label: "", + width: 90, + format: (r) => el("span", { class: "q-flags" }, [...flags(r).map((f) => el("b", { class: `q-flag ${f}`, text: f[0].toUpperCase(), title: f })), r.errors ? el("b", { class: "q-flag bad", text: "!", title: `${r.errors} error(s)` }) : r.warnings ? el("b", { class: "q-flag warn", text: "?", title: `${r.warnings} warning(s)` }) : null]), + sort: (r) => r.errors * 1000 + r.warnings, + }, + ], + { multi: false, rowHeight: 36, id: "quests" }, + ); + Q.table.keyOf = (r) => r.section; + Q.table.onSelect = (_s, r) => r && openQuest(r.section); + return Q.table; + } + + function fillClassFilter() { + const counts = {}; + for (const q of Q.data.quests) counts[q.cls] = (counts[q.cls] || 0) + 1; + const cur = $("q-class").value; + $("q-class").replaceChildren(el("option", { value: "", text: "All classes" }), ...Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([c, n]) => el("option", { value: c, text: `${c.replace(/^Quest/, "")} (${n})` }))); + $("q-class").value = cur; + } + + function showQuests({ keep = false } = {}) { + const t = qTable(); + t.setRows(Q.data.quests, { keepSelection: keep }); + filterQuests(); + } + + function filterQuests() { + const q = $("q-search").value.trim().toLowerCase(); + const cls = $("q-class").value; + const f = $("q-filter").value; + const now = new Date().toISOString().slice(0, 16).replace("T", " "); + qTable().setView((r) => { + if (cls && r.cls !== cls) return false; + if (f === "event" && !r.subs.some((s) => s.perform === 2)) return false; + if (f === "active" && !r.subs.some((s) => s.perform === 2 && s.start && s.end && s.start <= now && now < s.end)) return false; + if (f === "daily" && !r.subs.some((s) => s.oneday)) return false; + if (f === "repeat" && !r.subs.some((s) => s.repeat)) return false; + if (f === "problems" && !r.errors && !r.warnings) return false; + if (!q) return true; + return String(r.main) === q || r.section.toLowerCase().includes(q) || r.cls.toLowerCase().includes(q) || r.subs.some((s) => (s.title || "").toLowerCase().includes(q)); + }); + $("q-count").textContent = `${nf(qTable().view.length)} of ${nf(qTable().rows.length)}`; + } + + // ---------- quest editor ---------- + async function leaveDraft() { + if (!Q.draft?.dirty()) return true; + return confirmBox("Unsaved changes", `[${Q.draft.section}] has changes that aren't saved. Drop them?`, "Drop changes", "danger"); + } + + async function openQuest(section, { j = 1 } = {}) { + if (Q.draft && Q.draft.section !== section && !(await leaveDraft())) return; + let d; + try { + d = await call("q:quest", Q.src, Q.text, section); + } catch (err) { + return toast(err.message, true); + } + if (!d) return toast(`[${section}] isn't in the quest file.`, true); + Q.detail = d; + Q.draft = new Draft("quest", d.section, d); + Q.j = Math.min(j, Math.max(1, num(Q.draft.get("max_sub_quest")))); + renderQuest(); + } + + const clsSpec = (name) => Q.spec.classes.find((c) => c.name.toLowerCase() === String(name || "").toLowerCase()) || null; + + function renderQuest() { + const d = Q.draft; + const det = Q.detail || { subs: [], problems: [] }; + const box = $("q-detail"); + const maxSub = num(d.get("max_sub_quest")); + const j = Q.j; + const P = (k) => `sub${j}_${k}`; + const saveBtn = el("button", { class: "btn primary", text: d.create ? "Create" : "Save", disabled: !d.dirty(), onclick: () => saveQuest() }); + d.onChange = () => (saveBtn.disabled = !d.dirty()); + const cls = clsSpec(d.get("class_name")); + const clsSel = el("select"); + const names = Q.spec.classes.map((c) => c.name); + if (d.get("class_name") && !names.some((n) => n.toLowerCase() === d.get("class_name").toLowerCase())) names.unshift(d.get("class_name")); + for (const n of names) clsSel.append(el("option", { value: n, text: `${n.replace(/^Quest/, "")} — ${clsSpec(n)?.desc || "unknown class"}` })); + clsSel.value = names.find((n) => n.toLowerCase() === String(d.get("class_name") || "").toLowerCase()) || ""; + clsSel.addEventListener("change", () => { + d.set("class_name", clsSel.value); + renderQuest(); + }); + const subPick = + maxSub > 12 + ? el("select", { onchange: (e) => ((Q.j = Number(e.target.value)), renderQuest()) }, Array.from({ length: maxSub }, (_, i) => el("option", { value: i + 1, text: `Sub ${i + 1}: ${d.text(`sub${i + 1}_title`) || ""}`, selected: i + 1 === j }))) + : el("nav", { class: "seg q-subs" }, Array.from({ length: maxSub }, (_, i) => el("button", { class: i + 1 === j ? "on" : "", text: String(i + 1), title: d.text(`sub${i + 1}_title`), onclick: () => ((Q.j = i + 1), renderQuest()) }))); + const subDet = det.subs?.[j - 1] || {}; + const problems = (det.problems || []).filter((p) => !p.sub || p.sub === j); + + box.replaceChildren( + ...[ + el("div", { class: "it-head q-head" }, [ + iconCanvas(d.get(P("icon")), 56), + el("div", { class: "q-head-text" }, [ + el("h3", { text: d.text(P("title")) || "(no title)" }), + el("div", { class: "muted", text: `[${d.section}] · main ${d.get("main_index") || "?"} · code ${num(d.get("main_index")) * 10000 + j}${d.create ? " · new, not saved yet" : ""}` }), + ]), + el("div", { class: "q-head-actions" }, [ + saveBtn, + el("button", { class: "iconbtn only", title: "Revert", onclick: () => (d.create ? closeQuest() : openQuest(d.section, { j })) }, [icon("refresh")]), + el("button", { class: "iconbtn only", title: "Duplicate as a new quest", disabled: d.create, onclick: () => duplicateQuest() }, [icon("copy")]), + el("button", { class: "iconbtn only danger", title: "Delete this quest", disabled: d.create, onclick: () => deleteQuest() }, [icon("trash")]), + ]), + ]), + problems.length ? el("div", { class: "q-problems" }, problems.map((p) => el("div", { class: `q-problem ${p.sev}`, text: `${p.sub ? `Sub ${p.sub}: ` : ""}${p.msg}` }))) : null, + card("Quest", [ + row("Class", clsSel, "class_name — one class for every sub of the quest"), + cls ? el("small", { class: "muted q-note", text: cls.desc }) : null, + fRaw(d, "main_index", "Main index", { hint: "main_index: the quest id (1–65535), unique; its subs are one chain", onInput: () => renderHeadOnly() }), + row( + "Sub quests", + el("div", { class: "q-inline" }, [ + subPick, + el("button", { class: "iconbtn", title: "Add a sub quest after the last one (a copy of it, chained to it)", onclick: () => addSub() }, [icon("plus"), el("span", { text: "Add" })]), + el("button", { class: "iconbtn danger", title: "Remove the last sub quest", disabled: maxSub <= 1, onclick: () => removeSub() }, [icon("trash"), el("span", { text: "Remove last" })]), + ]), + ), + ]), + card(`Sub ${j}: texts`, [ + fText(d, P("title"), "Title"), + fText(d, P("progress"), "Progress text", { multiline: true }), + fText(d, P("progress_result"), "Counter suffix", { hint: "progress_result: shown after cur/max (e.g. 分鐘); empty = no counter" }), + iconField(d, P("icon"), "Icon"), + iconField(d, P("sub_icon"), "Small icon"), + ]), + goalCard(d, j, cls), + rewardsCard(d, j, subDet), + card("When it's offered", [ + fEnum(d, P("occur_value"), "Offered on", Q.spec.enums.occur, 0), + fRaw(d, P("occur_value_ex"), "Etc item type (occur 20)", { placeholder: -1 }), + fEnum(d, P("occur_mode"), "Only in mode", Q.spec.enums.mode, -1), + fEnum(d, P("occur_excepting_mode"), "Not in mode", Q.spec.enums.mode, -1), + fEnum(d, P("occur_room_style"), "Room", Q.spec.enums.room, -1), + fRaw(d, P("occur_limit_grade_min"), "From grade", { placeholder: -1 }), + fRaw(d, P("occur_limit_grade_max"), "Up to grade", { placeholder: -1 }), + chainRow(d, j, "prev", "Needs quest", subDet.prev), + fEnum(d, P("channeling_type"), "Channeling", Q.spec.enums.channeling, -1), + ]), + timingCard(d, j), + card("After completion / options", [ + chainRow(d, j, "next", "Then offer", subDet.next), + el("div", { class: "q-chips" }, [ + fBool(d, P("rewardselect_style"), "Player picks rewards"), + fBool(d, P("guild_style"), "Needs a guild"), + fBool(d, P("pcroom_style"), "PC room only"), + fBool(d, P("invited_pass"), "Auto-pass invites"), + fBool(d, P("user_mode_pass"), "Not in custom rooms"), + fBool(d, P("death_time_pass"), "Not while dead"), + fBool(d, P("complete_game_alarm"), "Announce completion"), + fBool(d, P("complete_web_alarm"), "Web board"), + fBool(d, P("costume"), "Costume mark"), + fBool(d, P("play_occur_show"), "Popup: offered (play)", 1), + fBool(d, P("play_complete_show"), "Popup: done (play)", 1), + fBool(d, P("round_end_occur_show"), "Popup: offered (round end)", 1), + fBool(d, P("round_end_complete_show"), "Popup: done (round end)", 1), + ]), + fRaw(d, P("rewardselect_num"), "Rewards to pick", { placeholder: 1 }), + fRaw(d, P("guild_terms_level"), "Min guild level", { placeholder: 0 }), + ]), + helpCard(d, j, subDet), + subDet.guides?.length + ? card( + "Guides", + subDet.guides.map((g) => el("div", { class: "q-guide mono", text: `[${g.section}] ${g.data.join(" → ")}` })), + ) + : null, + rawCard(d, `sub${j}_`, `All keys of sub ${j}`), + rawCard(d, "", "All keys of the quest", { exclude: /^sub\d+_/i }), + ].filter(Boolean), + ); + } + + function renderHeadOnly() { + const m = $("q-detail").querySelector(".q-head .muted"); + if (m && Q.draft) m.textContent = `[${Q.draft.section}] · main ${Q.draft.get("main_index") || "?"} · code ${num(Q.draft.get("main_index")) * 10000 + Q.j}${Q.draft.create ? " · new, not saved yet" : ""}`; + } + + function card(title, children, { open = true } = {}) { + return el("details", { class: "q-card", open }, [el("summary", { text: title }), el("div", { class: "q-card-body" }, children.filter(Boolean))]); + } + + function iconField(d, key, label) { + const holder = el("span", { class: "q-icon-prev" }, [iconCanvas(d.get(key), 28)]); + const input = el("input", { type: "text", spellcheck: "false", placeholder: "Set#Image" }); + input.value = d.get(key) || ""; + let t = 0; + input.addEventListener("input", () => { + d.set(key, input.value.trim()); + clearTimeout(t); + t = setTimeout(() => holder.replaceChildren(iconCanvas(input.value.trim(), 28)), 300); + }); + return row(label, el("div", { class: "q-inline" }, [holder, input]), key); + } + + function goalCard(d, j, cls) { + const P = (k) => `sub${j}_${k}`; + const labels = cls?.custom || []; + // how many custom values: the ones there (contiguous), at least the class's labels + let n = 0; + while (d.has(P(`custom_value${n + 1}`))) n++; + const list = cls?.list ? num(d.get(P(`custom_value${cls.list}`))) : 0; + const want = Math.max(n, labels.length, cls?.list ? cls.list + list : 0); + const rows = []; + for (let i = 1; i <= want; i++) { + let label = labels[i - 1] || (cls?.list && i > cls.list ? `Code ${i - cls.list}` : `Value ${i}`); + if (cls?.target === i) label += " (target)"; + rows.push(fRaw(d, P(`custom_value${i}`), label, { placeholder: "0", hint: `custom_value${i}`, onInput: () => {} })); + } + return card("Goal", [ + fEnum(d, P("complete_value"), "Completes on", Q.spec.enums.complete, 0, { onChange: () => {} }), + cls && cls.qc ? el("small", { class: "muted q-note", text: `This class normally uses ${cls.qc} (${Q.spec.enums.complete[cls.qc]}). Client and server must have the same value.` }) : null, + ...rows, + el("div", { class: "q-inline" }, [ + el("button", { class: "iconbtn", title: "Add the next custom value (they must stay contiguous)", onclick: () => (d.set(P(`custom_value${want + 1}`), "0"), renderQuest()) }, [icon("plus"), el("span", { text: "Add value" })]), + el("button", { class: "iconbtn", disabled: !want, title: "Remove the last custom value", onclick: () => (d.set(P(`custom_value${want}`), null), renderQuest()) }, [icon("trash"), el("span", { text: "Remove last" })]), + ]), + ]); + } + + function rewardsCard(d, j) { + const P = (k) => `sub${j}_${k}`; + const max = Math.min(num(d.get(P("max_reward"))), 5); + const rows = []; + for (let k = 1; k <= max; k++) { + const idx = num(d.get(P(`reward_present${k}`))); + const info = el("span", { class: "q-reward-info" }); + fillReward(info, idx); + const input = el("input", { type: "text", value: idx ? String(idx) : "", class: "q-num", spellcheck: "false" }); + input.addEventListener("change", () => { + d.set(P(`reward_present${k}`), input.value.trim()); + fillReward(info, num(input.value)); + }); + rows.push( + el("div", { class: "q-reward" }, [ + el("span", { class: "muted", text: `#${k}` }), + input, + info, + el("button", { + class: "iconbtn", + title: "Pick a reward", + onclick: async () => { + const r = await pickReward(); + if (r) { + input.value = String(r); + d.set(P(`reward_present${k}`), String(r)); + fillReward(info, r); + } + }, + }, [icon("search"), el("span", { text: "Pick" })]), + el("button", { class: "iconbtn only danger", title: "Remove this reward", onclick: () => removeReward(d, j, k) }, [icon("trash")]), + ]), + ); + } + return card(`Rewards (${max} of 5)`, [ + ...rows, + el("div", { class: "q-inline" }, [ + el("button", { + class: "iconbtn", + disabled: max >= 5, + onclick: async () => { + const r = await pickReward(); + if (!r) return; + d.set(P(`reward_present${max + 1}`), String(r)); + d.set(P("max_reward"), String(max + 1)); + renderQuest(); + }, + }, [icon("plus"), el("span", { text: "Add reward" })]), + el("span", { class: "muted", text: "Rewards are sp2_quest_present.ini entries (Rewards tab); the server gives them." }), + ]), + ]); + } + + function removeReward(d, j, k) { + const P = (x) => `sub${j}_${x}`; + const max = Math.min(num(d.get(P("max_reward"))), 5); + for (let i = k; i < max; i++) d.set(P(`reward_present${i}`), d.get(P(`reward_present${i + 1}`))); + d.set(P(`reward_present${max}`), null); + d.set(P("max_reward"), String(max - 1)); + renderQuest(); + } + + async function presentsList() { + if (!Q.presents) Q.presents = await call("q:presents", Q.src, Q.text); + return Q.presents; + } + + async function fillReward(node, idx) { + if (!idx) return node.replaceChildren(el("span", { class: "muted", text: "(none)" })); + const p = (await presentsList()).find((x) => x.index === idx); + if (!p) return node.replaceChildren(el("span", { class: "q-bad", text: `No reward ${idx}` })); + node.replaceChildren(iconCanvas(p.icon, 24), el("span", { text: p.label, title: `${p.typeName} · ${p.section} · value ${p.v1} / ${p.v2}${p.direct ? " · direct" : ""}` })); + } + + function chainRow(d, j, which, label, info) { + const P = (k) => `sub${j}_${which}_${k}`; + const main = el("input", { type: "text", class: "q-num", placeholder: "main", value: d.get(P("main_index")) || "" }); + const sub = el("input", { type: "text", class: "q-num", placeholder: "sub", value: d.get(P("sub_index")) || "" }); + main.addEventListener("input", () => d.set(P("main_index"), main.value.trim())); + sub.addEventListener("input", () => d.set(P("sub_index"), sub.value.trim())); + const link = info?.section + ? el("a", { class: "q-link", href: "#", text: `${info.title || "(no title)"} [${info.section}]`, onclick: (e) => (e.preventDefault(), openQuest(info.section, { j: info.sub })) }) + : info + ? el("span", { class: "q-bad", text: "doesn't exist" }) + : null; + return row(label, el("div", { class: "q-inline" }, [main, el("span", { class: "muted", text: "." }), sub, link]), `${which}_main_index / ${which}_sub_index`); + } + + function timingCard(d, j) { + const P = (k) => `sub${j}_${k}`; + const isEvent = num(d.get(P("perform_type")) || 0) === 2; + const dateRow = (which, label) => { + const y = num(d.get(P(`${which}_year`))); + const val = y ? `${y}-${String(num(d.get(P(`${which}_month`)))).padStart(2, "0")}-${String(num(d.get(P(`${which}_date`)))).padStart(2, "0")}` : ""; + const date = el("input", { type: "date", value: val }); + const hour = el("input", { type: "number", min: 0, max: 23, value: d.get(P(`${which}_hour`)) ?? "" }); + const put = () => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date.value); + d.set(P(`${which}_year`), m ? String(Number(m[1])) : null); + d.set(P(`${which}_month`), m ? String(Number(m[2])) : null); + d.set(P(`${which}_date`), m ? String(Number(m[3])) : null); + d.set(P(`${which}_hour`), m ? String(num(hour.value)) : null); + }; + date.addEventListener("change", put); + hour.addEventListener("change", put); + return row(label, el("div", { class: "q-inline" }, [date, hour, el("span", { class: "muted", text: ":00" })]), `${which}_year/month/date/hour`); + }; + return card("Type and timing", [ + fEnum(d, P("perform_type"), "Type", Q.spec.enums.perform, 1, { onChange: () => renderQuest() }), + isEvent ? dateRow("start", "Starts") : null, + isEvent ? dateRow("end", "Ends") : null, + isEvent ? el("small", { class: "muted q-note", text: "Only the server reads the dates: it turns the event on and off." }) : null, + el("div", { class: "q-chips" }, [fBool(d, P("oneday_style"), "Daily (reset every day)"), fBool(d, P("repeat_style"), "Repeatable")]), + ]); + } + + function helpCard(d, j, subDet) { + const P = (k) => `sub${j}_${k}`; + const helps = subDet.help || []; + return card("Help texts", [ + fRaw(d, P("help"), "Help index", { placeholder: 0, hint: "help: H = in progress, H+100000 = completed, H+200000 = objective (sp2_quest_help.ini)" }), + ...helps.map((h) => + el("div", { class: "q-help" }, [ + el("div", { class: "q-help-head" }, [ + el("b", { text: `${h.role} · ${h.index}` }), + h.section ? el("span", { class: "muted", text: ` [${h.section}]` }) : el("span", { class: "muted", text: " (none)" }), + h.section ? el("button", { class: "iconbtn", onclick: () => editHelp(h.section) }, [icon("edit"), el("span", { text: "Edit texts" })]) : null, + ]), + h.lines.length ? el("div", { class: "q-help-body" }, h.lines.map((l) => el("div", { class: "it-mline" }, runsEl(l.runs)))) : null, + ]), + ), + el("div", { class: "q-inline" }, [ + el("button", { + class: "iconbtn", + disabled: d.create, + title: "Copy these help texts to a new help index and point this sub quest at it", + onclick: () => newHelp(d, j, helps), + }, [icon("plus"), el("span", { text: helps.some((h) => h.section) ? "New help (copy of these)" : "New help" })]), + ]), + ]); + } + + /** Raw key/value grid of the draft's keys under a prefix (every key, also those the form doesn't show). */ + function rawCard(d, prefix, title, { exclude } = {}) { + const body = el("div", { class: "q-raw" }); + const draw = () => { + const rows = d.keys(prefix).filter((v) => !exclude || !exclude.test(v.key)); + body.replaceChildren( + ...rows.map((v) => { + const lk = v.key.toLowerCase(); + const isText = lk in d.texts; + const input = el("input", { type: "text", spellcheck: "false", value: isText ? d.texts[lk] : v.raw }); + input.addEventListener("change", () => (isText ? d.setText(v.key, input.value) : d.set(v.key, input.value.trim()))); + return el("div", { class: "q-raw-row" }, [el("code", { text: v.key, title: isText ? `${v.raw} (text)` : "" }), input, el("button", { class: "iconbtn only danger", title: "Remove the key", onclick: () => (d.set(v.key, null), draw()) }, [icon("trash")])]); + }), + el("div", { class: "q-raw-row" }, [ + el("button", { + class: "iconbtn", + onclick: async () => { + const k = await prompt("Add a key", `Key name${prefix ? ` (starts with ${prefix})` : ""}`, prefix); + if (!k) return; + d.set(k, "0"); + draw(); + }, + }, [icon("plus"), el("span", { text: "Add key" })]), + ]), + ); + }; + draw(); + return card(title, [body], { open: false }); + } + + // ---------- quest actions ---------- + async function saveQuest() { + const d = Q.draft; + const change = d.change(); + if (!change) return; + if (!change.values.class_name && d.create) return toast("Pick a class first.", true); + if (await save([change], { what: d.create ? `Created [${d.section}]` : `Saved [${d.section}]` })) { + Q.draft = null; + await refreshAfterSave(d.section); + } + } + + function closeQuest() { + Q.draft = null; + $("q-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a quest." })); + } + + function newQuestDraft(section, main, cls, title) { + const d = new Draft("quest", section, null, { create: true }); + const c = clsSpec(cls); + d.set("class_name", cls); + d.set("main_index", String(main)); + d.set("max_sub_quest", "1"); + d.set("sub1_perform_type", "1"); + d.set("sub1_channeling_type", "-1"); + d.set("sub1_occur_value", "1"); + d.set("sub1_complete_value", String(c?.qc || 0)); + d.set("sub1_max_reward", "0"); + d.set("sub1_help", "0"); + d.set("sub1_period_hour", "0"); + (c?.custom || []).forEach((label, i) => d.set(`sub1_custom_value${i + 1}`, /\(-1/.test(label) ? "-1" : "0")); + d.setText("sub1_title", title); + return d; + } + + async function newQuest() { + if (!(await leaveDraft())) return; + const next = await call("q:next", Q.src, Q.text); + const clsSel = el("select", {}, Q.spec.classes.map((c) => el("option", { value: c.name, text: `${c.name.replace(/^Quest/, "")} — ${c.desc}` }))); + clsSel.value = "QuestBasic"; + const title = el("input", { type: "text", spellcheck: "false", placeholder: "Title the players see" }); + const main = el("input", { type: "text", value: String(next.main) }); + const ok = await modal( + "New quest", + [el("label", { text: "Class (what the player has to do)" }), clsSel, el("label", { text: "Title" }), title, el("label", { text: `Main index (free: ${next.main})` }), main, el("p", { class: "muted", text: `Goes into [${next.questSection}] at the end of sp2_quest_info.ini. Nothing is written until you press Create.` })], + [{ label: "Cancel", value: false }, { label: "Next", kind: "primary", value: true }], + ); + if (!ok) return; + if (Q.data.quests.some((q) => q.main === num(main.value))) return toast(`Main index ${main.value} is already used.`, true); + Q.detail = { subs: [{}], problems: [] }; + Q.draft = newQuestDraft(next.questSection, num(main.value), clsSel.value, title.value.trim()); + Q.j = 1; + qTable().clearSelection(); + renderQuest(); + } + + async function duplicateQuest() { + if (!(await leaveDraft())) return; + const src = Q.draft; + const next = await call("q:next", Q.src, Q.text); + const d = new Draft("quest", next.questSection, null, { create: true }); + for (const v of src.values.values()) d.values.set(v.key.toLowerCase(), { ...v }); + for (const [k, t] of Object.entries(src.texts)) d.texts[k] = t; + d.set("main_index", String(next.main)); + // the copy's own chain: links inside the old main index move to the new one + const oldMain = src.get("main_index"); + for (const v of d.values.values()) if (/_(prev|next)_main_index$/i.test(v.key) && v.raw === oldMain) v.raw = String(next.main); + Q.detail = { subs: Q.detail.subs, problems: [] }; + Q.draft = d; + qTable().clearSelection(); + renderQuest(); + toast(`Copy made as [${d.section}] (main ${next.main}). Press Create to write it.`); + } + + async function deleteQuest() { + const d = Q.draft; + const ok = await confirmBox( + "Delete quest", + `Delete [${d.section}] (main ${d.get("main_index")}) from sp2_quest_info.ini? Players' progress on it is removed at their next login once the server has the new file. Its texts stay in the string table.`, + "Delete", + "danger", + ); + if (!ok) return; + if (await save([{ file: "quest", section: d.section, remove: true }], { what: `Deleted [${d.section}]` })) { + closeQuest(); + await refreshAfterSave(null); + } + } + + function addSub() { + const d = Q.draft; + const max = num(d.get("max_sub_quest")); + const from = `sub${max}_`; + const to = `sub${max + 1}_`; + for (const v of d.keys(from)) { + const k = to + v.key.slice(from.length); + if (v.key.toLowerCase() in d.texts) d.texts[k.toLowerCase()] = d.texts[v.key.toLowerCase()]; + d.values.set(k.toLowerCase(), { key: k, raw: /STR\(/i.test(v.raw) ? "STR(0)" : v.raw }); + } + // chained: the new sub needs the previous one + d.set(`${to}prev_main_index`, d.get("main_index")); + d.set(`${to}prev_sub_index`, String(max)); + d.set(`${from}next_main_index`, d.get("main_index")); + d.set(`${from}next_sub_index`, String(max + 1)); + d.set(`${to}next_main_index`, null); + d.set(`${to}next_sub_index`, null); + d.set("max_sub_quest", String(max + 1)); + Q.j = max + 1; + renderQuest(); + } + + async function removeSub() { + const d = Q.draft; + const max = num(d.get("max_sub_quest")); + if (max <= 1) return; + if (!(await confirmBox("Remove sub quest", `Remove sub ${max} (and all its keys)? Players who are on it lose that progress.`, "Remove", "danger"))) return; + for (const v of d.keys(`sub${max}_`)) { + delete d.texts[v.key.toLowerCase()]; + d.set(v.key, null); + } + d.set(`sub${max - 1}_next_main_index`, null); + d.set(`sub${max - 1}_next_sub_index`, null); + d.set("max_sub_quest", String(max - 1)); + Q.j = max - 1; + renderQuest(); + } + + // ---------- help texts ---------- + async function editHelp(section) { + const data = await call("q:section", Q.src, Q.text, "help", section); + if (!data) return; + const d = new Draft("help", section, data); + const lines = el("div", { class: "q-help-edit" }); + const max = num(d.get("max_line")); + for (let l = 1; l <= max; l++) { + const count = Math.max(num(d.get(`line${l}_count`)), 1); + for (let k = 1; k <= count; k++) { + const key = `line${l}_text${k}`; + const color = el("input", { type: "text", class: "q-color", value: d.get(`line${l}_color${k}`) || "", title: `line${l}_color${k}` }); + color.addEventListener("input", () => d.set(`line${l}_color${k}`, color.value.trim())); + const input = el("input", { type: "text", spellcheck: "false", value: d.text(key).trim() }); + input.addEventListener("input", () => d.setText(key, input.value || " ")); + lines.append(el("div", { class: "q-help-line" }, [el("span", { class: "muted", text: `${l}.${k}` }), color, input])); + } + } + const ok = await modal(`Help [${section}]`, [lines, el("p", { class: "muted", text: "Colors are 0xAARRGGBB. An empty line keeps a space so the layout stays." })], [{ label: "Cancel", value: false }, { label: "Save", kind: "primary", value: true }]); + if (!ok || !d.change()) return; + if (await save([d.change()], { what: `Saved help [${section}]` })) openQuest(Q.draft.section, { j: Q.j }); + } + + async function newHelp(d, j, helps) { + const next = await call("q:next", Q.src, Q.text); + const base = next.help; + const changes = []; + const offsets = [0, 100000, 200000]; + const template = helps.find((h) => h.section); + for (let i = 0; i < 3; i++) { + const srcSec = helps[i]?.section || (template && i === 0 ? template.section : null); + const section = `help${base + offsets[i]}`; + if (srcSec) { + const data = await call("q:section", Q.src, Q.text, "help", srcSec); + const c = new Draft("help", section, null, { create: true }); + for (const [k, v] of data.entries) c.values.set(k.toLowerCase(), { key: k, raw: v }); + for (const [k, t] of Object.entries(data.texts)) c.texts[k] = t; + c.set("index", String(base + offsets[i])); + changes.push(c.change()); + } else if (i < 2) { + const c = new Draft("help", section, null, { create: true }); + c.set("index", String(base + offsets[i])); + c.set("max_line", "1"); + c.set("line1_offsetx", "1"); + c.set("line1_offsety", "1"); + c.set("line1_size", "0.65f"); + c.set("line1_count", "1"); + c.set("line1_color1", "0xFF3A3A3A"); + c.setText("line1_text1", i === 0 ? d.text(`sub${j}_progress`) || "…" : "…"); + changes.push(c.change()); + } + } + d.set(`sub${j}_help`, String(base)); + const qc = d.change(); + if (!(await confirmBox("New help", `Create help ${base} (+100000${changes.length > 2 ? ", +200000" : ""}) and point sub ${j} at it? This also saves the quest's other changes.`, "Create"))) return; + if (await save([...changes, qc].filter(Boolean), { what: `Help ${base}` })) { + Q.draft = null; + await refreshAfterSave(d.section); + } + } + + // ---------- reward picker ---------- + async function pickReward() { + const list = await presentsList(); + const search = el("input", { type: "search", placeholder: "RewardIndex, name, type", spellcheck: "false" }); + const box = el("div", { class: "q-pick" }); + let chosen = null; + const draw = () => { + const q = search.value.trim().toLowerCase(); + const hits = list.filter((p) => !q || String(p.index) === q || p.label.toLowerCase().includes(q) || p.typeName.toLowerCase().includes(q)).slice(0, 300); + box.replaceChildren( + ...hits.map((p) => + el("div", { class: `q-pick-row${chosen === p.index ? " on" : ""}`, onclick: () => ((chosen = p.index), draw()), ondblclick: () => ((chosen = p.index), $("modal-actions").lastChild.click()) }, [iconCanvas(p.icon, 24), el("span", { class: "mono", text: String(p.index) }), el("span", { text: p.label }), el("span", { class: "muted", text: p.typeName })]), + ), + ); + }; + search.addEventListener("input", draw); + draw(); + const ok = await modal("Pick a reward", [search, box, el("p", { class: "muted", text: "New rewards are made in the Rewards tab." })], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]); + return ok ? chosen : null; + } + + // ---------- rewards tab ---------- + function rTable() { + if (Q.rtable) return Q.rtable; + Q.rtable = new VTable( + $("qr-table"), + [ + { key: "icon", label: "", width: 36, format: (r) => iconCanvas(r.icon, 26) }, + { key: "index", label: "Index", width: 70, cls: "num mono" }, + { key: "typeName", label: "Type", width: 110 }, + { key: "label", label: "Gives", grow: true, width: 240, title: true }, + { key: "v1", label: "Value 1", width: 90, cls: "num mono" }, + { key: "v2", label: "Value 2", width: 100, cls: "num mono" }, + { key: "used", label: "Used", width: 60, cls: "num", format: (r) => (r.used ? String(r.used) : "") }, + ], + { multi: false, rowHeight: 32, id: "q-rewards" }, + ); + Q.rtable.keyOf = (r) => r.index; + Q.rtable.onSelect = (_s, r) => r && openReward(r); + return Q.rtable; + } + + async function showRewards() { + const list = await presentsList(); + const types = {}; + for (const p of list) types[p.type] = p.typeName; + const cur = $("qr-type").value; + $("qr-type").replaceChildren(el("option", { value: "", text: "All types" }), ...Object.entries(Q.spec.presentTypes).map(([k, v]) => el("option", { value: k, text: `${k} · ${v.name}` }))); + $("qr-type").value = cur; + rTable().setRows(list, { keepSelection: true }); + filterRewards(); + } + + function filterRewards() { + const q = $("qr-search").value.trim().toLowerCase(); + const t = $("qr-type").value; + const unused = $("qr-unused").checked; + rTable().setView((p) => (!t || String(p.type) === t) && (!unused || !p.used) && (!q || String(p.index) === q || p.label.toLowerCase().includes(q) || p.section.toLowerCase().includes(q))); + $("qr-count").textContent = `${nf(rTable().view.length)} of ${nf(rTable().rows.length)}`; + } + + const PICK_CAT = { 1: "hero", 3: "etc", 5: "equip", 6: "equipbox", 9: "medal", 12: "costume", 15: "costumebox" }; + + async function openReward(p, { create = false, copyOf = null, draft = null } = {}) { + let d = draft; + if (d) { + // redraw with the same draft (the type changed) + } else if (create) { + const next = await call("q:next", Q.src, Q.text); + d = new Draft("present", next.presentSection, null, { create: true }); + const base = copyOf ? await call("q:section", Q.src, Q.text, "present", copyOf.section) : await call("q:section", Q.src, Q.text, "present", (await presentsList())[0]?.section); + for (const [k, v] of base?.entries || []) d.values.set(k.toLowerCase(), { key: k, raw: v }); + d.set("RewardIndex", String(next.rewardIndex)); + if (!copyOf) { + d.set("PresentType", "4"); + d.set("PresentValue1", "1000"); + d.set("PresentValue2", "0"); + } + } else d = new Draft("present", p.section, await call("q:section", Q.src, Q.text, "present", p.section)); + Q.rDraft = d; + const box = $("qr-detail"); + const label = el("span", { class: "q-reward-info" }); + const preview = async () => { + const r = await call("q:presentLabel", Q.src, Q.text, d.get("PresentType"), d.get("PresentValue1"), d.get("PresentValue2")); + label.replaceChildren(iconCanvas(r.icon, 32), el("b", { text: r.label })); + }; + const saveBtn = el("button", { class: "btn primary", text: d.create ? "Create" : "Save", disabled: !d.dirty(), onclick: () => saveReward() }); + d.onChange = () => { + saveBtn.disabled = !d.dirty(); + clearTimeout(Q.rPrev); + Q.rPrev = setTimeout(preview, 250); + }; + const type = num(d.get("PresentType")); + const T = Q.spec.presentTypes[type] || {}; + const v1 = fRaw(d, "PresentValue1", T.v1 || "Value 1"); + const pickBtn = PICK_CAT[type] + ? el("button", { + class: "iconbtn", + onclick: async () => { + const id = await pickItem(PICK_CAT[type], type === 1 ? (x) => x - 700000 : (x) => x); + if (id === null) return; + v1.querySelector("input").value = String(id); + d.set("PresentValue1", String(id)); + }, + }, [icon("search"), el("span", { text: "Pick item" })]) + : null; + box.replaceChildren( + el("div", { class: "it-head q-head" }, [ + el("div", { class: "q-head-text" }, [el("h3", { text: d.create ? "New reward" : `Reward ${d.get("RewardIndex")}` }), el("div", { class: "muted", text: `[${d.section}]${p?.used ? ` · used ${p.used}×` : ""}` })]), + el("div", { class: "q-head-actions" }, [ + saveBtn, + el("button", { class: "iconbtn only", title: "Duplicate as a new reward", disabled: d.create, onclick: () => openReward(null, { create: true, copyOf: p }) }, [icon("copy")]), + el("button", { class: "iconbtn only danger", title: "Delete", disabled: d.create, onclick: () => deleteReward(p) }, [icon("trash")]), + ]), + ]), + el("div", { class: "q-reward big" }, [label]), + card("Reward", [ + fRaw(d, "RewardIndex", "RewardIndex", { hint: "The id quests and missions point to (unique)" }), + fEnum(d, "PresentType", "Type", Object.fromEntries(Object.entries(Q.spec.presentTypes).map(([k, v]) => [k, v.name])), 4, { onChange: () => openReward(p, { draft: d }) }), + v1, + pickBtn ? row("", pickBtn) : null, + fRaw(d, "PresentValue2", T.v2 || "Value 2"), + el("div", { class: "q-chips" }, [fBool(d, "PresentDirect", "Straight into the inventory (types 1–6, 8–10)")]), + fRaw(d, "PresentPeriod", "Days in the gift box", { placeholder: 0 }), + fRaw(d, "PresentMent", "Gift message type", { placeholder: 0 }), + fRaw(d, "PresentAlarm", "Alarm (0 normal, 1 new)", { placeholder: 0 }), + row("Sender", el("span", { text: p?.sender || "(copied from the template)", class: "muted" }), "PresentSendID"), + ]), + rawCard(d, "", "All keys"), + ); + preview(); + } + + async function saveReward() { + const d = Q.rDraft; + const idx = num(d.get("RewardIndex")); + const list = await presentsList(); + if (list.some((x) => x.index === idx && x.section.toLowerCase() !== d.section.toLowerCase())) return toast(`RewardIndex ${idx} is already used.`, true); + const change = d.change(); + if (!change) return; + if (await save([change], { what: d.create ? `Created reward ${idx}` : `Saved reward ${idx}` })) { + Q.presents = null; + await showRewards(); + const p = (await presentsList()).find((x) => x.index === idx); + if (p) openReward(p); + } + } + + async function deleteReward(p) { + const ok = await confirmBox("Delete reward", `Delete reward ${p.index} [${p.section}]?${p.used ? ` ${p.used} quest(s) / mission(s) still give it: they'd give nothing.` : ""}`, "Delete", "danger"); + if (!ok) return; + if (await save([{ file: "present", section: p.section, remove: true }], { what: `Deleted reward ${p.index}` })) { + Q.presents = null; + $("qr-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a reward." })); + await showRewards(); + } + } + + async function pickItem(cat, toValue) { + if (!Q.pick) Q.pick = await call("q:pickLists", Q.src, Q.text); + const c = Q.pick.find((x) => x.id === cat); + if (!c) return null; + const search = el("input", { type: "search", placeholder: "Name or ID", spellcheck: "false" }); + const box = el("div", { class: "q-pick" }); + let chosen = null; + const draw = () => { + const q = search.value.trim().toLowerCase(); + const hits = c.items.filter((it) => it.name && (!q || String(it.id).includes(q) || it.name.toLowerCase().includes(q))).slice(0, 300); + box.replaceChildren( + ...hits.map((it) => el("div", { class: `q-pick-row${chosen === it.id ? " on" : ""}`, onclick: () => ((chosen = it.id), draw()), ondblclick: () => ((chosen = it.id), $("modal-actions").lastChild.click()) }, [iconCanvas(it.icon, 24), el("span", { class: "mono", text: String(it.id) }), el("span", { text: it.name })])), + ); + }; + search.addEventListener("input", draw); + draw(); + const ok = await modal(`Pick: ${c.label}`, [search, box], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]); + return ok && chosen !== null ? toValue(chosen) : null; + } + + // ---------- missions tab ---------- + function mTable() { + if (Q.mtable) return Q.mtable; + Q.mtable = new VTable( + $("qm-table"), + [ + { key: "section", label: "Section", width: 90, cls: "mono", sort: (r) => r.n }, + { key: "start", label: "Start", width: 80, cls: "mono" }, + { key: "end", label: "End", width: 80, cls: "mono" }, + { key: "count", label: "Missions", width: 70, cls: "num", format: (r) => String(r.missions.length), sort: (r) => r.missions.length }, + { key: "first", label: "Missions", grow: true, width: 240, format: (r) => r.missions.map((m) => m.titles[0].map((x) => x.text).join("")).join(" · "), title: true }, + ], + { multi: false, rowHeight: 30, id: "q-missions" }, + ); + Q.mtable.keyOf = (r) => r.section; + Q.mtable.onSelect = (_s, r) => r && openMission(r.section); + return Q.mtable; + } + + async function showMissions() { + const kind = Q.mKind; + if (!Q.missions[kind]) Q.missions[kind] = await call("q:missions", Q.src, Q.text, kind); + mTable().setRows(Q.missions[kind], { keepSelection: true }); + filterMissions(); + } + + function filterMissions() { + const q = $("qm-search").value.trim().toLowerCase(); + mTable().setView((r) => !q || r.section.toLowerCase().includes(q) || r.start.includes(q) || r.missions.some((m) => String(m.code) === q || m.titles.some((t) => t.some((x) => x.text.toLowerCase().includes(q))))); + $("qm-count").textContent = `${nf(mTable().view.length)} of ${nf(mTable().rows.length)}`; + } + + async function openMission(section, { draft = null } = {}) { + if (Q.mDraft?.dirty() && Q.mDraft.section !== section && !draft && !(await confirmBox("Unsaved changes", `[${Q.mDraft.section}] has unsaved changes. Drop them?`, "Drop changes", "danger"))) return; + const kind = Q.mKind; + const d = draft || new Draft(kind, section, await call("q:section", Q.src, Q.text, kind, section)); + Q.mDraft = d; + const info = Q.missions[kind]?.find((x) => x.section === section); + const saveBtn = el("button", { class: "btn primary", text: d.create ? "Create" : "Save", disabled: !d.dirty(), onclick: () => saveMission() }); + d.onChange = () => (saveBtn.disabled = !d.dirty()); + let count = 0; + while (num(d.get(`mission${count + 1}_code`))) count++; + const cards = []; + for (let k = 1; k <= count; k++) { + const M = (x) => `mission${k}_${x}`; + const type = num(d.get(M("type"))); + const mt = Q.spec.missionTypes[type]; + const rewardInfo = el("span", { class: "q-reward-info" }); + fillReward(rewardInfo, num(d.get(M("present")))); + const presentInput = fRaw(d, M("present"), "Reward (RewardIndex)", { onInput: (v) => fillReward(rewardInfo, num(v)) }); + const texts = d.keys(`mission${k}_`).filter((v) => /_(title\d_text\d|present_text\d)$/i.test(v.key)); + cards.push( + card(`Mission ${k} · ${info?.missions[k - 1]?.titles[0].map((x) => x.text).join("") || d.text(M("title1_text1")) || ""}`, [ + fRaw(d, M("code"), "Code", { hint: "Unique per file: progress is stored by code" }), + fEnum(d, M("type"), "Type", Object.fromEntries(Object.entries(Q.spec.missionTypes).map(([k2, v]) => [k2, v.name])), type, { onChange: () => openMission(section, { draft: d }) }), + fRaw(d, M("values"), "Values", { hint: mt ? `v1.v2.v3.v4: ${mt.values.join(" · ")}` : "v1.v2.v3.v4" }), + mt ? el("small", { class: "muted q-note", text: mt.values.map((v, i) => `v${i + 1} = ${v}`).join(" · ") }) : null, + presentInput, + row("", el("div", { class: "q-inline" }, [rewardInfo, el("button", { class: "iconbtn", onclick: async () => { const r = await pickReward(); if (r) { presentInput.querySelector("input").value = String(r); d.set(M("present"), String(r)); fillReward(rewardInfo, r); } } }, [icon("search"), el("span", { text: "Pick" })])])), + iconField(d, M("icon"), "Icon"), + ...texts.map((v) => fText(d, v.key, v.key.replace(/^mission\d+_/i, "").replace(/_/g, " "))), + ]), + ); + } + $("qm-detail").replaceChildren( + el("div", { class: "it-head q-head" }, [ + el("div", { class: "q-head-text" }, [el("h3", { text: `${Q.mKind[0].toUpperCase()}${Q.mKind.slice(1)} [${d.section}]` }), el("div", { class: "muted", text: `${count} mission(s)${d.create ? " · new, not saved yet" : ""}` })]), + el("div", { class: "q-head-actions" }, [saveBtn, el("button", { class: "iconbtn only danger", title: "Delete this date", disabled: d.create, onclick: () => deleteMission() }, [icon("trash")])]), + ]), + card("Date", [ + fRaw(d, "start_date", "Starts (YYMMDD)", { hint: "start_date, e.g. 251001; the server turns this date on at reset_hour" }), + Q.mKind === "weekly" ? fRaw(d, "end_date", "Ends (YYMMDD)") : null, + el("div", { class: "q-inline" }, [ + el("button", { class: "iconbtn", onclick: () => addMission(d, count) }, [icon("plus"), el("span", { text: "Add mission (copy of the last)" })]), + el("button", { class: "iconbtn danger", disabled: !count, onclick: () => removeMission(d, count) }, [icon("trash"), el("span", { text: "Remove last" })]), + ]), + ]), + ...cards, + rawCard(d, "", "All keys"), + ); + } + + async function addMission(d, count) { + const next = await call("q:next", Q.src, Q.text); + const from = `mission${count}_`; + const to = `mission${count + 1}_`; + const used = new Set([...d.values.values()].filter((v) => /_code$/i.test(v.key)).map((v) => num(v.raw))); + let code = next.missionCode[Q.mKind]; + while (used.has(code)) code++; + for (const v of d.keys(from)) { + const k = to + v.key.slice(from.length); + if (v.key.toLowerCase() in d.texts) d.texts[k.toLowerCase()] = d.texts[v.key.toLowerCase()]; + d.values.set(k.toLowerCase(), { key: k, raw: /STR\(/i.test(v.raw) ? "STR(0)" : v.raw }); + } + d.set(`${to}code`, String(code)); + openMission(d.section, { draft: d }); + } + + function removeMission(d, count) { + for (const v of d.keys(`mission${count}_`)) { + delete d.texts[v.key.toLowerCase()]; + d.set(v.key, null); + } + openMission(d.section, { draft: d }); + } + + async function newMissionDate() { + const sel = mTable().selected()[0] || Q.missions[Q.mKind]?.at(-1); + if (!sel) return; + const next = await call("q:next", Q.src, Q.text); + const data = await call("q:section", Q.src, Q.text, Q.mKind, sel.section); + const d = new Draft(Q.mKind, next.missionSection[Q.mKind], null, { create: true }); + for (const [k, v] of data.entries) d.values.set(k.toLowerCase(), { key: k, raw: v }); + for (const [k, t] of Object.entries(data.texts)) d.texts[k] = t; + // new codes for every mission + let code = next.missionCode[Q.mKind]; + for (const v of d.values.values()) if (/^mission\d+_code$/i.test(v.key)) v.raw = String(code++); + mTable().clearSelection(); + openMission(d.section, { draft: d }); + toast(`Copy of [${sel.section}] as [${d.section}] with new mission codes. Set its dates, then Create.`); + } + + async function saveMission() { + const d = Q.mDraft; + const change = d.change(); + if (!change) return; + if (await save([change], { what: `Saved ${Q.mKind} [${d.section}]` })) { + Q.mDraft = null; + delete Q.missions[Q.mKind]; + await showMissions(); + openMission(d.section); + } + } + + async function deleteMission() { + const d = Q.mDraft; + if (!(await confirmBox("Delete date", `Delete ${Q.mKind} [${d.section}]?`, "Delete", "danger"))) return; + if (await save([{ file: Q.mKind, section: d.section, remove: true }], { what: `Deleted [${d.section}]` })) { + Q.mDraft = null; + delete Q.missions[Q.mKind]; + $("qm-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a date." })); + await showMissions(); + } + } + + // ---------- checks tab ---------- + function cTable() { + if (Q.ctable) return Q.ctable; + Q.ctable = new VTable( + $("qc-table"), + [ + { key: "sev", label: "", width: 70, format: (r) => el("b", { class: `q-sev ${r.sev}`, text: r.sev }) }, + { key: "file", label: "File", width: 90 }, + { key: "section", label: "Section", width: 120, cls: "mono" }, + { key: "sub", label: "Sub", width: 50, cls: "num", format: (r) => (r.sub ? String(r.sub) : "") }, + { key: "msg", label: "Problem", grow: true, width: 400, title: true }, + ], + { multi: false, rowHeight: 28, id: "q-checks" }, + ); + Q.ctable.keyOf = (r) => `${r.file}|${r.section}|${r.sub}|${r.msg}`; + Q.ctable.onOpen = (r) => r && jump(r); + return Q.ctable; + } + + async function runChecks() { + $("qc-count").textContent = "Checking…"; + const list = await call("q:check", Q.src, Q.text); + cTable().setRows(list); + filterChecks(); + } + function filterChecks() { + const sev = $("qc-sev").value; + cTable().setView((r) => !sev || r.sev === sev); + const all = cTable().rows; + $("qc-count").textContent = `${nf(all.filter((r) => r.sev === "error").length)} errors · ${nf(all.filter((r) => r.sev === "warn").length)} warnings · double-click to open`; + } + function jump(r) { + if (r.file === "quest") { + subtab("quests"); + openQuest(r.section, { j: r.sub || 1 }); + } else if (r.file === "present") { + subtab("rewards"); + presentsList().then((l) => { + const p = l.find((x) => x.section.toLowerCase() === r.section.toLowerCase()); + if (p) openReward(p); + }); + } else { + Q.mKind = r.file; + for (const b of $("qm-kind").children) b.classList.toggle("on", b.dataset.kind === r.file); + subtab("missions"); + openMission(r.section); + } + } + + // ---------- wiring ---------- + function subtab(name) { + Q.sub = name; + for (const b of $("q-subtabs").children) b.classList.toggle("on", b.dataset.sub === name); + for (const s of document.querySelectorAll("#view-quests .q-sub")) s.classList.toggle("on", s.dataset.sub === name); + if (!Q.data) return; + if (name === "rewards") showRewards(); + if (name === "missions") showMissions(); + if (name === "checks" && !Q.ctable?.rows.length) runChecks(); + } + for (const b of $("q-subtabs").children) b.addEventListener("click", () => subtab(b.dataset.sub)); + for (const b of $("qm-kind").children) + b.addEventListener("click", () => { + Q.mKind = b.dataset.kind; + for (const x of $("qm-kind").children) x.classList.toggle("on", x === b); + Q.mDraft = null; + $("qm-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a date." })); + showMissions(); + }); + $("q-search").addEventListener("input", filterQuests); + $("q-class").addEventListener("change", filterQuests); + $("q-filter").addEventListener("change", filterQuests); + $("q-new").addEventListener("click", newQuest); + $("qr-search").addEventListener("input", filterRewards); + $("qr-type").addEventListener("change", filterRewards); + $("qr-unused").addEventListener("change", filterRewards); + $("qr-new").addEventListener("click", () => openReward(null, { create: true, copyOf: Q.rtable?.selected()[0] || null })); + $("qm-search").addEventListener("input", filterMissions); + $("qm-new").addEventListener("click", newMissionDate); + $("qc-run").addEventListener("click", runChecks); + $("qc-sev").addEventListener("change", filterChecks); + $("q-text").addEventListener("change", () => { + Q.text = $("q-text").value; + load(); + }); + $("q-reload").addEventListener("click", async () => { + if (!(await leaveDraft())) return; + Q.draft = null; + UIRes.reset(); + load({ reload: true }); + }); + $("q-folder").addEventListener("click", async () => { + const dir = await call("dialog:folder", "Game folder (with rs.iop / map.iop)"); + if (!dir) return; + Q.src = { kind: "packs", folder: dir, name: dir }; + Q.data = null; + Q.draft = null; + UIRes.reset(); + load(); + }); + $("q-export").addEventListener("click", async () => { + if (!Q.data) return; + const dir = await call("dialog:folder", "Folder to write the server's quest / reward / mission INIs into"); + if (!dir) return; + try { + const files = await call("q:export", Q.src, Q.text, dir); + toast(`Wrote ${files.join(", ")} — copy them into the game server's config folder, then reload quests on the server.`); + } catch (err) { + toast(err.message, true); + } + }); + document.querySelector('#tabs [data-view="quests"]').addEventListener("click", () => { + if (!Q.data) load(); + }); + window.addEventListener("profile-changed", () => { + Q.src = null; + Q.data = null; + Q.draft = null; + if ($("view-quests").classList.contains("on")) load(); + }); +})(); diff --git a/app/public/legacy/js/skin.js b/app/public/legacy/js/skin.js new file mode 100644 index 0000000..10bec67 --- /dev/null +++ b/app/public/legacy/js/skin.js @@ -0,0 +1,176 @@ +"use strict"; + +// Skeleton poses and CPU skinning, the io3DEngine way (D3D row vectors: v' = v · BipedSpaceTM · +// BoneWorld; keys replace the bone's local position / rotation; linked skeletons use the offset TM +// and bone-length ratio of the skeleton they link to). Used by the map editor for animated map +// objects; the model viewer (model.js) has the same maths inline. + +const SkinKit = (() => { + const M4 = { + fromQT(q, p) { + const [x, y, z, w] = q; + return new Float64Array([ + 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0, + 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0, + 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0, + p[0], p[1], p[2], 1, + ]); + }, + mul(a, b) { + const o = new Float64Array(16); + for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) o[r * 4 + c] = a[r * 4] * b[c] + a[r * 4 + 1] * b[4 + c] + a[r * 4 + 2] * b[8 + c] + a[r * 4 + 3] * b[12 + c]; + return o; + }, + /** Inverse of an affine row-vector matrix (rotation / scale part + translation row). */ + invertAffine(m) { + const a = m[0], b = m[1], c = m[2], d = m[4], e = m[5], f = m[6], g = m[8], h = m[9], i = m[10]; + const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + if (Math.abs(det) < 1e-12) return M4.fromQT([0, 0, 0, 1], [0, 0, 0]); + const k = 1 / det; + const r = new Float64Array(16); + r[0] = (e * i - f * h) * k; r[1] = (c * h - b * i) * k; r[2] = (b * f - c * e) * k; + r[4] = (f * g - d * i) * k; r[5] = (a * i - c * g) * k; r[6] = (c * d - a * f) * k; + r[8] = (d * h - e * g) * k; r[9] = (b * g - a * h) * k; r[10] = (a * e - b * d) * k; + const tx = m[12], ty = m[13], tz = m[14]; + r[12] = -(tx * r[0] + ty * r[4] + tz * r[8]); + r[13] = -(tx * r[1] + ty * r[5] + tz * r[9]); + r[14] = -(tx * r[2] + ty * r[6] + tz * r[10]); + r[15] = 1; + return r; + }, + }; + const qmul = (a, b) => [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + ]; + function qrot(q, v) { + const [x, y, z, w] = q; + const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0]; + const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux; + return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)]; + } + + /** A parsed .skl (LSF.parseSkl) → skeleton ready for posing. */ + function prepare(data) { + const n = data.bones.length; + const byName = new Map(); + data.bones.forEach((b, i) => byName.has(b.name.text.toLowerCase()) || byName.set(b.name.text.toLowerCase(), i)); + const parent = data.bones.map((b) => { + const p = b.parent.text.toLowerCase(); + return p === "noparent" || !byName.has(p) ? -1 : byName.get(p); + }); + const order = []; + const done = new Uint8Array(n); + const visit = (i, depth) => { + if (done[i] || depth > n) return; + if (parent[i] >= 0) visit(parent[i], depth + 1); + if (!done[i]) { + done[i] = 1; + order.push(i); + } + }; + for (let i = 0; i < n; i++) visit(i, 0); + return { data, byName, parent, order, bst: data.bones.map((b) => M4.fromQT(b.invRot, b.invPos)), rate: new Float64Array(n).fill(1), linked: data.linked || "" }; + } + + /** Applies the skeleton it links to (same bone count required, like the client). */ + function link(sk, other) { + if (!other || other.bones.length !== sk.data.bones.length) return false; + const rm = (a) => Float64Array.from(a); + sk.data.bones.forEach((b, i) => { + const o = other.bones[i]; + const offset = M4.mul(M4.invertAffine(rm(o.objectTM)), rm(b.objectTM)); + sk.bst[i] = M4.mul(offset, M4.fromQT(b.invRot, b.invPos)); + const l = Math.hypot(...o.localPos); + sk.rate[i] = l > 1e-6 ? Math.hypot(...b.localPos) / l : 1; + }); + return true; + } + + const mapTracks = (sk, ani) => Int32Array.from(ani.tracks, (t) => sk.byName.get(t.name.text.toLowerCase()) ?? -1); + const palette = (sk, meshData) => (meshData.boneNames ? Int32Array.from(meshData.boneNames, (b) => sk.byName.get(b.text.toLowerCase()) ?? -1) : null); + + /** Bone world transforms + skin matrices at t (ms). */ + function pose(sk, ani, map, t) { + const bones = sk.data.bones; + const n = bones.length; + const lp = bones.map((b) => b.localPos.slice()); + const lr = bones.map((b) => b.localRot.slice()); + if (ani) { + const p = [0, 0, 0]; + const q = [0, 0, 0, 1]; + ani.tracks.forEach((tr, ti) => { + const bi = map[ti]; + if (bi < 0 || tr.weight <= 0 || !LSF.sampleTrack(tr, t, p, q)) return; + const r = sk.rate[bi]; + lp[bi] = [p[0] * r, p[1] * r, p[2] * r]; + lr[bi] = q.slice(); + }); + } + const wp = new Array(n); + const wr = new Array(n); + for (const i of sk.order) { + const pi = sk.parent[i]; + if (pi < 0) { + wr[i] = lr[i]; + wp[i] = lp[i]; + } else { + wr[i] = qmul(wr[pi], lr[i]); + const v = qrot(wr[pi], lp[i]); + wp[i] = [v[0] + wp[pi][0], v[1] + wp[pi][1], v[2] + wp[pi][2]]; + } + } + const skin = new Array(n); + for (let i = 0; i < n; i++) skin[i] = M4.mul(sk.bst[i], M4.fromQT(wr[i] || [0, 0, 0, 1], wp[i] || [0, 0, 0])); + return { wp, wr, skin }; + } + + /** Skins a meshkit mesh (m.data, m.skinPos / m.skinNrm) with a palette into its buffers. */ + function skin(m, pal, ps) { + const d = m.data; + const V = d.vertexCount; + const P = d.pos; + const N = d.normal; + const out = m.skinPos; + const on = m.skinNrm; + for (let v = 0; v < V; v++) { + let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0, tw = 0; + const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2]; + const qx = N ? N[v * 3] : 0, qy = N ? N[v * 3 + 1] : 0, qz = N ? N[v * 3 + 2] : 0; + for (let j = 0; j < 4; j++) { + const w = d.weights[v * 4 + j]; + if (w <= 0) continue; + const bi = pal[d.boneIdx[v * 4 + j]]; + if (bi === undefined || bi < 0) continue; + const M = ps.skin[bi]; + x += w * (px * M[0] + py * M[4] + pz * M[8] + M[12]); + y += w * (px * M[1] + py * M[5] + pz * M[9] + M[13]); + z += w * (px * M[2] + py * M[6] + pz * M[10] + M[14]); + if (N) { + nx += w * (qx * M[0] + qy * M[4] + qz * M[8]); + ny += w * (qx * M[1] + qy * M[5] + qz * M[9]); + nz += w * (qx * M[2] + qy * M[6] + qz * M[10]); + } + tw += w; + } + if (tw <= 0) { + x = px; y = py; z = pz; nx = qx; ny = qy; nz = qz; + } else if (Math.abs(tw - 1) > 1e-3) { + x /= tw; y /= tw; z /= tw; + } + out[v * 3] = x; + out[v * 3 + 1] = y; + out[v * 3 + 2] = z; + if (on) { + on[v * 3] = nx; + on[v * 3 + 1] = ny; + on[v * 3 + 2] = nz; + } + } + } + + return { M4, prepare, link, mapTracks, palette, pose, skin }; +})(); +window.SkinKit = SkinKit; diff --git a/app/public/legacy/js/tables.js b/app/public/legacy/js/tables.js new file mode 100644 index 0000000..bef78f7 --- /dev/null +++ b/app/public/legacy/js/tables.js @@ -0,0 +1,768 @@ +"use strict"; + +// Tables tab (DAT Editor): the client's .dat tables (resource/tables in the packs) and the server's +// (ls_gamesvr\config\tables) as a spreadsheet, read and written by src/main/dattable.js. The columns +// come from the file and can't change (the game checks the header version); rows and values can. +// Grid: click / drag / shift to select, type or F2 / double-click to edit, Enter / Tab to move, +// Ctrl+C / Ctrl+V with Excel (tab-separated), Delete clears, Ctrl+Z / Ctrl+Y, Ctrl+S saves. + +(() => { + const T = { lists: { client: [], server: [] }, cur: null, meta: null, busy: false }; + window.DT = T; // (dev probes) + const LS = (k, v) => { + try { + if (v === undefined) return localStorage.getItem(k) || ""; + localStorage.setItem(k, v); + } catch {} + return ""; + }; + const RH = 26; + + // ---------- table list ---------- + const clientSrc = () => T.src || profileSource(); + + async function listTables() { + const src = clientSrc(); + const server = LS("dt:server"); + try { + T.lists.client = src ? await call("dat:client", src) : []; + } catch (err) { + T.lists.client = []; + toast(err.message, true); + } + T.lists.server = server ? await call("dat:folder", server) : []; + $("dt-source").textContent = [src ? `client: ${src.folder}` : "no game folder in this profile", server ? `server: ${server}` : "no server folder"].join(" · "); + renderList(); + } + + function renderList() { + const group = (title, items, kind, empty) => [ + el("div", { class: "dt-group", text: title }), + ...(items.length + ? items.map((it) => + el( + "button", + { + class: `dt-item${T.cur && T.cur.refKey === `${kind}|${it.key}` ? " on" : ""}`, + title: it.path, + onclick: () => openTable(kind === "client" ? { kind: "pack", src: clientSrc(), key: it.key } : { kind: "file", file: it.path }, `${kind}|${it.key}`), + }, + [el("span", { text: it.name.replace(/\.dat$/i, "") }), kind === "client" && it.pack ? el("small", { class: "muted", text: it.pack }) : null], + ), + ) + : [el("div", { class: "dt-none muted", text: empty })]), + ]; + const opened = T.cur && T.cur.ref.kind === "file" && !T.lists.server.some((x) => x.path === T.cur.ref.file) ? group("File", [{ key: T.cur.ref.file, name: T.cur.ref.file.split(/[\\/]/).pop(), path: T.cur.ref.file }], "file", "") : []; + $("dt-list").replaceChildren(...group("Client (packs)", T.lists.client, "client", "No resource/tables/*.dat in the packs."), ...group("Server", T.lists.server, "server", "Pick the server's tables folder."), ...opened); + } + + // ---------- opening ---------- + async function leave() { + if (!T.cur?.dirty()) return true; + return confirmBox("Unsaved changes", `${T.cur.name} has changes that aren't saved. Drop them?`, "Drop changes", "danger"); + } + + async function openTable(ref, refKey, { encoding = "auto" } = {}) { + if (T.cur && T.cur.refKey !== refKey && !(await leave())) return; + let d; + try { + d = await call("dat:open", ref, encoding); + } catch (err) { + return toast(err.message, true); + } + if (T.cur) call("dat:close", T.cur.id); + const folder = LS("dt:maker"); + let meta = null; + try { + meta = folder ? await call("dat:meta", folder, d.name) : null; + } catch {} + T.cur = new Sheet(d, ref, refKey, meta); + $("dt-enc").value = d.encoding; + $("dt-search").value = ""; + renderList(); + T.cur.mount($("dt-grid")); + status(); + } + + // ---------- the sheet ---------- + class Sheet { + constructor(d, ref, refKey, meta) { + this.id = d.id; + this.ref = ref; + this.refKey = refKey; + this.name = d.name; + this.info = d; + this.meta = meta; + this.cols = d.cols.map((c) => ({ ...c, meta: meta?.cols?.[c.name] || null, enums: meta?.enums?.[c.name] || null })); + this.setData(d.rows); + this.undo = []; + this.redo = []; + this.sort = null; + this.filter = ""; + this.act = { r: 0, c: 0 }; + this.anchor = { r: 0, c: 0 }; + this.widths = this.cols.map((c, i) => this.guessWidth(c, i)); + } + + setData(rows) { + this.orig = rows; + this.rows = rows.map((v, o) => ({ o, v: [...v] })); + this.view = this.rows.map((_, i) => i); + } + + guessWidth(c, i) { + let w = c.name.length * 7.5 + 26; + for (const r of this.rows.slice(0, 200)) w = Math.max(w, String(r.v[i]).length * 7 + 18); + return Math.round(Math.min(Math.max(w, 56), c.text ? 320 : 150)); + } + + dirty() { + if (this.rows.length !== this.orig.length) return true; + return this.rows.some((r, i) => r.o !== i || r.v.some((v, c) => v !== this.orig[i][c])); + } + + cellDirty(row, c) { + return row.o < 0 || row.v[c] !== this.orig[row.o]?.[c]; + } + + // ----- view ----- + applyView() { + const q = this.filter.toLowerCase(); + let idx = this.rows.map((_, i) => i); + if (q) idx = idx.filter((i) => this.rows[i].v.some((v) => String(v).toLowerCase().includes(q))); + if (this.sort) { + const { c, desc } = this.sort; + const num = !this.cols[c].text; + idx.sort((a, b) => { + const x = this.rows[a].v[c]; + const y = this.rows[b].v[c]; + const r = num ? Number(x) - Number(y) : String(x).localeCompare(String(y)); + return (desc ? -r : r) || a - b; + }); + } + this.view = idx; + this.act.r = Math.min(this.act.r, Math.max(0, idx.length - 1)); + this.anchor = { ...this.act }; + } + + // ----- DOM ----- + mount(host) { + this.host = host; + host.replaceChildren(); + host.tabIndex = 0; + this.head = el("div", { class: "dt-head" }); + this.body = el("div", { class: "dt-rows" }); + host.append(this.head, this.body); + host.onscroll = () => this.draw(); + host.onkeydown = (e) => this.key(e); + host.onmousedown = (e) => this.mouse(e); + host.ondblclick = (e) => { + const cell = e.target.closest(".dt-cell"); + if (cell) this.edit(); + }; + host.oncopy = (e) => this.copy(e); + host.onpaste = (e) => this.paste(e); + this.drawHead(); + this.layout(); + host.focus(); + } + + colLeft(c) { + let x = 56; + for (let i = 0; i < c; i++) x += this.widths[i]; + return x; + } + + layout() { + const total = this.colLeft(this.cols.length); + this.head.style.width = `${total}px`; + this.body.style.width = `${total}px`; + this.body.style.height = `${this.view.length * RH}px`; + this.draw(); + } + + drawHead() { + const cells = [el("div", { class: "dt-h dt-rn", text: "#", style: "width:56px" })]; + this.cols.forEach((c, i) => { + const tip = [`${c.name} — ${c.typeName}`, c.meta?.desc, c.meta?.export ? `used by: ${c.meta.export}` : null, c.enums ? `enum: ${c.enums.map((e) => `${e.value} ${e.name}`).join(", ")}` : null].filter(Boolean).join("\n"); + const arrow = this.sort?.c === i ? (this.sort.desc ? " ▼" : " ▲") : ""; + const h = el("div", { class: "dt-h", title: tip, style: `width:${this.widths[i]}px` }, [ + el("b", { text: c.name + arrow }), + el("small", { text: c.meta?.desc ? `${c.typeName} · ${c.meta.desc}` : c.typeName }), + el("i", { class: "dt-resize", onmousedown: (e) => this.resize(e, i) }), + ]); + h.addEventListener("click", (e) => { + if (e.target.classList.contains("dt-resize")) return; + this.sort = this.sort?.c !== i ? { c: i, desc: false } : this.sort.desc ? null : { c: i, desc: true }; + this.applyView(); + this.drawHead(); + this.layout(); + status(); + }); + cells.push(h); + }); + this.head.replaceChildren(...cells); + } + + resize(e, i) { + e.preventDefault(); + e.stopPropagation(); + const x0 = e.clientX; + const w0 = this.widths[i]; + const move = (ev) => { + this.widths[i] = Math.max(40, w0 + ev.clientX - x0); + this.drawHead(); + this.layout(); + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + } + + sel() { + return { r0: Math.min(this.act.r, this.anchor.r), r1: Math.max(this.act.r, this.anchor.r), c0: Math.min(this.act.c, this.anchor.c), c1: Math.max(this.act.c, this.anchor.c) }; + } + + display(c, v) { + if (c.enums && v !== "") { + const e = c.enums.find((x) => String(x.value) === String(v)); + if (e) return [el("span", { text: v }), el("small", { class: "dt-enum", text: e.name })]; + } + return [document.createTextNode(v)]; + } + + draw() { + if (!this.host) return; + const top = this.host.scrollTop - 34; + const h = this.host.clientHeight; + const first = Math.max(0, Math.floor(top / RH) - 4); + const last = Math.min(this.view.length, Math.ceil((top + h) / RH) + 4); + const s = this.sel(); + const frag = document.createDocumentFragment(); + for (let vr = first; vr < last; vr++) { + const row = this.rows[this.view[vr]]; + const line = el("div", { class: `dt-row${row.o < 0 ? " new" : ""}`, style: `top:${vr * RH}px` }); + line.append(el("div", { class: "dt-cell dt-rn", text: String(this.view[vr] + 1), style: "width:56px", "data-r": vr, "data-c": -1 })); + this.cols.forEach((c, ci) => { + const inSel = vr >= s.r0 && vr <= s.r1 && ci >= s.c0 && ci <= s.c1; + const cls = ["dt-cell", c.text ? "txt" : "num", this.cellDirty(row, ci) ? "dirty" : "", inSel ? "sel" : "", vr === this.act.r && ci === this.act.c ? "act" : ""].filter(Boolean).join(" "); + line.append(el("div", { class: cls, style: `width:${this.widths[ci]}px`, "data-r": vr, "data-c": ci }, this.display(c, row.v[ci]))); + }); + frag.append(line); + } + this.body.replaceChildren(frag); + } + + scrollTo(vr, c) { + const y = vr * RH; + const hh = 34; + if (y < this.host.scrollTop) this.host.scrollTop = y; + else if (y + RH > this.host.scrollTop + this.host.clientHeight - hh) this.host.scrollTop = y + RH - this.host.clientHeight + hh; + if (c >= 0) { + const x = this.colLeft(c); + const w = this.widths[c]; + if (x - 56 < this.host.scrollLeft) this.host.scrollLeft = x - 56; + else if (x + w > this.host.scrollLeft + this.host.clientWidth) this.host.scrollLeft = x + w - this.host.clientWidth; + } + } + + // ----- selection ----- + cellAt(e) { + const cell = e.target.closest?.(".dt-cell"); + if (!cell) return null; + return { r: Number(cell.dataset.r), c: Number(cell.dataset.c) }; + } + + mouse(e) { + if (e.button !== 0 || e.target.closest(".dt-h") || e.target.closest(".dt-editor")) return; + const at = this.cellAt(e); + if (!at) return; + this.commitEdit(); + if (at.c < 0) { + // row number: whole rows + if (!e.shiftKey) this.anchor = { r: at.r, c: 0 }; + this.act = { r: at.r, c: this.cols.length - 1 }; + this.anchor.c = 0; + } else { + this.act = at; + if (!e.shiftKey) this.anchor = { ...at }; + } + this.draw(); + status(); + const move = (ev) => { + const t = document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.(".dt-cell"); + if (!t || !this.host.contains(t)) return; + const r = Number(t.dataset.r); + const c = Number(t.dataset.c); + if (r === this.act.r && (c === this.act.c || c < 0)) return; + this.act = { r, c: c < 0 ? this.act.c : c }; + this.draw(); + status(); + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + } + + move(dr, dc, extend) { + this.act = { r: Math.max(0, Math.min(this.view.length - 1, this.act.r + dr)), c: Math.max(0, Math.min(this.cols.length - 1, this.act.c + dc)) }; + if (!extend) this.anchor = { ...this.act }; + this.scrollTo(this.act.r, this.act.c); + this.draw(); + status(); + } + + key(e) { + if (this.editor) return; + const ctrl = e.ctrlKey || e.metaKey; + const k = e.key; + const page = Math.max(1, Math.floor(this.host.clientHeight / RH) - 2); + if (k === "ArrowDown") this.move(ctrl ? this.view.length : 1, 0, e.shiftKey); + else if (k === "ArrowUp") this.move(ctrl ? -this.view.length : -1, 0, e.shiftKey); + else if (k === "ArrowRight") this.move(0, ctrl ? this.cols.length : 1, e.shiftKey); + else if (k === "ArrowLeft") this.move(0, ctrl ? -this.cols.length : -1, e.shiftKey); + else if (k === "PageDown") this.move(page, 0, e.shiftKey); + else if (k === "PageUp") this.move(-page, 0, e.shiftKey); + else if (k === "Home") this.move(ctrl ? -this.view.length : 0, -this.cols.length, e.shiftKey); + else if (k === "End") this.move(ctrl ? this.view.length : 0, this.cols.length, e.shiftKey); + else if (k === "Tab") this.move(0, e.shiftKey ? -1 : 1, false); + else if (k === "Enter" && ctrl) addRow(); + else if (k === "Enter" || k === "F2") this.edit(); + else if (k === "Delete" && ctrl) deleteRows(); + else if (k === "Delete" || k === "Backspace") this.clear(); + else if (ctrl && k.toLowerCase() === "a") { + this.anchor = { r: 0, c: 0 }; + this.act = { r: this.view.length - 1, c: this.cols.length - 1 }; + this.draw(); + status(); + } else if (ctrl && k.toLowerCase() === "z") this.doUndo(); + else if (ctrl && k.toLowerCase() === "y") this.doRedo(); + else if (ctrl && k.toLowerCase() === "s") save(); + else if (ctrl && k.toLowerCase() === "d") duplicateRows(); + else if (ctrl && k.toLowerCase() === "f") $("dt-search").focus(); + else if (!ctrl && !e.altKey && k.length === 1) { + this.edit(k); + e.preventDefault(); + return; + } else return; + e.preventDefault(); + } + + // ----- editing ----- + edit(start) { + if (!this.view.length) return; + const vr = this.act.r; + const c = this.act.c; + const row = this.rows[this.view[vr]]; + this.scrollTo(vr, c); + const col = this.cols[c]; + const input = el("input", { class: "dt-editor", type: "text", spellcheck: "false" }); + input.value = start ?? row.v[c]; + input.style.left = `${this.colLeft(c)}px`; + input.style.top = `${vr * RH}px`; + input.style.width = `${Math.max(this.widths[c], col.text ? 260 : 120)}px`; + if (col.enums) input.setAttribute("list", this.enumList(col)); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.commitEdit(); + this.move(e.shiftKey ? -1 : 1, 0, false); + this.host.focus(); + } else if (e.key === "Tab") { + e.preventDefault(); + this.commitEdit(); + this.move(0, e.shiftKey ? -1 : 1, false); + this.host.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.cancelEdit(); + } + e.stopPropagation(); + }); + input.addEventListener("blur", () => this.commitEdit()); + this.body.append(input); + this.editor = { input, row, c }; + input.focus(); + if (start === undefined) input.select(); + } + + enumList(col) { + const id = `dt-enum-${col.name}`; + if (!document.getElementById(id)) document.body.append(el("datalist", { id }, col.enums.map((e) => el("option", { value: String(e.value), label: e.name })))); + return id; + } + + commitEdit() { + const ed = this.editor; + if (!ed) return; + this.editor = null; + let v = ed.input.value; + const col = this.cols[ed.c]; + // an enum can be typed by its name + if (col.enums && !/^-?\d+$/.test(v.trim())) { + const e = col.enums.find((x) => x.name.toLowerCase() === v.trim().toLowerCase()); + if (e) v = String(e.value); + } + if (!col.text) v = v.trim(); + ed.input.remove(); + if (v !== ed.row.v[ed.c]) this.change([{ row: ed.row, c: ed.c, v }]); + else this.draw(); + } + + cancelEdit() { + const ed = this.editor; + if (!ed) return; + this.editor = null; + ed.input.remove(); + this.host.focus(); + this.draw(); + } + + /** cell changes [{ row, c, v }] as one undo step */ + change(list) { + const step = { cells: list.map((x) => ({ row: x.row, c: x.c, old: x.row.v[x.c], v: x.v })) }; + for (const x of step.cells) x.row.v[x.c] = x.v; + this.undo.push(step); + this.redo = []; + this.draw(); + status(); + } + + /** a row operation: the rows array before and after */ + rowsStep(after) { + this.undo.push({ before: this.rows, after }); + this.redo = []; + this.rows = after; + this.applyView(); + this.layout(); + status(); + } + + doUndo() { + const s = this.undo.pop(); + if (!s) return; + if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.old; + else { + this.rows = s.before; + this.applyView(); + this.layout(); + } + this.redo.push(s); + this.draw(); + status(); + } + + doRedo() { + const s = this.redo.pop(); + if (!s) return; + if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.v; + else { + this.rows = s.after; + this.applyView(); + this.layout(); + } + this.undo.push(s); + this.draw(); + status(); + } + + clear() { + const s = this.sel(); + const list = []; + for (let vr = s.r0; vr <= s.r1; vr++) for (let c = s.c0; c <= s.c1; c++) list.push({ row: this.rows[this.view[vr]], c, v: this.cols[c].text ? "" : "0" }); + this.change(list); + } + + // ----- clipboard (tab-separated, like Excel) ----- + copy(e) { + if (this.editor) return; + const s = this.sel(); + const lines = []; + for (let vr = s.r0; vr <= s.r1; vr++) { + const row = this.rows[this.view[vr]]; + lines.push(row.v.slice(s.c0, s.c1 + 1).map((v) => (/[\t\n"]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v)).join("\t")); + } + e.clipboardData.setData("text/plain", lines.join("\r\n")); + e.preventDefault(); + toast(`Copied ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} cells.`); + } + + paste(e) { + if (this.editor) return; + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + const grid = parseTsv(text); + if (!grid.length) return; + const s = this.sel(); + // one value into a bigger selection fills it; otherwise the block starts at the top-left cell + const fill = grid.length === 1 && grid[0].length === 1 && (s.r1 > s.r0 || s.c1 > s.c0); + const list = []; + let after = null; + const need = fill ? 0 : s.r0 + grid.length - this.view.length; + if (need > 0) { + // more rows than there are below: new rows at the end + after = [...this.rows]; + for (let i = 0; i < need; i++) after.push({ o: -1, v: this.cols.map((c) => (c.text ? "" : "0")) }); + this.rows = after; + this.applyView(); + } + const rowsR = fill ? s.r1 - s.r0 + 1 : grid.length; + const colsR = fill ? s.c1 - s.c0 + 1 : Math.min(grid[0].length, this.cols.length - s.c0); + for (let i = 0; i < rowsR; i++) { + const row = this.rows[this.view[s.r0 + i]]; + if (!row) break; + for (let j = 0; j < colsR; j++) { + const c = s.c0 + j; + if (c >= this.cols.length) break; + const v = fill ? grid[0][0] : grid[i][j] ?? ""; + list.push({ row, c, v: this.cols[c].text ? v : v.trim() }); + } + } + if (after) { + // rows added and filled: one undo step for both + const before = this.rows.slice(0, this.rows.length - need); + for (const x of list) x.row.v[x.c] = x.v; + this.undo.push({ before, after: this.rows }); + this.redo = []; + this.layout(); + } else this.change(list); + this.anchor = { r: s.r0, c: s.c0 }; + this.act = { r: s.r0 + rowsR - 1, c: s.c0 + colsR - 1 }; + this.draw(); + status(); + toast(`Pasted ${rowsR} × ${colsR} cells${need > 0 ? ` (${need} new rows)` : ""}.`); + } + + selectedRows() { + const s = this.sel(); + return this.view.slice(s.r0, s.r1 + 1); + } + } + + function parseTsv(text) { + const rows = []; + let row = []; + let cell = ""; + let q = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (q) { + if (ch === '"' && text[i + 1] === '"') (cell += '"'), i++; + else if (ch === '"') q = false; + else cell += ch; + } else if (ch === '"' && cell === "") q = true; + else if (ch === "\t") row.push(cell), (cell = ""); + else if (ch === "\n") row.push(cell.replace(/\r$/, "")), rows.push(row), (row = []), (cell = ""); + else cell += ch; + } + if (cell !== "" || row.length) row.push(cell.replace(/\r$/, "")), rows.push(row); + return rows; + } + + // ---------- row commands ---------- + function addRow() { + const S = T.cur; + if (!S) return; + const at = S.view.length ? S.view[S.sel().r1] + 1 : S.rows.length; + const blank = { o: -1, v: S.cols.map((c) => (c.text ? "" : "0")) }; + // a new key: the first column one above the largest when it's a number + const key = S.cols[0]; + if (key && !key.text) blank.v[0] = String(Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1); + const after = [...S.rows.slice(0, at), blank, ...S.rows.slice(at)]; + S.rowsStep(after); + const vr = S.view.indexOf(at); + if (vr >= 0) { + S.act = { r: vr, c: 0 }; + S.anchor = { ...S.act }; + S.scrollTo(vr, 0); + S.draw(); + } + } + + function duplicateRows() { + const S = T.cur; + if (!S || !S.view.length) return; + const pick = S.selectedRows().sort((a, b) => a - b); + const at = pick[pick.length - 1] + 1; + let next = Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1; + const copies = pick.map((i) => { + const v = [...S.rows[i].v]; + if (!S.cols[0].text) v[0] = String(next++); + return { o: -1, v }; + }); + S.rowsStep([...S.rows.slice(0, at), ...copies, ...S.rows.slice(at)]); + toast(`Duplicated ${copies.length} row(s)${!S.cols[0].text ? ` with new ${S.cols[0].name} values` : ""}.`); + } + + async function deleteRows() { + const S = T.cur; + if (!S || !S.view.length) return; + const pick = new Set(S.selectedRows()); + if (pick.size > 1 && !(await confirmBox("Delete rows", `Delete ${pick.size} rows?`, "Delete", "danger"))) return; + S.rowsStep(S.rows.filter((_, i) => !pick.has(i))); + } + + // ---------- save / import / export ---------- + async function save() { + const S = T.cur; + if (!S || T.busy || !S.dirty()) return; + S.commitEdit(); + T.busy = true; + try { + const enums = Object.fromEntries(S.cols.filter((c) => c.enums).map((c) => [c.name, c.enums])); + const r = await call( + "dat:save", + S.id, + S.rows.map((x) => ({ o: x.o, v: x.v })), + { enums }, + ); + S.info = r; + S.setData(r.rows); + S.undo = []; + S.redo = []; + S.applyView(); + S.layout(); + toast(`Saved ${S.name}: ${r.rows.length} rows${r.backup ? ` (backup: ${r.backup.split(/[\\/]/).pop()})` : ""}.`); + } catch (err) { + toast(err.message, true); + } finally { + T.busy = false; + status(); + } + } + + async function exportTable() { + const S = T.cur; + if (!S) return; + const file = await call("dialog:saveAs", `${S.name}.xlsx`, "xlsx"); + if (!file) return; + try { + const rows = S.rows.map((r) => r.v); + const out = /\.csv$/i.test(file) ? await call("dat:exportCsv", S.id, rows, file) : await call("dat:exportXlsx", S.id, rows, file); + toast(`Exported ${rows.length} rows to ${out}`); + } catch (err) { + toast(err.message, true); + } + } + + async function importTable() { + const S = T.cur; + if (!S) return; + const file = await call("dat:pickFile", "Import rows from .xlsx / .csv", "sheet"); + if (!file) return; + let r; + try { + r = await call("dat:import", S.id, file); + } catch (err) { + return toast(err.message, true); + } + const notes = [r.missing.length ? `Columns not in the file (left empty / 0): ${r.missing.join(", ")}.` : null, r.extra.length ? `Columns in the file that the table doesn't have (ignored): ${r.extra.join(", ")}.` : null].filter(Boolean); + const ok = await confirmBox("Import", [`Replace the ${S.rows.length} rows of ${S.name} with the ${r.rows.length} rows of ${file.split(/[\\/]/).pop()}? You can undo it, and nothing is written until you Save.`, ...notes].join("\n\n"), "Replace"); + if (!ok) return; + // rows that match an existing key keep their original (so unchanged cells stay byte-identical) + const byKey = new Map(S.rows.map((x) => [x.v[0], x.o])); + S.rowsStep(r.rows.map((v) => ({ o: byKey.has(v[0]) ? byKey.get(v[0]) : -1, v: v.map((x, i) => (S.cols[i].text ? x : x.trim() || "0")) }))); + } + + // ---------- status ---------- + function status() { + const S = T.cur; + $("dt-save").disabled = !S || !S.dirty() || T.busy; + $("dt-undo").disabled = !S?.undo.length; + $("dt-redo").disabled = !S?.redo.length; + for (const id of ["dt-add", "dt-dup", "dt-del", "dt-import", "dt-export", "dt-enc", "dt-search"]) $(id).disabled = !S; + if (!S) return ($("dt-status").textContent = ""); + const s = S.sel(); + const changed = S.rows.filter((r) => r.o < 0 || r.v.some((v, c) => v !== S.orig[r.o]?.[c])).length; + const col = S.cols[S.act.c]; + const row = S.rows[S.view[S.act.r]]; + $("dt-status").textContent = [ + `${S.name} · ${S.info.label}`, + `${S.rows.length} rows${S.view.length !== S.rows.length ? ` (${S.view.length} shown)` : ""} × ${S.cols.length} columns · ${S.info.rowSize} bytes/row · header ${S.info.headerVersion}`, + changed || S.rows.length !== S.orig.length ? `${changed} changed row(s)${S.rows.length !== S.orig.length ? `, ${S.rows.length - S.orig.length >= 0 ? "+" : ""}${S.rows.length - S.orig.length} rows` : ""}` : "no changes", + col && row ? `${col.name} (${col.typeName})${col.meta?.desc ? ` — ${col.meta.desc}` : ""}${s.r1 > s.r0 || s.c1 > s.c0 ? ` · ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} selected` : ""}` : "", + S.meta?.source ? `source: ${S.meta.source}` : "", + ] + .filter(Boolean) + .join(" · "); + } + + // ---------- wiring ---------- + $("dt-add").addEventListener("click", addRow); + $("dt-dup").addEventListener("click", duplicateRows); + $("dt-del").addEventListener("click", deleteRows); + $("dt-undo").addEventListener("click", () => T.cur?.doUndo()); + $("dt-redo").addEventListener("click", () => T.cur?.doRedo()); + $("dt-save").addEventListener("click", save); + $("dt-export").addEventListener("click", exportTable); + $("dt-import").addEventListener("click", importTable); + $("dt-search").addEventListener("input", () => { + if (!T.cur) return; + T.cur.filter = $("dt-search").value.trim(); + T.cur.applyView(); + T.cur.layout(); + status(); + }); + $("dt-enc").addEventListener("change", async () => { + const S = T.cur; + if (!S) return; + if (S.dirty() && !(await confirmBox("Code page", "Reading the texts in another code page drops the unsaved changes.", "Switch", "danger"))) { + $("dt-enc").value = S.info.encoding; + return; + } + const d = await call("dat:encoding", S.id, $("dt-enc").value); + S.info = { ...S.info, ...d }; + S.setData(d.rows); + S.undo = []; + S.redo = []; + S.applyView(); + S.layout(); + status(); + }); + $("dt-client").addEventListener("click", async () => { + const dir = await call("dialog:folder", "Game folder (with the .iop packs)"); + if (!dir) return; + T.src = { kind: "packs", folder: dir, name: dir }; + listTables(); + }); + $("dt-server").addEventListener("click", async () => { + const dir = await call("dialog:folder", "The game server's tables folder (ls_gamesvr\\config\\tables)"); + if (!dir) return; + LS("dt:server", dir); + listTables(); + }); + $("dt-file").addEventListener("click", async () => { + const file = await call("dat:pickFile", "Open a .dat table", "dat"); + if (file) openTable({ kind: "file", file }, `file|${file}`); + }); + $("dt-maker").addEventListener("click", async () => { + const dir = await call("dialog:folder", "ioDataMaker folder (with Config.xlsx and ExcelData)"); + if (!dir) return; + LS("dt:maker", dir); + toast("Column descriptions and enum names come from there now (reopen the table)."); + }); + $("dt-reload").addEventListener("click", listTables); + window.addEventListener("beforeunload", () => T.cur && call("dat:close", T.cur.id)); + document.querySelector('#tabs [data-view="tables"]').addEventListener("click", () => { + if (!T.listed) { + T.listed = true; + listTables(); + status(); + } + }); + window.addEventListener("profile-changed", () => { + T.listed = false; + T.src = null; + if ($("view-tables").classList.contains("on")) { + T.listed = true; + listTables(); + } + }); +})(); diff --git a/app/public/legacy/js/texkit.js b/app/public/legacy/js/texkit.js new file mode 100644 index 0000000..e23d6ab --- /dev/null +++ b/app/public/legacy/js/texkit.js @@ -0,0 +1,443 @@ +"use strict"; + +// Texture Studio maths (Texture tab): selections on RGBA pixels (magic wand, colour range, lasso, +// rectangle), recolouring a selection, resizing, and .dds writing the way the game's textures are +// stored (DXT1 / DXT3 / DXT5 or A8R8G8B8, with mip maps). Pure functions on Uint8(Clamped)Arrays, +// no DOM: the Texture tab draws, this computes. + +const TexKit = (() => { + // ===================================================================== + // selections: a mask of w × h bytes (0 = out, 255 = in, between = feathered) + // ===================================================================== + const dist = (px, i, c) => Math.max(Math.abs(px[i] - c[0]), Math.abs(px[i + 1] - c[1]), Math.abs(px[i + 2] - c[2]), Math.abs(px[i + 3] - c[3])); + + /** Pixels like the one at (x, y), within tol (0–255 per channel): touching it (contiguous) or anywhere. */ + function wand(px, w, h, x, y, tol, contiguous = true) { + const out = new Uint8Array(w * h); + if (x < 0 || y < 0 || x >= w || y >= h) return out; + const s = (y * w + x) * 4; + const c = [px[s], px[s + 1], px[s + 2], px[s + 3]]; + if (!contiguous) { + for (let i = 0; i < w * h; i++) if (dist(px, i * 4, c) <= tol) out[i] = 255; + return out; + } + const stack = new Int32Array(w * h); + let n = 0; + stack[n++] = y * w + x; + out[y * w + x] = 255; + while (n) { + const i = stack[--n]; + const ix = i % w, iy = (i - ix) / w; + for (const j of [ix > 0 ? i - 1 : -1, ix < w - 1 ? i + 1 : -1, iy > 0 ? i - w : -1, iy < h - 1 ? i + w : -1]) { + if (j < 0 || out[j] || dist(px, j * 4, c) > tol) continue; + out[j] = 255; + stack[n++] = j; + } + } + return out; + } + + /** A polygon [[x, y]…] (image pixels) filled even-odd, sampled at pixel centres. */ + function polygon(w, h, pts) { + const out = new Uint8Array(w * h); + if (pts.length < 3) return out; + const xs = []; + for (let y = 0; y < h; y++) { + const cy = y + 0.5; + xs.length = 0; + for (let i = 0; i < pts.length; i++) { + const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length]; + if ((y0 <= cy && y1 > cy) || (y1 <= cy && y0 > cy)) xs.push(x0 + ((cy - y0) / (y1 - y0)) * (x1 - x0)); + } + xs.sort((a, b) => a - b); + for (let k = 0; k + 1 < xs.length; k += 2) { + const a = Math.max(0, Math.ceil(xs[k] - 0.5)), b = Math.min(w - 1, Math.floor(xs[k + 1] - 0.5)); + for (let x = a; x <= b; x++) out[y * w + x] = 255; + } + } + return out; + } + + const rect = (w, h, x0, y0, x1, y1) => polygon(w, h, [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]); + + /** Mask a combined with b: "new" | "add" | "sub" | "and". */ + function combine(a, b, mode) { + if (mode === "new" || !a) return b; + const out = new Uint8Array(a.length); + for (let i = 0; i < a.length; i++) + out[i] = mode === "add" ? Math.max(a[i], b[i]) : mode === "sub" ? Math.min(a[i], 255 - b[i]) : Math.min(a[i], b[i]); + return out; + } + + const invert = (m) => m.map((v) => 255 - v); + + /** Grows (r > 0) or shrinks (r < 0) a mask by |r| pixels (square neighbourhood). */ + function grow(m, w, h, r) { + if (!r) return m; + const pick = r > 0 ? Math.max : Math.min; + let cur = m; + for (let it = 0; it < Math.abs(r); it++) { + const out = new Uint8Array(cur.length); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + let v = cur[y * w + x]; + if (x > 0) v = pick(v, cur[y * w + x - 1]); + if (x < w - 1) v = pick(v, cur[y * w + x + 1]); + if (y > 0) v = pick(v, cur[(y - 1) * w + x]); + if (y < h - 1) v = pick(v, cur[(y + 1) * w + x]); + out[y * w + x] = v; + } + cur = out; + } + return cur; + } + + /** Soft edges: a box blur of radius r (twice, about a Gaussian). */ + function feather(m, w, h, r) { + if (!r) return m; + let a = Float32Array.from(m); + const pass = (src, horiz) => { + const out = new Float32Array(src.length); + const n = horiz ? w : h, lines = horiz ? h : w; + for (let l = 0; l < lines; l++) { + const at = (k) => (horiz ? l * w + k : k * w + l); + let sum = 0; + for (let k = -r; k <= r; k++) sum += src[at(Math.max(0, Math.min(n - 1, k)))]; + for (let k = 0; k < n; k++) { + out[at(k)] = sum / (2 * r + 1); + sum += src[at(Math.min(n - 1, k + r + 1))] - src[at(Math.max(0, k - r))]; + } + } + return out; + }; + for (let i = 0; i < 2; i++) a = pass(pass(a, true), false); + return Uint8Array.from(a, (v) => Math.round(v)); + } + + const count = (m) => m.reduce((s, v) => s + (v >= 128 ? 1 : 0), 0); + + // ===================================================================== + // recolour + // ===================================================================== + function rgb2hsl(r, g, b) { + r /= 255, g /= 255, b /= 255; + const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2; + if (mx === mn) return [0, 0, l]; + const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); + const hh = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; + return [hh / 6, s, l]; + } + function hsl2rgb(hh, s, l) { + if (!s) return [l * 255, l * 255, l * 255]; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; + const f = (t) => { + t = ((t % 1) + 1) % 1; + return 255 * (t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p); + }; + return [f(hh + 1 / 3), f(hh), f(hh - 1 / 3)]; + } + + /** How bright the selection's brightest parts are (98th percentile of max(r, g, b)), for colourise. */ + function peak(px, mask) { + const hist = new Uint32Array(256); + let n = 0; + for (let i = 0; i < px.length / 4; i++) { + if (mask && mask[i] < 128) continue; + hist[Math.max(px[i * 4], px[i * 4 + 1], px[i * 4 + 2])]++; + n++; + } + let acc = 0; + for (let v = 0; v < 256; v++) if ((acc += hist[v]) >= n * 0.98) return Math.max(1, v); + return 255; + } + + /** + * src → dst (same size) with the selection recoloured; outside it dst = src. opts: + * { mode: "colorize" | "adjust", color: [r, g, b], strength (0–1), bright (−1…1), hue (−180…180°), + * sat (−1…1), light (−1…1), contrast (−1…1), ref (colorize: the source's peak) } + * colorize: each pixel's brightness (its max channel against the selection's brightest) times the + * colour, so a dark hair texture keeps its strands; adjust: hue / saturation / lightness shifts. + */ + function recolor(src, dst, mask, opts) { + const o = { mode: "colorize", color: [200, 160, 60], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0, ...opts }; + const ref = o.ref || peak(src, mask); + const gain = 1 + o.bright; + const k = 1 + o.contrast; + for (let i = 0; i < src.length / 4; i++) { + const p = i * 4, m = mask ? mask[i] / 255 : 1; + dst[p + 3] = src[p + 3]; + if (!m) { + dst[p] = src[p], dst[p + 1] = src[p + 1], dst[p + 2] = src[p + 2]; + continue; + } + let r = src[p], g = src[p + 1], b = src[p + 2]; + if (o.mode === "colorize") { + const t = Math.max(r, g, b) / ref; + const c = o.color; + r = r + (c[0] * t * gain - r) * o.strength; + g = g + (c[1] * t * gain - g) * o.strength; + b = b + (c[2] * t * gain - b) * o.strength; + } else { + let [hh, s, l] = rgb2hsl(r, g, b); + hh += o.hue / 360; + s = o.sat >= 0 ? s + (1 - s) * o.sat : s * (1 + o.sat); + l = o.light >= 0 ? l + (1 - l) * o.light : l * (1 + o.light); + [r, g, b] = hsl2rgb(hh, Math.max(0, Math.min(1, s)), Math.max(0, Math.min(1, l))); + } + if (o.contrast) (r = (r - 128) * k + 128), (g = (g - 128) * k + 128), (b = (b - 128) * k + 128); + dst[p] = src[p] + (r - src[p]) * m; + dst[p + 1] = src[p + 1] + (g - src[p + 1]) * m; + dst[p + 2] = src[p + 2] + (b - src[p + 2]) * m; + } + return dst; + } + + // ===================================================================== + // sizes + // ===================================================================== + const pow2 = (n) => 2 ** Math.floor(Math.log2(Math.max(1, n))); + + /** RGBA w × h → nw × nh (area average going down, bilinear going up). */ + function resize(px, w, h, nw, nh) { + if (nw === w && nh === h) return Uint8ClampedArray.from(px); + const out = new Uint8ClampedArray(nw * nh * 4); + const sx = w / nw, sy = h / nh; + for (let y = 0; y < nh; y++) + for (let x = 0; x < nw; x++) { + const acc = [0, 0, 0, 0]; + let n = 0; + if (sx >= 1 && sy >= 1) { + for (let yy = Math.floor(y * sy); yy < Math.min(h, Math.ceil((y + 1) * sy)); yy++) + for (let xx = Math.floor(x * sx); xx < Math.min(w, Math.ceil((x + 1) * sx)); xx++) { + const p = (yy * w + xx) * 4; + for (let c = 0; c < 4; c++) acc[c] += px[p + c]; + n++; + } + } else { + const fx = Math.max(0, (x + 0.5) * sx - 0.5), fy = Math.max(0, (y + 0.5) * sy - 0.5); + const x0 = Math.min(w - 1, Math.floor(fx)), y0 = Math.min(h - 1, Math.floor(fy)); + const x1 = Math.min(w - 1, x0 + 1), y1 = Math.min(h - 1, y0 + 1), ax = fx - x0, ay = fy - y0; + for (const [xx, yy, f] of [[x0, y0, (1 - ax) * (1 - ay)], [x1, y0, ax * (1 - ay)], [x0, y1, (1 - ax) * ay], [x1, y1, ax * ay]]) { + const p = (yy * w + xx) * 4; + for (let c = 0; c < 4; c++) acc[c] += px[p + c] * f; + } + n = 1; + } + const q = (y * nw + x) * 4; + for (let c = 0; c < 4; c++) out[q + c] = acc[c] / n; + } + return out; + } + + /** Every pixel opaque / alpha only 0 or 255 / real alpha. */ + function alphaKind(px) { + let binary = true, opaque = true; + for (let i = 3; i < px.length; i += 4) { + const a = px[i]; + if (a !== 255) opaque = false; + if (a !== 0 && a !== 255) { + binary = false; + break; + } + } + return opaque ? "opaque" : binary ? "binary" : "alpha"; + } + + // ===================================================================== + // DXT (BC1 / BC2 / BC3) encoding + // ===================================================================== + const to565 = (r, g, b) => ((Math.round((r * 31) / 255) << 11) | (Math.round((g * 63) / 255) << 5) | Math.round((b * 31) / 255)) >>> 0; + const from565 = (c) => [(((c >> 11) & 31) * 255) / 31, (((c >> 5) & 63) * 255) / 63, ((c & 31) * 255) / 31]; + + /** The 16 pixels of block (bx, by), edge pixels repeated: Float32Array(64). */ + function block(px, w, h, bx, by) { + const b = new Float32Array(64); + for (let j = 0; j < 4; j++) + for (let i = 0; i < 4; i++) { + const x = Math.min(w - 1, bx * 4 + i), y = Math.min(h - 1, by * 4 + j); + const p = (y * w + x) * 4, q = (j * 4 + i) * 4; + b[q] = px[p], b[q + 1] = px[p + 1], b[q + 2] = px[p + 2], b[q + 3] = px[p + 3]; + } + return b; + } + + /** Colour part of a block (8 bytes) into out at o; use: which of the 16 pixels count (DXT1 transparent ones don't). */ + function colorBlock(b, out, o, dxt1Alpha) { + const use = []; + for (let k = 0; k < 16; k++) if (!dxt1Alpha || b[k * 4 + 3] >= 128) use.push(k); + const dv = new DataView(out.buffer, out.byteOffset); + if (!use.length) { + // all transparent: 3-colour mode, every index 3 (transparent black) + dv.setUint16(o, 0, true), dv.setUint16(o + 2, 0, true), dv.setUint32(o + 4, 0xffffffff, true); + return; + } + // principal axis of the colours (a few power iterations), endpoints at the extremes along it + const mean = [0, 0, 0]; + for (const k of use) for (let c = 0; c < 3; c++) mean[c] += b[k * 4 + c] / use.length; + const cov = [0, 0, 0, 0, 0, 0]; + for (const k of use) { + const r = b[k * 4] - mean[0], g = b[k * 4 + 1] - mean[1], bl = b[k * 4 + 2] - mean[2]; + cov[0] += r * r, cov[1] += r * g, cov[2] += r * bl, cov[3] += g * g, cov[4] += g * bl, cov[5] += bl * bl; + } + let ax = [1, 1, 1]; + for (let it = 0; it < 6; it++) { + const n = [cov[0] * ax[0] + cov[1] * ax[1] + cov[2] * ax[2], cov[1] * ax[0] + cov[3] * ax[1] + cov[4] * ax[2], cov[2] * ax[0] + cov[4] * ax[1] + cov[5] * ax[2]]; + const l = Math.hypot(...n); + if (l < 1e-6) break; + ax = n.map((v) => v / l); + } + let lo = Infinity, hi = -Infinity, kl = use[0], kh = use[0]; + for (const k of use) { + const t = (b[k * 4] - mean[0]) * ax[0] + (b[k * 4 + 1] - mean[1]) * ax[1] + (b[k * 4 + 2] - mean[2]) * ax[2]; + if (t < lo) (lo = t), (kl = k); + if (t > hi) (hi = t), (kh = k); + } + // the endpoints a touch inside the extremes (less error for the middle colours) + const inset = (k, toward) => [0, 1, 2].map((c) => b[k * 4 + c] + (b[toward * 4 + c] - b[k * 4 + c]) / 16); + const e0 = inset(kh, kl), e1 = inset(kl, kh); + let c0 = to565(...e0), c1 = to565(...e1); + const threeColor = dxt1Alpha && use.length < 16; + if (threeColor ? c0 > c1 : c0 < c1) [c0, c1] = [c1, c0]; + if (!threeColor && c0 === c1) { + // one colour: 4-colour mode needs c0 > c1 + if (c1 > 0) c1--; + else c0++; + } + const p0 = from565(c0), p1 = from565(c1); + const pal = threeColor + ? [p0, p1, p0.map((v, i) => (v + p1[i]) / 2)] + : [p0, p1, p0.map((v, i) => (2 * v + p1[i]) / 3), p0.map((v, i) => (v + 2 * p1[i]) / 3)]; + let bits = 0; + for (let k = 15; k >= 0; k--) { + let idx; + if (threeColor && b[k * 4 + 3] < 128) idx = 3; + else { + let best = Infinity; + idx = 0; + for (let i = 0; i < pal.length; i++) { + const c = pal[i], dr = c[0] - b[k * 4], dg = c[1] - b[k * 4 + 1], db = c[2] - b[k * 4 + 2]; + const d = dr * dr + dg * dg + db * db; + if (d < best) (best = d), (idx = i); + } + } + bits = (bits << 2) | idx; + } + dv.setUint16(o, c0, true); + dv.setUint16(o + 2, c1, true); + dv.setUint32(o + 4, bits >>> 0, true); + } + + /** DXT5 alpha (8 bytes): two endpoints, 3-bit indices. */ + function alphaBlock5(b, out, o) { + let lo = 255, hi = 0; + for (let k = 0; k < 16; k++) (lo = Math.min(lo, b[k * 4 + 3])), (hi = Math.max(hi, b[k * 4 + 3])); + lo = Math.round(lo), hi = Math.round(hi); + // a0 > a1: the 8-value mode (one alpha: a0 = a1, every pixel takes index 0 exactly) + const a0 = hi, a1 = lo; + const pal = [a0, a1]; + for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7); + out[o] = a0, out[o + 1] = a1; + // 16 indices × 3 bits: pixels 0–7 in the first 3 bytes, 8–15 in the next 3 + for (let half = 0; half < 2; half++) { + let bits = 0; + for (let k = 7; k >= 0; k--) { + const a = b[(half * 8 + k) * 4 + 3]; + let best = Infinity, idx = 0; + for (let i = 0; i < 8; i++) { + const d = Math.abs(pal[i] - a); + if (d < best) (best = d), (idx = i); + } + bits = (bits << 3) | idx; + } + for (let i = 0; i < 3; i++) out[o + 2 + half * 3 + i] = (bits >> (8 * i)) & 255; + } + } + + /** DXT3 alpha (8 bytes): 4 bits a pixel. */ + function alphaBlock3(b, out, o) { + for (let k = 0; k < 16; k += 2) out[o + k / 2] = (Math.round(b[k * 4 + 3] / 17) & 15) | ((Math.round(b[(k + 1) * 4 + 3] / 17) & 15) << 4); + } + + /** One mip level → its DXT bytes. */ + function dxt(px, w, h, kind) { + const bw = Math.max(1, Math.ceil(w / 4)), bh = Math.max(1, Math.ceil(h / 4)); + const size = kind === "DXT1" ? 8 : 16; + const out = new Uint8Array(bw * bh * size); + for (let by = 0; by < bh; by++) + for (let bx = 0; bx < bw; bx++) { + const b = block(px, w, h, bx, by), o = (by * bw + bx) * size; + if (kind === "DXT1") colorBlock(b, out, o, true); + else { + (kind === "DXT5" ? alphaBlock5 : alphaBlock3)(b, out, o); + colorBlock(b, out, o + 8, false); + } + } + return out; + } + + /** "auto" → what the game would use: opaque or cut-out (transparent black) DXT1, real alpha DXT5. */ + const pickFormat = (px, format) => (format && format !== "auto" ? format : alphaKind(px) === "alpha" ? "DXT5" : "DXT1"); + + /** + * RGBA → a .dds: { format: "auto" | "DXT1" | "DXT3" | "DXT5" | "ARGB", mips: true }. → { bytes, format, levels } + */ + function encodeDds(px, w, h, opts = {}) { + const format = pickFormat(px, opts.format); + const levels = []; + let cur = px, cw = w, ch = h; + for (;;) { + levels.push(format === "ARGB" ? argb(cur) : dxt(cur, cw, ch, format)); + if (!opts.mips || (cw === 1 && ch === 1)) break; + const nw = Math.max(1, cw >> 1), nh = Math.max(1, ch >> 1); + cur = resize(cur, cw, ch, nw, nh); + (cw = nw), (ch = nh); + } + const total = levels.reduce((s, l) => s + l.length, 0); + const out = new Uint8Array(128 + total); + const dv = new DataView(out.buffer); + dv.setUint32(0, 0x20534444, true); // "DDS " + dv.setUint32(4, 124, true); + const compressed = format !== "ARGB"; + // caps | height | width | pixel format, + pitch or linear size, + mip map count + dv.setUint32(8, 0x1007 | (compressed ? 0x80000 : 0x8) | (levels.length > 1 ? 0x20000 : 0), true); + dv.setUint32(12, h, true); + dv.setUint32(16, w, true); + dv.setUint32(20, compressed ? levels[0].length : w * 4, true); + dv.setUint32(28, levels.length > 1 ? levels.length : 0, true); + dv.setUint32(76, 32, true); + if (compressed) { + dv.setUint32(80, 0x4, true); // FOURCC + for (let i = 0; i < 4; i++) out[84 + i] = format.charCodeAt(i); + } else { + dv.setUint32(80, 0x41, true); // RGB | alpha pixels + dv.setUint32(88, 32, true); + dv.setUint32(92, 0x00ff0000, true); + dv.setUint32(96, 0x0000ff00, true); + dv.setUint32(100, 0x000000ff, true); + dv.setUint32(104, 0xff000000, true); + } + dv.setUint32(108, 0x1000 | (levels.length > 1 ? 0x400008 : 0), true); // texture (| mip map | complex) + let o = 128; + for (const l of levels) out.set(l, o), (o += l.length); + return { bytes: out, format, levels: levels.length }; + } + + function argb(px) { + const out = new Uint8Array(px.length); + for (let i = 0; i < px.length; i += 4) (out[i] = px[i + 2]), (out[i + 1] = px[i + 1]), (out[i + 2] = px[i]), (out[i + 3] = px[i + 3]); + return out; + } + + /** What a .dds is stored as (its header): { format, w, h, mips }. */ + function ddsInfo(bytes) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length < 128 || dv.getUint32(0, true) !== 0x20534444) return null; + const pf = dv.getUint32(80, true); + const format = pf & 4 ? String.fromCharCode(...bytes.subarray(84, 88)) : `RGB${dv.getUint32(88, true)}`; + return { format, w: dv.getUint32(16, true), h: dv.getUint32(12, true), mips: Math.max(1, dv.getUint32(28, true)) }; + } + + return { wand, polygon, rect, combine, invert, grow, feather, count, peak, recolor, rgb2hsl, hsl2rgb, resize, pow2, alphaKind, pickFormat, encodeDds, ddsInfo }; +})(); + +if (typeof window !== "undefined") window.TexKit = TexKit; diff --git a/app/public/legacy/js/texture.js b/app/public/legacy/js/texture.js new file mode 100644 index 0000000..b5f903b --- /dev/null +++ b/app/public/legacy/js/texture.js @@ -0,0 +1,724 @@ +"use strict"; + +// Texture Studio (Texture tab): open the pictures of a folder (.png / .jpg / .tga / .bmp / .dds), +// select parts of one (magic wand, colour range, lasso, rectangle), recolour the selection (colourise +// to a colour keeping the shading, or hue / saturation / lightness), and save it as a .dds the way the +// game stores textures (DXT1 / DXT3 / DXT5 / A8R8G8B8, with mip maps) — one file or a whole folder. +// The maths is in texkit.js. + +(() => { + const TK = TexKit; + const TEX = { + dir: null, + files: [], // { name, size, mtime } + file: null, // { path, name, stem, ext, info (a .dds: its format) } + w: 0, + h: 0, + base: null, // the pixels as they are now (Uint8ClampedArray RGBA) + shown: null, // what the view shows: base, or base with the recolour previewed + raw: null, // the selection as picked (Uint8Array, 0 / 255) + mask: null, // raw, gaps closed and feathered (what the recolour uses) + undo: [], + redo: [], + tool: "wand", + tol: 24, + contiguous: true, + close: 0, + feather: 1, + rc: { mode: "colorize", color: [230, 190, 100], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0 }, + rcOn: false, // the recolour is previewed (not applied yet) + showSel: true, + picking: false, // the next click takes the colourise colour + exp: { format: "auto", mips: true, max: 0 }, + dirty: false, + zoom: 1, + pan: [0, 0], + fitted: true, // the view follows the window size until zoomed / moved + }; + window.TEX = TEX; + const KEY = "tex:dir"; + const stem = (n) => n.replace(/\.[^.]+$/, ""); + const extOf = (n) => (n.match(/\.([^.]+)$/)?.[1] || "").toLowerCase(); + const join = (d, n) => `${d.replace(/[\\/]+$/, "")}\\${n}`; + const hex = (c) => "#" + c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join(""); + const unhex = (s) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16)); + const field = (label, input) => [el("label", { text: label }), input]; + const fill = (box, ...kids) => box.replaceChildren(...kids.filter(Boolean)); + + let cv, g, imgCanvas, selCanvas; + let started = false; + + // ===================================================================== + // folder + files + // ===================================================================== + async function openFolder(dir) { + dir = dir || (await call("dialog:folder", "Folder with the textures")); + if (!dir) return; + TEX.dir = dir; + try { + localStorage.setItem(KEY, dir); + } catch {} + await listFiles(); + } + async function listFiles() { + if (!TEX.dir) return; + try { + TEX.files = (await call("tex:list", TEX.dir)).sort((a, b) => a.name.localeCompare(b.name)); + } catch (err) { + TEX.files = []; + toast(`${TEX.dir}: ${err.message}`, true); + } + $("tex-dir").textContent = TEX.dir; + $("tex-dir").title = TEX.dir; + renderList(); + } + function renderList() { + const q = $("tex-search").value.trim().toLowerCase(); + const rows = TEX.files.filter((f) => !q || f.name.toLowerCase().includes(q)); + $("tex-count").textContent = rows.length ? String(rows.length) : ""; + // web fix: draw 300 rows at a time, more while scrolling (10 000+ textures froze the page) + const box = $("tex-list"); + const CHUNK = 300; + let shown = Math.min(rows.length, Math.max(CHUNK, TEX._shown || 0)); + const rowEl = (f) => + el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [ + el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }), + el("span", { class: "grow", text: f.name }), + el("span", { class: "dim", text: size(f.size) }), + ]); + box.onscroll = () => { + if (shown >= rows.length || box.scrollTop + box.clientHeight < box.scrollHeight - 400) return; + box.append(...rows.slice(shown, shown + CHUNK).map(rowEl)); + shown = Math.min(rows.length, shown + CHUNK); + TEX._shown = shown; + }; + if (TEX._q !== q) (TEX._q = q), (shown = Math.min(rows.length, CHUNK)), (box.scrollTop = 0); + TEX._shown = shown; + $("tex-list").replaceChildren( + ...rows.slice(0, shown).map((f) => + el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [ + el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }), + el("span", { class: "grow", text: f.name }), + el("span", { class: "dim", text: size(f.size) }), + ]), + ), + ); + if (!rows.length) $("tex-list").append(el("div", { class: "ans-note", text: TEX.dir ? "No pictures here (.png .jpg .tga .bmp .dds)." : "Open a folder with textures." })); + } + + async function keepChanges() { + if (!TEX.dirty) return true; + const r = await modal("Unsaved changes", `${TEX.file.name} has changes that aren't saved.`, [ + { label: "Cancel", value: false }, + { label: "Discard", kind: "danger", value: true }, + ]); + return r; + } + + async function openFile(path) { + if (!(await keepChanges())) return; + const name = path.replace(/\\/g, "/").split("/").pop(); + const ext = extOf(name); + loading(true, `Opening ${name}…`); + try { + const { bytes } = await call("mdl:readFile", path); + const bmp = await TexDecode.decode(bytes, ext); + const c = new OffscreenCanvas(bmp.width, bmp.height); + const cx = c.getContext("2d", { willReadFrequently: true }); + cx.drawImage(bmp, 0, 0); + const px = cx.getImageData(0, 0, bmp.width, bmp.height).data; + TEX.file = { path, name, stem: stem(name), ext, dir: path.replace(/[\\/][^\\/]*$/, ""), info: ext === "dds" ? TK.ddsInfo(bytes) : null }; + TEX.w = bmp.width; + TEX.h = bmp.height; + TEX.base = new Uint8ClampedArray(px); + TEX.raw = TEX.mask = null; + TEX.undo = []; + TEX.redo = []; + TEX.rcOn = false; + TEX.dirty = false; + // a .dds keeps its own format when saved again + if (TEX.file.info && /^DXT[135]$/.test(TEX.file.info.format)) TEX.exp.format = TEX.file.info.format; + else if (TEX.file.info?.format === "RGB32") TEX.exp.format = "ARGB"; + else TEX.exp.format = "auto"; + TEX.exp.mips = TEX.file.info ? TEX.file.info.mips > 1 : true; + $("tex-empty").hidden = true; + refresh(); + fitView(); + renderList(); + renderSide(); + } catch (err) { + toast(`${name}: ${err.message}`, true); + } finally { + loading(false); + } + } + + function loading(on, text) { + $("tex-loading").hidden = !on; + if (text) $("tex-loading").querySelector("span").textContent = text; + } + const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0))); + + // ===================================================================== + // pixels → view + // ===================================================================== + /** Recomputes the effective mask, the preview and the view images. */ + function refresh() { + if (!TEX.base) return; + const { w, h } = TEX; + if (TEX.raw) { + let m = TEX.raw; + if (TEX.close) m = TK.grow(TK.grow(m, w, h, TEX.close), w, h, -TEX.close); + TEX.mask = TK.feather(m, w, h, TEX.feather); + } else TEX.mask = null; + TEX.shown = TEX.rcOn ? TK.recolor(TEX.base, new Uint8ClampedArray(TEX.base.length), TEX.mask, TEX.rc) : TEX.base; + imgCanvas = imgCanvas && imgCanvas.width === w && imgCanvas.height === h ? imgCanvas : new OffscreenCanvas(w, h); + imgCanvas.getContext("2d").putImageData(new ImageData(TEX.shown, w, h), 0, 0); + drawSelection(); + $("tex-dirty").hidden = !TEX.dirty; + $("tex-title").textContent = TEX.file ? `${TEX.file.name} — ${w}×${h}${TEX.file.info ? ` ${TEX.file.info.format}${TEX.file.info.mips > 1 ? " +mips" : ""}` : ""}` : ""; + $("tex-save").disabled = !TEX.base; + $("tex-saveas").disabled = !TEX.base; + draw(); + } + + /** The selection's outline (and a light tint inside) as an image the size of the texture. */ + function drawSelection() { + const { w, h, raw } = TEX; + if (!raw) return (selCanvas = null); + const id = new ImageData(w, h); + const d = id.data; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const i = y * w + x; + if (raw[i] < 128) continue; + const edge = x === 0 || y === 0 || x === w - 1 || y === h - 1 || raw[i - 1] < 128 || raw[i + 1] < 128 || raw[i - w] < 128 || raw[i + w] < 128; + if (edge) d.set([60, 220, 255, 255], i * 4); + else d.set([60, 180, 255, 38], i * 4); + } + selCanvas = new OffscreenCanvas(w, h); + selCanvas.getContext("2d").putImageData(id, 0, 0); + } + + function draw() { + if (!cv) return; + const r = cv.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + if (cv.width !== Math.round(r.width * dpr) || cv.height !== Math.round(r.height * dpr)) (cv.width = Math.round(r.width * dpr)), (cv.height = Math.round(r.height * dpr)); + g.setTransform(1, 0, 0, 1, 0, 0); + g.clearRect(0, 0, cv.width, cv.height); + if (!TEX.base) return; + g.setTransform(dpr, 0, 0, dpr, 0, 0); + const z = TEX.zoom, [px, py] = TEX.pan; + // checkerboard under the picture (transparent parts) + g.save(); + g.beginPath(); + g.rect(px, py, TEX.w * z, TEX.h * z); + g.clip(); + const s = 10; + for (let y = Math.floor(py / s) * s; y < py + TEX.h * z; y += s) + for (let x = Math.floor(px / s) * s; x < px + TEX.w * z; x += s) { + g.fillStyle = ((x / s + y / s) & 1) === 0 ? "#2a2a30" : "#1e1e22"; + g.fillRect(x, y, s, s); + } + g.restore(); + g.imageSmoothingEnabled = z < 1; + g.drawImage(imgCanvas, px, py, TEX.w * z, TEX.h * z); + if (selCanvas && TEX.showSel) { + g.imageSmoothingEnabled = false; + g.drawImage(selCanvas, px, py, TEX.w * z, TEX.h * z); + } + // the lasso / rectangle being drawn + if (drag?.pts?.length > 1) { + g.strokeStyle = "#3cdcff"; + g.lineWidth = 1; + g.setLineDash([4, 3]); + g.beginPath(); + drag.pts.forEach(([x, y], i) => (i ? g.lineTo : g.moveTo).call(g, px + x * z, py + y * z)); + if (drag.tool === "rect") g.closePath(); + g.stroke(); + g.setLineDash([]); + } + $("tex-zoom").textContent = `${Math.round(z * 100)}%`; + } + + function fitView() { + if (!cv || !TEX.base) return; + const r = cv.getBoundingClientRect(); + TEX.zoom = Math.min((r.width - 40) / TEX.w, (r.height - 40) / TEX.h); + TEX.zoom = TEX.zoom > 1 ? Math.floor(TEX.zoom) : TEX.zoom; + TEX.pan = [(r.width - TEX.w * TEX.zoom) / 2, (r.height - TEX.h * TEX.zoom) / 2]; + TEX.fitted = true; + draw(); + } + + // ===================================================================== + // editing: undo, selection, recolour + // ===================================================================== + function pushUndo() { + TEX.undo.push(TEX.base); + if (TEX.undo.length > 20) TEX.undo.shift(); + TEX.redo = []; + } + function undo(back = true) { + const from = back ? TEX.undo : TEX.redo, to = back ? TEX.redo : TEX.undo; + if (!from.length) return; + to.push(TEX.base); + TEX.base = from.pop(); + TEX.dirty = true; + refresh(); + } + + /** A new selection piece: shift adds, alt takes away, else it replaces. */ + function select(piece, e) { + const mode = e?.shiftKey ? "add" : e?.altKey ? "sub" : "new"; + TEX.raw = TK.combine(TEX.raw, piece, mode); + if (!TK.count(TEX.raw)) TEX.raw = null; + refresh(); + renderSel(); + } + function selectAll(how) { + if (!TEX.base) return; + const n = TEX.w * TEX.h; + if (how === "all") TEX.raw = new Uint8Array(n).fill(255); + else if (how === "none") TEX.raw = null; + else if (how === "invert") TEX.raw = TEX.raw ? TK.invert(TEX.raw) : new Uint8Array(n).fill(255); + else if (TEX.raw) TEX.raw = TK.grow(TEX.raw, TEX.w, TEX.h, how); + refresh(); + renderSel(); + } + + function applyRecolor() { + if (!TEX.rcOn) return; + pushUndo(); + TEX.base = TEX.shown; + TEX.rcOn = false; + TEX.dirty = true; + refresh(); + renderRecolor(); + } + function cancelRecolor() { + TEX.rcOn = false; + refresh(); + renderRecolor(); + } + let rcTimer = 0; + function setRc(k, v) { + TEX.rc[k] = v; + TEX.rcOn = true; + clearTimeout(rcTimer); + rcTimer = setTimeout(() => { + refresh(); + renderRecolorButtons(); + }, 30); + } + + // ===================================================================== + // mouse + // ===================================================================== + let drag = null; + const toImage = (e) => { + const r = cv.getBoundingClientRect(); + return [(e.clientX - r.left - TEX.pan[0]) / TEX.zoom, (e.clientY - r.top - TEX.pan[1]) / TEX.zoom]; + }; + function bindCanvas() { + cv.addEventListener("contextmenu", (e) => e.preventDefault()); + cv.addEventListener("mousedown", (e) => { + if (!TEX.base) return; + cv.focus(); + const [x, y] = toImage(e); + const pan = e.button === 1 || e.button === 2 || TEX.tool === "pan" || spaceDown; + if (pan) { + drag = { pan: true, sx: e.clientX, sy: e.clientY, p0: TEX.pan.slice() }; + return; + } + if (e.button !== 0) return; + const ix = Math.floor(x), iy = Math.floor(y); + if (TEX.picking) { + if (ix >= 0 && iy >= 0 && ix < TEX.w && iy < TEX.h) { + const p = (iy * TEX.w + ix) * 4; + TEX.picking = false; + cv.classList.remove("pick"); + setRc("color", [TEX.base[p], TEX.base[p + 1], TEX.base[p + 2]]); + renderRecolor(); + } + return; + } + if (TEX.tool === "wand" || TEX.tool === "color") { + if (ix < 0 || iy < 0 || ix >= TEX.w || iy >= TEX.h) return; + select(TK.wand(TEX.base, TEX.w, TEX.h, ix, iy, TEX.tol, TEX.tool === "wand" && TEX.contiguous), e); + return; + } + drag = { tool: TEX.tool, pts: [[x, y]], shift: e.shiftKey, alt: e.altKey }; + if (TEX.tool === "rect") drag.pts = [[x, y], [x, y], [x, y], [x, y]]; + }); + window.addEventListener("mousemove", (e) => { + if (!drag || !visible()) return; + if (drag.pan) { + TEX.pan = [drag.p0[0] + e.clientX - drag.sx, drag.p0[1] + e.clientY - drag.sy]; + TEX.fitted = false; + return draw(); + } + const [x, y] = toImage(e); + if (drag.tool === "rect") { + const [x0, y0] = drag.pts[0]; + drag.pts = [[x0, y0], [x, y0], [x, y], [x0, y]]; + } else { + const last = drag.pts[drag.pts.length - 1]; + if (Math.hypot(x - last[0], y - last[1]) * TEX.zoom > 2) drag.pts.push([x, y]); + } + draw(); + }); + window.addEventListener("mouseup", () => { + if (!drag) return; + const d = drag; + drag = null; + if (d.pan) return; + const pts = d.pts.map(([x, y]) => [Math.max(0, Math.min(TEX.w, x)), Math.max(0, Math.min(TEX.h, y))]); + const piece = d.tool === "rect" ? TK.rect(TEX.w, TEX.h, pts[0][0], pts[0][1], pts[2][0], pts[2][1]) : TK.polygon(TEX.w, TEX.h, pts); + if (TK.count(piece)) select(piece, { shiftKey: d.shift, altKey: d.alt }); + else draw(); + }); + cv.addEventListener( + "wheel", + (e) => { + if (!TEX.base) return; + e.preventDefault(); + const r = cv.getBoundingClientRect(); + const mx = e.clientX - r.left, my = e.clientY - r.top; + const z0 = TEX.zoom, z1 = Math.max(0.05, Math.min(32, z0 * (e.deltaY < 0 ? 1.2 : 1 / 1.2))); + TEX.pan = [mx - ((mx - TEX.pan[0]) * z1) / z0, my - ((my - TEX.pan[1]) * z1) / z0]; + TEX.zoom = z1; + TEX.fitted = false; + draw(); + }, + { passive: false }, + ); + cv.addEventListener("dragover", (e) => e.preventDefault()); + cv.addEventListener("drop", (e) => { + e.preventDefault(); + const f = e.dataTransfer.files[0]; + if (f) openFile(bridge.pathOf(f)); + }); + } + let spaceDown = false; + const visible = () => $("view-tex").classList.contains("on"); + window.addEventListener("keydown", (e) => { + if (!visible() || /input|select|textarea/i.test(document.activeElement?.tagName || "")) return; + if (e.code === "Space") (spaceDown = true), e.preventDefault(); + const k = e.key.toLowerCase(); + if (e.ctrlKey && k === "z") (undo(!e.shiftKey), e.preventDefault()); + else if (e.ctrlKey && k === "y") (undo(false), e.preventDefault()); + else if (e.ctrlKey && k === "a") (selectAll("all"), e.preventDefault()); + else if (e.ctrlKey && k === "d") (selectAll("none"), e.preventDefault()); + else if (e.ctrlKey && e.shiftKey && k === "i") (selectAll("invert"), e.preventDefault()); + else if (e.ctrlKey && k === "s") (save(), e.preventDefault()); + else if (!e.ctrlKey && TOOLS.some((t) => t.key === k)) setTool(TOOLS.find((t) => t.key === k).id); + else if (k === "enter" && TEX.rcOn) applyRecolor(); + else if (k === "escape") TEX.rcOn ? cancelRecolor() : TEX.picking ? ((TEX.picking = false), cv.classList.remove("pick")) : selectAll("none"); + else if (k === "0" && !e.ctrlKey) fitView(); + }); + window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false)); + + // ===================================================================== + // side cards + // ===================================================================== + const TOOLS = [ + { id: "wand", key: "w", label: "Magic wand", tip: "Click: the area of similar colour around that point (W)" }, + { id: "color", key: "c", label: "Colour range", tip: "Click: every pixel of similar colour in the picture (C)" }, + { id: "lasso", key: "l", label: "Lasso", tip: "Drag around what to select (L)" }, + { id: "rect", key: "m", label: "Rectangle", tip: "Drag a box (M)" }, + { id: "pan", key: "h", label: "Hand", tip: "Drag to move the view (H) — or the middle / right button, or hold Space" }, + ]; + function setTool(id) { + TEX.tool = id; + for (const b of $("tex-tools").children) b.classList.toggle("on", b.dataset.tool === id); + cv?.setAttribute("data-tool", id); + renderSel(); + } + + function renderSide() { + renderSel(); + renderRecolor(); + renderExport(); + } + + function renderSel() { + const box = $("tex-sel"); + const n = TEX.raw ? TK.count(TEX.raw) : 0; + const num = (value, min, max, onchange) => el("input", { type: "number", min, max, step: "1", value: String(value), onchange: (e) => onchange(Math.max(min, Math.min(max, Math.round(Number(e.target.value) || 0)))) }); + fill(box, + el("div", { class: "ans-grid" }, [ + ...(TEX.tool === "wand" || TEX.tool === "color" + ? field("Tolerance", el("div", { class: "ans-vec" }, [ + el("input", { type: "range", min: "0", max: "128", value: String(TEX.tol), class: "grow", oninput: (e) => (($("tex-tol-v").textContent = e.target.value), (TEX.tol = Number(e.target.value))) }), + el("span", { id: "tex-tol-v", class: "mono", text: String(TEX.tol) }), + ])) + : []), + ...field("Close gaps", num(TEX.close, 0, 16, (v) => ((TEX.close = v), refresh()))), + ...field("Feather", num(TEX.feather, 0, 16, (v) => ((TEX.feather = v), refresh()))), + ]), + TEX.tool === "wand" ? el("label", { class: "ans-check", title: "Off: like Colour range (the colour anywhere in the picture)" }, [el("input", { type: "checkbox", checked: TEX.contiguous, onchange: (e) => (TEX.contiguous = e.target.checked) }), "Only touching pixels"]) : null, + el("div", { class: "ans-row" }, [ + el("button", { class: "btn", text: "All", title: "Ctrl+A", onclick: () => selectAll("all") }), + el("button", { class: "btn", text: "None", title: "Ctrl+D / Esc", onclick: () => selectAll("none") }), + el("button", { class: "btn", text: "Invert", title: "Ctrl+Shift+I", onclick: () => selectAll("invert") }), + el("button", { class: "btn", text: "Grow", title: "+2 px", onclick: () => selectAll(2) }), + el("button", { class: "btn", text: "Shrink", title: "−2 px", onclick: () => selectAll(-2) }), + ]), + el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: TEX.showSel, onchange: (e) => ((TEX.showSel = e.target.checked), draw()) }), "Show the selection"]), + el("div", { class: "ans-note", text: n ? `${n.toLocaleString()} pixels selected (${((100 * n) / (TEX.w * TEX.h)).toFixed(1)}%). Shift + click / drag adds, Alt takes away.` : "Nothing selected: a recolour changes the whole picture. Shift + click / drag adds to a selection, Alt takes away. Close gaps fills the dark strands a colour pick misses." }), + ); + } + + function slider(label, k, min, max, scale = 1, unit = "") { + const out = el("span", { class: "mono tex-val", text: `${Math.round(TEX.rc[k] * scale)}${unit}` }); + return field(label, el("div", { class: "ans-vec" }, [ + el("input", { type: "range", min: String(min), max: String(max), value: String(Math.round(TEX.rc[k] * scale)), class: "grow", oninput: (e) => { + out.textContent = `${e.target.value}${unit}`; + setRc(k, Number(e.target.value) / scale); + }, ondblclick: (e) => { + // back to the start value + const v = k === "strength" ? 1 : 0; + e.target.value = String(Math.round(v * scale)); + out.textContent = `${Math.round(v * scale)}${unit}`; + setRc(k, v); + } }), + out, + ])); + } + + const SWATCHES = [[34, 32, 32], [150, 145, 128], [235, 190, 90], [130, 95, 35], [105, 38, 14], [190, 20, 20], [72, 10, 28], [20, 40, 120], [90, 30, 120], [20, 110, 60], [140, 145, 155], [240, 150, 190]]; + function renderRecolor() { + const r = TEX.rc; + const box = $("tex-rc"); + const mode = el("div", { class: "seg" }, [["colorize", "Colourise"], ["adjust", "Hue / Sat / Light"]].map(([v, t]) => + el("button", { class: r.mode === v ? "on" : "", text: t, onclick: () => (setRc("mode", v), renderRecolor()) }), + )); + const color = el("input", { type: "color", value: hex(r.color), oninput: (e) => setRc("color", unhex(e.target.value)) }); + fill(box, + mode, + r.mode === "colorize" + ? el("div", {}, [ + el("div", { class: "ans-grid" }, [ + ...field("Colour", el("div", { class: "ans-vec" }, [ + color, + el("button", { class: `iconbtn only${TEX.picking ? " on" : ""}`, title: "Take the colour from the picture (click it)", onclick: () => ((TEX.picking = !TEX.picking), cv.classList.toggle("pick", TEX.picking), renderRecolor()) }, [icon("target")]), + ])), + ...slider("Strength", "strength", 0, 100, 100, "%"), + ...slider("Brightness", "bright", -100, 100, 100), + ...slider("Contrast", "contrast", -100, 100, 100), + ]), + el("div", { class: "tex-swatches" }, SWATCHES.map((c) => el("button", { class: "tex-sw", title: hex(c), style: `background:${hex(c)}`, onclick: () => (setRc("color", c), renderRecolor()) }))), + el("div", { class: "ans-note", text: "Each pixel keeps its light and dark (strands, shading) in the new colour: made for dark or brown hair." }), + ]) + : el("div", { class: "ans-grid" }, [...slider("Hue", "hue", -180, 180, 1, "°"), ...slider("Saturation", "sat", -100, 100, 100), ...slider("Lightness", "light", -100, 100, 100), ...slider("Contrast", "contrast", -100, 100, 100)]), + el("div", { id: "tex-rc-btns", class: "ans-row" }), + ); + renderRecolorButtons(); + } + function renderRecolorButtons() { + const b = $("tex-rc-btns"); + if (!b) return; + b.replaceChildren( + el("button", { class: "btn primary", text: "Apply", title: "Enter", disabled: !TEX.rcOn || !TEX.base, onclick: applyRecolor }), + el("button", { class: "btn", text: "Cancel", title: "Esc", disabled: !TEX.rcOn, onclick: cancelRecolor }), + el("span", { class: "grow" }), + el("button", { class: "iconbtn only", title: "Undo (Ctrl+Z)", disabled: !TEX.undo.length, onclick: () => undo(true) }, [icon("undo")]), + el("button", { class: "iconbtn only", title: "Redo (Ctrl+Y)", disabled: !TEX.redo.length, onclick: () => undo(false) }, [icon("redo")]), + ); + } + + const FORMATS = [ + ["auto", "Auto (DXT1, DXT5 with alpha)"], + ["DXT1", "DXT1 — no / cut-out alpha (smallest)"], + ["DXT3", "DXT3 — sharp alpha"], + ["DXT5", "DXT5 — smooth alpha"], + ["ARGB", "A8R8G8B8 — uncompressed (4× bigger)"], + ]; + function outSize() { + let w = TEX.w, h = TEX.h; + if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) { + const f = TEX.exp.max / Math.max(w, h); + (w = Math.max(1, Math.round(w * f))), (h = Math.max(1, Math.round(h * f))); + } + // the game's cards want powers of two + return [TK.pow2(w), TK.pow2(h)]; + } + function renderExport() { + const box = $("tex-exp"); + if (!TEX.base) return box.replaceChildren(el("div", { class: "ans-note", text: "Open a picture." })); + const e = TEX.exp; + const [ow, oh] = outSize(); + const fmt = TK.pickFormat(TEX.base, e.format); + fill(box, + el("div", { class: "ans-grid" }, [ + ...field("Format", el("select", { onchange: (ev) => ((e.format = ev.target.value), renderExport()) }, FORMATS.map(([v, t]) => el("option", { value: v, text: t, selected: v === e.format })))), + ...field("Size", el("select", { onchange: (ev) => ((e.max = Number(ev.target.value)), renderExport()) }, [[0, "Keep"], [2048, "≤ 2048"], [1024, "≤ 1024"], [512, "≤ 512"], [256, "≤ 256"], [128, "≤ 128"]].map(([v, t]) => el("option", { value: v, text: t, selected: v === e.max })))), + ]), + el("label", { class: "ans-check", title: "Smaller copies for far away (what most of the game's textures have)" }, [el("input", { type: "checkbox", checked: e.mips, onchange: (ev) => ((e.mips = ev.target.checked), renderExport()) }), "Mip maps"]), + el("div", { class: "ans-note", text: `Saves ${ow}×${oh} ${fmt === "ARGB" ? "A8R8G8B8" : fmt}${e.mips ? " + mip maps" : ""}${ow !== TEX.w || oh !== TEX.h ? ` (resized from ${TEX.w}×${TEX.h}${TK.pow2(TEX.w) !== TEX.w || TK.pow2(TEX.h) !== TEX.h ? ": the game wants powers of two" : ""})` : ""}. Transparent black in DXT1 / alpha 0 is where the game shows the skin colour (#MODEL_COLOR).` }), + ); + } + + // ===================================================================== + // saving + // ===================================================================== + /** The current pixels (recolour applied if previewed) as .dds bytes with the export settings. */ + function ddsBytes(px = TEX.rcOn ? TEX.shown : TEX.base, w = TEX.w, h = TEX.h) { + const [ow, oh] = TEX.base && px === (TEX.rcOn ? TEX.shown : TEX.base) ? outSize() : [TK.pow2(w), TK.pow2(h)]; + const p = ow !== w || oh !== h ? TK.resize(px, w, h, ow, oh) : px; + return TK.encodeDds(p, ow, oh, { format: TEX.exp.format, mips: TEX.exp.mips }); + } + + async function confirmOverwrite(names) { + if (!names.length) return true; + return modal("Replace files?", [el("p", { text: `${names.length} file(s) already exist and will be replaced:` }), el("div", { class: "mono ans-note" }, names.map((n) => el("div", { text: n })))], [ + { label: "Cancel", value: false }, + { label: "Replace", kind: "danger", value: true }, + ]); + } + + async function writeDds(folder, name) { + if (TEX.rcOn) applyRecolor(); + loading(true, `Encoding ${name}…`); + await frame(); + try { + const r = ddsBytes(); + await call("mdl:writeFiles", folder, [{ name, data: r.bytes }]); + TEX.dirty = false; + refresh(); + toast(`Saved ${name} (${r.format === "ARGB" ? "A8R8G8B8" : r.format}, ${r.levels} level${r.levels > 1 ? "s" : ""}, ${size(r.bytes.length)}).`); + if (folder === TEX.dir) await listFiles(); + } catch (err) { + toast(`Save: ${err.message}`, true); + } finally { + loading(false); + } + } + + /** Save: .dds next to the picture (a .dds is replaced). */ + async function save() { + if (!TEX.base) return; + const name = `${TEX.file.stem}.dds`; + const exists = TEX.file.ext === "dds" || (TEX.file.dir === TEX.dir && TEX.files.some((f) => f.name.toLowerCase() === name.toLowerCase())); + if (exists && !(await confirmOverwrite([name]))) return; + await writeDds(TEX.file.dir, name); + } + + async function saveAs() { + if (!TEX.base) return; + const kind = await modal("Save as", [el("p", { text: "Save the picture as:" })], [ + { label: "Cancel", value: null }, + { label: "PNG", value: "png" }, + { label: "DDS", kind: "primary", value: "dds" }, + ]); + if (!kind) return; + const file = await call("dialog:saveAs", `${TEX.file.stem}.${kind}`, kind); + if (!file) return; + const folder = file.replace(/[\\/][^\\/]*$/, ""), name = file.replace(/\\/g, "/").split("/").pop(); + if (kind === "dds") return writeDds(folder, /\.dds$/i.test(name) ? name : `${name}.dds`); + if (TEX.rcOn) applyRecolor(); + const c = new OffscreenCanvas(TEX.w, TEX.h); + c.getContext("2d").putImageData(new ImageData(TEX.base, TEX.w, TEX.h), 0, 0); + const blob = await c.convertToBlob({ type: "image/png" }); + const b64 = await new Promise((res) => { + const fr = new FileReader(); + fr.onload = () => res(String(fr.result).split(",")[1]); + fr.readAsDataURL(blob); + }); + await call("file:writeBase64", file, b64); + toast(`Saved ${name}.`); + if (folder === TEX.dir) listFiles(); + } + + /** Every picture of the folder that isn't a .dds → .dds with the export settings. */ + async function convertFolder() { + if (!TEX.dir) return openFolder(); + const src = TEX.files.filter((f) => extOf(f.name) !== "dds"); + if (!src.length) return toast("No .png / .jpg / .tga / .bmp in this folder.", true); + const have = new Set(TEX.files.map((f) => f.name.toLowerCase())); + const list = el("div", { class: "tex-conv" }, src.map((f) => el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: true, "data-name": f.name }), `${f.name} → ${stem(f.name)}.dds${have.has(`${stem(f.name)}.dds`.toLowerCase()) ? " (replaces)" : ""}`]))); + const fmt = FORMATS.find(([v]) => v === TEX.exp.format)[1]; + const ok = await modal("Convert to .dds", [ + el("p", { text: `Into ${TEX.dir}, as ${fmt}${TEX.exp.mips ? " with mip maps" : ""}${TEX.exp.max ? `, at most ${TEX.exp.max}` : ""} (sizes to powers of two). Change it in Save as .dds.` }), + list, + ], [{ label: "Cancel", value: false }, { label: "Convert", kind: "primary", value: true }]); + if (!ok) return; + const names = [...list.querySelectorAll("input:checked")].map((i) => i.dataset.name); + let done = 0; + loading(true, "Converting…"); + try { + for (const n of names) { + $("tex-loading").querySelector("span").textContent = `Converting ${n} (${done + 1} / ${names.length})…`; + await frame(); + const { bytes } = await call("mdl:readFile", join(TEX.dir, n)); + const bmp = await TexDecode.decode(bytes, extOf(n)); + const c = new OffscreenCanvas(bmp.width, bmp.height); + const cx = c.getContext("2d", { willReadFrequently: true }); + cx.drawImage(bmp, 0, 0); + let px = cx.getImageData(0, 0, bmp.width, bmp.height).data, w = bmp.width, h = bmp.height; + if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) { + const f = TEX.exp.max / Math.max(w, h); + const nw = Math.max(1, Math.round(w * f)), nh = Math.max(1, Math.round(h * f)); + (px = TK.resize(px, w, h, nw, nh)), (w = nw), (h = nh); + } + const r = ddsBytes(px, w, h); + await call("mdl:writeFiles", TEX.dir, [{ name: `${stem(n)}.dds`, data: r.bytes }]); + done++; + } + toast(`Converted ${done} file(s) to .dds.`); + } catch (err) { + toast(`Convert: ${err.message} (${done} done)`, true); + } finally { + loading(false); + listFiles(); + } + } + + // ===================================================================== + // start + // ===================================================================== + function start() { + if (started) return; + started = true; + cv = $("tex-canvas"); + g = cv.getContext("2d"); + bindCanvas(); + new ResizeObserver(() => (TEX.fitted ? fitView() : draw())).observe(cv); + $("tex-tools").replaceChildren(...TOOLS.map((t) => el("button", { "data-tool": t.id, title: `${t.label}: ${t.tip}`, text: t.label, onclick: () => setTool(t.id) }))); + setTool("wand"); + $("tex-open").onclick = () => openFolder(); + $("tex-openfile").onclick = async () => { + const f = await call("dialog:openExt", "Open a picture", "Pictures", ["png", "jpg", "jpeg", "tga", "bmp", "dds"]); + if (!f) return; + const dir = f.replace(/[\\/][^\\/]*$/, ""); + if (dir !== TEX.dir) await openFolder(dir); + openFile(f); + }; + $("tex-refresh").onclick = () => listFiles(); + $("tex-convert").onclick = () => convertFolder(); + $("tex-save").onclick = () => save(); + $("tex-saveas").onclick = () => saveAs(); + $("tex-fit").onclick = () => fitView(); + $("tex-100").onclick = () => { + const r = cv.getBoundingClientRect(); + TEX.zoom = 1; + TEX.fitted = false; + TEX.pan = [(r.width - TEX.w) / 2, (r.height - TEX.h) / 2]; + draw(); + }; + $("tex-search").addEventListener("input", renderList); + renderSide(); + let dir = null; + try { + dir = localStorage.getItem(KEY); + } catch {} + if (dir) openFolder(dir); + else renderList(); + } + + // the tab opens: set up once + document.querySelector('#tabs button[data-view="tex"]')?.addEventListener("click", () => setTimeout(start, 0)); + Object.assign(TEX, { openFolder, openFile, save, refresh, selectAll, applyRecolor, setRc, convertFolder, start }); +})(); diff --git a/app/public/legacy/js/uires.js b/app/public/legacy/js/uires.js new file mode 100644 index 0000000..f5a1ed8 --- /dev/null +++ b/app/public/legacy/js/uires.js @@ -0,0 +1,671 @@ +"use strict"; + +// UI resources and drawing for the window editor, following the client (io3DEngine ioGUIManager, +// ioUIRenderImage, ioUIRenderFrame, ioUITitle, ioButton, ioStringManager): +// UIRes imagesets (xml/uiimageset.xml), frame templates (xml/defaultuiframe.xml), textures, +// the UI texts (text/text.txt) and the game font (font/lostsaga.ttf) +// UIText STR(n) → text ("xml__"), CP949 encoding +// UIDraw one window: frames, images, title / texts, button states, as the client draws them +// UIProps inputs for attributes (colours, alignments…) and the quick form of a window + +const UIRes = (() => { + let src = null; + let sets = new Map(); // imageset name (lower) → { file, images: Map(name lower → {x,y,w,h,ox,oy}) } + let frames = new Map(); // template name (lower) → { name, parts: [{ type, w, h, set, image }] } + const tex = new Map(); // texture file → Promise + const imgCache = new Map(); + let texts = null; // { encoding, xml, source } + let fontFamily = ""; + + const toLatin1 = (u8) => { + let s = ""; + for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192)); + return s; + }; + + function reset() { + src = null; + sets = new Map(); + frames = new Map(); + tex.clear(); + imgCache.clear(); + texts = null; + } + + async function load(s) { + if (src && JSON.stringify(src) === JSON.stringify(s)) return; + reset(); + src = s; + if (!s) return; + const [is, fr, tx, font] = await Promise.all([ + call("mdl:readPath", s, "xml/uiimageset.xml").catch(() => null), + call("mdl:readPath", s, "xml/defaultuiframe.xml").catch(() => null), + call("wnd:texts", s).catch(() => null), + fontFamily ? null : call("wnd:font", s).catch(() => null), + ]); + if (is) { + const t = toLatin1(is.bytes); + for (const m of t.matchAll(/]*)>([\s\S]*?)<\/Imageset>/gi)) { + const a = attrs(m[1]); + const images = new Map(); + for (const im of m[2].matchAll(/]*)\/?>/gi)) { + const b = attrs(im[1]); + images.set((b.name || "").toLowerCase(), { x: +b.x || 0, y: +b.y || 0, w: +b.width || 0, h: +b.height || 0, ox: +b.offsetx || 0, oy: +b.offsety || 0 }); + } + sets.set((a.name || "").toLowerCase(), { file: a.file || "", images }); + } + } + if (fr) { + const t = toLatin1(fr.bytes); + for (const m of t.matchAll(/]*)>([\s\S]*?)<\/FrameComponent>/gi)) { + const a = attrs(m[1]); + const parts = []; + for (const im of m[2].matchAll(/]*)\/?>/gi)) { + const b = attrs(im[1]); + const [w, h] = String(b.size || "").split(".").map(Number); + parts.push({ type: (b.type || "").toLowerCase(), w: w || 0, h: h || 0, set: b.imageset || "", image: b.image || "" }); + } + frames.set((a.name || "").toLowerCase(), { name: a.name, parts }); + } + } + texts = tx; + if (font && !fontFamily) { + try { + const face = new FontFace("LostSagaUI", font.bytes); + await face.load(); + document.fonts.add(face); + fontFamily = "LostSagaUI"; + } catch (err) { + console.warn("[wnd] font", err); + } + } + } + function attrs(s) { + const o = {}; + for (const m of s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) o[m[1].toLowerCase()] = m[2]; + return o; + } + + function texture(file) { + const key = file.toLowerCase(); + if (!tex.has(key)) { + tex.set( + key, + (async () => { + if (!src) return null; + const t = await call("mdl:texture", src, file); + if (!t) return null; + try { + return await TexDecode.decode(t.bytes, t.ext); + } catch { + return null; + } + })(), + ); + } + return tex.get(key); + } + + /** An imageset image (or a whole texture with File=): { bmp, x, y, w, h, ox, oy }. */ + async function image(set, name, file) { + const key = file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase(); + if (imgCache.has(key)) return imgCache.get(key); + if (file) { + const bmp = await texture(file); + const v = bmp ? { bmp, x: 0, y: 0, w: bmp.width, h: bmp.height, ox: 0, oy: 0 } : null; + imgCache.set(key, v); + return v; + } + const s = sets.get((set || "").toLowerCase()); + const r = s?.images.get((name || "").toLowerCase()); + if (!s || !r) { + imgCache.set(key, null); + return null; + } + const bmp = await texture(s.file); + const v = bmp ? { bmp, ...r } : null; + imgCache.set(key, v); + return v; + } + const imageSync = (set, name, file) => imgCache.get(file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase()); + const frame = (name) => frames.get((name || "").toLowerCase()) || null; + const imagesets = () => [...sets.keys()]; + const imagesOf = (set) => [...(sets.get((set || "").toLowerCase())?.images.keys() || [])]; + const frameNames = () => [...frames.values()].map((f) => f.name); + + return { reset, load, image, imageSync, frame, imagesets, imagesOf, frameNames, texts: () => texts, font: () => fontFamily }; +})(); +window.UIRes = UIRes; + +const UIText = (() => { + // CP949 encoder: the reverse of the browser's euc-kr decoder, built once + let table = null; + function build() { + table = new Map(); + const dec = new TextDecoder("euc-kr"); + const b = new Uint8Array(2); + for (let hi = 0x81; hi <= 0xfe; hi++) { + for (let lo = 0x41; lo <= 0xfe; lo++) { + b[0] = hi; + b[1] = lo; + const ch = dec.decode(b); + if (ch.length === 1 && ch !== "�" && !table.has(ch)) table.set(ch, [hi, lo]); + } + } + } + function encodeCp949(str) { + if (!table) build(); + const out = []; + for (const ch of str) { + const c = ch.codePointAt(0); + if (c < 0x80) out.push(c); + else { + const t = table.get(ch); + if (!t) throw new Error(`“${ch}” can't be written in CP949 (Korean encoding of the client files).`); + out.push(t[0], t[1]); + } + } + return Uint8Array.from(out); + } + + let fileKey = ""; // xml file name without folder / extension, lowercase (ioGUIManager::AddWnd) + const setFile = (name) => (fileKey = (name || "").replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "").toLowerCase()); + const key = (n) => `xml_${fileKey}_${n}`; + + /** A Title / Text / Tooltip value as the client shows it: STR(n) → text.txt "xml__". */ + function resolve(doc, el, text) { + const t = (text || "").trim(); + const m = /STR\((\d+)/i.exec(t); + const decoded = LSF.decode(Uint8Array.from(t, (c) => c.charCodeAt(0) & 255)); + if (!m) return decoded; + const tx = UIRes.texts(); + return tx?.xml[key(m[1])] ?? key(m[1]); // a missing key shows the key, like the client + } + function titleOf(doc, w) { + const title = XmlDoc.child(w, "Title"); + const te = title && XmlDoc.child(title, "Text"); + return te ? resolve(doc, te, XmlDoc.text(doc, te)) : ""; + } + return { encodeCp949, resolve, titleOf, setFile, key }; +})(); +window.UIText = UIText; + +const UIDraw = (() => { + const X = XmlDoc; + const low = (s) => (s || "").toLowerCase(); + + /** AARRGGBB like ioStringConverter: missing → FF000000, a value of 0 → FFFFFFFF. */ + function colorOf(s, dflt = "FF000000") { + if (s === undefined || s === null || s === "") s = dflt; + let v = parseInt(String(s).replace(/^0x/i, ""), 16); + if (!isFinite(v)) v = parseInt(dflt, 16); + if (v === 0) v = 0xffffffff; + if (String(s).replace(/^0x/i, "").length <= 6) v = (0xff000000 | v) >>> 0; + return { a: ((v >>> 24) & 255) / 255, r: (v >>> 16) & 255, g: (v >>> 8) & 255, b: v & 255 }; + } + const css = (c, alpha = 1) => `rgba(${c.r},${c.g},${c.b},${(c.a * alpha).toFixed(3)})`; + + const BLEND = { add: "lighter", addalpha: "lighter", addcolor: "lighter", screen: "screen", multiply: "multiply", lighten: "lighten", darken: "darken" }; + function withBlend(ctx, renderType, fn) { + const rt = low(renderType); + ctx.save(); + if (BLEND[rt]) ctx.globalCompositeOperation = BLEND[rt]; + if (rt === "gray" || rt === "graylight") ctx.filter = "grayscale(1)"; + fn(); + ctx.restore(); + } + + // tinted copies (Color on an image / frame multiplies RGB, keeps alpha) + const tintCache = new Map(); + function tinted(im, color) { + if (!color || /^f{6}$/i.test(color.slice(-6))) return im; + const k = `${im.x},${im.y},${im.w},${im.h},${color}`; + let t = tintCache.get(im.bmp)?.get(k); + if (t) return t; + const c = document.createElement("canvas"); + c.width = im.w; + c.height = im.h; + const g = c.getContext("2d"); + g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h); + g.globalCompositeOperation = "multiply"; + const col = colorOf(color, "FFFFFFFF"); + g.fillStyle = `rgb(${col.r},${col.g},${col.b})`; + g.fillRect(0, 0, im.w, im.h); + g.globalCompositeOperation = "destination-in"; + g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h); + t = { ...im, bmp: c, x: 0, y: 0 }; + if (!tintCache.has(im.bmp)) tintCache.set(im.bmp, new Map()); + tintCache.get(im.bmp).set(k, t); + return t; + } + + function blit(ctx, im, x, y, w, h, flipH, flipV) { + if (!im || !im.bmp || !im.w || !im.h || w <= 0 || h <= 0) return; + if (!flipH && !flipV) return ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, x, y, w, h); + ctx.save(); + ctx.translate(x + (flipH ? w : 0), y + (flipV ? h : 0)); + ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1); + ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, w, h); + ctx.restore(); + } + + /** + * A window (ioUIRenderImage): Area gives the render rect relative to the window (only + * the position when W or H is 0); x = win + Area.X − imageset OffsetX (+ −w/2 Center, −w Right); + * natural size except Stretch; Tile repeats; Reverse flips; Scale scales; Color tints. + */ + function drawImageEl(ctx, e, r) { + const im0 = UIRes.imageSync(X.attr(e, "Imageset"), X.attr(e, "Image"), X.attr(e, "File")); + if (!im0) return; + const im = tinted(im0, X.attr(e, "Color")); + const area = X.child(e, "Area"); + const scale = X.num(e, "Scale", 1) || 1; + const aw = X.num(area, "Width"), ah = X.num(area, "Height"); + const hasRect = aw > 0 && ah > 0; + const ha = low(X.attr(area, "HorzAlign") || "left"), va = low(X.attr(area, "VertAlign") || "top"); + const nw = im.w * scale, nh = im.h * scale; + const bx = r.x + X.num(area, "X") - im.ox * scale, by = r.y + X.num(area, "Y") - im.oy * scale; + const rev = low(X.attr(area, "Reverse")); + const fh = rev.includes("horz"), fv = rev.includes("vert"); + const w = hasRect && ha === "stretch" ? aw : nw; + const h = hasRect && va === "stretch" ? ah : nh; + let x = bx + (ha === "center" ? -w / 2 : ha === "right" ? -w : 0); + let y = by + (va === "center" ? -h / 2 : va === "bottom" ? -h : 0); + withBlend(ctx, X.attr(area, "RenderType"), () => { + const tx = hasRect && ha === "tile" ? Math.max(1, Math.ceil(aw / nw)) : 1; + const ty = hasRect && va === "tile" ? Math.max(1, Math.ceil(ah / nh)) : 1; + for (let i = 0; i < tx; i++) for (let j = 0; j < ty; j++) blit(ctx, im, x + i * w, y + j * h, w, h, fh, fv); + }); + } + + /** + * A frame template (ioUIRenderFrame) inside a rect: corners at their image size, the part Size + * ("w.h") sets the layout thickness, edges stretched along, centre stretched both ways. + */ + function drawFrame(ctx, tpl, r, color, renderType) { + const f = UIRes.frame(tpl); + if (!f) return false; + const p = Object.fromEntries(f.parts.map((x) => [x.type, x])); + const img = (x) => { + const im = x && UIRes.imageSync(x.set, x.image); + return im ? tinted(im, color) : null; + }; + const W = (x) => (x ? x.w || img(x)?.w || 0 : 0); + const H = (x) => (x ? x.h || img(x)?.h || 0 : 0); + const nat = (x) => img(x) || { w: 0, h: 0 }; + const fw = r.w, fh = r.h; + withBlend(ctx, renderType, () => { + const put = (x, px, py, pw, ph) => { + const im = img(x); + if (im && pw > 0 && ph > 0) blit(ctx, im, r.x + px - (im.ox || 0), r.y + py - (im.oy || 0), pw, ph); + }; + const tl = p.topleft, tr = p.topright, bl = p.bottomleft, br = p.bottomright; + put(tl, 0, 0, nat(tl).w, nat(tl).h); + put(p.topedge, W(tl), 0, fw - W(tl) - W(tr), nat(p.topedge).h); + put(tr, fw - W(tr), 0, nat(tr).w, nat(tr).h); + put(p.leftedge, 0, H(tl), nat(p.leftedge).w, fh - H(tl) - H(bl)); + put(p.center, W(p.leftedge), H(p.topedge), fw - W(p.leftedge) - W(p.rightedge), fh - H(p.topedge) - H(p.bottomedge)); + put(p.rightedge, fw - W(p.rightedge), H(tr), nat(p.rightedge).w, fh - H(tr) - H(br)); + put(bl, 0, fh - H(bl), nat(bl).w, nat(bl).h); + put(p.bottomedge, W(bl), fh - H(p.bottomedge), fw - W(bl) - W(br), nat(p.bottomedge).h); + put(br, fw - W(br), fh - H(br), nat(br).w, nat(br).h); + }); + return true; + } + + function drawFrameEl(ctx, e, r) { + const area = X.child(e, "Area"); + // no Width / Height: to the window's right / bottom edge (e.g. a BottomBar at Y=193) + const ax = X.num(area, "X"), ay = X.num(area, "Y"); + const ar = area ? { x: r.x + ax, y: r.y + ay, w: X.num(area, "Width") || Math.max(0, r.w - ax), h: X.num(area, "Height") || Math.max(0, r.h - ay) } : r; + const tpl = X.attr(e, "Templete") || X.attr(e, "Template"); + // "…multiply…" templates / MultiplyFrm are the darkening strips the client draws with Multiply + const rt = X.attr(area, "RenderType") || (/multiply/i.test(tpl || "") || /multiply/i.test(X.attr(e, "Type") || "") ? "Multiply" : null); + drawFrame(ctx, tpl, ar, X.attr(e, "Color"), rt); + } + + // ---------- text (ioUITitle + ioFontManager) ---------- + function fontCss(size, bold) { + const fam = UIRes.font(); + return `${bold ? "bold " : ""}${Math.max(1, size)}px ${fam ? `"${fam}", ` : ""}"Microsoft JhengHei", "Malgun Gothic", "Leelawadee UI", sans-serif`; + } + /** Draws one text with the client's styles (outline / shadow in the Back colour). */ + function drawText(ctx, str, { x, y, size = 12, style = "Normal", halign = "left", valign = "top", gap = 0, text, back, alpha = 1, widthCut = 0 }) { + if (!str) return; + const st = low(style); + const bold = st.startsWith("bold"); + ctx.save(); + ctx.font = fontCss(size, bold); + ctx.textAlign = halign === "center" ? "center" : halign === "right" ? "right" : "left"; + ctx.textBaseline = "top"; + const lines = String(str).split(/#|\n/); + const pitch = gap || 0; // VertGap is 0 when missing (the client overlaps un-gapped lines) + const totalH = size + pitch * (lines.length - 1); + let y0 = valign === "center" ? y - totalH / 2 : valign === "bottom" ? y - totalH : y; + const m = /outline(_full)?(?:_(\d)x)?/.exec(st); + const ring = m ? (m[1] ? Number(m[2] || 1) : 1) : 0; + lines.forEach((line, i) => { + let s = line; + if (widthCut > 0) { + while (s.length > 1 && ctx.measureText(s).width > widthCut) s = s.slice(0, -1); + } + const ly = y0 + i * pitch; + if (ring) { + ctx.lineJoin = "round"; + ctx.lineWidth = ring * 2; + ctx.strokeStyle = css(back, alpha); + ctx.strokeText(s, x, ly); + } else if (st.includes("shadow")) { + ctx.fillStyle = css(back, alpha); + ctx.fillText(s, x + 1, ly + 1); + } + ctx.fillStyle = css(text, alpha); + ctx.fillText(s, x, ly); + }); + ctx.restore(); + } + + function drawTitle(ctx, doc, w, r, state) { + const title = X.child(w, "Title"); + if (!title) return; + const fmt = X.child(title, "Format"); + const colors = X.childrenOf(title, "Color"); + const disabled = state === "Disable"; + const col = colors.find((c) => low(X.attr(c, "Type")) === low(state)) || colors.find((c) => low(X.attr(c, "Type")) === "normal") || colors[0]; + const te = X.child(title, "Text"); + const str = te ? UIText.resolve(doc, te, X.text(doc, te)) : ""; + let style = X.attr(fmt, "Style") || "Normal"; + let dx = 0, dy = 0; + if (disabled && X.attr(fmt, "DisableStyle")) { + const ds = X.attr(fmt, "DisableStyle"); + if (low(ds) !== low(style) && low(ds) === "normal" && low(X.attr(fmt, "VertAlign") || "top") === "top") (dx = 2), (dy = 2); + style = ds; + } + drawText(ctx, str, { + x: r.x + X.num(fmt, "X") + dx, + y: r.y + X.num(fmt, "Y") + dy, + size: X.num(fmt, "Size", 12), + style, + halign: low(X.attr(fmt, "HorzAlign") || "left"), + valign: low(X.attr(fmt, "VertAlign") || "top"), + gap: X.num(fmt, "VertGap", 0), + text: colorOf(X.attr(col, "Text")), + back: colorOf(X.attr(col, "Back")), + }); + } + + /** Window-level list entries: Format fields as attributes or a child, one Color. */ + function drawTexts(ctx, doc, w, r) { + for (const t of X.childrenOf(w, "Text")) { + const fmt = X.child(t, "Format") || t; + const col = X.child(t, "Color") || t; + const body = X.child(t, "Text") || (t.children.length ? null : t); + const raw = body ? X.text(doc, body) : ""; + if (!raw.trim()) continue; + drawText(ctx, UIText.resolve(doc, body, raw), { + x: r.x + X.num(fmt, "X"), + y: r.y + X.num(fmt, "Y"), + size: X.num(fmt, "Size", 12), + style: X.attr(fmt, "Style") || "Normal", + halign: low(X.attr(fmt, "HorzAlign") || "left"), + valign: low(X.attr(fmt, "VertAlign") || "top"), + gap: X.num(fmt, "VertGap", 0), + text: colorOf(X.attr(col, "Text") || X.attr(col, "Color")), + back: colorOf(X.attr(col, "Back")), + widthCut: X.num(fmt, "WidthCut", 0), + }); + } + } + + // ioRichLabel: … laid out one after another + function drawTextBlocks(ctx, doc, w, r) { + const extra = X.child(w, "ExtraInfo"); + const blocks = X.childrenOf(extra, "TextBlock"); + if (!blocks.length) return; + let x = r.x, y = r.y; + for (const b of blocks) { + const raw = X.attr(b, "Text") || ""; + const str = /STR\(/i.test(raw) ? UIText.resolve(doc, b, raw) : LSF.decode(Uint8Array.from(raw, (c) => c.charCodeAt(0) & 255)); + const size = X.num(b, "Size", 12); + const parts = str.split("#"); + parts.forEach((p, i) => { + if (i) { + x = r.x; + y += size + 2; + } + const col = colorOf(X.attr(b, "Color") || "FFFFFFFF"); + drawText(ctx, p, { x, y, size, style: X.attr(b, "Style") || "Normal", text: col, back: colorOf("FF000000") }); + ctx.font = fontCss(size); + x += ctx.measureText(p).width; + }); + } + } + + // EX classes (ioLabelWndEX / ioButtonWndEX / ioFrameWndEX): ExtraInfo children that fill the window + const INT_H = ["left", "center", "right"]; + const INT_V = ["top", "center", "bottom"]; + function drawEx(ctx, doc, w, r, state) { + const extra = X.child(w, "ExtraInfo"); + if (!extra) return; + const want = { Normal: ["NormalFrame", "NormalImage"], Over: ["OverFrame", "OverImage"], Push: ["PushFrame", "PushImage"], Disable: ["DisableFrame", "NormalImage"] }[state] || ["NormalFrame", "NormalImage"]; + for (const c of extra.children) { + const tag = c.tag; + if (tag === "FrameInfo" || (tag.endsWith("Frame") && want.includes(tag)) || (tag === "OverAddFrame" && state === "Over")) { + drawFrame(ctx, X.attr(c, "Template") || X.attr(c, "Templete"), r, X.attr(c, "Color"), tag === "OverAddFrame" ? "Add" : null); + } else if (tag === "ImageInfo" || (tag.endsWith("Image") && want.includes(tag))) { + const im = UIRes.imageSync(X.attr(c, "Imageset"), X.attr(c, "Image"), X.attr(c, "File")); + if (im) blit(ctx, tinted(im, X.attr(c, "Color")), r.x, r.y, r.w || im.w, r.h || im.h); + } else if (tag === "LabelInfo") { + const h = INT_H[X.num(c, "HorzAlign", 0)] || "left"; + const v = INT_V[X.num(c, "VertAlign", 0)] || "top"; + const size = X.num(c, "Size", 12); + const raw = X.attr(c, "Text") || ""; + drawText(ctx, LSF.decode(Uint8Array.from(raw, (ch) => ch.charCodeAt(0) & 255)), { + x: r.x + (h === "center" ? r.w / 2 : h === "right" ? r.w : 0) + X.num(extra, "LabelOffsetX"), + y: r.y + (v === "center" ? r.h / 2 : v === "bottom" ? r.h : 0) + X.num(extra, "LabelOffsetY"), + size, + halign: h, + valign: v, + text: colorOf(X.attr(c, state === "Over" ? "OverColor" : "NormalColor") || X.attr(c, "Color") || "FFFFFFFF"), + back: colorOf(X.attr(c, "BKColor")), + alpha: X.num(c, "Alpha", 255) / 255, + }); + } + } + } + + const FIRST = ["selected", "multiplyfrm", "selectedtitle"]; + // engine classes (ioGUIManager::CreateWnd): they draw every image Type in their list; client + // classes claim their own Types and draw them in code, so an Area-less image with such a Type + // isn't at the window origin in the game + const ENGINE = new Set("ioWnd ioButton ioScreenButton ioConvexButton ioScaleButton ioCheckButton ioRadioButton ioStretchButton ioActiveScreenBtn ioEdit ioProgressBar ioScroll ioSlide ioToggleButton ioWndEX ioFrameWndEX ioImageWndEX ioLabelWndEX ioFrameButtonWndEX ioImageButtonWndEX ioCheckBoxEX ioFlashPlayer ioTabFrameButtonEX ioTabWndEX ioScrollBarEX ioRichLabel ioRadioWndEX ioRadioFrameButtonEX ioMovingWnd ioMovingBtn ioMovingNormalBtn ioCustomWnd".toLowerCase().split(" ")); + const GENERIC_TYPES = new Set(["", "normal", "back", "background", "bg"]); + /** An image a client class draws itself (so the generic list skips it). */ + const classDrawn = (w, e) => { + if (e.tag !== "Image" || X.child(e, "Area")) return false; + const cls = low(X.attr(w, "ClassName")); + if (ENGINE.has(cls)) return false; + const t = low(X.attr(e, "Type")); + return !GENERIC_TYPES.has(t) && !t.includes("#"); + }; + const BUTTON_STATES = ["normal", "over", "overadd", "push", "disable", "exdisablenor", "exdisablepush", "back", "normallist", "disablelist", "check"]; + + /** Draws one window (not its child windows) at r. opts: { state, text, images, preview } */ + function window(ctx, doc, w, r, { state = "Normal", text = true, images = true, preview = false } = {}) { + const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : ""; + const inactive = /IWS_INACTIVE/.test(style); + const st = inactive ? "Disable" : state; + const items = X.childrenOf(w, "Imagery").flatMap((im) => im.children); + const cls = X.attr(w, "ClassName") || ""; + const isButton = /button|btn/i.test(cls) || items.some((e) => ["over", "push"].includes(low(X.attr(e, "Type")))); + const isCheck = /check/i.test(cls); + const draw = (e) => { + if (e.tag === "DefaultFrameImagery" || e.tag === "FrameImagery") return drawFrameEl(ctx, e, r); + if (e.tag !== "Image") return; + if (classDrawn(w, e)) { + // placed by the class's code: hidden in the preview, faint at the origin while editing + if (preview) return; + ctx.save(); + ctx.globalAlpha *= 0.35; + drawImageEl(ctx, e, r); + ctx.restore(); + return; + } + drawImageEl(ctx, e, r); + }; + ctx.save(); + if (inactive) ctx.filter = "grayscale(1)"; + if (images) { + // Selected / MultiplyFrm / SelectedTitle frames first (ioWnd::OnRender) + for (const e of items) if (FIRST.includes(low(X.attr(e, "Type")))) draw(e); + if (isButton) { + const of = (t) => items.filter((e) => low(X.attr(e, "Type")) === t); + of("back").forEach(draw); + const s = low(st); + const stateItems = of(s === "disable" ? "disable" : s); + (stateItems.length ? stateItems : of("normal")).forEach(draw); + if (s === "over") of("overadd").forEach((e) => withBlend(ctx, "Add", () => draw(e))); + if (isCheck) of("check").forEach(draw); + // NormalList / DisableList images (the "x" of a close button…) come after the state art + (s === "disable" && of("disablelist").length ? of("disablelist") : of("normallist")).forEach(draw); + for (const e of items) { + const t = low(X.attr(e, "Type")); + if (!BUTTON_STATES.includes(t) && !FIRST.includes(t)) draw(e); + } + } else { + for (const e of items) { + const t = low(X.attr(e, "Type")); + if (FIRST.includes(t) || ["over", "overadd", "push", "disable"].includes(t)) continue; + draw(e); + } + } + drawEx(ctx, doc, w, r, st); + } + if (text) { + drawTitle(ctx, doc, w, r, st); + drawTexts(ctx, doc, w, r); + drawTextBlocks(ctx, doc, w, r); + } + ctx.restore(); + } + + /** IWS_MODAL: a full-screen BaseImage001/strip_black behind the window (×3 for MODAL_EXTEND). */ + function modalBack(ctx, doc, w, screen) { + const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : ""; + if (!/IWS_MODAL/.test(style)) return; + const im = UIRes.imageSync("BaseImage001", "strip_black"); + const n = /IWS_MODAL_EXTEND/.test(style) ? 3 : 1; + for (let i = 0; i < n; i++) { + if (im) blit(ctx, im, 0, 0, screen[0], screen[1]); + else { + ctx.fillStyle = "rgba(0,0,0,.5)"; + ctx.fillRect(0, 0, screen[0], screen[1]); + } + } + } + + /** Every image name a document can draw (to load them before drawing). */ + function wanted(doc) { + const out = []; + for (const e of doc.elements) { + const set = X.attr(e, "Imageset"), img = X.attr(e, "Image"), file = X.attr(e, "File"); + if ((set && img) || (file && /\.(dds|png|tga|bmp|jpg)$/i.test(file))) out.push([set, img, file]); + const tpl = X.attr(e, "Templete") || X.attr(e, "Template"); + if (tpl) for (const p of UIRes.frame(tpl)?.parts || []) out.push([p.set, p.image]); + } + out.push(["BaseImage001", "strip_black"]); + return out; + } + + return { window, modalBack, drawFrame, drawText, colorOf, wanted }; +})(); +window.UIDraw = UIDraw; + +const UIProps = (() => { + const X = XmlDoc; + const ENUMS = { + horzalign: ["Left", "Center", "Right", "Stretch", "Tile"], + vertalign: ["Top", "Center", "Bottom", "Stretch", "Tile"], + xalign: ["", "Left", "Center", "Right", "Rate"], + yalign: ["", "Top", "Center", "Bottom", "Rate"], + autoresize: ["True", "False"], + rendertype: ["", "Normal", "Screen", "Gray", "Add", "AddColor", "ColorAlpha", "Lighten", "Darken", "Multiply", "GrayLight", "AddAlpha"], + reverse: ["", "Horz", "Vert", "HorzVert"], + style: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Outline_full_4x", "Bold", "Bold_shadow", "Bold_outline", "Bold_outline_full", "Bold_outline_full_2x", "Bold_outline_full_3x", "Bold_outline_full_4x"], + disablestyle: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Bold", "Bold_shadow"], + }; + /** An input for an attribute: colours get a picker, known enums a list, the rest text. */ + function input(el, name, value, onChange) { + const n = name.toLowerCase(); + const enumOk = ENUMS[n] && !(n === "style" && el.tag !== "Format"); + if (enumOk) { + const sel = document.createElement("select"); + for (const v of new Set([...ENUMS[n], value])) { + const o = document.createElement("option"); + o.value = v; + o.textContent = v || "(none)"; + o.selected = v === value; + sel.append(o); + } + sel.onchange = () => onChange(sel.value); + return sel; + } + if ((n === "templete" || n === "template") && UIRes.frameNames().length) return withList(value, onChange, "wnd-dl-frames", UIRes.frameNames()); + if (n === "imageset" && UIRes.imagesets().length) return withList(value, onChange, "wnd-dl-sets", UIRes.imagesets()); + const t = document.createElement("input"); + t.type = "text"; + t.value = value; + t.spellcheck = false; + t.addEventListener("change", () => onChange(t.value)); + t.addEventListener("keydown", (e) => e.key === "Enter" && t.blur()); + if (/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) && /color|^text$|^back$|bkcolor/i.test(name)) { + const pick = document.createElement("input"); + pick.type = "color"; + pick.value = `#${value.slice(-6)}`; + pick.onchange = () => onChange((value.length === 8 ? value.slice(0, 2) : "") + pick.value.slice(1).toUpperCase()); + const wrap = document.createElement("div"); + wrap.className = "wnd-color"; + wrap.append(pick, t); + return wrap; + } + return t; + } + function withList(value, onChange, id, names) { + if (!document.getElementById(id)) { + const dl = document.createElement("datalist"); + dl.id = id; + for (const n of names) { + const o = document.createElement("option"); + o.value = n; + dl.append(o); + } + document.body.append(dl); + } + const t = document.createElement("input"); + t.type = "text"; + t.value = value; + t.spellcheck = false; + t.setAttribute("list", id); + t.addEventListener("change", () => onChange(t.value)); + return t; + } + + /** The quick form of a window: position, size, alignment. */ + function quick(doc, w, { edit }) { + const s = X.child(w, "Size"); + const box = document.createElement("div"); + box.className = "wnd-quick"; + if (!s) return box; + for (const k of ["X", "Y", "Width", "Height", "XAlign", "YAlign"]) { + const lab = document.createElement("label"); + lab.className = "fld"; + const sp = document.createElement("span"); + sp.textContent = k; + lab.append(sp, input(s, k, X.attr(s, k) ?? "", (v) => edit(s, (d, x) => (v === "" ? X.removeAttr(d, x, k) : X.setAttr(d, x, k, v))))); + box.append(lab); + } + return box; + } + return { input, quick }; +})(); +window.UIProps = UIProps; diff --git a/app/public/legacy/js/uiwnd.js b/app/public/legacy/js/uiwnd.js new file mode 100644 index 0000000..f59d737 --- /dev/null +++ b/app/public/legacy/js/uiwnd.js @@ -0,0 +1,739 @@ +"use strict"; + +// UI window editor (Wnd tab): the client's window layouts, rs.iop xml/*.xml ( trees). +// Shows the window as the game draws it (frames from xml/defaultuiframe.xml, images from +// xml/uiimageset.xml, titles with their text), lets windows be moved / resized on the canvas and +// every element / attribute be edited in the side panel or as XML source. Edits touch only the +// characters they change (xmldoc.js), so a patch carries just what was edited. + +(() => { + const WND = { + src: null, + rows: [], + doc: null, + where: null, + title: "", + dirty: false, + undo: [], + redo: [], + sel: null, // index of the selected in doc.elements + zoom: 1, + panX: 40, + panY: 40, + screen: [1024, 768], + state: "Normal", + showHidden: true, + showBounds: true, + tex: new Map(), + }; + window.WND = WND; + const toggled = (id) => $(id).classList.contains("on"); + const X = XmlDoc; + const baseName = (p) => p.replace(/\\/g, "/").split("/").pop(); + const toLatin1 = (u8) => { + let s = ""; + for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192)); + return s; + }; + const fromLatin1 = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255); + const cp949 = (s) => LSF.decode(fromLatin1(s)); + + // ===================================================================== + // source + list + // ===================================================================== + async function setSource(src, { quiet = false } = {}) { + try { + const d = await call("mdl:describe", src); + WND.src = { ...src, label: d.label }; + } catch (err) { + return toast(err.message, true); + } + $("wnd-src-label").textContent = WND.src.kind === "dir" ? "Extracted folder" : WND.src.kind === "packs" ? "Pack folder" : `Packs next to ${WND.src.name}`; + $("wnd-src").title = `Reading from ${WND.src.label}`; + WND.tex.clear(); + UIRes.reset(); + await loadList(); + if (!quiet) toast(`Reading windows from ${WND.src.label}.`); + } + async function chooseSource() { + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + let choice = null; + const options = [ + ...profileSourceOption(), + ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), + { value: "packs", title: "A folder with .iop packs…", sub: "the client folder: rs.iop (xml/, texts), map.iop / map2.iop (textures)" }, + { value: "dir", title: "A folder of extracted files…", sub: "xml/…, resource/texture… extracted from the packs" }, + ]; + const list = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [el("input", { type: "radio", name: "wndsrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])]))); + choice = options[0].value; + if (!(await modal("Where are the UI files?", ["Window layouts are in rs.iop (xml/…); their pictures in the textures of map.iop / map2.iop. Pick the folder with all of them.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false; + let src = choice; + if (choice === "packs" || choice === "dir") { + const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); + if (!folder) return false; + src = { kind: choice, folder }; + } + await setSource(src); + return true; + } + $("wnd-src").onclick = () => chooseSource(); + + const list = new VTable($("wnd-list"), [{ key: 1, label: "Window file", width: 220, grow: true, title: true }], { multi: false, id: "wnd" }); + list.keyOf = (r) => r[0]; + list.onOpen = (r) => openRow(r); + + async function loadList() { + if (!WND.src) return; + try { + const rows = await call("xml:list", WND.src); + WND.rows = rows.map(([key, path, pack]) => [key, path.replace(/^.*?xml\//i, ""), pack, path]); + } catch (err) { + toast(err.message, true); + WND.rows = []; + } + filterList(); + } + function filterList() { + const q = $("wnd-search").value.trim().toLowerCase(); + const rows = WND.rows.filter((r) => !q || r[1].toLowerCase().includes(q)); + list.setRows(rows); + $("wnd-count").textContent = rows.length.toLocaleString(); + } + $("wnd-search").addEventListener("input", filterList); + + async function confirmDiscard() { + if (!WND.dirty) return true; + return confirmBox("Unsaved changes", "This window file has changes that aren't saved. Discard them?", "Discard", "danger"); + } + async function openRow(r) { + if (!r || !(await confirmDiscard())) return; + try { + const hit = await call("mdl:readKey", WND.src, r[0]); + if (!hit) throw new Error("Not found in the packs any more."); + await openText(toLatin1(hit.bytes), hit.where, r[1]); + } catch (err) { + toast(err.message, true); + } + } + $("wnd-open-file").onclick = async () => { + if (!(await confirmDiscard())) return; + const file = await call("dialog:openXml"); + if (!file) return; + try { + const hit = await call("mdl:readFile", file); + await openText(toLatin1(hit.bytes), hit.where, file); + } catch (err) { + toast(err.message, true); + } + }; + + async function openText(text, where, title) { + const doc = X.parse(text); + if (!doc.root || doc.root.tag !== "Window") throw new Error(`This isn't a window layout (root <${doc.root?.tag || "?"}>).`); + WND.doc = doc; + WND.where = where; + WND.title = title; + WND.dirty = false; + WND.undo = []; + WND.redo = []; + WND.sel = doc.elements.indexOf(doc.root); + UIText.setFile(title); + $("wnd-title").textContent = title; + $("wnd-dirty").hidden = true; + $("wnd-empty").hidden = true; + $("wnd-side").hidden = false; + if (doc.warnings?.length) toast(`Loaded with ${doc.warnings.length} XML quirk(s): ${doc.warnings[0]}`, true); + await UIRes.load(WND.src); + const tx = UIRes.texts(); + $("wnd-textinfo").textContent = tx ? `${tx.count.toLocaleString()} UI texts · ${tx.encoding}` : "no text.txt"; + $("wnd-textinfo").title = tx ? `STR(n) → ${UIText.key("n")} from ${tx.source}` : ""; + await prefetch(); + fit(); + renderAll(); + } + + // ===================================================================== + // edits (all through the source text, with undo) + // ===================================================================== + function selEl() { + return WND.doc?.elements[WND.sel] || null; + } + /** Path of an element (tag + index among same-tag siblings) so it can be found again after a re-parse. */ + function pathOf(el) { + const p = []; + for (let e = el; e && e.parent; e = e.parent) p.unshift([e.tag, e.parent.children.filter((c) => c.tag === e.tag).indexOf(e)]); + return p; + } + function byPath(doc, p) { + let e = doc.root; + for (const [tag, i] of p) { + e = e?.children.filter((c) => c.tag === tag)[i]; + if (!e) return null; + } + return e; + } + function apply(newSrc, { keep = true } = {}) { + if (newSrc === WND.doc.src) return; + const selPath = keep && selEl() ? pathOf(selEl()) : null; + WND.undo.push(WND.doc.src); + if (WND.undo.length > 300) WND.undo.shift(); + WND.redo = []; + reparse(newSrc, selPath); + } + function reparse(src, selPath) { + WND.doc = X.parse(src); + const s = selPath ? byPath(WND.doc, selPath) : null; + WND.sel = WND.doc.elements.indexOf(s || WND.doc.root); + WND.dirty = true; + $("wnd-dirty").hidden = false; + renderAll(); + } + function undoRedo(from, to) { + const s = from.pop(); + if (s === undefined) return; + const selPath = selEl() ? pathOf(selEl()) : null; + to.push(WND.doc.src); + reparse(s, selPath); + } + $("wnd-undo").onclick = () => undoRedo(WND.undo, WND.redo); + $("wnd-redo").onclick = () => undoRedo(WND.redo, WND.undo); + /** Edits one element: fn(doc, el) → new source. */ + const editEl = (el, fn) => apply(fn(WND.doc, el)); + + async function save(as = false) { + if (!WND.doc) return; + let where = WND.where; + if (as || !where) { + const file = await call("dialog:saveXml", baseName(WND.title || "window.xml")); + if (!file) return; + where = { kind: "file", file }; + } else if (where.kind === "pack" && !(await confirmBox("Save into the pack", `Write ${where.entry} into ${baseName(where.file)}? The new copy replaces the old one.`, "Save"))) return; + try { + await call("mdl:writeBack", where, fromLatin1(WND.doc.src)); + WND.where = where; + WND.dirty = false; + $("wnd-dirty").hidden = true; + toast(`Saved ${where.kind === "pack" ? `${where.entry} into ${baseName(where.file)}` : where.file}.`); + } catch (err) { + toast(err.message, true); + } + } + $("wnd-save").onclick = (e) => + showMenu( + [ + { label: WND.where?.kind === "pack" ? `Save into ${baseName(WND.where.file)}` : "Save", icon: WND.where?.kind === "pack" ? "box" : "save", hint: "Ctrl+S", disabled: !WND.doc, run: () => save() }, + { label: "Save as file…", icon: "download", disabled: !WND.doc, run: () => save(true) }, + "-", + { label: "Screenshot (PNG)…", icon: "camera", disabled: !WND.doc, run: screenshot }, + ], + { anchor: e.currentTarget }, + ); + + // ===================================================================== + // layout (window rectangles in screen space) + // ===================================================================== + const windows = () => (WND.doc ? WND.doc.elements.filter((e) => e.tag === "Window") : []); + const sizeOf = (w) => X.child(w, "Size"); + const styleText = (w) => (X.child(w, "Style") ? X.text(WND.doc, X.child(w, "Style")) : ""); + const isHidden = (w) => /IWS_START_HIDE/.test(styleText(w)); + + /** Screen rectangle of a window: its Size relative to the parent window (with X/Y align). */ + function rectOf(w, cache = new Map()) { + if (cache.has(w)) return cache.get(w); + const s = sizeOf(w); + const pw = w.parent && w.parent.tag === "Window" ? w.parent : null; + const pr = pw ? rectOf(pw, cache) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] }; + // ioWnd::SetSize: Width/Height "Parent" fill the parent; X/YAlign Center / Right / Bottom + // position against the parent, Rate scales by screen / 800×600 + let x = X.num(s, "X"), y = X.num(s, "Y"); + const wd = /^parent$/i.test(X.attr(s, "Width") || "") ? pr.w : X.num(s, "Width"); + const ht = /^parent$/i.test(X.attr(s, "Height") || "") ? pr.h : X.num(s, "Height"); + const xa = (X.attr(s, "XAlign") || "").toLowerCase(); + const ya = (X.attr(s, "YAlign") || "").toLowerCase(); + if (xa === "center") x = x + (pr.w - wd) / 2; + else if (xa === "right") x = x + pr.w - wd; + else if (xa === "rate") x = x * (WND.screen[0] / 800); + if (ya === "center") y = y + (pr.h - ht) / 2; + else if (ya === "bottom") y = y + pr.h - ht; + else if (ya === "rate") y = y * (WND.screen[1] / 600); + const r = { x: pr.x + x, y: pr.y + y, w: wd, h: ht, lx: x, ly: y }; + cache.set(w, r); + return r; + } + + // ===================================================================== + // drawing (UIRes / UIDraw do the game-specific parts) + // ===================================================================== + const canvas = $("wnd-canvas"); + const ctx = canvas.getContext("2d"); + function resize() { + const r = canvas.parentElement.getBoundingClientRect(); + const dpr = devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(r.width * dpr)); + canvas.height = Math.max(1, Math.round(r.height * dpr)); + canvas.style.width = `${r.width}px`; + canvas.style.height = `${r.height}px`; + draw(); + } + new ResizeObserver(resize).observe(canvas.parentElement); + + function fit() { + const r = canvas.parentElement.getBoundingClientRect(); + const root = WND.doc?.root; + const rr = root ? rectOf(root) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] }; + const w = Math.max(rr.w, 50), h = Math.max(rr.h, 50); + WND.zoom = Math.max(0.1, Math.min(4, Math.min((r.width - 60) / w, (r.height - 60) / h))); + WND.panX = (r.width - w * WND.zoom) / 2 - rr.x * WND.zoom; + WND.panY = (r.height - h * WND.zoom) / 2 - rr.y * WND.zoom; + $("wnd-zoom").textContent = `${Math.round(WND.zoom * 100)}%`; + draw(); + } + + async function prefetch() { + // textures used by the file, so the first draw has them + await Promise.all(UIDraw.wanted(WND.doc).map(([s, i, f]) => UIRes.image(s, i, f))); + } + + let drawQueued = false; + function draw() { + if (drawQueued) return; + drawQueued = true; + requestAnimationFrame(() => { + drawQueued = false; + drawNow(); + }); + } + function drawNow() { + const dpr = devicePixelRatio || 1; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!WND.doc) return; + ctx.setTransform(dpr * WND.zoom, 0, 0, dpr * WND.zoom, dpr * WND.panX, dpr * WND.panY); + // the game screen: an opaque, scene-like backdrop (Multiply / Add images need something under them) + backdrop(ctx); + ctx.strokeStyle = "rgba(255,255,255,0.12)"; + ctx.lineWidth = 1 / WND.zoom; + ctx.strokeRect(0, 0, WND.screen[0], WND.screen[1]); + const cache = new Map(); + const preview = toggled("wnd-t-preview"); + const showHidden = WND.showHidden && !preview; + if (preview) UIDraw.modalBack(ctx, WND.doc, WND.doc.root, WND.screen); + const drawWin = (w) => { + if (!showHidden && isHidden(w) && w !== WND.doc.root) return; + const r = rectOf(w, cache); + UIDraw.window(ctx, WND.doc, w, r, { state: WND.state, text: toggled("wnd-t-text"), images: toggled("wnd-t-img"), preview }); + for (const c of w.children) if (c.tag === "Window") drawWin(c); + }; + drawWin(WND.doc.root); + // bounds + selection + const px = 1 / WND.zoom; + if (preview) return; + if (WND.showBounds) { + ctx.lineWidth = px; + for (const w of windows()) { + if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue; + const r = rectOf(w, cache); + ctx.strokeStyle = isHidden(w) ? "rgba(150,150,255,.35)" : "rgba(233,180,60,.35)"; + ctx.setLineDash(isHidden(w) ? [3 * px, 3 * px] : []); + ctx.strokeRect(r.x + px / 2, r.y + px / 2, Math.max(0, r.w - px), Math.max(0, r.h - px)); + } + ctx.setLineDash([]); + } + const s = selWindow(); + if (s) { + const r = rectOf(s, cache); + ctx.lineWidth = 2 * px; + ctx.strokeStyle = "#f4c95a"; + ctx.strokeRect(r.x, r.y, r.w, r.h); + ctx.fillStyle = "#f4c95a"; + for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px); + ctx.font = `${11 * px}px ${getComputedStyle(document.body).fontFamily}`; + const label = `${X.attr(s, "ClassName") || "Window"} #${X.attr(s, "Id") ?? ""} ${Math.round(r.lx)},${Math.round(r.ly)} ${r.w}×${r.h}`; + const tw = ctx.measureText(label).width + 8 * px; + ctx.fillStyle = "rgba(20,19,24,.9)"; + ctx.fillRect(r.x, r.y - 16 * px, tw, 15 * px); + ctx.fillStyle = "#f4c95a"; + ctx.fillText(label, r.x + 4 * px, r.y - 5 * px); + } + } + function backdrop(g) { + const grad = g.createLinearGradient(0, 0, 0, WND.screen[1]); + grad.addColorStop(0, "#3b4a5e"); + grad.addColorStop(1, "#1b222c"); + g.fillStyle = grad; + g.fillRect(0, 0, WND.screen[0], WND.screen[1]); + } + const handles = (r) => [ + [r.x, r.y, "nw"], [r.x + r.w / 2, r.y, "n"], [r.x + r.w, r.y, "ne"], [r.x + r.w, r.y + r.h / 2, "e"], + [r.x + r.w, r.y + r.h, "se"], [r.x + r.w / 2, r.y + r.h, "s"], [r.x, r.y + r.h, "sw"], [r.x, r.y + r.h / 2, "w"], + ]; + const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" }; + function selWindow() { + let e = selEl(); + while (e && e.tag !== "Window") e = e.parent; + return e; + } + + // ---------- mouse: select, move, resize, pan, zoom ---------- + const toScreen = (e) => { + const b = canvas.getBoundingClientRect(); + return { x: (e.clientX - b.left - WND.panX) / WND.zoom, y: (e.clientY - b.top - WND.panY) / WND.zoom }; + }; + function hitWindow(p) { + const cache = new Map(); + let best = null; + let area = Infinity; + for (const w of windows()) { + if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue; + const r = rectOf(w, cache); + if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h <= area) { + best = w; + area = r.w * r.h; + } + } + return best; + } + function hitHandle(p) { + const s = selWindow(); + if (!s) return null; + const r = rectOf(s); + const tol = 6 / WND.zoom; + return handles(r).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null; + } + let drag = null; + canvas.addEventListener("mousedown", (e) => { + if (!WND.doc) return; + canvas.focus(); + const p = toScreen(e); + if (e.button !== 0 || e.altKey) { + drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY }; + return e.preventDefault(); + } + const h = hitHandle(p); + const s = selWindow(); + if (h && s) { + drag = { mode: "resize", h, start: p, w: s, s: sizeRect(s), before: WND.doc.src, path: pathOf(s) }; + } else { + const w = hitWindow(p); + if (w) { + select(w); + drag = { mode: "move", start: p, w, s: sizeRect(w), before: WND.doc.src, path: pathOf(w), moved: false }; + } else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY }; + } + e.preventDefault(); + }); + canvas.addEventListener("contextmenu", (e) => e.preventDefault()); + const sizeRect = (w) => { + const s = sizeOf(w); + return { x: X.num(s, "X"), y: X.num(s, "Y"), w: X.num(s, "Width"), h: X.num(s, "Height"), xa: (X.attr(s, "XAlign") || "").toLowerCase(), ya: (X.attr(s, "YAlign") || "").toLowerCase() }; + }; + window.addEventListener("mousemove", (e) => { + if (!$("view-wnd").classList.contains("on") || !WND.doc) return; + if (!drag) { + if (e.target !== canvas) return; + const p = toScreen(e); + const h = hitHandle(p); + canvas.style.cursor = h ? CURSOR[h] : hitWindow(p) ? "move" : "grab"; + $("wnd-hover").textContent = `${Math.floor(p.x)}, ${Math.floor(p.y)}`; + return; + } + if (drag.mode === "pan") { + WND.panX = drag.px + e.clientX - drag.sx; + WND.panY = drag.py + e.clientY - drag.sy; + return draw(); + } + const p = toScreen(e); + const dx = Math.round(p.x - drag.start.x); + const dy = Math.round(p.y - drag.start.y); + const s0 = drag.s; + const sx = s0.xa === "rate" ? 800 / WND.screen[0] : 1; + const sy = s0.ya === "rate" ? 600 / WND.screen[1] : 1; + let { x, y, w, h } = s0; + if (drag.mode === "move") { + x += dx * sx; + y += dy * sy; + } else { + if (drag.h.includes("e")) w = Math.max(0, s0.w + dx); + if (drag.h.includes("s")) h = Math.max(0, s0.h + dy); + if (drag.h.includes("w")) { + w = Math.max(0, s0.w - dx); + x = s0.x + dx * sx * (s0.xa === "center" ? 0.5 : 1); + } + if (drag.h.includes("n")) { + h = Math.max(0, s0.h - dy); + y = s0.y + dy * sy * (s0.ya === "center" ? 0.5 : 1); + } + if (s0.xa === "center" && drag.h.includes("e")) x = s0.x + dx / 2; + if (s0.ya === "center" && drag.h.includes("s")) y = s0.y + dy / 2; + } + // live: write straight into the source (undo is added on mouse-up) + const doc = X.parse(drag.before); + const win = byPath(doc, drag.path); + let src = drag.before; + const set = (k, v) => { + const d = X.parse(src); + src = X.setAttr(d, X.child(byPath(d, drag.path), "Size"), k, String(Math.round(v))); + }; + if (win && X.child(win, "Size")) { + if (Math.round(x) !== s0.x || drag.mode === "resize") set("X", x); + if (Math.round(y) !== s0.y || drag.mode === "resize") set("Y", y); + if (drag.mode === "resize") { + set("Width", w); + set("Height", h); + } + drag.moved = true; + WND.doc = X.parse(src); + WND.sel = WND.doc.elements.indexOf(byPath(WND.doc, drag.path)); + draw(); + $("wnd-hover").textContent = `X ${Math.round(x)} Y ${Math.round(y)} ${Math.round(w)} × ${Math.round(h)}`; + } + }); + window.addEventListener("mouseup", () => { + if (!drag) return; + const d = drag; + drag = null; + if ((d.mode === "move" || d.mode === "resize") && d.moved && WND.doc.src !== d.before) { + WND.undo.push(d.before); + WND.redo = []; + WND.dirty = true; + $("wnd-dirty").hidden = false; + renderSide(); + } + canvas.style.cursor = ""; + }); + canvas.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + const b = canvas.getBoundingClientRect(); + const cx = e.clientX - b.left, cy = e.clientY - b.top; + const nz = Math.max(0.1, Math.min(8, WND.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15))); + WND.panX = cx - ((cx - WND.panX) * nz) / WND.zoom; + WND.panY = cy - ((cy - WND.panY) * nz) / WND.zoom; + WND.zoom = nz; + $("wnd-zoom").textContent = `${Math.round(nz * 100)}%`; + draw(); + }, + { passive: false }, + ); + $("wnd-fit").onclick = fit; + $("wnd-zoom").onclick = () => { + WND.zoom = 1; + $("wnd-zoom").textContent = "100%"; + draw(); + }; + $("wnd-screen").onchange = () => { + WND.screen = $("wnd-screen").value.split("x").map(Number); + fit(); + }; + $("wnd-state").onchange = () => { + WND.state = $("wnd-state").value; + draw(); + }; + for (const id of ["wnd-t-text", "wnd-t-img"]) $(id).onclick = () => ($(id).classList.toggle("on"), draw()); + $("wnd-t-preview").onclick = () => { + $("wnd-t-preview").classList.toggle("on"); + draw(); + }; + $("wnd-t-bounds").onclick = () => { + WND.showBounds = !WND.showBounds; + $("wnd-t-bounds").classList.toggle("on", WND.showBounds); + draw(); + }; + $("wnd-t-hidden").onclick = () => { + WND.showHidden = !WND.showHidden; + $("wnd-t-hidden").classList.toggle("on", WND.showHidden); + draw(); + renderTree(); + }; + + async function screenshot() { + const file = await call("dialog:savePng", `${baseName(WND.title).replace(/\.xml$/i, "")}.png`); + if (!file) return; + const root = rectOf(WND.doc.root); + const c = document.createElement("canvas"); + c.width = Math.max(1, root.w); + c.height = Math.max(1, root.h); + const g = c.getContext("2d"); + g.translate(-root.x, -root.y); + backdrop(g); + const cache = new Map(); + const drawWin = (w) => { + if (isHidden(w) && w !== WND.doc.root) return; // the screenshot shows what the game shows + UIDraw.window(g, WND.doc, w, rectOf(w, cache), { state: WND.state, text: true, images: true, preview: true }); + for (const ch of w.children) if (ch.tag === "Window") drawWin(ch); + }; + drawWin(WND.doc.root); + await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]); + toast(`Saved ${file}.`); + } + + // ===================================================================== + // side panel: window tree, properties, source + // ===================================================================== + function select(el) { + WND.sel = WND.doc.elements.indexOf(el); + renderSide(); + draw(); + } + function renderAll() { + renderSide(); + draw(); + $("wnd-undo").disabled = !WND.undo.length; + $("wnd-redo").disabled = !WND.redo.length; + } + function renderSide() { + renderTree(); + renderProps(); + $("wnd-undo").disabled = !WND.undo.length; + $("wnd-redo").disabled = !WND.redo.length; + } + + function renderTree() { + const out = []; + const sw = selWindow(); + const walk = (w, depth) => { + const hidden = isHidden(w); + if (!WND.showHidden && hidden && w !== WND.doc.root) return; + const title = UIText.titleOf(WND.doc, w); + out.push( + el("div", { class: `wnd-node${w === sw ? " on" : ""}${hidden ? " hid" : ""}`, style: `padding-left:${8 + depth * 14}px`, onclick: () => select(w) }, [ + el("b", { text: X.attr(w, "ClassName") || "Window" }), + el("em", { text: `#${X.attr(w, "Id") ?? ""}${title ? ` ${title}` : ""}` }), + ]), + ); + for (const c of w.children) if (c.tag === "Window") walk(c, depth + 1); + }; + if (WND.doc) walk(WND.doc.root, 0); + $("wnd-tree").replaceChildren(...out); + $("wnd-tree").querySelector(".on")?.scrollIntoView({ block: "nearest" }); + $("wnd-count-win").textContent = String(windows().length); + } + + /** One element as an editable card: its attributes, text, child elements (not child windows). */ + function elementCard(e, depth = 0) { + const doc = WND.doc; + const rows = []; + for (const a of e.attrs) { + const input = UIProps.input(e, a.name, a.value, (v) => editEl(e, (d, x) => X.setAttr(d, byPath(d, pathOf(x)) || x, a.name, v))); + rows.push( + el("div", { class: "map-kv" }, [ + el("span", { class: "k", text: a.name }), + input, + el("button", { class: "iconbtn only", title: "Remove this attribute", onclick: () => editEl(e, (d, x) => X.removeAttr(d, x, a.name)) }, [icon("minus")]), + ]), + ); + } + if (!e.children.length && (e.textStart !== undefined || ["Style", "ExStyle", "Text"].includes(e.tag))) { + const t = X.text(doc, e); + const input = el("input", { type: "text", value: t.trim(), spellcheck: "false" }); + input.addEventListener("change", () => editEl(e, (d, x) => X.setText(d, x, input.value))); + const resolved = e.tag === "Text" ? UIText.resolve(doc, e, t) : ""; + rows.push(el("div", { class: "map-kv" }, [el("span", { class: "k", text: "(text)" }), input, el("span")])); + if (resolved && resolved !== t.trim()) rows.push(el("div", { class: "wnd-resolved", text: `→ ${resolved}` })); + } + // add attribute + const nk = el("input", { type: "text", placeholder: "attribute", spellcheck: "false" }); + const nv = el("input", { type: "text", placeholder: "value", spellcheck: "false" }); + const add = () => nk.value.trim() && editEl(e, (d, x) => X.setAttr(d, x, nk.value.trim(), nv.value)); + nv.addEventListener("keydown", (ev) => ev.key === "Enter" && add()); + const kids = e.children.filter((c) => c.tag !== "Window"); + const head = el("div", { class: "wnd-el-head" }, [ + el("b", { text: `<${e.tag}>` }), + el("span", { class: "spacer" }), + el("button", { class: "iconbtn only", title: "Copy this element", onclick: () => editEl(e, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy")]), + el("button", { class: "iconbtn only danger", title: "Remove this element", onclick: () => editEl(e, (d, x) => X.removeElement(d, x)) }, [icon("trash")]), + ]); + return el("div", { class: `wnd-el depth${Math.min(depth, 3)}` }, [head, ...rows, el("div", { class: "map-kv add" }, [nk, nv, el("button", { class: "iconbtn only", title: "Add attribute", onclick: add }, [icon("plus")])]), ...kids.map((k) => elementCard(k, depth + 1))]); + } + + function renderProps() { + const w = selWindow(); + $("wnd-props-card").hidden = !w; + if (!w) return; + $("wnd-props-name").textContent = `${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`; + const doc = WND.doc; + const quick = UIProps.quick(doc, w, { edit: editEl, pathOf, byPath }); + const tools = el("div", { class: "map-sec-tools" }, [ + el("button", { class: "iconbtn", title: "Add a child window", onclick: () => addChild(w) }, [icon("plus"), el("span", { text: "Child" })]), + el("button", { class: "iconbtn", title: "Duplicate this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy"), el("span", { text: "Copy" })]), + el("button", { class: "iconbtn", title: "Add an element (Title, Imagery, ExtraInfo…)", onclick: (ev) => addElementMenu(ev, w) }, [icon("fileplus"), el("span", { text: "Element" })]), + el("span", { class: "spacer" }), + el("button", { class: "iconbtn only", title: "Edit this window as XML", onclick: () => editSource(w) }, [icon("note")]), + el("button", { class: "iconbtn only danger", title: "Delete this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.removeElement(d, x)) }, [icon("trash")]), + ]); + $("wnd-props").replaceChildren(tools, quick, el("h5", { text: "Elements" }), ...w.children.filter((c) => c.tag !== "Window").map((c) => elementCard(c))); + } + + function addChild(w) { + const ids = w.children.filter((c) => c.tag === "Window").map((c) => Number(X.attr(c, "Id")) || 0); + const id = (ids.length ? Math.max(...ids) : 0) + 1; + editEl(w, (d, x) => X.insertChild(d, x, `\n\t\n`)); + } + function addElementMenu(ev, w) { + const snippets = { + Title: `\n\t<Format X="0" Y="0" Size="12" Style="Normal" HorzAlign="Left"/>\n\t<Color Type="Normal" Text="FFFFFFFF" Back="FF000000"/>\n\t<Text>Text</Text>\n`, + "Imagery (image)": `\n\t\n`, + "Imagery (frame)": `\n\t\n\t\t\n\t\n`, + Style: ``, + ExStyle: `IWS_EX_PUSH_DOUBLE`, + ExtraInfo: ``, + }; + showMenu(Object.entries(snippets).map(([label, s]) => ({ label, icon: "plus", run: () => editEl(w, (d, x) => X.insertChild(d, x, s)) })), { x: ev.clientX, y: ev.clientY }); + } + async function editSource(w) { + const ta = el("textarea", { class: "mdl-mat", spellcheck: "false" }); + ta.value = cp949(X.markupOf(WND.doc, w)); + const ok = await modal(`XML — ${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`, [el("p", { class: "muted", text: "The window and its children as XML. Korean text is shown decoded and written back as CP949." }), ta], [{ label: "Cancel", value: false }, { label: "Apply", kind: "primary", value: true }]); + if (!ok) return; + let bytes; + try { + bytes = UIText.encodeCp949(ta.value); + } catch (err) { + return toast(err.message, true); + } + const markup = toLatin1(bytes); + try { + X.parse(markup); + } catch (err) { + return toast(`Not valid XML: ${err.message}`, true); + } + editEl(w, (d, x) => d.src.slice(0, x.start) + markup + d.src.slice(x.end)); + } + + // keys + document.addEventListener("keydown", (e) => { + if (!$("view-wnd").classList.contains("on") || !WND.doc) return; + if (e.target.matches?.("input, textarea, select")) return; + const k = e.key.toLowerCase(); + if (e.ctrlKey && k === "z") return e.preventDefault(), undoRedo(WND.undo, WND.redo); + if (e.ctrlKey && k === "y") return e.preventDefault(), undoRedo(WND.redo, WND.undo); + if (e.ctrlKey && k === "s") return e.preventDefault(), save(); + const w = selWindow(); + const mv = { arrowleft: [-1, 0], arrowright: [1, 0], arrowup: [0, -1], arrowdown: [0, 1] }[k]; + if (w && mv && sizeOf(w)) { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + const s = sizeRect(w); + const sx = 1, sy = 1; + let src = WND.doc.src; + let d = WND.doc; + if (mv[0]) src = X.setAttr(d, sizeOf(w), "X", String(s.x + mv[0] * step * sx)); + d = X.parse(src); + if (mv[1]) src = X.setAttr(d, X.child(byPath(d, pathOf(w)), "Size"), "Y", String(s.y + mv[1] * step * sy)); + apply(src); + } + if (e.key === "Delete" && w && w !== WND.doc.root) editEl(w, (d, x) => X.removeElement(d, x)); + }); + + document.querySelector('[data-view="wnd"]').addEventListener("click", async () => { + resize(); + if (WND.src) return; + if (profileSource()) return setSource(profileSource(), { quiet: true }); + const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); + const s = sessions.find((x) => /^rs\.iop$/i.test(x.name)) || sessions[0]; + if (s) await setSource({ kind: "session", sessionId: s.id, name: s.name }, { quiet: true }); + }); + + Object.assign(WND, { setSource, openRow, openText, draw: drawNow, select, rectOf, windows, save, apply }); +})(); diff --git a/app/public/legacy/js/vtable.js b/app/public/legacy/js/vtable.js new file mode 100644 index 0000000..bb67dff --- /dev/null +++ b/app/public/legacy/js/vtable.js @@ -0,0 +1,283 @@ +"use strict"; + +// Virtual table: only the visible rows exist in the DOM, so 50 000+ pack entries scroll smoothly. +// columns: [{ key, label, width (px), grow, cls, format(row) → text|Node, sort(row) → value }] +// Column widths can be dragged at the header's right edge; the `grow` column takes the free width. +// The table scrolls sideways when the columns are wider than the view. + +class VTable { + constructor(host, columns, { rowHeight = 28, multi = true, id = null } = {}) { + this.host = host; + this.columns = columns; + this.rh = rowHeight; + this.multi = multi; + this.id = id; // remembers column widths per table + this.rows = []; + this.view = []; + this.sel = new Set(); + this.anchor = -1; + this.sortKey = null; + this.sortDesc = false; + this.sortFirst = null; // (row) → rank, applied before the column sort (folders first) + this.onSelect = () => {}; + this.onOpen = () => {}; + this.keyOf = (r) => r.key ?? r[0]; + this.loadWidths(); + + host.innerHTML = ""; + host.classList.add("vtable"); + this.head = document.createElement("div"); + this.head.className = "vt-head"; + this.body = document.createElement("div"); + this.body.className = "vt-rows"; + host.append(this.head, this.body); + for (const c of columns) { + const h = document.createElement("div"); + h.className = "vt-h"; + const label = document.createElement("span"); + label.textContent = c.label; + h.append(label); + if (c.cls?.includes("num")) h.style.justifyContent = "flex-end"; + h.onclick = (e) => !e.target.classList.contains("vt-resize") && this.sortBy(c.key); + const grip = document.createElement("i"); + grip.className = "vt-resize"; + grip.onmousedown = (e) => this.startResize(e, c); + grip.ondblclick = () => this.autoFit(c); + h.append(grip); + c.headEl = h; + this.head.append(h); + } + host.addEventListener("scroll", () => this.draw()); + // Double-click is caught here: rows are re-created while scrolling, so a per-row handler + // could miss the second click + host.addEventListener("dblclick", (e) => { + const row = e.target.closest(".vt-row"); + if (row) this.onOpen(this.view[Number(row.dataset.i)]); + }); + new ResizeObserver(() => this.layout()).observe(host); + host.tabIndex = 0; + host.addEventListener("keydown", (e) => this.key(e)); + this.layout(); + } + + // ---------- column widths ---------- + loadWidths() { + if (!this.id) return; + try { + const saved = JSON.parse(localStorage.getItem(`vt:${this.id}`) || "{}"); + for (const c of this.columns) if (saved[c.key] > 20) c.width = saved[c.key]; + } catch {} + } + saveWidths() { + if (!this.id) return; + try { + localStorage.setItem(`vt:${this.id}`, JSON.stringify(Object.fromEntries(this.columns.map((c) => [c.key, c.width])))); + } catch {} + } + + /** Column widths in px, the grow column stretched to fill the view. */ + widths() { + const base = this.columns.map((c) => c.width); + const total = base.reduce((a, b) => a + b, 0); + const free = Math.max(0, this.host.clientWidth - total); + const g = this.columns.findIndex((c) => c.grow); + if (g >= 0) base[g] += free; + return base; + } + + layout() { + const w = this.widths(); + this.totalWidth = w.reduce((a, b) => a + b, 0); + this.head.style.width = this.body.style.width = `${this.totalWidth}px`; + this.columns.forEach((c, i) => (c.headEl.style.width = `${w[i]}px`)); + this.draw(); + } + + startResize(e, col) { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const start = col.width; + document.body.classList.add("col-resizing"); + const move = (ev) => { + col.width = Math.max(40, Math.round(start + ev.clientX - startX)); + this.layout(); + }; + const up = () => { + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + document.body.classList.remove("col-resizing"); + this.saveWidths(); + }; + document.addEventListener("mousemove", move); + document.addEventListener("mouseup", up); + } + + /** Double-click on a column edge: fit the widest visible value. */ + autoFit(col) { + const probe = document.createElement("span"); + probe.className = "vt-measure"; + document.body.append(probe); + let max = col.label.length * 8 + 30; + for (const r of this.view.slice(0, 2000)) { + const v = col.format ? col.format(r) : r[col.key]; + probe.textContent = v instanceof Node ? v.textContent : String(v ?? ""); + max = Math.max(max, probe.offsetWidth + 34); + } + probe.remove(); + col.width = Math.min(1200, max); + this.layout(); + this.saveWidths(); + } + + // ---------- data ---------- + setRows(rows, { keepSelection = false, animate = null } = {}) { + this.rows = rows; + if (!keepSelection) { + this.sel.clear(); + this.anchor = -1; + } else { + const keys = new Set(rows.map(this.keyOf)); + for (const k of [...this.sel]) if (!keys.has(k)) this.sel.delete(k); + } + if (animate) { + this.host.scrollTop = 0; + this.body.classList.remove("in-left", "in-right", "in-fade"); + void this.body.offsetWidth; + this.body.classList.add(`in-${animate}`); + } + this.applySort(); + } + + setView(filterFn) { + this.filter = filterFn; + this.applySort(); + } + + sortBy(key) { + if (this.sortKey === key) this.sortDesc = !this.sortDesc; + else { + this.sortKey = key; + this.sortDesc = false; + } + for (const c of this.columns) { + c.headEl.classList.toggle("sorted", c.key === this.sortKey); + c.headEl.classList.toggle("desc", c.key === this.sortKey && this.sortDesc); + } + this.applySort(); + } + + applySort() { + const v = this.filter ? this.rows.filter(this.filter) : this.rows.slice(); + const col = this.columns.find((c) => c.key === this.sortKey); + const first = this.sortFirst; + if (col || first) { + const get = col ? col.sort || ((r) => r[col.key]) : null; + v.sort((a, b) => { + if (first) { + const f = first(a) - first(b); + if (f) return f; + } + if (!get) return 0; + const x = get(a); + const y = get(b); + const d = typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y)); + return this.sortDesc ? -d : d; + }); + } + this.view = v; + this.body.style.height = `${v.length * this.rh}px`; + this.draw(); + } + + draw() { + if (!this.totalWidth) return; + const w = this.widths(); + const top = this.host.scrollTop; + const height = this.host.clientHeight; + const first = Math.max(0, Math.floor(top / this.rh) - 8); + const last = Math.min(this.view.length, Math.ceil((top + height) / this.rh) + 8); + const frag = document.createDocumentFragment(); + for (let i = first; i < last; i++) { + const r = this.view[i]; + const row = document.createElement("div"); + row.className = "vt-row" + (this.sel.has(this.keyOf(r)) ? " sel" : "") + (this.rowClass ? ` ${this.rowClass(r)}` : ""); + row.style.top = `${i * this.rh}px`; + row.style.width = `${this.totalWidth}px`; + row.dataset.i = i; + this.columns.forEach((c, ci) => { + const cell = document.createElement("div"); + cell.style.width = `${w[ci]}px`; + if (c.cls) cell.className = c.cls; + const v = c.format ? c.format(r) : r[c.key]; + if (v instanceof Node) cell.append(v); + else { + cell.textContent = v ?? ""; + if (c.title) cell.title = cell.textContent; + } + row.append(cell); + }); + row.onmousedown = (e) => { + if (e.button !== 0) return; + // e.g. a folder: one click opens it (Ctrl/Shift still select) + if (!e.ctrlKey && !e.shiftKey && this.onActivate?.(r, e)) return; + this.click(i, e); + }; + frag.append(row); + } + this.body.replaceChildren(frag); + } + + // ---------- selection / keys ---------- + click(i, e) { + const key = this.keyOf(this.view[i]); + if (this.multi && e.shiftKey && this.anchor >= 0) { + if (!e.ctrlKey) this.sel.clear(); + const [a, b] = [Math.min(this.anchor, i), Math.max(this.anchor, i)]; + for (let k = a; k <= b; k++) this.sel.add(this.keyOf(this.view[k])); + } else if (this.multi && e.ctrlKey) { + if (this.sel.has(key)) this.sel.delete(key); + else this.sel.add(key); + this.anchor = i; + } else { + this.sel.clear(); + this.sel.add(key); + this.anchor = i; + } + this.markSelection(); + this.onSelect(this.selected(), this.view[i]); + } + + /** Updates the highlight on the rows already on screen (keeps the elements, so double-click works). */ + markSelection() { + for (const row of this.body.children) row.classList.toggle("sel", this.sel.has(this.keyOf(this.view[Number(row.dataset.i)]))); + } + + key(e) { + if (e.ctrlKey && e.key.toLowerCase() === "a" && this.multi) { + e.preventDefault(); + for (const r of this.view) this.sel.add(this.keyOf(r)); + this.markSelection(); + this.onSelect(this.selected()); + } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + if (!this.view.length) return; + const i = Math.max(0, Math.min(this.view.length - 1, this.anchor + (e.key === "ArrowDown" ? 1 : -1))); + this.click(i, { shiftKey: e.shiftKey, ctrlKey: false }); + const y = i * this.rh; + if (y < this.host.scrollTop + 30) this.host.scrollTop = y - 30; + else if (y > this.host.scrollTop + this.host.clientHeight - this.rh * 2) this.host.scrollTop = y - this.host.clientHeight + this.rh * 2; + } else if (e.key === "Enter" && this.anchor >= 0) this.onOpen(this.view[this.anchor]); + else if (e.key === "Backspace") this.onBack?.(); + } + + selected() { + return this.rows.filter((r) => this.sel.has(this.keyOf(r))); + } + + clearSelection() { + this.sel.clear(); + this.draw(); + } +} +window.VTable = VTable; diff --git a/app/public/legacy/js/xmldoc.js b/app/public/legacy/js/xmldoc.js new file mode 100644 index 0000000..f34e1d1 --- /dev/null +++ b/app/public/legacy/js/xmldoc.js @@ -0,0 +1,227 @@ +"use strict"; + +// Source-preserving XML: parses into elements that remember where every attribute value and text +// sits in the original string, so edits replace just those characters (comments, spacing, line +// breaks, CP949 bytes — the text travels as latin1 — all stay as they were). After an edit the +// document is re-parsed (UI files are small). + +const XmlDoc = (() => { + const isName = (c) => /[A-Za-z0-9_:.\-]/.test(c); + + /** → { src, root, elements } ; element = { tag, attrs: [{ name, value, vs, ve, ns }], start, openEnd, selfClose, end, children, parent, textStart, textEnd } */ + function parse(src) { + const els = []; + const stack = []; + const warnings = []; + let root = null; + let i = 0; + const n = src.length; + const err = (msg) => { + const line = src.slice(0, i).split("\n").length; + throw new Error(`XML line ${line}: ${msg}`); + }; + while (i < n) { + const lt = src.indexOf("<", i); + if (lt < 0) break; + const top = stack[stack.length - 1]; + if (top && top.textStart === undefined && lt > i) { + // text between tags (only kept for leaf elements such as