diff --git a/.gitignore b/.gitignore index 224c138..d867904 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ isle.wasm isle.wasm.map isle.js LEGO +save-editor.bin diff --git a/README.md b/README.md index 22bf53f..d1c56a9 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,25 @@ A custom web frontend for the Emscripten port of [isle-portable](https://github. npm run prepare:assets -- -p /path/to/your/LEGO ``` -5. Start the development server: +5. Generate the save editor asset bundle (requires game assets from step 4): + ```bash + npm run generate:save-editor-assets + ``` + This extracts animations, sounds, textures, and character icons from the game files into `save-editor.bin`, used by the save editor's 3D previews. + +6. Start the development server: ```bash npm run dev ``` -6. Open the URL shown in the terminal (usually `http://localhost:5173`). +7. Open the URL shown in the terminal (usually `http://localhost:5173`). ## Scripts | Command | Description | |---------|-------------| | `npm run prepare:assets` | Set up LEGO Island game assets via symlinks | +| `npm run generate:save-editor-assets` | Extract save editor assets (animations, sounds, textures, icons) into `save-editor.bin` | | `npm run dev` | Start development server with hot reload | | `npm run build` | Build for production (outputs to `dist/`) | | `npm run preview` | Preview the production build locally | @@ -56,14 +63,20 @@ isle.pizza/ │ ├── App.svelte # Main application component │ ├── app.css # Global styles │ ├── stores.js # Svelte stores for state management -│ ├── core/ # Core modules (audio, OPFS, service worker, etc.) -│ └── lib/ # UI components -├── public/ # Static assets (images, fonts, PDFs) -├── scripts/ # Build scripts +│ ├── core/ +│ │ ├── formats/ # Binary file parsers/serializers (WDB, save games, animations, textures) +│ │ ├── rendering/ # Three.js renderers (BaseRenderer, VehiclePartRenderer, ActorRenderer, etc.) +│ │ ├── savegame/ # Save game constants, actor data, color tables +│ │ └── ... # Audio, OPFS, service worker, asset loading +│ └── lib/ # UI components and pages (save editor, configure, etc.) +├── public/ +│ └── images/ # UI images (menu buttons, tab icons) +├── scripts/ # Build and asset generation scripts ├── src-sw/ # Service worker source ├── index.html # HTML entry point ├── isle.js # Emscripten JS (not in repo, build from isle-portable) ├── isle.wasm # Emscripten WASM (not in repo, build from isle-portable) +├── save-editor.bin # Packed save editor assets: animations, sounds, textures, icons (not in repo, generated) └── LEGO/ # Game data directory (not in repo) ``` @@ -79,6 +92,7 @@ Alternatively, a [Docker image that bundles the runtime with this frontend](http ## Tech Stack - [Svelte 5](https://svelte.dev/) - UI framework +- [Three.js](https://threejs.org/) - 3D rendering for save editor previews - [Vite](https://vitejs.dev/) - Build tool and dev server - [Workbox](https://developer.chrome.com/docs/workbox/) - Service worker and offline support diff --git a/package.json b/package.json index b3b2200..ab6e622 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && cp isle.js isle.wasm dist/ && node scripts/workbox-inject.js", + "build": "vite build && cp isle.js isle.wasm save-editor.bin dist/ && node scripts/workbox-inject.js", "build:ci": "vite build && node scripts/workbox-inject.js", "check": "svelte-check --fail-on-warnings", "preview": "vite preview", - "prepare:assets": "node scripts/prepare.js" + "prepare:assets": "node scripts/prepare.js", + "generate:save-editor-assets": "node scripts/generate-save-editor-assets.js" }, "dependencies": { "@floating-ui/dom": "^1.7.5", diff --git a/public/images/globe1.webp b/public/images/globe1.webp deleted file mode 100644 index cef69ca..0000000 Binary files a/public/images/globe1.webp and /dev/null differ diff --git a/public/images/globe2.webp b/public/images/globe2.webp deleted file mode 100644 index 5bafe2c..0000000 Binary files a/public/images/globe2.webp and /dev/null differ diff --git a/public/images/globe3.webp b/public/images/globe3.webp deleted file mode 100644 index 8889b8f..0000000 Binary files a/public/images/globe3.webp and /dev/null differ diff --git a/public/images/globe4.webp b/public/images/globe4.webp deleted file mode 100644 index 12272ab..0000000 Binary files a/public/images/globe4.webp and /dev/null differ diff --git a/public/images/globe5.webp b/public/images/globe5.webp deleted file mode 100644 index 63fa410..0000000 Binary files a/public/images/globe5.webp and /dev/null differ diff --git a/public/images/globe6.webp b/public/images/globe6.webp deleted file mode 100644 index 6f83bd2..0000000 Binary files a/public/images/globe6.webp and /dev/null differ diff --git a/public/images/laura-selected.webp b/public/images/laura-selected.webp deleted file mode 100644 index 70b5f86..0000000 Binary files a/public/images/laura-selected.webp and /dev/null differ diff --git a/public/images/laura.webp b/public/images/laura.webp deleted file mode 100644 index 6f0983f..0000000 Binary files a/public/images/laura.webp and /dev/null differ diff --git a/public/images/mama-selected.webp b/public/images/mama-selected.webp deleted file mode 100644 index 45a4f83..0000000 Binary files a/public/images/mama-selected.webp and /dev/null differ diff --git a/public/images/mama.webp b/public/images/mama.webp deleted file mode 100644 index 55c51bf..0000000 Binary files a/public/images/mama.webp and /dev/null differ diff --git a/public/images/nick-selected.webp b/public/images/nick-selected.webp deleted file mode 100644 index d6e626c..0000000 Binary files a/public/images/nick-selected.webp and /dev/null differ diff --git a/public/images/nick.webp b/public/images/nick.webp deleted file mode 100644 index bf46c22..0000000 Binary files a/public/images/nick.webp and /dev/null differ diff --git a/public/images/papa-selected.webp b/public/images/papa-selected.webp deleted file mode 100644 index cc6b221..0000000 Binary files a/public/images/papa-selected.webp and /dev/null differ diff --git a/public/images/papa.webp b/public/images/papa.webp deleted file mode 100644 index 8393524..0000000 Binary files a/public/images/papa.webp and /dev/null differ diff --git a/public/images/pepper-selected.webp b/public/images/pepper-selected.webp deleted file mode 100644 index 1cacc14..0000000 Binary files a/public/images/pepper-selected.webp and /dev/null differ diff --git a/public/images/pepper.webp b/public/images/pepper.webp deleted file mode 100644 index fdf9fa1..0000000 Binary files a/public/images/pepper.webp and /dev/null differ diff --git a/public/textures/CHJETL1.tex b/public/textures/CHJETL1.tex deleted file mode 100644 index 8795c99..0000000 Binary files a/public/textures/CHJETL1.tex and /dev/null differ diff --git a/public/textures/CHJETL2.tex b/public/textures/CHJETL2.tex deleted file mode 100644 index 64183a4..0000000 Binary files a/public/textures/CHJETL2.tex and /dev/null differ diff --git a/public/textures/CHJETL3.tex b/public/textures/CHJETL3.tex deleted file mode 100644 index e1e7196..0000000 Binary files a/public/textures/CHJETL3.tex and /dev/null differ diff --git a/public/textures/CHJETL4.tex b/public/textures/CHJETL4.tex deleted file mode 100644 index 5a887db..0000000 Binary files a/public/textures/CHJETL4.tex and /dev/null differ diff --git a/public/textures/CHJETR1.tex b/public/textures/CHJETR1.tex deleted file mode 100644 index 9f70c42..0000000 Binary files a/public/textures/CHJETR1.tex and /dev/null differ diff --git a/public/textures/CHJETR2.tex b/public/textures/CHJETR2.tex deleted file mode 100644 index 04fa132..0000000 Binary files a/public/textures/CHJETR2.tex and /dev/null differ diff --git a/public/textures/CHJETR3.tex b/public/textures/CHJETR3.tex deleted file mode 100644 index 81a40e7..0000000 Binary files a/public/textures/CHJETR3.tex and /dev/null differ diff --git a/public/textures/CHJETR4.tex b/public/textures/CHJETR4.tex deleted file mode 100644 index 17cbca8..0000000 Binary files a/public/textures/CHJETR4.tex and /dev/null differ diff --git a/public/textures/CHWIND1.tex b/public/textures/CHWIND1.tex deleted file mode 100644 index 565eadf..0000000 Binary files a/public/textures/CHWIND1.tex and /dev/null differ diff --git a/public/textures/CHWIND2.tex b/public/textures/CHWIND2.tex deleted file mode 100644 index 622ec2f..0000000 Binary files a/public/textures/CHWIND2.tex and /dev/null differ diff --git a/public/textures/CHWIND3.tex b/public/textures/CHWIND3.tex deleted file mode 100644 index e732ddb..0000000 Binary files a/public/textures/CHWIND3.tex and /dev/null differ diff --git a/public/textures/CHWIND4.tex b/public/textures/CHWIND4.tex deleted file mode 100644 index a02c4df..0000000 Binary files a/public/textures/CHWIND4.tex and /dev/null differ diff --git a/public/textures/Dbfrfn1.tex b/public/textures/Dbfrfn1.tex deleted file mode 100644 index dcda861..0000000 Binary files a/public/textures/Dbfrfn1.tex and /dev/null differ diff --git a/public/textures/Dbfrfn2.tex b/public/textures/Dbfrfn2.tex deleted file mode 100644 index 1c594ea..0000000 Binary files a/public/textures/Dbfrfn2.tex and /dev/null differ diff --git a/public/textures/Dbfrfn3.tex b/public/textures/Dbfrfn3.tex deleted file mode 100644 index ae0162b..0000000 Binary files a/public/textures/Dbfrfn3.tex and /dev/null differ diff --git a/public/textures/Dbfrfn4.tex b/public/textures/Dbfrfn4.tex deleted file mode 100644 index d93381c..0000000 Binary files a/public/textures/Dbfrfn4.tex and /dev/null differ diff --git a/public/textures/JSWNSH1.tex b/public/textures/JSWNSH1.tex deleted file mode 100644 index 4061a65..0000000 Binary files a/public/textures/JSWNSH1.tex and /dev/null differ diff --git a/public/textures/JSWNSH2.tex b/public/textures/JSWNSH2.tex deleted file mode 100644 index 7b8341a..0000000 Binary files a/public/textures/JSWNSH2.tex and /dev/null differ diff --git a/public/textures/JSWNSH3.tex b/public/textures/JSWNSH3.tex deleted file mode 100644 index 784375e..0000000 Binary files a/public/textures/JSWNSH3.tex and /dev/null differ diff --git a/public/textures/JSWNSH4.tex b/public/textures/JSWNSH4.tex deleted file mode 100644 index f08f0ea..0000000 Binary files a/public/textures/JSWNSH4.tex and /dev/null differ diff --git a/public/textures/jsfrnt1.tex b/public/textures/jsfrnt1.tex deleted file mode 100644 index 6652e44..0000000 Binary files a/public/textures/jsfrnt1.tex and /dev/null differ diff --git a/public/textures/jsfrnt2.tex b/public/textures/jsfrnt2.tex deleted file mode 100644 index 0623692..0000000 Binary files a/public/textures/jsfrnt2.tex and /dev/null differ diff --git a/public/textures/jsfrnt3.tex b/public/textures/jsfrnt3.tex deleted file mode 100644 index ea16767..0000000 Binary files a/public/textures/jsfrnt3.tex and /dev/null differ diff --git a/public/textures/jsfrnt4.tex b/public/textures/jsfrnt4.tex deleted file mode 100644 index d80c799..0000000 Binary files a/public/textures/jsfrnt4.tex and /dev/null differ diff --git a/public/textures/rcback1.tex b/public/textures/rcback1.tex deleted file mode 100644 index 43f3749..0000000 Binary files a/public/textures/rcback1.tex and /dev/null differ diff --git a/public/textures/rcback2.tex b/public/textures/rcback2.tex deleted file mode 100644 index 0705599..0000000 Binary files a/public/textures/rcback2.tex and /dev/null differ diff --git a/public/textures/rcback3.tex b/public/textures/rcback3.tex deleted file mode 100644 index 8be13f5..0000000 Binary files a/public/textures/rcback3.tex and /dev/null differ diff --git a/public/textures/rcback4.tex b/public/textures/rcback4.tex deleted file mode 100644 index 76dc789..0000000 Binary files a/public/textures/rcback4.tex and /dev/null differ diff --git a/public/textures/rcfrnt1.tex b/public/textures/rcfrnt1.tex deleted file mode 100644 index 1c44503..0000000 Binary files a/public/textures/rcfrnt1.tex and /dev/null differ diff --git a/public/textures/rcfrnt2.tex b/public/textures/rcfrnt2.tex deleted file mode 100644 index 799409e..0000000 Binary files a/public/textures/rcfrnt2.tex and /dev/null differ diff --git a/public/textures/rcfrnt3.tex b/public/textures/rcfrnt3.tex deleted file mode 100644 index 790027b..0000000 Binary files a/public/textures/rcfrnt3.tex and /dev/null differ diff --git a/public/textures/rcfrnt4.tex b/public/textures/rcfrnt4.tex deleted file mode 100644 index b7290f8..0000000 Binary files a/public/textures/rcfrnt4.tex and /dev/null differ diff --git a/public/textures/rctail1.tex b/public/textures/rctail1.tex deleted file mode 100644 index 5315552..0000000 Binary files a/public/textures/rctail1.tex and /dev/null differ diff --git a/public/textures/rctail2.tex b/public/textures/rctail2.tex deleted file mode 100644 index 3ec3112..0000000 Binary files a/public/textures/rctail2.tex and /dev/null differ diff --git a/public/textures/rctail3.tex b/public/textures/rctail3.tex deleted file mode 100644 index d07c5e8..0000000 Binary files a/public/textures/rctail3.tex and /dev/null differ diff --git a/public/textures/rctail4.tex b/public/textures/rctail4.tex deleted file mode 100644 index ba43831..0000000 Binary files a/public/textures/rctail4.tex and /dev/null differ diff --git a/scripts/generate-save-editor-assets.js b/scripts/generate-save-editor-assets.js new file mode 100644 index 0000000..2f5b932 --- /dev/null +++ b/scripts/generate-save-editor-assets.js @@ -0,0 +1,429 @@ +#!/usr/bin/env node +// Scans LEGO Island SI files to extract embedded assets into a packed binary bundle. +// Writes save-editor.bin: [U32LE index length][JSON index][fragment data]. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +// [name, objectId, size, md5] +const ANIMATIONS = [ + ['CNs001Bd', 223, 657, 'eec976da0035968ee65cde2444d66fdd'], + ['CNs001Br', 207, 1617, 'ca07df295c5146da01efd57aa42e6f43'], + ['CNs001La', 179, 1335, '2f3c2d17a404e3ee06e24c9b79a5bc93'], + ['CNs001Ma', 117, 1545, '6d7eb4527cab9d589f61e931f76a7ccf'], + ['CNs001Ni', 145, 1233, '8777efe8556288cedba7ac67ae5b8e75'], + ['CNs001Pa', 131, 1277, '222c7c997e79d77228c3607ed6ff9754'], + ['CNs001Pe', 107, 1209, 'f0057004a852fb8a6385c075bcb425f8'], + ['CNs001Pg', 224, 657, 'c9293ae1cadcd769c57f16d721189cbe'], + ['CNs001Rd', 225, 657, 'cb6c0ec1203b644f16451f7caf5538e9'], + ['CNs001Sk', 227, 1590, 'b9e19a58b4a6d8bb9e63a12af08ce5ee'], + ['CNs001Sy', 226, 657, '986645d7598ba921113aef9d72ff74fd'], + ['CNs001xx', 80, 1101, '79a32fb54e881403cf785caf2f07fb99'], + ['CNs002Br', 208, 1393, 'f489e8ae4ed591f35738a9f4d1259450'], + ['CNs002La', 196, 1447, '0738817ecc1ed6afc8c605537afbcbc9'], + ['CNs002Ma', 118, 1461, 'b8b1a40dd9ee102de0724fa4f0fa4047'], + ['CNs002Ni', 146, 1201, '6e761fa60aea9e5c8d37ffa39a3aaf24'], + ['CNs002Pa', 132, 1149, '7774a58da0fc3d3dd9bdaec4a4feebcb'], + ['CNs002Pe', 108, 1065, '1bcb02590f35c2fc7a248fcfb00e6e58'], + ['CNs002xx', 87, 1472, '9541d8c34615d28998985de01cbc7dfd'], + ['CNs003Br', 209, 1497, 'a2d6094d7f4da36772d7d4a739a2831c'], + ['CNs003La', 197, 1675, '7687af38a09b16be8028e73b39b99981'], + ['CNs003Ma', 119, 1749, '5ca15ea1f1fd84a775b044c6ca068ba1'], + ['CNs003Ni', 147, 1369, 'd2533306f8ce771d958e2839a0fc0ae9'], + ['CNs003Pa', 133, 1369, '1fef78d548e65839093d2ff0b1a4460f'], + ['CNs003Pe', 109, 1373, 'f9815a0266a1c63a3f3518cca85beb49'], + ['CNs003xx', 88, 1377, 'f61d8fd473121bface739c7a557c05ec'], + ['CNs004Br', 210, 1741, 'e777c1dabff4f0c756445c588b7c2ef2'], + ['CNs004La', 198, 2139, '8dc93c005ebcedecbea0db2bdf351434'], + ['CNs004Ma', 120, 2597, '332eaac68b2fb4244fd62789bc2c5508'], + ['CNs004Ni', 148, 1853, '8a209f3790a1d9b7b58f8909151de2bf'], + ['CNs004Pa', 134, 1961, '80bb689a754f174ead41f5b01019a00e'], + ['CNs004Pe', 110, 1577, '70bf03ce873583554dcc8955ff307975'], + ['CNs004xx', 89, 1581, '65d27ca748b437676c4a162e7d5a599c'], + ['CNs005Br', 211, 1373, '837455903a2d23ea77833e9d96697b18'], + ['CNs005La', 199, 1427, '9b4d20d1bbca5a81ec5805ac768b3f70'], + ['CNs005Ma', 121, 2117, '8d5bb9ec4905efbc0a0d29b700eeee0d'], + ['CNs005Ni', 149, 1245, '2696c22fa69a403e5a07406d9425400a'], + ['CNs005Pa', 135, 1545, '8217a905ce20b4958695c9e5de3fc372'], + ['CNs005Pe', 111, 1249, 'ed5a06a87baf507ccd35c96407af1da7'], + ['CNs005xx', 90, 1253, 'bfe52167e229966987552da768ab5e44'], + ['CNs006Br', 213, 1269, '1d451aee9a06118c5a944750ddf4fedc'], + ['CNs006La', 201, 1587, '99f0f187b6128626ac2106e010a30373'], + ['CNs006Ma', 123, 1445, '13f5e003ff05a3d87a7866bcb195fbfc'], + ['CNs006Ni', 157, 1365, 'e76dbff6c14206cc20bbe02be377ee2f'], + ['CNs006Pa', 137, 1665, '831badb8a5065d1f64b6123891b7907e'], + ['CNs006Pe', 113, 1369, '619630dec24433c8f4863c16d09ebc14'], + ['CNs006xx', 101, 1373, '18a8f17d235c89b5365e6f7a83dc9311'], + ['CNs007Br', 212, 1353, 'b7ae9c9eb305e75572a472d65683f040'], + ['CNs007La', 200, 1567, '0a0da9bf5043c1dcb537069f9febbeae'], + ['CNs007Ma', 122, 1501, '81f94684fb3f508b697ba47af1c6079a'], + ['CNs007Ni', 156, 1205, 'b228c9b6fce5ce909963b614cd2e4163'], + ['CNs007Pa', 136, 1293, '2708a4b6e72f62ae96da476e6d67210e'], + ['CNs007Pe', 112, 1249, '2b2aba18edb8945800203a1c4d985c34'], + ['CNs007xx', 100, 2144, 'b4be30f4c60a1ecc8b01f4414af14455'], + ['CNs008Br', 214, 1113, 'b11de30162588dfcc0bf1512469dd72a'], + ['CNs008La', 202, 1047, '1bad27d6ff48c93bf5c9d476e5d64fbb'], + ['CNs008Ma', 124, 1045, '4a12974aee2e9f81aa995120b5efd54b'], + ['CNs008Ni', 158, 1005, 'ad3f7af37af89fe1b29175943cee52c2'], + ['CNs008Pa', 138, 1305, '075566a5c2905b0126761750488a5f8d'], + ['CNs008Pe', 114, 1009, 'ea176943a17569bea30695e10e6277f4'], + ['CNs008xx', 102, 1013, 'b5610e2318eb760a9d6ecd915026edfa'], + ['CNs009Br', 215, 1005, '4e8633be87a0d3e86018bb79d6df6647'], + ['CNs009La', 203, 1179, '172d6534981dc3c82a6edf46b0a46a90'], + ['CNs009Ma', 125, 917, '122d10aceea520188e614f4100cff493'], + ['CNs009Ni', 159, 1269, 'd075ca06d9bca5509cd01367282d6322'], + ['CNs009Pa', 139, 1757, '85ee3e3915ed3a81470b30da9985ee94'], + ['CNs009Pe', 115, 941, '368209e2db8b682b9f5ee8c2449b3c14'], + ['CNs009xx', 103, 945, '8a15d1553d6d81a0d741c1e11adb5b29'], + ['CNs010Br', 216, 1469, 'b7c1a5844f0a4710d15c67eed8c19418'], + ['CNs010La', 204, 1543, '77347221f51e10470ea06d514430663a'], + ['CNs010Ma', 126, 1493, '846a971fe79f314ef9eb4788776d337d'], + ['CNs010Ni', 168, 1541, '6e25bd94e5cb76e0acb642d4a52a922f'], + ['CNs010Pa', 140, 1637, '94be270722ab8a69c6c81c9e0467a97c'], + ['CNs010Pe', 116, 1465, 'ba3abad56fb12c025840df86ea5ea7b1'], + ['CNs010xx', 104, 1469, 'aebf4eb621c5eb4b202751c11383d3c9'], + ['CNs011Br', 217, 1161, '2e0e6495387746460c66a37f6ea4d9b3'], + ['CNs011La', 205, 798, 'ce98ce48a5396559ee55fb786f122fcc'], + ['CNs011Ma', 127, 1561, '1a4f6b4d89c9bd867d4433a81423d178'], + ['CNs011Ni', 169, 699, '0b24eb57d4225b8737bf959aecf14430'], + ['CNs011Pa', 141, 3747, '94cb14868ed2957db9a156f832221637'], + ['CNs011xx', 105, 990, '5b09efc169a758325b76e6654f9777e0'], + ['CNs012Br', 218, 2245, 'b83f53d4361a980de9abb186c3a2806b'], + ['CNs012Ma', 128, 1593, '17e75f084d52d712012adeb92f7cda54'], + ['CNs012Pa', 142, 6900, 'e61e78454d7ff3e7bae7def8b3dfa9d6'], + ['CNs012xx', 106, 1830, '17ab918c42f311064f4e4c3560c93f51'], + ['CNs013Br', 219, 3417, '8b7482f00475111add6829dcb6434d96'], + ['CNs013Ma', 129, 3169, '8f017d5092a216d2a32a33e1de4ac118'], + ['CNs013Pa', 143, 3647, '3e3cf7409b766b80653f8571801ebf71'], + ['CNs014Br', 220, 3174, '1a47cb0ebd2c812273a5338b8495204f'], + ['CNs0x4Ma', 130, 2657, '81e016edfb3acdd25fe4cb3a1b74d376'], + ['CNs0x4Pa', 144, 2005, 'a7272eb818e1cd00d9b5ee65f5b592f4'], + ['CNs900Br', 221, 3617, 'd9d4c57e6ec4061a464b45bb0560fd47'], + ['CNs901BR', 222, 3917, '6378d58ab123dd09a0ca5460ef2a1112'], + ['CNsx11La', 206, 881, 'b1ac6017e17a0f93e6af3962a8c3ae66'], + ['CNsx11Ni', 178, 879, '0e09f9119f37308af94956c38527e758'], +]; + +// Click animations from SNDANIM.SI (objectId = m_move + 10) +// [name, objectId, size, md5] +const CLICK_ANIMATIONS = [ + ['ClickAnim0', 10, 1898, 'e8bb524cc29c6bdc9416ae3a95727dd1'], + ['ClickAnim1', 11, 2038, '21444b8952df188cb338e830a8ee1e00'], + ['ClickAnim2', 12, 2606, '5b49aeb7dcd7e52f22febc6502b9f8a2'], + ['ClickAnim3', 13, 4218, 'e25f074d7012f89868011dc2bd5c0586'], +]; + +// Click sounds from SNDANIM.SI (objectId = m_sound + 50) +// [name, objectId, size, md5] +const CLICK_SOUNDS = [ + ['ClickSound0', 50, 10078, '928eeb70f8dadbc400f5c150727fde69'], + ['ClickSound1', 51, 15988, '9c8aa04b0e4683976c3f2c2be868b37e'], + ['ClickSound2', 52, 4114, 'a94a6dc7ae24fc42b1b9be962bbf3bf1'], + ['ClickSound3', 53, 7741, '96bd26dc212ffd31da365ea1d088bfa3'], + ['ClickSound4', 54, 23705, 'ca79cc736729c12aed6da018725fb0e3'], + ['ClickSound5', 55, 24179, 'b7c97cb776f0afbba40f2e21fc0b309d'], + ['ClickSound6', 56, 17675, 'b69b07bba21c6667d0af651c89828815'], + ['ClickSound7', 57, 18953, '65d9cc0d09e3bfb831cee014a84085f7'], + ['ClickSound8', 58, 7344, '7bbc41251b750835989cb3b35c8546a4'], +]; + +// Mood sounds from SNDANIM.SI (objectId = m_mood + 66) +// [name, objectId, size, md5] +const MOOD_SOUNDS = [ + ['MoodSound0', 66, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound1', 67, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound2', 68, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound3', 69, 11534, '91379f36012f600a4b7432e003e16c3a'], +]; + +// [name, siFile, objectId, size, md5] +const TEXTURES = [ + ['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'], + ['CHJETL2', 'Scripts/Build/COPTER.SI', 118, 4235, '130322a91a293b85551f59e1b5fb1c6f'], + ['CHJETL3', 'Scripts/Build/COPTER.SI', 115, 4235, 'a922a1cf56da0ab47426cc3d0f581339'], + ['CHJETL4', 'Scripts/Build/COPTER.SI', 121, 4235, '624b3aa949f2e2db5c8820caffbe8f58'], + ['CHJETR1', 'Scripts/Build/COPTER.SI', 127, 4235, '924b8ae4db6c60003aba9994720ad0d6'], + ['CHJETR2', 'Scripts/Build/COPTER.SI', 133, 4235, 'b4edeba59b44b2b37124a8da02693a10'], + ['CHJETR3', 'Scripts/Build/COPTER.SI', 130, 4235, '18b50ad01a7aee7cf47f378d278be9eb'], + ['CHJETR4', 'Scripts/Build/COPTER.SI', 136, 4235, 'f6874aec4931186782aa2e360fe1861f'], + ['CHWIND1', 'Scripts/Build/COPTER.SI', 97, 4235, '860a0c8cacf27d3e2faed9030cc1be69'], + ['CHWIND2', 'Scripts/Build/COPTER.SI', 103, 4235, 'b99fec10adf4660aa19b067483970f8f'], + ['CHWIND3', 'Scripts/Build/COPTER.SI', 100, 4235, '05d7068d58105292632cdab56d3f67e4'], + ['CHWIND4', 'Scripts/Build/COPTER.SI', 106, 4235, '24eb83a84cad5e5926fc2db010bd93d8'], + ['Dbfrfn1', 'Scripts/Build/DUNECAR.SI', 96, 16524, '255fd145075b02d16fee2ac8bfeab3de'], + ['Dbfrfn2', 'Scripts/Build/DUNECAR.SI', 99, 16524, 'a6b3e5a02bb1ab0b139cb76b8cc00e9b'], + ['Dbfrfn3', 'Scripts/Build/DUNECAR.SI', 102, 16524, '60b6758fd34a74abf868fca98be3a3fa'], + ['Dbfrfn4', 'Scripts/Build/DUNECAR.SI', 105, 16524, 'b57ff13872e67c8e52953b943d4244a8'], + ['JSWNSH1', 'Scripts/Build/JETSKI.SI', 124, 16511, 'faf25d963756e335bd3e97b5383ed3a4'], + ['JSWNSH2', 'Scripts/Build/JETSKI.SI', 130, 16484, 'bce7b364358238f8adf15a2f7242ed1b'], + ['JSWNSH3', 'Scripts/Build/JETSKI.SI', 136, 16484, 'c502a5ca2f43f73320960c201ecef96a'], + ['JSWNSH4', 'Scripts/Build/JETSKI.SI', 142, 16511, '9dbddced239fe2e6f04704116a6dc98c'], + ['jsfrnt1', 'Scripts/Build/JETSKI.SI', 100, 8325, '3bc6cee56e1b282e1271d823c932b140'], + ['jsfrnt2', 'Scripts/Build/JETSKI.SI', 106, 8331, 'f0b3ba901b7302d6ea72fdadaee5def0'], + ['jsfrnt3', 'Scripts/Build/JETSKI.SI', 112, 8325, '13431821186bb466fce71c34ecc008e7'], + ['jsfrnt4', 'Scripts/Build/JETSKI.SI', 118, 8331, 'a173f79e05be78fba888d89aa5ee5ed1'], + ['rcback1', 'Scripts/Build/RACECAR.SI', 110, 16524, '97c1c6f3673bcceb340149627a5d656c'], + ['rcback2', 'Scripts/Build/RACECAR.SI', 113, 16524, 'd730986dc5a0b4f3199dcd31246c32a3'], + ['rcback3', 'Scripts/Build/RACECAR.SI', 116, 16524, 'a2574a6c6d9c16d41f001fb0eb908726'], + ['rcback4', 'Scripts/Build/RACECAR.SI', 119, 16512, '37291480ea6c96145c99e659d4d6cbd4'], + ['rcfrnt1', 'Scripts/Build/RACECAR.SI', 95, 16524, '8962ce972e6122ab7f7b87efa40591c2'], + ['rcfrnt2', 'Scripts/Build/RACECAR.SI', 98, 16524, 'ca235a1cdd432f6cc5b64610e62eb94f'], + ['rcfrnt3', 'Scripts/Build/RACECAR.SI', 101, 16524, '017301e33ff6dc8dfe41afc6558055f1'], + ['rcfrnt4', 'Scripts/Build/RACECAR.SI', 104, 16512, '3064a4627325d2325ee07b98b49f4a58'], + ['rctail1', 'Scripts/Build/RACECAR.SI', 125, 4227, '5c28aa88d5971f73575315b09359ce57'], + ['rctail2', 'Scripts/Build/RACECAR.SI', 128, 4236, 'a69ae67432ecccfd88a567ce2d8973c0'], + ['rctail3', 'Scripts/Build/RACECAR.SI', 131, 4230, '55d628507bf0968037422aefb3494184'], + ['rctail4', 'Scripts/Build/RACECAR.SI', 134, 4236, '614cb9aa532ee85c119cc1432e6d65e9'], +]; + +// [name, siFile, objectId, size, md5] +const BITMAPS = [ + ['globe1', 'Scripts/Isle/ISLE.SI', 1130, 5824, '12554d2a7d38bdc0e6bc1709f8404293'], + ['globe2', 'Scripts/Isle/ISLE.SI', 1131, 5824, 'b0b39a4b959b4bf1605a6c695d9e3dd0'], + ['globe3', 'Scripts/Isle/ISLE.SI', 1132, 5824, '71672bff19044f7df059c87a0759d950'], + ['globe4', 'Scripts/Isle/ISLE.SI', 1133, 5824, 'f5421e06ae9997d9cbfc774942f097d4'], + ['globe5', 'Scripts/Isle/ISLE.SI', 1134, 5824, '47974b0577cab1c3775175eab074e5b5'], + ['globe6', 'Scripts/Isle/ISLE.SI', 1135, 5824, '65237421063fa993167ed4af9be9180c'], + ['pepper', 'Scripts/Infocntr/INFOMAIN.SI', 80, 2904, '4143f58632135089b7bc695bff406077'], + ['pepper-selected', 'Scripts/Infocntr/INFOMAIN.SI', 81, 2904, '01f57a3a32f7aea6bc48a78a8c95ee1b'], + ['mama', 'Scripts/Infocntr/INFOMAIN.SI', 76, 2904, 'ad68ae8fe78c368cac026ae09f1ad8c4'], + ['mama-selected', 'Scripts/Infocntr/INFOMAIN.SI', 77, 2904, '72f041d1080b20f713d39f7ae00d966d'], + ['papa', 'Scripts/Infocntr/INFOMAIN.SI', 78, 2904, '0cd5ef1c6d68198862c102b36b2f04fe'], + ['papa-selected', 'Scripts/Infocntr/INFOMAIN.SI', 79, 2904, 'ec7d3d87d796dd824dfd5110911d4aa4'], + ['nick', 'Scripts/Infocntr/INFOMAIN.SI', 82, 2904, 'c29ecdce0ffae81a71b973b8af26c26c'], + ['nick-selected', 'Scripts/Infocntr/INFOMAIN.SI', 83, 2904, '8db4e63632c6f90e543832e6c92c89fa'], + ['laura', 'Scripts/Infocntr/INFOMAIN.SI', 84, 2904, '99abd3415870285d487da20882f3bbf3'], + ['laura-selected', 'Scripts/Infocntr/INFOMAIN.SI', 85, 2904, 'f56c2efb4f744d306d5a3d4ac8d332ca'], +]; + +const MXCH_SIGNATURE = Buffer.from('MxCh'); +const MXCH_HEADER_SIZE = 22; // MxCh(4) + chunkSize(4) + flags(2) + objectId(4) + time(4) + dataSize(4) + +const LEGO_DIR = path.join(process.cwd(), 'LEGO'); +const BIN_PATH = path.join(process.cwd(), 'save-editor.bin'); + +const siCache = new Map(); + +async function loadSI(siRelPath) { + if (siCache.has(siRelPath)) return siCache.get(siRelPath); + const buf = await fs.readFile(path.join(LEGO_DIR, siRelPath)); + siCache.set(siRelPath, buf); + return buf; +} + +function md5(buf) { + return crypto.createHash('md5').update(buf).digest('hex'); +} + +/** + * Scan a SI buffer for all MxCh chunks and group data ranges by objectId. + * Clips each chunk's data to the physical space before the next MxCh header, + * since interleaving can split a logical chunk across sector boundaries. + * Returns Map. + */ +function findMxChByObjectId(siBuf, targetIds) { + // First pass: collect all MxCh header positions + const allPositions = []; + let pos = 0; + while (pos <= siBuf.length - MXCH_HEADER_SIZE) { + const idx = siBuf.indexOf(MXCH_SIGNATURE, pos); + if (idx === -1) break; + allPositions.push(idx); + pos = idx + 4; + } + + // Second pass: extract data ranges for target objectIds + const result = new Map(); + for (const id of targetIds) result.set(id, []); + + for (let i = 0; i < allPositions.length; i++) { + const idx = allPositions[i]; + const dataSize = siBuf.readUInt32LE(idx + 18); + const objectId = siBuf.readUInt32LE(idx + 10); + + if (dataSize > 0 && result.has(objectId)) { + const dataStart = idx + MXCH_HEADER_SIZE; + const physicalEnd = i + 1 < allPositions.length ? allPositions[i + 1] : siBuf.length; + const actualSize = Math.min(dataSize, physicalEnd - dataStart); + if (actualSize > 0) { + result.get(objectId).push([dataStart, actualSize]); + } + } + } + + return result; +} + +/** + * Assemble MxCh data from ranges, verify against expected size and md5. + * Only assembles up to `size` bytes (objectIds can be reused across streams). + * Returns the assembled Buffer, or null on failure. + */ +function extractAndVerify(siBuf, ranges, size, expectedMd5) { + if (!ranges || ranges.length === 0) return null; + + const assembled = Buffer.alloc(size); + let writePos = 0; + for (const [rOff, rLen] of ranges) { + if (writePos >= size) break; + const take = Math.min(rLen, size - writePos); + siBuf.copy(assembled, writePos, rOff, rOff + take); + writePos += take; + } + + if (writePos !== size || md5(assembled) !== expectedMd5) return null; + return assembled; +} + +async function main() { + console.log('Generating asset fragment bundle...\n'); + + const fragments = []; // [{type, name, data: Buffer}, ...] + let found = 0; + let failed = 0; + + // --- Animations (all in ISLE.SI) --- + const isleSI = await loadSI('Scripts/Isle/ISLE.SI'); + console.log(`Loaded ISLE.SI (${(isleSI.length / 1024 / 1024).toFixed(1)} MB)`); + + const aniObjectIds = new Set(ANIMATIONS.map(([, objectId]) => objectId)); + const aniRanges = findMxChByObjectId(isleSI, aniObjectIds); + + for (const [name, objectId, size, expectedMd5] of ANIMATIONS) { + const data = extractAndVerify(isleSI, aniRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'animations', name, data }); + found++; + } else { + console.error(` FAILED: ${name}.ani (objectId ${objectId})`); + failed++; + } + } + console.log(` ${found}/${ANIMATIONS.length} walking animations found\n`); + + // --- Click Animations (in SNDANIM.SI) --- + const sndanimSI = await loadSI('Scripts/SNDANIM.SI'); + console.log(`Loaded SNDANIM.SI (${(sndanimSI.length / 1024 / 1024).toFixed(1)} MB)`); + + const clickObjectIds = new Set(CLICK_ANIMATIONS.map(([, objectId]) => objectId)); + const clickRanges = findMxChByObjectId(sndanimSI, clickObjectIds); + + let clickFound = 0; + for (const [name, objectId, size, expectedMd5] of CLICK_ANIMATIONS) { + const data = extractAndVerify(sndanimSI, clickRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'animations', name, data }); + clickFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); + + // --- Sounds (in SNDANIM.SI) --- + const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS]; + const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId)); + const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds); + + let soundFound = 0; + for (const [name, objectId, size, expectedMd5] of allSounds) { + const data = extractAndVerify(sndanimSI, soundRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'sounds', name, data }); + soundFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${soundFound}/${allSounds.length} sounds found\n`); + + // --- Textures (across Build SI files) --- + const texBySI = new Map(); + for (const entry of TEXTURES) { + const siFile = entry[1]; + if (!texBySI.has(siFile)) texBySI.set(siFile, []); + texBySI.get(siFile).push(entry); + } + + let texFound = 0; + for (const [siFile, entries] of texBySI) { + const siBuf = await loadSI(siFile); + const objectIds = new Set(entries.map(([, , objectId]) => objectId)); + const texRanges = findMxChByObjectId(siBuf, objectIds); + console.log(`Loaded ${siFile} (${(siBuf.length / 1024).toFixed(0)} KB)`); + + for (const [name, , objectId, size, expectedMd5] of entries) { + const data = extractAndVerify(siBuf, texRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'textures', name, data }); + texFound++; + found++; + } else { + console.error(` FAILED: ${name}.tex in ${siFile} (objectId ${objectId})`); + failed++; + } + } + } + console.log(` ${texFound}/${TEXTURES.length} textures found\n`); + + // --- Bitmaps (across SI files) --- + const bmpBySI = new Map(); + for (const entry of BITMAPS) { + const siFile = entry[1]; + if (!bmpBySI.has(siFile)) bmpBySI.set(siFile, []); + bmpBySI.get(siFile).push(entry); + } + + let bmpFound = 0; + for (const [siFile, entries] of bmpBySI) { + const siBuf = await loadSI(siFile); + const objectIds = new Set(entries.map(([, , objectId]) => objectId)); + const bmpRanges = findMxChByObjectId(siBuf, objectIds); + + for (const [name, , objectId, size, expectedMd5] of entries) { + const data = extractAndVerify(siBuf, bmpRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'bitmaps', name, data }); + bmpFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + } + console.log(` ${bmpFound}/${BITMAPS.length} bitmaps found\n`); + + if (failed > 0) { + console.error(`Failed to find ${failed} assets. Bundle not written.`); + process.exit(1); + } + + // --- Write single bundle: [U32LE indexLen][JSON index][data] --- + const index = {}; + let offset = 0; + for (const { type, name, data } of fragments) { + index[`${type}/${name}`] = [offset, data.length]; + offset += data.length; + } + + const indexBuf = Buffer.from(JSON.stringify(index)); + const header = Buffer.alloc(4); + header.writeUInt32LE(indexBuf.length); + const dataBuf = Buffer.concat(fragments.map(f => f.data)); + const bundle = Buffer.concat([header, indexBuf, dataBuf]); + await fs.writeFile(BIN_PATH, bundle); + console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`); + + console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); +} + +main().catch(err => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/src/core/assetLoader.js b/src/core/assetLoader.js new file mode 100644 index 0000000..3119c3f --- /dev/null +++ b/src/core/assetLoader.js @@ -0,0 +1,139 @@ +// Loads assets from a packed binary bundle generated by scripts/generate-save-editor-assets.js +// Format: [U32LE indexLen][JSON index][fragment data] + +let bundleIndex = null; +let dataOffset = 0; +let bundleBuffer = null; +let bundlePromise = null; + +async function loadBundle() { + if (!bundlePromise) { + bundlePromise = fetch('/save-editor.bin').then(async (resp) => { + bundleBuffer = await resp.arrayBuffer(); + const indexLen = new DataView(bundleBuffer).getUint32(0, true); + const indexJson = new TextDecoder().decode(new Uint8Array(bundleBuffer, 4, indexLen)); + bundleIndex = JSON.parse(indexJson); + dataOffset = 4 + indexLen; + }); + } + await bundlePromise; +} + +function getAsset(type, name) { + const entry = bundleIndex[`${type}/${name}`]; + if (!entry) return null; + const [offset, size] = entry; + return bundleBuffer.slice(dataOffset + offset, dataOffset + offset + size); +} + +export async function fetchAnimation(name) { + await loadBundle(); + return getAsset('animations', name); +} + +export async function fetchTexture(name) { + await loadBundle(); + return getAsset('textures', name); +} + +async function fetchBitmap(name) { + await loadBundle(); + return getAsset('bitmaps', name); +} + +async function fetchSound(name) { + await loadBundle(); + return getAsset('sounds', name); +} + +/** + * Build a WAV file from raw MxCh sound data. + * Layout: bytes 0-15 = PCMWAVEFORMAT, 16-19 = m_dataSize, 20-23 = m_flags, 24+ = PCM data. + * Uses actual available size since sector interleaving may clip the last chunk. + */ +function buildWav(buffer) { + const dataSize = buffer.byteLength - 24; + const wavSize = 44 + dataSize; + const wav = new ArrayBuffer(wavSize); + const view = new DataView(wav); + const bytes = new Uint8Array(wav); + + // RIFF header + bytes.set([0x52, 0x49, 0x46, 0x46]); // "RIFF" + view.setUint32(4, wavSize - 8, true); + bytes.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE" + + // fmt chunk — copy PCMWAVEFORMAT (16 bytes) directly from source header + bytes.set([0x66, 0x6D, 0x74, 0x20], 12); // "fmt " + view.setUint32(16, 16, true); + bytes.set(new Uint8Array(buffer, 0, 16), 20); + + // data chunk + bytes.set([0x64, 0x61, 0x74, 0x61], 36); // "data" + view.setUint32(40, dataSize, true); + bytes.set(new Uint8Array(buffer, 24, dataSize), 44); + + return wav; +} + +export async function fetchSoundAsWav(name) { + const buffer = await fetchSound(name); + if (!buffer) return null; + return buildWav(buffer); +} + +/** + * Decode a raw Windows DIB (no BM file header) into RGBA ImageData. + * Supports 8-bit indexed color only. + */ +function decodeDib(buffer) { + const view = new DataView(buffer); + const width = view.getInt32(4, true); + const height = view.getInt32(8, true); + const bpp = view.getUint16(14, true); + if (bpp !== 8) return null; + + // Palette: 256 BGRA entries starting at offset 40 + const palette = new Uint8Array(buffer, 40, 1024); + + // Pixel data starts after header + palette + const pixelOffset = 40 + 1024; + const rowStride = (width + 3) & ~3; // rows padded to 4-byte boundary + const absHeight = Math.abs(height); + const bottomUp = height > 0; + + const imageData = new ImageData(width, absHeight); + const pixels = new Uint8Array(buffer, pixelOffset); + + for (let y = 0; y < absHeight; y++) { + const srcRow = bottomUp ? (absHeight - 1 - y) : y; + for (let x = 0; x < width; x++) { + const idx = pixels[srcRow * rowStride + x] * 4; + const dst = (y * width + x) * 4; + imageData.data[dst] = palette[idx + 2]; // R (from BGR) + imageData.data[dst + 1] = palette[idx + 1]; // G + imageData.data[dst + 2] = palette[idx]; // B + imageData.data[dst + 3] = 255; + } + } + + return imageData; +} + +/** + * Fetch a bitmap from an SI file and return a blob URL for use in tags. + */ +export async function fetchBitmapAsURL(name) { + const buffer = await fetchBitmap(name); + if (!buffer) return null; + + const imageData = decodeDib(buffer); + if (!imageData) return null; + + const canvas = new OffscreenCanvas(imageData.width, imageData.height); + const ctx = canvas.getContext('2d'); + ctx.putImageData(imageData, 0, 0); + + const blob = await canvas.convertToBlob({ type: 'image/png' }); + return URL.createObjectURL(blob); +} diff --git a/src/core/formats/AnimationParser.js b/src/core/formats/AnimationParser.js new file mode 100644 index 0000000..8970269 --- /dev/null +++ b/src/core/formats/AnimationParser.js @@ -0,0 +1,212 @@ +/** + * Parser for LEGO Island .ani animation files. + * Format spec: isle/docs/animation.ksy + * + * Binary layout: + * S32 magic (0x11) + * F32 boundingRadius, F32 centerX, F32 centerY, F32 centerZ + * S32 hasCameraAnim, S32 unused + * U32 numActors + * For each actor: U32 nameLen, char[nameLen] name, U32 actorType (if nameLen > 0) + * S32 duration (ms) + * [optional camera_anim if hasCameraAnim != 0] + * tree_node root + * + * tree_node: + * node_data data + * U32 numChildren + * tree_node[numChildren] children + * + * node_data: + * U32 nameLen, char[nameLen] name (if nameLen > 0) + * U16 numTranslationKeys, translation_key[...] + * U16 numRotationKeys, rotation_key[...] + * U16 numScaleKeys, scale_key[...] + * U16 numMorphKeys, morph_key[...] + * + * Keys share packed time_and_flags (S32): bits 0-23 = time ms, bits 24-31 = flags + * translation_key: anim_key + F32 x,y,z + * rotation_key: anim_key + F32 angle(w), F32 x, F32 y, F32 z (quaternion) + * scale_key: anim_key + F32 x,y,z + * morph_key: anim_key + U8 visible + */ + +import { BinaryReader } from './BinaryReader.js'; + +export class AnimationParser { + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + this.reader = new BinaryReader(buffer); + } + + parse() { + const magic = this.reader.readS32(); + if (magic !== 0x11) { + throw new Error(`Invalid animation magic: 0x${magic.toString(16)}, expected 0x11`); + } + + const boundingRadius = this.reader.readF32(); + const centerX = this.reader.readF32(); + const centerY = this.reader.readF32(); + const centerZ = this.reader.readF32(); + const hasCameraAnim = this.reader.readS32(); + const unused = this.reader.readS32(); + + const numActors = this.reader.readU32(); + const actors = []; + for (let i = 0; i < numActors; i++) { + actors.push(this.parseActorEntry()); + } + + const duration = this.reader.readS32(); + + let cameraAnim = null; + if (hasCameraAnim !== 0) { + cameraAnim = this.parseCameraAnim(); + } + + const rootNode = this.parseTreeNode(); + + return { + boundingRadius, + center: { x: centerX, y: centerY, z: centerZ }, + actors, + duration, + cameraAnim, + rootNode + }; + } + + parseActorEntry() { + const nameLen = this.reader.readU32(); + if (nameLen === 0) { + return { name: '', actorType: 0 }; + } + const name = this.reader.readString(nameLen); + const actorType = this.reader.readU32(); + return { name, actorType }; + } + + parseCameraAnim() { + const numTranslationKeys = this.reader.readU16(); + const translationKeys = []; + for (let i = 0; i < numTranslationKeys; i++) { + translationKeys.push(this.parseTranslationKey()); + } + + const numTargetKeys = this.reader.readU16(); + const targetKeys = []; + for (let i = 0; i < numTargetKeys; i++) { + targetKeys.push(this.parseTranslationKey()); + } + + const numRotationKeys = this.reader.readU16(); + const rotationKeys = []; + for (let i = 0; i < numRotationKeys; i++) { + rotationKeys.push(this.parseRotationZKey()); + } + + return { translationKeys, targetKeys, rotationKeys }; + } + + parseTreeNode() { + const data = this.parseNodeData(); + const numChildren = this.reader.readU32(); + const children = []; + for (let i = 0; i < numChildren; i++) { + children.push(this.parseTreeNode()); + } + return { data, children }; + } + + parseNodeData() { + const nameLen = this.reader.readU32(); + let name = ''; + if (nameLen > 0) { + name = this.reader.readString(nameLen); + } + + const numTranslationKeys = this.reader.readU16(); + const translationKeys = []; + for (let i = 0; i < numTranslationKeys; i++) { + translationKeys.push(this.parseTranslationKey()); + } + + const numRotationKeys = this.reader.readU16(); + const rotationKeys = []; + for (let i = 0; i < numRotationKeys; i++) { + rotationKeys.push(this.parseRotationKey()); + } + + const numScaleKeys = this.reader.readU16(); + const scaleKeys = []; + for (let i = 0; i < numScaleKeys; i++) { + scaleKeys.push(this.parseScaleKey()); + } + + const numMorphKeys = this.reader.readU16(); + const morphKeys = []; + for (let i = 0; i < numMorphKeys; i++) { + morphKeys.push(this.parseMorphKey()); + } + + return { name, translationKeys, rotationKeys, scaleKeys, morphKeys }; + } + + parseAnimKey() { + const timeAndFlags = this.reader.readS32(); + return { + time: timeAndFlags & 0xFFFFFF, + flags: (timeAndFlags >>> 24) & 0xFF + }; + } + + parseTranslationKey() { + const key = this.parseAnimKey(); + const x = this.reader.readF32(); + const y = this.reader.readF32(); + const z = this.reader.readF32(); + return { ...key, x, y, z }; + } + + parseRotationKey() { + const key = this.parseAnimKey(); + const w = this.reader.readF32(); // angle/scalar component + const x = this.reader.readF32(); + const y = this.reader.readF32(); + const z = this.reader.readF32(); + return { ...key, w, x, y, z }; + } + + parseScaleKey() { + const key = this.parseAnimKey(); + const x = this.reader.readF32(); + const y = this.reader.readF32(); + const z = this.reader.readF32(); + return { ...key, x, y, z }; + } + + parseMorphKey() { + const key = this.parseAnimKey(); + const visible = this.reader.readU8(); + return { ...key, visible: visible !== 0 }; + } + + parseRotationZKey() { + const key = this.parseAnimKey(); + const z = this.reader.readF32(); + return { ...key, z }; + } +} + +/** + * Parse an animation buffer. + * @param {ArrayBuffer} buffer + * @returns {Object} Parsed animation data + */ +export function parseAnimation(buffer) { + const parser = new AnimationParser(buffer); + return parser.parse(); +} diff --git a/src/core/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js index f3e71fd..494f219 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -100,10 +100,31 @@ export class SaveGameParser { } /** - * Skip character manager data (66 characters * 16 bytes = 1056 bytes) + * Parse character manager data (66 characters * 16 bytes = 1056 bytes) + * Each character: sound(S32) + move(S32) + mood(U8) + * + hatPartNameIndex(U8) + hatNameIndex(U8) + infogronNameIndex(U8) + * + armlftNameIndex(U8) + armrtNameIndex(U8) + leglftNameIndex(U8) + legrtNameIndex(U8) */ - skipCharacters() { - this.reader.skip(66 * 16); + parseCharacters() { + this.parsed.charactersOffset = this.reader.tell(); + const characters = []; + + for (let i = 0; i < 66; i++) { + characters.push({ + sound: this.reader.readS32(), + move: this.reader.readS32(), + mood: this.reader.readU8(), + hatPartNameIndex: this.reader.readU8(), + hatNameIndex: this.reader.readU8(), + infogronNameIndex: this.reader.readU8(), + armlftNameIndex: this.reader.readU8(), + armrtNameIndex: this.reader.readU8(), + leglftNameIndex: this.reader.readU8(), + legrtNameIndex: this.reader.readU8() + }); + } + + this.parsed.characters = characters; } /** @@ -403,7 +424,7 @@ export class SaveGameParser { parse() { this.parseHeader(); this.parseVariables(); - this.skipCharacters(); + this.parseCharacters(); this.skipPlants(); this.skipBuildings(); this.parseGameStates(); diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index c39110e..5c0aa5c 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -5,6 +5,7 @@ import { SaveGameParser } from './SaveGameParser.js'; import { BinaryWriter } from './BinaryWriter.js'; import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js'; +import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js'; /** * Offsets for header fields @@ -208,41 +209,9 @@ export class SaveGameSerializer { return null; } - const view = new DataView(workingBuffer); - - // Calculate offset based on mission type - let offset; - - if (missionType === 'pizza') { - // Pizza: 5 actors * 8 bytes (unk(2) + counter(2) + score(2) + hiScore(2)) - const actorIndex = actorId - Actor.PEPPER; // 0-4 - const entryOffset = stateLocation.dataOffset + (actorIndex * 8); - if (scoreType === 'score') { - offset = entryOffset + 4; // Skip unk + counter - } else { - offset = entryOffset + 6; // Skip unk + counter + score - } - } else if (missionType === 'carRace' || missionType === 'jetskiRace') { - // Race: 5 actors * 5 bytes (id(1) + lastScore(2) + highScore(2)) - const actorIndex = actorId - Actor.PEPPER; - const entryOffset = stateLocation.dataOffset + (actorIndex * 5); - if (scoreType === 'score') { - offset = entryOffset + 1; // Skip id - } else { - offset = entryOffset + 3; // Skip id + lastScore - } - } else if (missionType === 'towTrack' || missionType === 'ambulance') { - // Score mission: 5 scores then 5 high scores (all S16) - const actorIndex = actorId - Actor.PEPPER; - if (scoreType === 'score') { - offset = stateLocation.dataOffset + (actorIndex * 2); - } else { - offset = stateLocation.dataOffset + 10 + (actorIndex * 2); // Skip 5 scores - } - } - - if (offset !== undefined) { - view.setInt16(offset, value, true); + const offset = this.getMissionScoreOffset(missionType, actorId, scoreType); + if (offset !== null) { + new DataView(workingBuffer).setInt16(offset, value, true); } return workingBuffer; @@ -461,6 +430,27 @@ export class SaveGameSerializer { return newBuffer; } + /** + * Update a character field in the save file + * @param {number} characterIndex - Character index (0-65) + * @param {string} field - Field name from CharacterFieldOffsets + * @param {number} value - New value + * @returns {ArrayBuffer} - Modified buffer + */ + updateCharacter(characterIndex, field, value) { + const workingBuffer = this.createCopy(); + const view = new DataView(workingBuffer); + const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + CharacterFieldOffsets[field]; + + if (field === 'sound' || field === 'move') { + view.setInt32(offset, value, true); + } else { + view.setUint8(offset, value); + } + + return workingBuffer; + } + /** * Get the byte offset for a mission score * @param {string} missionType diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js index 3e08deb..ba1f351 100644 --- a/src/core/formats/WdbParser.js +++ b/src/core/formats/WdbParser.js @@ -23,14 +23,18 @@ export class WdbParser { } const globalTexturesSize = this.reader.readU32(); - // Skip global textures for now - BIGCUBE.GIF is in model_data - this.reader.skip(globalTexturesSize); + let globalTextures = []; + if (globalTexturesSize > 0) { + globalTextures = this.parseTextureInfo(); + } const globalPartsSize = this.reader.readU32(); - // Skip global parts - this.reader.skip(globalPartsSize); + let globalParts = null; + if (globalPartsSize > 0) { + globalParts = this.parseGlobalParts(globalPartsSize); + } - return { worlds, globalTexturesSize, globalPartsSize }; + return { worlds, globalTexturesSize, globalPartsSize, globalParts, globalTextures }; } parseWorldEntry() { @@ -125,6 +129,18 @@ export class WdbParser { return { parts, textures }; } + /** + * Parse global parts block (same structure as parsePartData) + * @param {number} size - Size of global parts block + * @returns {{ parts: Array, textures: Array }} + */ + parseGlobalParts(size) { + const startOffset = this.reader.tell(); + const result = this.parsePartData(startOffset); + this.reader.seek(startOffset + size); + return result; + } + /** * Parse model_data blob at specified offset * @param {number} offset - Absolute file offset @@ -510,6 +526,21 @@ export function resolveLods(roi, partsMap) { return []; } +/** + * Build a parts lookup map from global parts + * @param {{ parts: Array }} globalParts - Parsed global parts from WdbParser + * @returns {Map} - Map of part name (lowercase) -> part data + */ +export function buildGlobalPartsMap(globalParts) { + const partsMap = new Map(); + if (!globalParts || !globalParts.parts) return partsMap; + + for (const part of globalParts.parts) { + partsMap.set(part.name.toLowerCase(), part); + } + return partsMap; +} + /** * Build a parts lookup map from a world's parts array * @param {WdbParser} parser - Parser instance for reading part data diff --git a/src/core/formats/index.js b/src/core/formats/index.js index a6d3ca4..aaddc97 100644 --- a/src/core/formats/index.js +++ b/src/core/formats/index.js @@ -7,7 +7,7 @@ export { BinaryReader } from './BinaryReader.js'; export { BinaryWriter } from './BinaryWriter.js'; // WDB format -export { WdbParser, findRoi } from './WdbParser.js'; +export { WdbParser, findRoi, buildGlobalPartsMap } from './WdbParser.js'; // Save game format export { SaveGameParser, parseSaveGame } from './SaveGameParser.js'; @@ -19,3 +19,6 @@ export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer. // Texture format export { parseTex } from './TexParser.js'; + +// Animation format +export { AnimationParser, parseAnimation } from './AnimationParser.js'; diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js new file mode 100644 index 0000000..34de012 --- /dev/null +++ b/src/core/rendering/ActorRenderer.js @@ -0,0 +1,903 @@ +import * as THREE from 'three'; +import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js'; +import { LegoColors } from '../savegame/constants.js'; +import { parseAnimation } from '../formats/AnimationParser.js'; +import { fetchAnimation } from '../assetLoader.js'; +import { BaseRenderer } from './BaseRenderer.js'; + +/** + * Map actor index to animation suffix index (from g_characters[].m_unk0x16). + * This maps the 66 ActorInfoInit indices to the g_cycles row index. + */ +const ACTOR_SUFFIX_INDEX = (() => { + // Default all to 0 (xx) + const map = new Array(66).fill(0); + map[0] = 1; // pepper → Pe + map[1] = 2; // mama → Ma + map[2] = 3; // papa → Pa + map[3] = 4; // nick → Ni + map[4] = 5; // laura → La + map[5] = 0; // infoman → xx (not in g_characters, uses default) + map[6] = 6; // brickstr → Br + // 7-35: all 0 (xx) — generic NPCs + // Note: g_characters indices don't perfectly align with ActorInfoInit indices + // for the NPCs after brickstr. The g_characters array has 47 entries + // matching by name. We map the special ones: + map[37] = 9; // rd → Rd (g_characters index 36) + map[38] = 8; // pg → Pg (g_characters index 37) + map[39] = 7; // bd → Bd (g_characters index 38) + map[40] = 10; // sy → Sy (g_characters index 39) + map[56] = 1; // pep → Pe (same as pepper) + return map; +})(); + +/** + * g_cycles[11][17] — animation name table from legoanimationmanager.cpp. + * Rows = character type suffix index, columns = mood (0-3) for walking, higher indices for other animations. + */ +const G_CYCLES = [ + // 0: xx + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs011xx','CNs012xx',null,null,null,null,null], + // 1: Pe + ['CNs001Pe','CNs002Pe','CNs003Pe','CNs004Pe','CNs005Pe','CNs007Pe','CNs006Pe','CNs008Pe','CNs009Pe','CNs010Pe','CNs001sk',null,null,null,null,null,null], // CNs001sk = skateboard + // 2: Ma + ['CNs001Ma','CNs002Ma','CNs003Ma','CNs004Ma','CNs005Ma','CNs007Ma','CNs006Ma','CNs008Ma','CNs009Ma','CNs010Ma','CNs0x4Ma',null,null,'CNs011Ma','CNs012Ma','CNs013Ma',null], + // 3: Pa + ['CNs001Pa','CNs002Pa','CNs003Pa','CNs004Pa','CNs005Pa','CNs007Pa','CNs006Pa','CNs008Pa','CNs009Pa','CNs010Pa','CNs0x4Pa',null,null,'CNs011Pa','CNs012Pa','CNs013Pa',null], + // 4: Ni + ['CNs001Ni','CNs002Ni','CNs003Ni','CNs004Ni','CNs005Ni','CNs007Ni','CNs006Ni','CNs008Ni','CNs009Ni','CNs010Ni','CNs011Ni','CNsx11Ni',null,null,null,null,null], + // 5: La + ['CNs001La','CNs002La','CNs003La','CNs004La','CNs005La','CNs007La','CNs006La','CNs008La','CNs009La','CNs010La','CNs011La','CNsx11La',null,null,null,null,null], + // 6: Br + ['CNs001Br','CNs002Br','CNs003Br','CNs004Br','CNs005Br','CNs007Br','CNs006Br','CNs008Br','CNs009Br','CNs010Br','CNs011Br','CNs900Br','CNs901Br','CNs011Br','CNs012Br','CNs013Br','CNs014Br'], + // 7: Bd + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Bd','CNs012xx',null,null,null,null,null], + // 8: Pg + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Pg','CNs012xx',null,null,null,null,null], + // 9: Rd + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Rd','CNs012xx',null,null,null,null,null], + // 10: Sy + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Sy','CNs012xx',null,null,null,null,null], +]; + +/** + * Map ActorLOD names (used in our part hierarchy) to animation node names. + * Animation files use uppercase names like "BODY", "HEAD", "LEG-RT", etc. + */ +const PART_NAME_TO_ANIM_NODE = { + 'body': 'BODY', + 'infohat': 'INFOHAT', + 'infogron': 'INFOGRON', + 'head': 'HEAD', + 'arm-lft': 'ARM-LFT', + 'arm-rt': 'ARM-RT', + 'claw-lft': 'CLAW-LFT', + 'claw-rt': 'CLAW-RT', + 'leg-lft': 'LEG-LFT', + 'leg-rt': 'LEG-RT' +}; + +/** + * Renderer for full LEGO characters assembled from WDB global parts. + * Mirrors the game's LegoCharacterManager::CreateActorROI logic. + */ +export class ActorRenderer extends BaseRenderer { + constructor(canvas) { + super(canvas); + this.partGroups = []; // 10 part groups for click targeting + this.clock = new THREE.Clock(); + this.mixer = null; + this.currentAction = null; + this.animationCache = new Map(); // suffix → parsed animation data + this._queuedClickMove = null; // queued click animation move index (0-3) + + this.camera.position.set(2, 0.8, 3.5); + this.camera.lookAt(0, 0.2, 0); + + this.setupControls(new THREE.Vector3(0, 0.2, 0)); + this.controls.autoRotate = false; + this._initialAutoRotate = false; + + this.raycaster = new THREE.Raycaster(); + } + + /** + * Load a full actor from global parts, optionally with a vehicle. + * @param {number} actorIndex - Index into ActorInfoInit (0-65) + * @param {Array} characters - Parsed character state from save file (66 entries) + * @param {Map} globalPartsMap - Name→part lookup for global parts + * @param {Array} globalTextures - Global texture list from WDB + * @param {Map|null} vehiclePartsMap - Name→part lookup for vehicle parts (null if no vehicle) + * @param {Array|null} vehicleTextures - Vehicle texture list (null if no vehicle) + * @param {object|null} vehicleInfo - { vehicleModel, vehicleAnim } or null + */ + loadActor(actorIndex, characters, globalPartsMap, globalTextures, vehiclePartsMap, vehicleTextures, vehicleInfo) { + this.clearModel(); + + const actorInfo = ActorInfoInit[actorIndex]; + const charState = characters[actorIndex]; + + // Build texture lookup + this.textures.clear(); + for (const tex of globalTextures) { + if (tex.name) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); + } + } + + // Merge vehicle textures (if present) + if (vehicleInfo && vehicleTextures) { + for (const tex of vehicleTextures) { + if (tex.name && !this.textures.has(tex.name.toLowerCase())) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); + } + } + } + + this.modelGroup = new THREE.Group(); + this.partGroups = []; + this.vehicleGroup = null; + this.vehicleInfo = vehicleInfo || null; + + // Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10]) + for (let i = 0; i < 10; i++) { + const actorLOD = ActorLODs[i + 1]; + const part = actorInfo.parts[i]; + + // Resolve part name for body (i=0) and hat (i=1) + let partName; + if (i === 0 || i === 1) { + partName = this.resolvePartName(part, charState, i); + } else { + partName = actorLOD.parentName; + } + + if (!partName) continue; + + // Find the part's LOD data in global parts + const partData = globalPartsMap.get(partName.toLowerCase()); + if (!partData) continue; + + const partGroup = new THREE.Group(); + partGroup.userData.partIndex = i; + partGroup.userData.partName = partName; + partGroup.userData.lodName = actorLOD.name; // for animation matching + // Name used by Three.js PropertyBinding to match animation tracks + partGroup.name = `part_${actorLOD.name}`; + + // Resolve color/texture for this part + const resolvedName = this.resolveNameValue(part, charState, i); + + // Create meshes from LODs + const lods = partData.lods || []; + if (lods.length > 0) { + const lod = lods[lods.length - 1]; // Highest quality + this.createPartMeshes(lod, actorLOD, part, resolvedName, i, partGroup); + } + + // Position the part using ActorLOD transform + this.applyPartTransform(partGroup, actorLOD); + + this.modelGroup.add(partGroup); + this.partGroups[i] = partGroup; + } + + // Create vehicle mesh if vehicle info is provided + if (vehicleInfo && vehiclePartsMap) { + this.createVehicleMesh(vehicleInfo, vehiclePartsMap); + } + + this.centerAndScaleModel(1.8); + // Rotate 180° around Y so actor faces the camera (negating X for + // left-to-right-handed conversion flips the facing direction) + this.modelGroup.rotation.y = Math.PI; + // Shift model up in vehicle mode so it's better framed + if (this.vehicleGroup) { + this.modelGroup.position.y += 0.2; + } + this.scene.add(this.modelGroup); + + // Load and start walking/vehicle animation based on mood + const mood = charState?.mood ?? 0; + this.loadAnimationForActor(actorIndex, mood, vehicleInfo); + + this.renderer.render(this.scene, this.camera); + } + + /** + * Resolve which part geometry to use (body variant or hat type). + */ + resolvePartName(part, charState, partIdx) { + if (!part.partNameIndices || !part.partNames) return null; + + let nameIdx = part.partNameIndex; + if (partIdx === 1 && charState) { + nameIdx = charState.hatPartNameIndex; + } + + return part.partNames[part.partNameIndices[nameIdx]]; + } + + /** + * Resolve the color or texture name for a part. + */ + resolveNameValue(part, charState, partIdx) { + if (!part.nameIndices || !part.names) return null; + + let nameIdx = part.nameIndex; + + if (charState) { + switch (partIdx) { + case 1: nameIdx = charState.hatNameIndex; break; + case 2: nameIdx = charState.infogronNameIndex; break; + case 4: nameIdx = charState.armlftNameIndex; break; + case 5: nameIdx = charState.armrtNameIndex; break; + case 8: nameIdx = charState.leglftNameIndex; break; + case 9: nameIdx = charState.legrtNameIndex; break; + } + } + + return part.names[part.nameIndices[nameIdx]]; + } + + /** + * Create meshes for a single body part. + */ + createPartMeshes(lod, actorLOD, part, resolvedName, partIdx, group) { + const useTexture = (actorLOD.flags & ActorLODFlags.USE_TEXTURE) !== 0; + const useColor = (actorLOD.flags & ActorLODFlags.USE_COLOR) !== 0; + + // Special case: body part (i=0) with partNameIndex 0 uses color instead of texture + // (matches the C++ condition: i != 0 || part.m_partNameIndices[part.m_partNameIndex] != 0) + const bodyUsesDefaultGeom = partIdx === 0 && part.partNameIndices && + part.partNameIndices[part.partNameIndex] === 0; + + let partColor = null; + let partTexture = null; + + if (useTexture && !bodyUsesDefaultGeom) { + // Look up texture by resolved name + const texName = resolvedName?.toLowerCase(); + if (texName && this.textures.has(texName)) { + partTexture = this.textures.get(texName); + } + } + + if ((useColor || bodyUsesDefaultGeom) && !partTexture) { + // Resolve LEGO color + const colorEntry = LegoColors[resolvedName] || LegoColors['lego white']; + partColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255); + } + + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + + // Check for per-mesh texture from the WDB geometry + let meshTexture = null; + const meshTexName = mesh.properties?.textureName?.toLowerCase(); + if (meshTexName && this.textures.has(meshTexName)) { + meshTexture = this.textures.get(meshTexName); + } + + let material; + if (partTexture) { + material = new THREE.MeshLambertMaterial({ + map: partTexture, + side: THREE.DoubleSide, + color: 0xffffff + }); + } else if (meshTexture) { + material = new THREE.MeshLambertMaterial({ + map: meshTexture, + side: THREE.DoubleSide, + color: 0xffffff + }); + } else if (partColor) { + material = new THREE.MeshLambertMaterial({ + color: partColor, + side: THREE.DoubleSide + }); + } else { + const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), + side: THREE.DoubleSide + }); + } + + const threeMesh = new THREE.Mesh(geometry, material); + group.add(threeMesh); + } + } + + /** + * Create vehicle mesh from WDB model ROIs and add to modelGroup. + * vehiclePartsMap maps model name → array of { name, lods }. + */ + createVehicleMesh(vehicleInfo, vehiclePartsMap) { + const rois = vehiclePartsMap.get(vehicleInfo.vehicleModel.toLowerCase()); + if (!rois || rois.length === 0) return; + + this.vehicleGroup = new THREE.Group(); + this.vehicleGroup.name = `vehicle_${vehicleInfo.vehicleModel}`; + + for (const roi of rois) { + const lods = roi.lods || []; + if (lods.length === 0) continue; + + const lod = lods[lods.length - 1]; // Highest quality + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + + let material; + const meshTexName = mesh.properties?.textureName?.toLowerCase(); + if (meshTexName && this.textures.has(meshTexName)) { + material = new THREE.MeshLambertMaterial({ + map: this.textures.get(meshTexName), + side: THREE.DoubleSide, + color: 0xffffff + }); + } else { + const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), + side: THREE.DoubleSide + }); + } + + this.vehicleGroup.add(new THREE.Mesh(geometry, material)); + } + } + + this.modelGroup.add(this.vehicleGroup); + } + + /** + * Center and scale the actor, excluding the hat from the bounding box + * so that changing hats doesn't shift the actor's position. + */ + centerAndScaleModel(scaleFactor) { + if (!this.modelGroup) return; + + const box = new THREE.Box3(); + for (let i = 0; i < this.partGroups.length; i++) { + if (i === 1 || !this.partGroups[i]) continue; // skip hat + box.expandByObject(this.partGroups[i]); + } + if (this.vehicleGroup) { + box.expandByObject(this.vehicleGroup); + } + + if (box.isEmpty()) { + super.centerAndScaleModel(scaleFactor); + return; + } + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + + this.modelGroup.position.sub(center); + + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim > 0) { + const scale = scaleFactor / maxDim; + this.modelGroup.scale.setScalar(scale); + } + } + + /** + * Apply position/direction/up transform from ActorLOD data. + * The game uses CalcLocalTransform with direction/up vectors. + */ + applyPartTransform(group, actorLOD) { + const pos = actorLOD.position; + + // Negate X for our coordinate system (matching VehiclePartRenderer's -v.x) + group.position.set(-pos[0], pos[1], pos[2]); + } + + /** + * Get which body part was clicked + * @returns {number} Part index (0-9) or -1 if nothing hit + */ + getClickedPart(mouseEvent) { + if (!this.modelGroup) return -1; + + const rect = this.canvas.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1, + -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1 + ); + + this.raycaster.setFromCamera(mouse, this.camera); + + for (let i = 0; i < this.partGroups.length; i++) { + const partGroup = this.partGroups[i]; + if (!partGroup) continue; + + const meshes = []; + partGroup.traverse((child) => { + if (child instanceof THREE.Mesh) meshes.push(child); + }); + + const intersects = this.raycaster.intersectObjects(meshes); + if (intersects.length > 0) return i; + } + + return -1; + } + + // ─── Animation System ──────────────────────────────────────────── + + /** + * Compute secondary animation column index from mood, matching FUN_10063b90. + * Primary: columns 0-3 (speed 0.7), Secondary: columns 4-6 (speed 4.0). + * NPCs walk at speed 0.6-2.0, so most use the secondary animation which has + * independent head/hat movement. Mood adjustment: if (mood >= 2) mood--. + */ + static getSecondaryAnimColumn(mood) { + let adjMood = mood; + if (adjMood >= 2) adjMood--; + return adjMood + 4; + } + + /** + * Queue a click animation to play after the next model load/reload. + * @param {number} move - The actor's m_move value (0-3) + */ + queueClickAnimation(move) { + this._queuedClickMove = move; + } + + /** + * Load and start the animation for the given actor. If a click animation + * is queued, plays it first (one-shot), then resumes the walking loop. + * Otherwise loads the walking animation from the g_cycles table using the + * secondary (speed 4.0) variant which NPCs typically use in-game. + * When vehicleInfo is provided, uses the vehicle animation instead. + * Falls back to Y-axis rotation if unavailable. + */ + async loadAnimationForActor(actorIndex, mood = 0, vehicleInfo = undefined) { + if (!this.modelGroup) return; + + // Use stored vehicleInfo when not explicitly provided (e.g. resuming after click anim) + if (vehicleInfo === undefined) { + vehicleInfo = this.vehicleInfo; + } + + // If a click animation is queued (skip in vehicle mode), play it first + if (this._queuedClickMove !== null && !vehicleInfo) { + const move = this._queuedClickMove; + this._queuedClickMove = null; + await this.playClickAnimation(move, actorIndex, mood); + return; + } + this._queuedClickMove = null; + + this.stopAnimation(); + + let animName; + if (vehicleInfo) { + // Vehicle mode: use the vehicle animation name + animName = vehicleInfo.vehicleAnim; + } else { + const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0; + // Use secondary animation (speed 4.0 threshold) — this is what NPCs use in-game + // since their walking speed (0.6-2.0) exceeds the 0.7 primary threshold + const secondaryCol = ActorRenderer.getSecondaryAnimColumn(mood); + const primaryCol = mood; + animName = G_CYCLES[suffixIdx]?.[secondaryCol] ?? G_CYCLES[suffixIdx]?.[primaryCol]; + } + + if (!animName) return; // null entry in g_cycles — no animation for this combo + + try { + const animData = await this.fetchAnimationByName(animName); + if (!animData || !this.modelGroup) return; + + const nodeToPartGroup = this.buildNodeToPartGroupMap(); + + // Map vehicle animation nodes if in vehicle mode + if (vehicleInfo && this.vehicleGroup) { + this.mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup); + } + + const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup); + if (tracks.length === 0) return; + + const clip = new THREE.AnimationClip('walk', -1, tracks); + this.mixer = new THREE.AnimationMixer(this.modelGroup); + this.currentAction = this.mixer.clipAction(clip); + this.currentAction.play(); + } catch (e) { + // Animation unavailable — fall back to rotation (handled in updateAnimation()) + } + } + + /** + * Build a lookup mapping animation node names to part groups. + */ + buildNodeToPartGroupMap() { + const map = new Map(); + for (let i = 0; i < this.partGroups.length; i++) { + const pg = this.partGroups[i]; + if (!pg) continue; + const lodName = pg.userData.lodName; + const animNodeName = PART_NAME_TO_ANIM_NODE[lodName]; + if (animNodeName) { + map.set(animNodeName.toLowerCase(), pg); + } + } + return map; + } + + /** + * Map vehicle animation tree nodes to the vehicle group. + * Scans the animation tree for nodes whose name (stripped of trailing + * digits/underscores) matches the vehicle model name, and maps them + * to the vehicleGroup so buildHierarchicalTracks can drive the vehicle. + */ + mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup) { + const vehicleName = vehicleInfo.vehicleModel.toLowerCase(); + + const scanTree = (node) => { + const name = node.data.name?.toLowerCase(); + if (name) { + // Strip trailing digits and underscores to get base name + const baseName = name.replace(/[\d_]+$/, ''); + if (baseName === vehicleName) { + nodeToPartGroup.set(name, this.vehicleGroup); + } + } + for (const child of node.children) { + scanTree(child); + } + }; + + scanTree(animData.rootNode); + } + + /** + * Play a one-shot click animation (pose/gesture) determined by the actor's + * m_move value (0-3). After it finishes, the walking animation resumes. + * Matches LegoEntity::ClickAnimation which uses objectId = m_move + 10. + */ + async playClickAnimation(move, actorIndex, mood) { + if (!this.modelGroup) return; + + this.stopAnimation(); + + const animName = `ClickAnim${move}`; + try { + const animData = await this.fetchAnimationByName(animName); + if (!animData || !this.modelGroup) { + this.loadAnimationForActor(actorIndex, mood); + return; + } + + const nodeToPartGroup = this.buildNodeToPartGroupMap(); + + const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup); + if (tracks.length === 0) { + this.loadAnimationForActor(actorIndex, mood); + return; + } + + const clip = new THREE.AnimationClip('click', -1, tracks); + this.mixer = new THREE.AnimationMixer(this.modelGroup); + const action = this.mixer.clipAction(clip); + action.setLoop(THREE.LoopOnce); + action.clampWhenFinished = true; + this.currentAction = action; + action.play(); + + // When click animation finishes, resume walking + this.mixer.addEventListener('finished', () => { + this.loadAnimationForActor(actorIndex, mood); + }); + } catch (e) { + console.error('ActorRenderer: click animation error', e); + this.loadAnimationForActor(actorIndex, mood); + } + } + + /** + * Fetch and parse an animation file by name (e.g. "CNs001xx"), with caching. + */ + async fetchAnimationByName(animName) { + if (this.animationCache.has(animName)) { + return this.animationCache.get(animName); + } + + const buffer = await fetchAnimation(animName); + if (!buffer) return null; + const animData = parseAnimation(buffer); + this.animationCache.set(animName, animData); + return animData; + } + + /** + * Build world-space keyframe tracks by evaluating the animation tree + * hierarchically. At each unique keyframe time, walks the tree composing + * parent * child transforms via matrix multiplication, then decomposes + * to world-space position/quaternion for each part. + */ + buildHierarchicalTracks(animData, nodeToPartGroup) { + const duration = animData.duration; + + // Collect all unique keyframe times from the tree + const timesSet = new Set([0]); + this.collectKeyframeTimes(animData.rootNode, timesSet); + const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b); + + // For each time, evaluate the full tree and store world-space transforms + const valueMap = new Map(); + const identity = new THREE.Matrix4(); + + for (const time of times) { + this.evaluateNode(animData.rootNode, time, identity, nodeToPartGroup, valueMap, true); + } + + // Convert to Three.js KeyframeTracks + const timesSec = times.map(t => t / 1000); + const tracks = []; + for (const [name, values] of valueMap) { + if (name.endsWith('.position')) { + tracks.push(new THREE.VectorKeyframeTrack(name, timesSec, values)); + } else if (name.endsWith('.quaternion')) { + tracks.push(new THREE.QuaternionKeyframeTrack(name, timesSec, values)); + } else if (name.endsWith('.visible')) { + tracks.push(new THREE.BooleanKeyframeTrack(name, timesSec, values)); + } + } + return tracks; + } + + /** + * Recursively collect all unique keyframe times from the animation tree. + */ + collectKeyframeTimes(node, timesSet) { + const data = node.data; + for (const key of data.translationKeys) timesSet.add(key.time); + for (const key of data.rotationKeys) timesSet.add(key.time); + for (const key of data.scaleKeys) timesSet.add(key.time); + for (const key of data.morphKeys) timesSet.add(key.time); + for (const child of node.children) { + this.collectKeyframeTimes(child, timesSet); + } + } + + /** + * Evaluate a single animation node at a given time, composing its local + * transform with the parent's world matrix. If the node maps to a part + * group, stores the decomposed world-space position and quaternion. + * Recurses into children with the composed matrix. + */ + evaluateNode(node, time, parentMatrix, nodeToPartGroup, valueMap, isRoot = false) { + const data = node.data; + let mat = new THREE.Matrix4(); + + // Strip XZ translation on the actor root to keep the actor in place (treadmill fix). + // Walking anims: the root node IS the actor (named "pepper", "mama", "actor_01", etc.) + // Click anims: actor_01 is nested under wrapper nodes like "-NPa001ns" + const isActorRoot = isRoot || data.name?.toLowerCase() === 'actor_01'; + + // 1. Scale (applied first) + if (data.scaleKeys.length > 0) { + const scale = this.interpolateVertex(data.scaleKeys, time, false); + if (scale) { + mat.scale(scale); + } + if (data.rotationKeys.length > 0) { + mat = this.evaluateRotation(data.rotationKeys, time).multiply(mat); + } + } else if (data.rotationKeys.length > 0) { + mat = this.evaluateRotation(data.rotationKeys, time); + } + + // 2. Translation + if (data.translationKeys.length > 0) { + const vertex = this.interpolateVertex(data.translationKeys, time, true); + if (vertex) { + if (isActorRoot) { + // Actor_01: only apply vertical (Y) to preserve bounce, + // strip horizontal (XZ) so the actor animates in place + mat.elements[13] += vertex.y; + } else { + mat.elements[12] += vertex.x; + mat.elements[13] += vertex.y; + mat.elements[14] += vertex.z; + } + } + } + + // 3. Compose with parent: world = parent * local + mat = parentMatrix.clone().multiply(mat); + + // 4. If this node maps to a part group, decompose and store + const nodeName = data.name?.toLowerCase(); + if (nodeName) { + const partGroup = nodeToPartGroup.get(nodeName); + if (partGroup) { + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + mat.decompose(position, quaternion, scale); + + if (Math.abs(scale.x) < 1e-8 || Math.abs(scale.y) < 1e-8 || Math.abs(scale.z) < 1e-8) { + quaternion.identity(); + } + + const trackName = partGroup.name; + this.pushValues(valueMap, `${trackName}.position`, position.toArray()); + this.pushValues(valueMap, `${trackName}.quaternion`, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]); + + // Evaluate visibility from morph keys (matches game's SetVisibility(data->GetVisibility(p_time))) + if (data.morphKeys.length > 0) { + const visible = this.getVisibility(data.morphKeys, time); + this.pushValues(valueMap, `${trackName}.visible`, [visible]); + } + } + } + + // 5. Recurse into children + for (const child of node.children) { + this.evaluateNode(child, time, mat, nodeToPartGroup, valueMap); + } + } + + /** + * Evaluate rotation keyframes at a given time. + * Handles slerp interpolation between keyframes with flag-based control. + * Coordinate conversion: game (w,x,y,z) → Three.js with X negated. + */ + evaluateRotation(keys, time) { + const { before, after } = this.getBeforeAndAfter(keys, time); + + const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w); + + if (!after) { + if (before.flags & 0x01) { + return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before)); + } + return new THREE.Matrix4(); + } + + if ((before.flags & 0x01) || (after.flags & 0x01)) { + const beforeQ = toQuat(before); + + // Flag 0x04: skip interpolation, use before value + if (after.flags & 0x04) { + return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ); + } + + let afterQ = toQuat(after); + // Flag 0x02: negate the after quaternion before slerp + if (after.flags & 0x02) { + afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w); + } + + const t = (time - before.time) / (after.time - before.time); + const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t); + return new THREE.Matrix4().makeRotationFromQuaternion(result); + } + + return new THREE.Matrix4(); + } + + /** + * Interpolate translation or scale keyframes at a given time. + * For translation: negates X for coordinate system conversion. + * For scale: no negation. + */ + interpolateVertex(keys, time, isTranslation) { + const { before, after } = this.getBeforeAndAfter(keys, time); + + const toVec = (key) => isTranslation + ? new THREE.Vector3(-key.x, key.y, key.z) + : new THREE.Vector3(key.x, key.y, key.z); + + if (!after) { + if (isTranslation && !(before.flags & 0x01)) { + // Check if vertex is non-zero (matching reference behavior) + if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) { + return null; + } + } + return toVec(before); + } + + if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) { + // Both inactive — check if vertices are non-zero + const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5; + const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5; + if (!bNonZero && !aNonZero) return null; + } + + const t = (time - before.time) / (after.time - before.time); + return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t); + } + + /** + * Find the keyframes immediately before and after the given time. + */ + getBeforeAndAfter(keys, time) { + let idx = keys.findIndex(k => k.time > time); + if (idx < 0) idx = keys.length; + const before = keys[Math.max(0, idx - 1)]; + return { before, after: keys[idx] || null }; + } + + /** + * Evaluate visibility from morph keys at a given time. + * Matches game's GetVisibility: returns true (visible) by default, + * or the last morph key's visible flag at or before the given time. + */ + getVisibility(morphKeys, time) { + let lastKey = null; + for (const key of morphKeys) { + if (key.time <= time) { + lastKey = key; + } else { + break; + } + } + return lastKey ? lastKey.visible : true; + } + + /** + * Append values to a named entry in the value map. + */ + pushValues(map, key, values) { + const existing = map.get(key); + if (!existing) { + map.set(key, [...values]); + } else { + existing.push(...values); + } + } + + stopAnimation() { + if (this.currentAction) { + this.currentAction.stop(); + this.currentAction = null; + } + if (this.mixer) { + this.mixer.stopAllAction(); + this.mixer = null; + } + } + + // ─── Scene Management ──────────────────────────────────────────── + + clearModel() { + this.stopAnimation(); + super.clearModel(); + this.partGroups = []; + this.vehicleGroup = null; + this.vehicleInfo = null; + } + + start() { + this.animating = true; + this.clock.start(); + this.animate(); + } + + updateAnimation() { + const delta = this.clock.getDelta(); + + if (this.mixer) { + this.mixer.update(delta); + } + this.controls?.update(); + } + + dispose() { + this.stopAnimation(); + super.dispose(); + this.animationCache.clear(); + } +} diff --git a/src/core/rendering/BaseRenderer.js b/src/core/rendering/BaseRenderer.js new file mode 100644 index 0000000..4cb55ad --- /dev/null +++ b/src/core/rendering/BaseRenderer.js @@ -0,0 +1,258 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +/** + * Base renderer providing shared Three.js setup, lighting, texture creation, + * geometry building, and animation loop for LEGO model viewers. + */ +export class BaseRenderer { + constructor(canvas) { + this.canvas = canvas; + this.animating = false; + this.modelGroup = null; + this.textures = new Map(); + + this.scene = new THREE.Scene(); + + this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true + }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x000000, 0); + + this.setupLighting(); + + this.controls = null; + this._didDrag = false; + } + + setupLighting() { + const ambient = new THREE.AmbientLight(0xffffff, 0.8); + this.scene.add(ambient); + + const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); + sunLight.position.set(1, 2, 3); + this.scene.add(sunLight); + } + + setupControls(target) { + this.controls = new OrbitControls(this.camera, this.canvas); + this.controls.enableZoom = true; + this.controls.enablePan = true; + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.1; + this.controls.autoRotate = true; + this.controls.autoRotateSpeed = 4.0; + this.controls.target.copy(target); + + this.controls.addEventListener('start', () => { + this.controls.autoRotate = false; + }); + + this._onPointerDown = (e) => { + this._didDrag = false; + this._pointerStart = { x: e.clientX, y: e.clientY }; + }; + this._onPointerMove = (e) => { + if (!this._pointerStart) return; + const dx = e.clientX - this._pointerStart.x; + const dy = e.clientY - this._pointerStart.y; + if (dx * dx + dy * dy > 9) this._didDrag = true; + }; + + this.canvas.addEventListener('pointerdown', this._onPointerDown); + this.canvas.addEventListener('pointermove', this._onPointerMove); + + this._initialAutoRotate = this.controls.autoRotate; + this.controls.saveState(); + } + + resetView() { + if (!this.controls) return; + this.controls.reset(); + this.controls.autoRotate = this._initialAutoRotate; + } + + wasDragged() { + return this._didDrag; + } + + /** + * Create a Three.js texture from parsed texture data + */ + createTexture(textureData) { + const canvas = document.createElement('canvas'); + canvas.width = textureData.width; + canvas.height = textureData.height; + const ctx = canvas.getContext('2d'); + + const imageData = ctx.createImageData(textureData.width, textureData.height); + for (let i = 0; i < textureData.pixels.length; i++) { + const colorIdx = textureData.pixels[i]; + const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 }; + imageData.data[i * 4 + 0] = color.r; + imageData.data[i * 4 + 1] = color.g; + imageData.data[i * 4 + 2] = color.b; + imageData.data[i * 4 + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + return texture; + } + + /** + * Create a single geometry from mesh data + */ + createGeometry(mesh, lod) { + if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) { + return null; + } + + const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; + + const vertexIndicesPacked = []; + for (const poly of mesh.polygonIndices) { + vertexIndicesPacked.push(poly.a, poly.b, poly.c); + } + + const textureIndicesFlat = []; + if (hasTexture) { + for (const texPoly of mesh.textureIndices) { + textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); + } + } + + const meshVertices = []; + const meshNormals = []; + const meshUvs = []; + const indices = []; + + for (let i = 0; i < vertexIndicesPacked.length; i++) { + const packed = vertexIndicesPacked[i]; + + if ((packed & 0x80000000) !== 0) { + indices.push(meshVertices.length); + + const gv = packed & 0xFFFF; + const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; + meshVertices.push([-v.x, v.y, v.z]); + + const gn = (packed >>> 16) & 0x7fff; + const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; + meshNormals.push([-n.x, n.y, n.z]); + + if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) { + const tex = textureIndicesFlat[i]; + const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; + meshUvs.push([uv.u, 1 - uv.v]); + } + } else { + indices.push(packed & 0xFFFF); + } + } + + // Reverse face winding + for (let i = 0; i < indices.length; i += 3) { + const temp = indices[i]; + indices[i] = indices[i + 2]; + indices[i + 2] = temp; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3)); + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3)); + geometry.setIndex(indices); + + if (hasTexture && meshUvs.length > 0) { + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2)); + } + + return geometry; + } + + centerAndScaleModel(scaleFactor) { + if (!this.modelGroup) return; + + const box = new THREE.Box3().setFromObject(this.modelGroup); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + + this.modelGroup.position.sub(center); + + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim > 0) { + const scale = scaleFactor / maxDim; + this.modelGroup.scale.setScalar(scale); + } + } + + clearModel() { + if (this.modelGroup) { + this.modelGroup.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry?.dispose(); + child.material?.dispose(); + } + }); + this.scene.remove(this.modelGroup); + this.modelGroup = null; + } + + for (const texture of this.textures.values()) { + texture.dispose(); + } + this.textures.clear(); + } + + start() { + this.animating = true; + this.animate(); + } + + stop() { + this.animating = false; + } + + animate = () => { + if (!this.animating) return; + requestAnimationFrame(this.animate); + + this.updateAnimation(); + + this.renderer.render(this.scene, this.camera); + } + + /** + * Override in subclasses for custom animation logic. + * Called each frame before rendering. + */ + updateAnimation() { + this.controls?.update(); + } + + resize(width, height) { + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + } + + dispose() { + this.animating = false; + if (this.controls) { + this.controls.dispose(); + this.canvas.removeEventListener('pointerdown', this._onPointerDown); + this.canvas.removeEventListener('pointermove', this._onPointerMove); + } + this.clearModel(); + this.renderer?.dispose(); + } +} diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js index 7a872bf..36f4293 100644 --- a/src/core/rendering/VehiclePartRenderer.js +++ b/src/core/rendering/VehiclePartRenderer.js @@ -1,43 +1,21 @@ import * as THREE from 'three'; import { LegoColors } from '../savegame/constants.js'; import { resolveLods } from '../formats/WdbParser.js'; +import { BaseRenderer } from './BaseRenderer.js'; /** * Specialized renderer for LEGO vehicle parts * Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName */ -export class VehiclePartRenderer { +export class VehiclePartRenderer extends BaseRenderer { constructor(canvas) { - this.canvas = canvas; - this.animating = false; - this.modelGroup = null; + super(canvas); this.colorableMeshes = []; // Meshes with INH prefix - this.textures = new Map(); // Cache for loaded textures - this.scene = new THREE.Scene(); - - this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); this.camera.position.set(0, 0, 3); this.camera.lookAt(0, 0, 0); - this.renderer = new THREE.WebGLRenderer({ - canvas, - antialias: true, - alpha: true - }); - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.renderer.setClearColor(0x000000, 0); - - this.setupLighting(); - } - - setupLighting() { - const ambient = new THREE.AmbientLight(0xffffff, 0.8); - this.scene.add(ambient); - - const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); - sunLight.position.set(1, 2, 3); - this.scene.add(sunLight); + this.setupControls(new THREE.Vector3(0, 0, 0)); } /** @@ -50,34 +28,6 @@ export class VehiclePartRenderer { return texName.startsWith('inh') || matName.startsWith('inh'); } - /** - * Create a Three.js texture from parsed texture data - */ - createTexture(textureData) { - const canvas = document.createElement('canvas'); - canvas.width = textureData.width; - canvas.height = textureData.height; - const ctx = canvas.getContext('2d'); - - const imageData = ctx.createImageData(textureData.width, textureData.height); - for (let i = 0; i < textureData.pixels.length; i++) { - const colorIdx = textureData.pixels[i]; - const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 }; - imageData.data[i * 4 + 0] = color.r; - imageData.data[i * 4 + 1] = color.g; - imageData.data[i * 4 + 2] = color.b; - imageData.data[i * 4 + 3] = 255; - } - ctx.putImageData(imageData, 0, 0); - - const texture = new THREE.CanvasTexture(canvas); - texture.minFilter = THREE.NearestFilter; - texture.magFilter = THREE.NearestFilter; - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - return texture; - } - /** * Load part geometry with proper textures and colorable mesh detection * @param {object} roiData - Parsed ROI data with lods @@ -105,9 +55,10 @@ export class VehiclePartRenderer { this.createMeshesFromROI(roiData, threeLegoColor); - this.centerAndScaleModel(); + this.centerAndScaleModel(1.5); this.scene.add(this.modelGroup); + this.controls.autoRotate = true; this.renderer.render(this.scene, this.camera); } @@ -187,76 +138,6 @@ export class VehiclePartRenderer { } } - /** - * Create a single geometry from mesh data - */ - createGeometry(mesh, lod) { - if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) { - return null; - } - - const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; - - const vertexIndicesPacked = []; - for (const poly of mesh.polygonIndices) { - vertexIndicesPacked.push(poly.a, poly.b, poly.c); - } - - const textureIndicesFlat = []; - if (hasTexture) { - for (const texPoly of mesh.textureIndices) { - textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); - } - } - - const meshVertices = []; - const meshNormals = []; - const meshUvs = []; - const indices = []; - - for (let i = 0; i < vertexIndicesPacked.length; i++) { - const packed = vertexIndicesPacked[i]; - - if ((packed & 0x80000000) !== 0) { - indices.push(meshVertices.length); - - const gv = packed & 0xFFFF; - const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; - meshVertices.push([-v.x, v.y, v.z]); - - const gn = (packed >>> 16) & 0x7fff; - const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; - meshNormals.push([-n.x, n.y, n.z]); - - if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) { - const tex = textureIndicesFlat[i]; - const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; - meshUvs.push([uv.u, 1 - uv.v]); - } - } else { - indices.push(packed & 0xFFFF); - } - } - - // Reverse face winding - for (let i = 0; i < indices.length; i += 3) { - const temp = indices[i]; - indices[i] = indices[i + 2]; - indices[i + 2] = temp; - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3)); - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3)); - geometry.setIndex(indices); - - if (hasTexture && meshUvs.length > 0) { - geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2)); - } - - return geometry; - } - /** * Update texture on meshes matching a given texture name * @param {string} textureName - Texture name to match (case-insensitive) @@ -301,70 +182,8 @@ export class VehiclePartRenderer { this.renderer.render(this.scene, this.camera); } - centerAndScaleModel() { - if (!this.modelGroup) return; - - const box = new THREE.Box3().setFromObject(this.modelGroup); - const center = box.getCenter(new THREE.Vector3()); - const size = box.getSize(new THREE.Vector3()); - - this.modelGroup.position.sub(center); - - const maxDim = Math.max(size.x, size.y, size.z); - if (maxDim > 0) { - const scale = 1.5 / maxDim; - this.modelGroup.scale.setScalar(scale); - } - } - clearModel() { - if (this.modelGroup) { - this.modelGroup.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry?.dispose(); - child.material?.dispose(); - } - }); - this.scene.remove(this.modelGroup); - this.modelGroup = null; - } + super.clearModel(); this.colorableMeshes = []; - - for (const texture of this.textures.values()) { - texture.dispose(); - } - this.textures.clear(); - } - - start() { - this.animating = true; - this.animate(); - } - - stop() { - this.animating = false; - } - - animate = () => { - if (!this.animating) return; - requestAnimationFrame(this.animate); - - if (this.modelGroup) { - this.modelGroup.rotation.y += 0.01; - } - - this.renderer.render(this.scene, this.camera); - } - - resize(width, height) { - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(width, height, false); - } - - dispose() { - this.animating = false; - this.clearModel(); - this.renderer?.dispose(); } } diff --git a/src/core/rendering/WdbModelRenderer.js b/src/core/rendering/WdbModelRenderer.js index 26f47c9..c3da856 100644 --- a/src/core/rendering/WdbModelRenderer.js +++ b/src/core/rendering/WdbModelRenderer.js @@ -1,46 +1,23 @@ import * as THREE from 'three'; +import { BaseRenderer } from './BaseRenderer.js'; /** - * Generic Three.js renderer for LEGO Island WDB models - * Handles D3DRM packed vertex format and paletted textures + * Renderer for LEGO Island WDB models with mutable canvas textures. + * Extends BaseRenderer with model loading, canvas-based texture painting, + * and UV raycasting for click interaction. */ -export class WdbModelRenderer { +export class WdbModelRenderer extends BaseRenderer { constructor(canvas) { - this.canvas = canvas; - this.animating = false; - this.modelGroup = null; + super(canvas); this.texturedMesh = null; this.texture = null; this.textureCanvas = null; this.baseImageData = null; this.palette = null; - this.scene = new THREE.Scene(); - - this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); this.camera.position.set(0, 0.2, 7); - this.renderer = new THREE.WebGLRenderer({ - canvas, - antialias: true, - alpha: true - }); - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.renderer.setClearColor(0x000000, 0); - - this.setupLighting(); - } - - /** - * Setup scene lighting - override to customize - */ - setupLighting() { - const ambient = new THREE.AmbientLight(0xffffff, 0.8); - this.scene.add(ambient); - - const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); - sunLight.position.set(1, 2, 3); - this.scene.add(sunLight); + this.setupControls(new THREE.Vector3(0, 0.2, 0)); } /** @@ -52,29 +29,39 @@ export class WdbModelRenderer { this.palette = textureData.palette; this.modelGroup = new THREE.Group(); - const { texturedGeometry, nonTexturedGeometries } = this.createGeometries(roiData); + if (!roiData.lods || roiData.lods.length === 0) { + this.scene.add(this.modelGroup); + return; + } + + const lod = roiData.lods[0]; this.textureCanvas = this.createTextureCanvas(textureData); this.texture = new THREE.CanvasTexture(this.textureCanvas); this.texture.minFilter = THREE.LinearFilter; this.texture.magFilter = THREE.LinearFilter; - if (texturedGeometry) { - const texturedMaterial = new THREE.MeshLambertMaterial({ - map: this.texture, - side: THREE.DoubleSide - }); - this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial); - this.modelGroup.add(this.texturedMesh); - } + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; - for (const { geometry, color } of nonTexturedGeometries) { - const material = new THREE.MeshLambertMaterial({ - color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255), - side: THREE.DoubleSide - }); - const mesh = new THREE.Mesh(geometry, material); - this.modelGroup.add(mesh); + const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; + + if (hasTexture) { + const material = new THREE.MeshLambertMaterial({ + map: this.texture, + side: THREE.DoubleSide + }); + this.texturedMesh = new THREE.Mesh(geometry, material); + this.modelGroup.add(this.texturedMesh); + } else { + const color = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + const material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255), + side: THREE.DoubleSide + }); + this.modelGroup.add(new THREE.Mesh(geometry, material)); + } } this.scene.add(this.modelGroup); @@ -82,107 +69,9 @@ export class WdbModelRenderer { } /** - * Create Three.js BufferGeometries from ROI LOD data - * - * D3DRM packed polygon index format (32-bit): - * - Bits 0-15: vertex index (16 bits) into positions array, OR destination index when reusing - * - Bits 16-30: normal index into normals array - * - Bit 31: "create new vertex" flag - when set, create a new mesh vertex; - * when clear, bits 0-15 is the INDEX into the created mesh vertices array - * - * @param {object} roiData - ROI with lods array - * @returns {{ texturedGeometry: THREE.BufferGeometry|null, nonTexturedGeometries: Array }} - */ - createGeometries(roiData) { - if (!roiData.lods || roiData.lods.length === 0) { - console.warn('ROI has no LODs'); - return { texturedGeometry: null, nonTexturedGeometries: [] }; - } - - const lod = roiData.lods[0]; - let texturedGeometry = null; - const nonTexturedGeometries = []; - - for (const mesh of lod.meshes) { - const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; - - // Flatten polygon indices - const vertexIndicesPacked = []; - for (const poly of mesh.polygonIndices) { - vertexIndicesPacked.push(poly.a, poly.b, poly.c); - } - - // Flatten texture indices if present - const textureIndicesFlat = []; - if (hasTexture) { - for (const texPoly of mesh.textureIndices) { - textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); - } - } - - const meshVertices = []; - const meshNormals = []; - const meshUvs = []; - const indices = []; - - for (let i = 0; i < vertexIndicesPacked.length; i++) { - const packed = vertexIndicesPacked[i]; - - if ((packed & 0x80000000) !== 0) { - // Create flag is set - create new mesh vertex - indices.push(meshVertices.length); - - const gv = packed & 0xFFFF; // Vertex index (16 bits) - const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; - // Negate X for coordinate system conversion - meshVertices.push([-v.x, v.y, v.z]); - - const gn = (packed >>> 16) & 0x7fff; // Normal index (15 bits) - const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; - meshNormals.push([-n.x, n.y, n.z]); - - if (hasTexture && lod.textureVertices.length > 0) { - const tex = textureIndicesFlat[i]; - const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; - meshUvs.push([uv.u, 1 - uv.v]); - } - } else { - // Create flag NOT set - reuse existing mesh vertex by index - indices.push(packed & 0xFFFF); - } - } - - // Reverse face winding (swap indices 0 and 2 of each triangle) - for (let i = 0; i < indices.length; i += 3) { - const temp = indices[i]; - indices[i] = indices[i + 2]; - indices[i + 2] = temp; - } - - // Create geometry - const geometry = new THREE.BufferGeometry(); - const vertices = meshVertices.flat(); - const normals = meshNormals.flat(); - - geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); - geometry.setIndex(indices); - - if (hasTexture) { - const uvs = meshUvs.flat(); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); - texturedGeometry = geometry; - } else { - const color = mesh.properties?.color || { r: 128, g: 128, b: 128 }; - nonTexturedGeometries.push({ geometry, color }); - } - } - - return { texturedGeometry, nonTexturedGeometries }; - } - - /** - * Create canvas texture from paletted LEGO texture data + * Create canvas texture from paletted LEGO texture data. + * Unlike BaseRenderer.createTexture(), this keeps a reference to the + * canvas and base image data so subclasses can paint over it (e.g. scores). * @param {object} textureData - { width, height, palette, pixels } * @returns {HTMLCanvasElement} */ @@ -234,63 +123,8 @@ export class WdbModelRenderer { return null; } - /** - * Start animation loop - */ - start() { - this.animating = true; - this.animate(); - } - - /** - * Stop animation loop - */ - stop() { - this.animating = false; - } - - /** - * Animation loop - override to customize animation - */ - animate = () => { - if (!this.animating) return; - requestAnimationFrame(this.animate); - - if (this.modelGroup) { - this.modelGroup.rotation.y += 0.008; - } - - this.renderer.render(this.scene, this.camera); - } - - /** - * Resize renderer to match canvas size - * @param {number} width - * @param {number} height - */ - resize(width, height) { - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(width, height, false); - } - - /** - * Clean up resources - */ dispose() { - this.animating = false; - - if (this.modelGroup) { - this.modelGroup.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry?.dispose(); - child.material?.dispose(); - } - }); - this.scene.remove(this.modelGroup); - } - this.texture?.dispose(); - this.renderer?.dispose(); + super.dispose(); } } diff --git a/src/core/savegame/actorConstants.js b/src/core/savegame/actorConstants.js new file mode 100644 index 0000000..1615040 --- /dev/null +++ b/src/core/savegame/actorConstants.js @@ -0,0 +1,715 @@ +/** + * Actor data constants ported from LEGO1 source: + * isle/LEGO1/lego/legoomni/src/common/legoactors.cpp + * isle/LEGO1/lego/legoomni/include/legoactors.h + */ + +// LegoActorLOD flags +export const ActorLODFlags = Object.freeze({ + USE_TEXTURE: 0x01, + USE_COLOR: 0x02 +}); + +// LegoActorParts enum — indices into the 10-part array on each actor +export const ActorPart = Object.freeze({ + BODY: 0, + INFOHAT: 1, + INFOGRON: 2, + HEAD: 3, + ARMLFT: 4, + ARMRT: 5, + CLAWLFT: 6, + CLAWRT: 7, + LEGLFT: 8, + LEGRT: 9 +}); + +/** + * g_actorLODs[11] — transform/bounding data for each body part position. + * Fields: name, parentName, flags, boundingSphere[4], boundingBox[6], + * position[3], direction[3], up[3] + */ +export const ActorLODs = Object.freeze([ + { name: 'top', parentName: 'top', flags: 0, + boundingSphere: [0.000267, 0.780808, -0.01906, 0.951612], + boundingBox: [-0.461166, -0.002794, -0.299442, 0.4617, 1.56441, 0.261321], + position: [0, 0, 0], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'body', parentName: 'body', flags: ActorLODFlags.USE_TEXTURE, + boundingSphere: [0.00158332, 0.401828, -0.00048697, 0.408071], + boundingBox: [-0.287507, 0.150419, -0.147452, 0.289219, 0.649774, 0.14258], + position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'infohat', parentName: 'infohat', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0, -0.00938, -0.01955, 0.35], + boundingBox: [-0.231822, -0.140237, -0.320954, 0.234149, 0.076968, 0.249083], + position: [0.000191, 1.519793, 0.001767], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'infogron', parentName: 'infogron', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0, 0.11477, 0.00042, 0.26], + boundingBox: [-0.285558, -0.134391, -0.142231, 0.285507, 0.152986, 0.143071], + position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'head', parentName: 'head', flags: ActorLODFlags.USE_TEXTURE, + boundingSphere: [0, -0.03006, 0, 0.3], + boundingBox: [-0.189506, -0.209665, -0.189824, 0.189532, 0.228822, 0.194945], + position: [-0.00105, 1.293115, 0.001781], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'arm-lft', parentName: 'arm-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [-0.06815, -0.0973747, 0.0154655, 0.237], + boundingBox: [-0.137931, -0.282775, -0.105316, 0.000989, 0.100221, 0.140759], + position: [-0.225678, 0.963312, 0.023286], + direction: [-0.003031, -0.017187, 0.999848], up: [0.173622, 0.984658, 0.017453] }, + { name: 'arm-rt', parentName: 'arm-rt', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.0680946, -0.097152, 0.0152722, 0.237], + boundingBox: [0.00141, -0.289604, -0.100831, 0.138786, 0.09291, 0.145437], + position: [0.223494, 0.963583, 0.018302], + direction: [0, 0, 1], up: [-0.173648, 0.984808, 0] }, + { name: 'claw-lft', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15], + boundingBox: [-0.089838, -0.246208, -0.117735, 0.091275, 0.000263, 0.07215], + position: [-0.341869, 0.700355, 0.092779], + direction: [0.000001, 0.000003, 1], up: [0.190812, 0.981627, -0.000003] }, + { name: 'claw-rt', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15], + boundingBox: [-0.095016, -0.245349, -0.117979, 0.086528, 0.00067, 0.069743], + position: [0.343317, 0.69924, 0.096123], + direction: [0.00606, -0.034369, 0.999391], up: [-0.190704, 0.981027, 0.034894] }, + { name: 'leg-lft', parentName: 'leg', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33], + boundingBox: [-0.129782, -0.440428, -0.184207, 0.13817, 0.118415, 0.122607], + position: [-0.156339, 0.436087, 0.006822], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'leg-rt', parentName: 'leg', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33], + boundingBox: [-0.132864, -0.437138, -0.183944, 0.134614, 0.12043, 0.121888], + position: [0.151154, 0.436296, 0.007373], direction: [0, 0, 1], up: [0, 1, 0] } +]); + +// Index arrays — 0xff marks end-of-list sentinel +export const hatPartIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +export const pepperHatPartIndices = [21, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +export const infomanHatPartIndices = [22]; +export const ghostHatPartIndices = [20]; +export const bodyPartIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const hatColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const faceTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; +export const chestTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 27]; +export const armColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const clawRightColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const clawLeftColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const gronColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const legColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; + +// Name arrays +export const hatPartNames = [ + 'baseball', 'chef', 'cap', 'cophat', 'helmet', 'ponytail', 'pageboy', 'shrthair', + 'bald', 'flower', 'cboyhat', 'cuphat', 'cathat', 'backbcap', 'pizhat', 'caprc', + 'capch', 'capdb', 'capjs', 'capmd', 'sheet', 'phat', 'icap' +]; + +export const bodyPartNames = [ + 'body', 'bodyred', 'bodyblck', 'bodywhte', 'bodyyllw', 'bodyblue', 'bodygren', 'bodybrwn' +]; + +export const chestTextures = [ + 'peprchst.gif', 'mamachst.gif', 'papachst.gif', 'nickchst.gif', 'norachst.gif', + 'infochst.gif', 'shftchst.gif', 'rac1chst.gif', 'rac2chst.gif', 'bth1chst.gif', + 'bth2chst.gif', 'mech.gif', 'polkadot.gif', 'bowtie.gif', 'postchst.gif', + 'vest.gif', 'doctor.gif', 'copchest.gif', 'l.gif', 'e.gif', + 'g.gif', 'o.gif', 'fruit.gif', 'flowers.gif', 'construct.gif', + 'paint.gif', 'l6.gif', 'unkchst.gif' +]; + +export const faceTextures = [ + 'peprface.gif', 'mamaface.gif', 'papaface.gif', 'nickface.gif', 'noraface.gif', + 'infoface.gif', 'shftface.gif', 'dogface.gif', 'womanshd.gif', 'smileshd.gif', + 'woman.gif', 'smile.gif', 'mustache.gif', 'black.gif' +]; + +export const colorAliases = [ + 'lego white', 'lego black', 'lego yellow', 'lego red', 'lego blue', 'lego brown', 'lego lt grey', 'lego green' +]; + +// Reference names for the index arrays (used to build part configs) +const HP = 'hatPartIndices'; +const PHP = 'pepperHatPartIndices'; +const IHP = 'infomanHatPartIndices'; +const GHP = 'ghostHatPartIndices'; +const BP = 'bodyPartIndices'; +const HC = 'hatColorIndices'; +const FT = 'faceTextureIndices'; +const CT = 'chestTextureIndices'; +const AC = 'armColorIndices'; +const CRC = 'clawRightColorIndices'; +const CLC = 'clawLeftColorIndices'; +const GC = 'gronColorIndices'; +const LC = 'legColorIndices'; + +// Lookup tables for the index array references +const indexArrays = { + [HP]: hatPartIndices, + [PHP]: pepperHatPartIndices, + [IHP]: infomanHatPartIndices, + [GHP]: ghostHatPartIndices, + [BP]: bodyPartIndices, + [HC]: hatColorIndices, + [FT]: faceTextureIndices, + [CT]: chestTextureIndices, + [AC]: armColorIndices, + [CRC]: clawRightColorIndices, + [CLC]: clawLeftColorIndices, + [GC]: gronColorIndices, + [LC]: legColorIndices +}; + +const nameArrays = { + hatPartNames, + bodyPartNames, + chestTextures, + faceTextures, + colorAliases +}; + +/** + * Helper to build a Part entry from compact form. + * C++ Part has: { partNameIndices, partName, partNameIndex, nameIndices, names, nameIndex } + * We store references to our JS arrays. + */ +function P(partNameIndicesRef, partNamesRef, partNameIndex, nameIndicesRef, namesRef, nameIndex) { + return { + partNameIndices: partNameIndicesRef ? indexArrays[partNameIndicesRef] : null, + partNames: partNamesRef ? nameArrays[partNamesRef] : null, + partNameIndex, + nameIndices: indexArrays[nameIndicesRef], + names: nameArrays[namesRef], + nameIndex + }; +} + +// Short aliases for name array refs +const HPN = 'hatPartNames'; +const BPN = 'bodyPartNames'; +const CTN = 'chestTextures'; +const FTN = 'faceTextures'; +const CA = 'colorAliases'; + +/** + * g_actorInfoInit[66] — All 66 game actors. + * Each entry: { name, sound, move, mood, parts[10] } + * Parts order: body, infohat, infogron, head, armlft, armrt, clawlft, clawrt, leglft, legrt + */ +export const ActorInfoInit = Object.freeze([ + /* 0 */ { name: 'pepper', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 1 */ { name: 'mama', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 1), P(HP, HPN, 1, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 1), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 2 */ { name: 'papa', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 2), P(HP, HPN, 1, HC, CA, 0), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 2), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 3 */ { name: 'nick', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 3), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 3), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 4 */ { name: 'laura', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 4), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 4), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 5 */ { name: 'infoman', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 5), P(IHP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 5), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 6 */ { name: 'brickstr', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 6), P(HP, HPN, 13, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 6), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 4), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 7 */ { name: 'studs', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 7), P(HP, HPN, 4, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 8 */ { name: 'rhoda', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 8), P(HP, HPN, 4, HC, CA, 3), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 9 */ { name: 'valerie', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 10 */ { name: 'snap', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 11 */ { name: 'pt', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 6, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 12 */ { name: 'mg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 13 */ { name: 'bu', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] }, + /* 14 */ { name: 'ml', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 14), P(HP, HPN, 2, HC, CA, 4), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 15 */ { name: 'nu', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 16 */ { name: 'na', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 15), P(HP, HPN, 10, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 17 */ { name: 'cl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 19, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 18 */ { name: 'en', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 19 */ { name: 're', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 20 */ { name: 'ro', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 21 */ { name: 'd1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 15, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 22 */ { name: 'd2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 16, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 23 */ { name: 'd3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 17, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 24 */ { name: 'd4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 18, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 25 */ { name: 'l1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 18), P(HP, HPN, 5, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 26 */ { name: 'l2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 19), P(HP, HPN, 6, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 27 */ { name: 'l3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 20), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 28 */ { name: 'l4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 21), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 29 */ { name: 'l5', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 30 */ { name: 'l6', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 0, HC, CA, 1), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 31 */ { name: 'b1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 32 */ { name: 'b2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 5, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 33 */ { name: 'b3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 34 */ { name: 'b4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 35 */ { name: 'cm', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 36 */ { name: 'gd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 37 */ { name: 'rd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] }, + /* 38 */ { name: 'pg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 39 */ { name: 'bd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 40 */ { name: 'sy', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 41 */ { name: 'gn', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 6, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] }, + /* 42 */ { name: 'df', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 23), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 43 */ { name: 'bs', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 44 */ { name: 'lt', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 45 */ { name: 'st', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 5), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 46 */ { name: 'bm', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 47 */ { name: 'jk', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 48 */ { name: 'ghost', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 49 */ { name: 'ghost01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 50 */ { name: 'ghost02', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 51 */ { name: 'ghost03', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 52 */ { name: 'ghost04', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 53 */ { name: 'ghost05', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 54 */ { name: 'hg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 8, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 55 */ { name: 'pntgy', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 56 */ { name: 'pep', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 57 */ { name: 'cop01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 58 */ { name: 'actor_01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 59 */ { name: 'actor_02', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 60 */ { name: 'actor_03', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 61 */ { name: 'actor_04', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 62 */ { name: 'actor_05', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 63 */ { name: 'btmncycl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 64 */ { name: 'cboycycl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 65 */ { name: 'boatman', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] } +]); + +/** + * Display names for the 66 actors, from savegame.ksy doc comments. + * Falls back to the internal name (ActorInfoInit[i].name) when not listed. + */ +export const ActorDisplayNames = Object.freeze([ + /* 0 */ 'Pepper Roni', + /* 1 */ 'Mama Brickolini', + /* 2 */ 'Papa Brickolini', + /* 3 */ 'Nick Brick', + /* 4 */ 'Laura Brick', + /* 5 */ 'Infomaniac', + /* 6 */ 'Brickster', + /* 7 */ 'Studs Linkin', + /* 8 */ 'Rhoda Hogg', + /* 9 */ 'Valerie Stubbins', + /* 10 */ 'Snap Lockitt', + /* 11 */ 'pt', + /* 12 */ 'Maggie Post', + /* 13 */ 'Buck Pounds', + /* 14 */ 'Ed Mail', + /* 15 */ 'Nubby Stevens', + /* 16 */ 'Nancy Nubbins', + /* 17 */ 'Dr. Clickitt', + /* 18 */ 'Enter', + /* 19 */ 'Return', + /* 20 */ 'Captain D. Rom', + /* 21 */ 'Bill Ding (Race Car)', + /* 22 */ 'Bill Ding (Helicopter)', + /* 23 */ 'Bill Ding (Dune Buggy)', + /* 24 */ 'Bill Ding (Jetski)', + /* 25 */ 'Flying Legandos #1', + /* 26 */ 'Flying Legandos #2', + /* 27 */ 'Flying Legandos #3', + /* 28 */ 'Flying Legandos #4', + /* 29 */ 'Flying Legandos #5', + /* 30 */ 'Flying Legandos #6', + /* 31 */ 'Legobobs #1', + /* 32 */ 'Legobobs #2', + /* 33 */ 'Legobobs #3', + /* 34 */ 'Legobobs #4', + /* 35 */ 'Brazilian Carmen', + /* 36 */ 'Gideon Worse', + /* 37 */ 'Red Greenbase', + /* 38 */ 'Polly Gone', + /* 39 */ 'Bradford Brickford', + /* 40 */ 'Shiney Doris', + /* 41 */ 'Glen Funberg', + /* 42 */ 'Dorothy Funberg', + /* 43 */ 'Brian Shrimp', + /* 44 */ 'Luke Tepid', + /* 45 */ 'Shorty Tails', + /* 46 */ 'Bumpy Kindergreen', + /* 47 */ "Jack O'Trades", + /* 48 */ 'Ghost #1', + /* 49 */ 'Ghost #2', + /* 50 */ 'Ghost #3', + /* 51 */ 'Ghost #4', + /* 52 */ 'Ghost #5', + /* 53 */ 'Ghost #6', + /* 54 */ 'hg', + /* 55 */ 'pntgy', + /* 56 */ 'pep', + /* 57 */ 'cop01', + /* 58 */ 'actor_01', + /* 59 */ 'actor_02', + /* 60 */ 'actor_03', + /* 61 */ 'actor_04', + /* 62 */ 'actor_05', + /* 63 */ 'btmncycl', + /* 64 */ 'cboycycl', + /* 65 */ 'boatman' +]); + +/** + * Vehicle associations for actors. Maps ActorInfoInit index -> vehicle info. + * From g_characters[].m_vehicleId and g_vehicles[] in legoanimationmanager.cpp. + * vehicleAnim = g_cycles[row][10] for each character. + */ +export const ActorVehicles = Object.freeze({ + 0: { vehicleModel: 'board', vehicleAnim: 'CNs001Sk' }, // pepper -> skateboard + 3: { vehicleModel: 'motoni', vehicleAnim: 'CNs011Ni' }, // nick -> motorcycle + 4: { vehicleModel: 'motola', vehicleAnim: 'CNs011La' }, // laura -> motorcycle + 37: { vehicleModel: 'bikerd', vehicleAnim: 'CNs001Rd' }, // rd -> bicycle + 38: { vehicleModel: 'bikepg', vehicleAnim: 'CNs001Pg' }, // pg -> bicycle + 39: { vehicleModel: 'bikebd', vehicleAnim: 'CNs001Bd' }, // bd -> bicycle + 40: { vehicleModel: 'bikesy', vehicleAnim: 'CNs001Sy' }, // sy -> bicycle + 56: { vehicleModel: 'board', vehicleAnim: 'CNs001Sk' }, // pep (pepper alias) +}); + +export const VehicleDisplayNames = Object.freeze({ + 'board': 'Skateboard', + 'motoni': 'Motorcycle', + 'motola': 'Motorcycle', + 'bikebd': 'Bicycle', + 'bikepg': 'Bicycle', + 'bikerd': 'Bicycle', + 'bikesy': 'Bicycle', +}); + +/** + * Save file field offsets within the 16-byte character record. + * The save file stores these per-character values that override defaults: + * sound(S32) + move(S32) + mood(U8) + hatPartNameIndex(U8) + hatNameIndex(U8) + * + infogronNameIndex(U8) + armlftNameIndex(U8) + armrtNameIndex(U8) + * + leglftNameIndex(U8) + legrtNameIndex(U8) + */ +export const CharacterFieldOffsets = Object.freeze({ + sound: 0, // S32 + move: 4, // S32 + mood: 8, // U8 + hatPartNameIndex: 9, // U8 + hatNameIndex: 10, // U8 + infogronNameIndex: 11, // U8 + armlftNameIndex: 12, // U8 + armrtNameIndex: 13, // U8 + leglftNameIndex: 14, // U8 + legrtNameIndex: 15 // U8 +}); + +export const CHARACTER_RECORD_SIZE = 16; + diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js index eb6c8cf..e168c0f 100644 --- a/src/core/savegame/constants.js +++ b/src/core/savegame/constants.js @@ -197,7 +197,9 @@ export const LegoColors = Object.freeze({ 'lego green': { r: 0x00, g: 0x78, b: 0x2d }, 'lego red': { r: 0xcb, g: 0x12, b: 0x20 }, 'lego white': { r: 0xfa, g: 0xfa, b: 0xfa }, - 'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 } + 'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 }, + 'lego brown': { r: 0x4a, g: 0x23, b: 0x00 }, + 'lego lt grey': { r: 0xc0, g: 0xc0, b: 0xc0 } }); // LEGO color display names and order diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index 14e2e7c..8dd334c 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -91,6 +91,8 @@ export async function listSaveSlots() { missions: null, variables: null, act1State: null, + characters: null, + charactersOffset: null, playerName: null, buffer: null }; @@ -104,6 +106,8 @@ export async function listSaveSlots() { slot.missions = parsed.missions; slot.variables = parsed.variables; slot.act1State = parsed.act1State || null; + slot.characters = parsed.characters || null; + slot.charactersOffset = parsed.charactersOffset || null; slot.buffer = buffer; // Try to get player name @@ -168,6 +172,8 @@ export async function loadSaveSlot(slotNumber) { missions: parsed.missions, variables: parsed.variables, act1State: parsed.act1State || null, + characters: parsed.characters || null, + charactersOffset: parsed.charactersOffset || null, playerName, buffer }; @@ -238,6 +244,19 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply character update(s) + if (updates.character) { + const entries = Array.isArray(updates.character) ? updates.character : [updates.character]; + for (const { characterIndex, field, value } of entries) { + const charSerializer = createSerializer(newBuffer); + const result = charSerializer.updateCharacter(characterIndex, field, value); + if (result) { + newBuffer = result; + modified = true; + } + } + } + // Apply texture update if (updates.texture) { const { textureName, textureData } = updates.texture; diff --git a/src/lib/EditorTooltip.svelte b/src/lib/EditorTooltip.svelte index c178b3d..4918415 100644 --- a/src/lib/EditorTooltip.svelte +++ b/src/lib/EditorTooltip.svelte @@ -1,11 +1,26 @@
- ? - {text} - +
+ {#if onResetCamera} + + {/if} + ? + {text} + +
@@ -17,11 +32,51 @@ width: 100%; } - .tooltip-trigger { + .tooltip-icons { position: absolute; top: 0; right: 0; z-index: 1; + display: flex; + align-items: center; + gap: 8px; + } + + .reset-camera-btn { + position: relative; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--color-border-medium); + color: #eee; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + padding: 0; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + /* Expand touch target on mobile */ + @media (pointer: coarse) { + .reset-camera-btn::before { + content: ''; + position: absolute; + inset: -10px; + } + } + + @media (hover: hover) { + .reset-camera-btn:hover { + background-color: var(--color-primary); + color: var(--color-bg-panel); + } + } + + .reset-camera-btn:active { + background-color: var(--color-primary); + color: var(--color-bg-panel); } .editor-tooltip-content { diff --git a/src/lib/ReadMePage.svelte b/src/lib/ReadMePage.svelte index 320b48a..1473d45 100644 --- a/src/lib/ReadMePage.svelte +++ b/src/lib/ReadMePage.svelte @@ -37,7 +37,13 @@ { type: 'New', text: 'Save Editor lets you view and modify save files — change your player name, character, and high scores directly from the browser' }, { type: 'New', text: 'Sky Color Editor allows customizing the island sky gradient colors in your save file' }, { type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' }, - { type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' } + { type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' }, + { type: 'New', text: 'Actor Editor with animated 3D character preview — customize hats, colors, moods, sounds, and moves for all 66 game actors' }, + { type: 'New', text: 'Vehicle rendering in Actor Editor — toggle to see actors with their assigned vehicles' }, + { type: 'New', text: 'Click animations and sound effects in Actor Editor matching the original game behavior' }, + { type: 'New', text: 'Drag-to-orbit, zoom, and pan controls on all 3D previews (vehicle, actor, and score cube editors)' }, + { type: 'New', text: 'Camera reset button on 3D editors to restore the default view' }, + { type: 'Fixed', text: 'Sticky hover highlights on touch devices for editor buttons' } ]}, { id: 'cl1', title: 'January 2026', items: [ { type: 'New', text: 'Debug menu for developers and power users. Tap the LEGO Island logo 5 times to unlock OGEL mode and access debug features like teleporting to locations, switching acts, and playing animations' }, diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index 8970c86..b1ff811 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -1,11 +1,13 @@ + + renderer?.resetView()}> +
+ + + {#if loading} +
+
+
+ {:else if error} +
{error}
+ {/if} +
+ +
+
+ +
+ {actorIndex + 1} / {ActorInfoInit.length} + {actorName} +
+ +
+ {#if vehicleInfo} + + {/if} +
+ +
+ {#if !isDefault && !loading && !error} + + {/if} +
+
+ + diff --git a/src/lib/save-editor/LightPositionEditor.svelte b/src/lib/save-editor/LightPositionEditor.svelte index 21fd7d7..9957cab 100644 --- a/src/lib/save-editor/LightPositionEditor.svelte +++ b/src/lib/save-editor/LightPositionEditor.svelte @@ -1,4 +1,6 @@ - + renderer?.resetView()}>
{ - const response = await fetch(`/textures/${texFile}.tex`); - if (!response.ok) return null; - const buffer = await response.arrayBuffer(); + const buffer = await fetchTexture(texFile); + if (!buffer) return null; const parsed = parseTex(buffer); if (parsed.textures.length > 0) { return { name: texFile, ...parsed.textures[0] }; @@ -251,6 +251,7 @@ function cycleColor() { if (!currentPart || partError) return; + if (renderer?.wasDragged()) return; // Find current color index and cycle to next const currentIdx = LegoColorNames.indexOf(currentColorValue); @@ -322,7 +323,7 @@ - + renderer?.resetView()}>