* Improve Lighthouse LCP and accessibility scores Preload the LCP image (install.webp) from the HTML and add fetchpriority="high" so the browser discovers it before JS executes. Use a <main> landmark for the primary content container to satisfy the "document has a main landmark" accessibility audit. * Add actor editor with animated 3D character preview Browse and customize all 66 game actors with a Three.js rendered preview featuring skeletal walk cycle animations. Click interaction matches the game's character-dependent behavior (Pepper=hat, Nick=color, etc.). - Parse WDB global parts and global textures for character assembly - Parse and serialize character data (66 entries x 16 bytes) in save files - AnimationParser for .ani binary format with hierarchical keyframe evaluation - Full g_cycles animation table (11 types x 17 animations) driven by move/sound - Per-mesh texture support for hats, torso, and face textures * Extract BaseRenderer to deduplicate actor and vehicle renderers - Extract shared Three.js setup, lighting, texture, geometry, and animation loop code into BaseRenderer base class (~170 lines) - Deduplicate WdbParser.parseGlobalParts via parsePartData delegation - Consolidate lego brown/lt grey into shared LegoColors constant - Remove dead code: updatePartColor, SUFFIX_NAMES, CharacterType, getCharacterType, partToLODIndex, unused imports and re-exports - Simplify updateCharacter and resolve methods by removing unnecessary defensive checks on frozen data and bounded UI inputs - Extract actorKey helper in ActorEditor to deduplicate key computation - Delete unused animations/manifest.json * Add reset to default button for actor editor Compare each actor's character state against ActorInfoInit defaults and show a reset button when any field differs. Resets all 10 fields (sound, move, mood, hat, colors) in a single save round-trip by extending updateSaveSlot to accept batch character updates. * Show full character names in actor editor Add ActorDisplayNames lookup with names from savegame.ksy doc comments (e.g. "Pepper Roni", "Mama Brickolini") instead of internal IDs. Widen nav label min-width to 150px to prevent button shifting. * Stabilize actor position when hat changes Override centerAndScaleModel in ActorRenderer to exclude the hat part from the bounding box calculation, so switching between hats of different sizes no longer shifts the body/head position. * Fetch assets from SI files via HTTP Range requests Replace static animation, texture, and globe bitmap files with a manifest-driven approach that extracts them directly from the game's SI files at runtime using HTTP Range requests. A new generate-manifest script scans SI files by MxCh objectId to locate each asset's byte offset(s), verifies integrity via MD5, and writes an asset-ranges.json manifest. The app consumes this manifest to fetch assets on demand, including support for files split across MxCh interleave boundaries. Also removes unused constants (ActorLODIndex, animation keyframe flag constants). * Fetch character icons from SI files via HTTP Range requests Replace static webp character icons with runtime extraction from INFOMAIN.SI, extending the bitmap manifest to support multiple SI files. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * Fix actor editor animation and interaction bugs Use mood (not sound+4*move) to select walking animation, matching FUN_10063b90. Load secondary animation tier (speed 4.0 threshold) which NPCs typically use in-game, producing the independent head/hat movement. Fix switchSound wrap to 9 values, add switchColor click remapping for claws/head/body, fix g_cycles case mismatches, add morph key visibility support, and preserve root Y-translation for vertical bounce while stripping horizontal movement. * Add click animations to actor editor Play a one-shot gesture animation when clicking an actor, matching the in-game LegoEntity::ClickAnimation behavior (objectId = m_move + 10). After the click animation finishes, the walking loop resumes. Adds the 4 click animations from SNDANIM.SI to the asset manifest and extends ActorRenderer with queue-based click animation playback. Also fixes treadmill XZ stripping for click animations where actor_01 is nested under wrapper nodes. * Add vehicle rendering to actor editor Actors with personal vehicles (skateboard, motorcycles, bicycles) can now be toggled between walking and vehicle mode via a button in the actor navigation bar. Vehicle geometries are loaded from WDB world models and rendered alongside the character with matching animations. * Add click sound playback to actor editor Plays the character's click sound (m_sound + 50) and, for Laura's SwitchMood, an additional mood sound (m_mood + 66) from SNDANIM.SI, matching the original game behavior. Sounds are fetched on demand, decoded as WAV, and cached as AudioBuffers. * Replace Range request asset loading with packed binary bundle Extract save editor assets (animations, sounds, textures, bitmaps) into a single save-editor.bin file at build time instead of fetching byte ranges from ~550MB SI files at runtime. The bundle packs an embedded JSON index and all fragment data into one file (~756KB), eliminating Range request complexity and enabling proper Workbox precaching. * Clean up actor editor branch: DRY, dead code, CSS - Extract buildNodeToPartGroupMap() in ActorRenderer to deduplicate map-building logic in loadAnimationForActor and playClickAnimation - Refactor updateMissionScore() to use getMissionScoreOffset() instead of duplicating offset calculation - Remove unused ActorPartLabels export from actorConstants - Make fetchBitmap module-private (only used by fetchBitmapAsURL) - Merge duplicate .globe-btn CSS blocks in LightPositionEditor * Add drag-to-orbit controls to vehicle and actor editors Use Three.js OrbitControls in BaseRenderer for rotation-only orbiting with damping. Vehicle editor auto-rotates and resets on part switch. Actor editor uses orbit without auto-rotate (has skeletal animations). Drag vs click detection uses pointermove threshold to avoid false positives from autoRotate damping. * Rebase WdbModelRenderer on BaseRenderer Remove duplicated scene/camera/renderer/lighting setup, geometry unpacking, animation loop, and dispose logic. Score cube gets orbit controls and drag-vs-click detection for free. * Add zoom, pan, and camera reset to 3D editors Enable zoom (scroll/pinch) and pan (right-click/two-finger drag) on all OrbitControls. Add resetView() to BaseRenderer that restores initial camera state and auto-rotate via OrbitControls.saveState/reset. Add reset camera button to EditorTooltip with mobile-friendly touch targets and hover-only highlight to avoid sticky state on touch. * Update changelog and fix sticky hover on touch devices Add actor editor features, 3D orbit/zoom/pan controls, and camera reset button to the February 2026 changelog. Wrap hover styles in @media (hover: hover) for vehicle toggle and texture buttons. * Update README with save editor setup, project structure, and Three.js
1
.gitignore
vendored
@ -4,3 +4,4 @@ isle.wasm
|
||||
isle.wasm.map
|
||||
isle.js
|
||||
LEGO
|
||||
save-editor.bin
|
||||
|
||||
26
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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 918 B |
|
Before Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 964 B |
|
Before Width: | Height: | Size: 1016 B |
|
Before Width: | Height: | Size: 876 B |
|
Before Width: | Height: | Size: 986 B |
|
Before Width: | Height: | Size: 948 B |
|
Before Width: | Height: | Size: 980 B |
|
Before Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 946 B |
429
scripts/generate-save-editor-assets.js
Normal file
@ -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<objectId, [[dataOffset, dataSize], ...]>.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
139
src/core/assetLoader.js
Normal file
@ -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 <img> 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);
|
||||
}
|
||||
212
src/core/formats/AnimationParser.js
Normal file
@ -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();
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
903
src/core/rendering/ActorRenderer.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
258
src/core/rendering/BaseRenderer.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
for (const mesh of lod.meshes) {
|
||||
const geometry = this.createGeometry(mesh, lod);
|
||||
if (!geometry) continue;
|
||||
|
||||
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(texturedGeometry, texturedMaterial);
|
||||
this.texturedMesh = new THREE.Mesh(geometry, material);
|
||||
this.modelGroup.add(this.texturedMesh);
|
||||
}
|
||||
|
||||
for (const { geometry, color } of nonTexturedGeometries) {
|
||||
} 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
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
this.modelGroup.add(mesh);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
715
src/core/savegame/actorConstants.js
Normal file
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,11 +1,26 @@
|
||||
<script>
|
||||
export let text = '';
|
||||
export let onResetCamera = null;
|
||||
</script>
|
||||
|
||||
<div class="editor-tooltip-wrapper">
|
||||
<div class="tooltip-icons">
|
||||
{#if onResetCamera}
|
||||
<button
|
||||
type="button"
|
||||
class="reset-camera-btn"
|
||||
onclick={onResetCamera}
|
||||
title="Reset camera"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1a7 7 0 0 0-7 7h2a5 5 0 0 1 9.17-2.74L10 7h5V2l-1.87 1.87A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<span class="tooltip-trigger">?
|
||||
<span class="tooltip-content">{text}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-tooltip-content">
|
||||
<slot />
|
||||
</div>
|
||||
@ -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 {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import BackButton from './BackButton.svelte';
|
||||
import Carousel from './Carousel.svelte';
|
||||
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
|
||||
import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
|
||||
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
|
||||
import VehicleEditor from './save-editor/VehicleEditor.svelte';
|
||||
import ActorEditor from './save-editor/ActorEditor.svelte';
|
||||
import { fetchBitmapAsURL } from '../core/assetLoader.js';
|
||||
import { saveEditorState, currentPage } from '../stores.js';
|
||||
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
||||
import { Actor, ActorNames } from '../core/savegame/constants.js';
|
||||
@ -23,7 +25,8 @@
|
||||
{ id: 'player', label: 'Player', firstSection: 'name' },
|
||||
{ id: 'scores', label: 'Scores', firstSection: null },
|
||||
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
|
||||
{ id: 'vehicles', label: 'Vehicles', firstSection: null }
|
||||
{ id: 'vehicles', label: 'Vehicles', firstSection: null },
|
||||
{ id: 'actors', label: 'Actors', firstSection: null }
|
||||
];
|
||||
|
||||
// Reset state when navigating to this page
|
||||
@ -41,20 +44,36 @@
|
||||
let currentAct = 0;
|
||||
let actorId = 1;
|
||||
|
||||
// Character icons mapping
|
||||
const characterIcons = {
|
||||
[Actor.PEPPER]: { normal: 'images/pepper.webp', selected: 'images/pepper-selected.webp' },
|
||||
[Actor.MAMA]: { normal: 'images/mama.webp', selected: 'images/mama-selected.webp' },
|
||||
[Actor.PAPA]: { normal: 'images/papa.webp', selected: 'images/papa-selected.webp' },
|
||||
[Actor.NICK]: { normal: 'images/nick.webp', selected: 'images/nick-selected.webp' },
|
||||
[Actor.LAURA]: { normal: 'images/laura.webp', selected: 'images/laura-selected.webp' }
|
||||
};
|
||||
// Character icons — loaded from SI file bitmaps
|
||||
const iconNames = ['pepper', 'mama', 'papa', 'nick', 'laura'];
|
||||
let characterIcons = {};
|
||||
let iconUrls = [];
|
||||
|
||||
// Carousel state (bound from Carousel component)
|
||||
let carouselHasDragged = false;
|
||||
|
||||
onMount(async () => {
|
||||
await loadSlots();
|
||||
|
||||
// Load character icons from SI file in background
|
||||
const urls = await Promise.all(iconNames.flatMap(name => [
|
||||
fetchBitmapAsURL(name),
|
||||
fetchBitmapAsURL(`${name}-selected`)
|
||||
]));
|
||||
iconUrls = urls;
|
||||
characterIcons = {
|
||||
[Actor.PEPPER]: { normal: urls[0], selected: urls[1] },
|
||||
[Actor.MAMA]: { normal: urls[2], selected: urls[3] },
|
||||
[Actor.PAPA]: { normal: urls[4], selected: urls[5] },
|
||||
[Actor.NICK]: { normal: urls[6], selected: urls[7] },
|
||||
[Actor.LAURA]: { normal: urls[8], selected: urls[9] }
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const url of iconUrls) {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSlots() {
|
||||
@ -120,7 +139,7 @@
|
||||
if (updated) {
|
||||
slots = slots.map(s =>
|
||||
s.slotNumber === selectedSlot
|
||||
? { ...s, variables: updated.variables, act1State: updated.act1State }
|
||||
? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters }
|
||||
: s
|
||||
);
|
||||
}
|
||||
@ -270,12 +289,14 @@
|
||||
class:selected={selectedSlot === slot.slotNumber}
|
||||
onclick={() => handleSlotSelect(slot.slotNumber)}
|
||||
>
|
||||
{#if characterIcons[slot.header?.actorId]?.selected}
|
||||
<img
|
||||
src={characterIcons[slot.header?.actorId]?.selected || 'images/pepper-selected.webp'}
|
||||
src={characterIcons[slot.header?.actorId].selected}
|
||||
alt={ActorNames[slot.header?.actorId] || 'Character'}
|
||||
class="slot-character-icon"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<span class="slot-name">{slot.playerName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
@ -356,12 +377,14 @@
|
||||
onclick={() => handleActorSelect(actor.id)}
|
||||
title={actor.name}
|
||||
>
|
||||
{#if characterIcons[actor.id]}
|
||||
<img
|
||||
src={actorId === actor.id
|
||||
? characterIcons[actor.id].selected
|
||||
: characterIcons[actor.id].normal}
|
||||
alt={actor.name}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@ -405,6 +428,13 @@
|
||||
<VehicleEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actors Tab -->
|
||||
<div class:hidden={activeTab !== 'actors'}>
|
||||
{#if $currentPage === 'save-editor'}
|
||||
<ActorEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
512
src/lib/save-editor/ActorEditor.svelte
Normal file
@ -0,0 +1,512 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { ActorRenderer } from '../../core/rendering/ActorRenderer.js';
|
||||
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
|
||||
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
|
||||
import { Actor } from '../../core/savegame/constants.js';
|
||||
import { fetchSoundAsWav } from '../../core/assetLoader.js';
|
||||
import NavButton from '../NavButton.svelte';
|
||||
import ResetButton from '../ResetButton.svelte';
|
||||
import EditorTooltip from '../EditorTooltip.svelte';
|
||||
|
||||
export let slot;
|
||||
export let onUpdate = () => {};
|
||||
|
||||
let canvas;
|
||||
let renderer = null;
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
// Cached WDB data
|
||||
let globalPartsMap = null;
|
||||
let globalTextures = null;
|
||||
let vehiclePartsMap = null;
|
||||
let vehicleTextures = null;
|
||||
|
||||
let actorIndex = 0;
|
||||
let loadedActorKey = null;
|
||||
let showVehicle = false;
|
||||
|
||||
let audioContext = null;
|
||||
let gainNode = null;
|
||||
const soundCache = new Map();
|
||||
|
||||
async function playSound(name) {
|
||||
try {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
gainNode = audioContext.createGain();
|
||||
gainNode.gain.value = 0.3;
|
||||
gainNode.connect(audioContext.destination);
|
||||
}
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
let audioBuffer = soundCache.get(name);
|
||||
if (!audioBuffer) {
|
||||
const wav = await fetchSoundAsWav(name);
|
||||
if (!wav) return;
|
||||
audioBuffer = await audioContext.decodeAudioData(wav);
|
||||
soundCache.set(name, audioBuffer);
|
||||
}
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(gainNode);
|
||||
source.start();
|
||||
} catch (e) {
|
||||
console.error(`Failed to play sound ${name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
$: actorInfo = ActorInfoInit[actorIndex];
|
||||
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
|
||||
$: charState = slot?.characters?.[actorIndex];
|
||||
$: vehicleInfo = ActorVehicles[actorIndex] || null;
|
||||
$: vehicleName = vehicleInfo ? VehicleDisplayNames[vehicleInfo.vehicleModel] : null;
|
||||
|
||||
$: isDefault = actorInfo && charState &&
|
||||
charState.sound === actorInfo.sound &&
|
||||
charState.move === actorInfo.move &&
|
||||
charState.mood === actorInfo.mood &&
|
||||
charState.hatPartNameIndex === actorInfo.parts[1].partNameIndex &&
|
||||
charState.hatNameIndex === actorInfo.parts[1].nameIndex &&
|
||||
charState.infogronNameIndex === actorInfo.parts[2].nameIndex &&
|
||||
charState.armlftNameIndex === actorInfo.parts[4].nameIndex &&
|
||||
charState.armrtNameIndex === actorInfo.parts[5].nameIndex &&
|
||||
charState.leglftNameIndex === actorInfo.parts[8].nameIndex &&
|
||||
charState.legrtNameIndex === actorInfo.parts[9].nameIndex;
|
||||
|
||||
function actorKey(slotNumber, idx, cs, vehicle) {
|
||||
return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.mood}-${vehicle}`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('/LEGO/data/WORLD.WDB');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load WORLD.WDB: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const wdbParser = new WdbParser(buffer);
|
||||
const wdbData = wdbParser.parse();
|
||||
|
||||
if (wdbData.globalParts) {
|
||||
globalPartsMap = buildGlobalPartsMap(wdbData.globalParts);
|
||||
// Merge global textures (chest/face textures) with global parts textures (hat textures etc.)
|
||||
globalTextures = [
|
||||
...(wdbData.globalTextures || []),
|
||||
...(wdbData.globalParts.textures || [])
|
||||
];
|
||||
} else {
|
||||
throw new Error('No global parts found in WORLD.WDB');
|
||||
}
|
||||
|
||||
// Collect vehicle model names needed
|
||||
const neededVehicles = new Set();
|
||||
for (const v of Object.values(ActorVehicles)) {
|
||||
neededVehicles.add(v.vehicleModel.toLowerCase());
|
||||
}
|
||||
|
||||
// Vehicle geometries are stored as models (not parts) in WDB worlds.
|
||||
// Scan worlds for matching model entries, parse model data, and
|
||||
// collect all ROIs (root + children) with resolved LODs.
|
||||
const vModelsMap = new Map();
|
||||
const vTextures = [];
|
||||
for (const world of wdbData.worlds) {
|
||||
let worldPartsMap = null;
|
||||
for (const model of world.models) {
|
||||
const modelKey = model.name.toLowerCase();
|
||||
if (!neededVehicles.has(modelKey) || vModelsMap.has(modelKey)) continue;
|
||||
const modelData = wdbParser.parseModelData(model.dataOffset);
|
||||
const roi = modelData.roi;
|
||||
if (!roi) continue;
|
||||
|
||||
// Build world parts map lazily (needed for shared LOD resolution)
|
||||
if (!worldPartsMap) {
|
||||
worldPartsMap = buildPartsMap(wdbParser, world.parts);
|
||||
}
|
||||
|
||||
// Collect all renderable ROIs (root + children recursively)
|
||||
const rois = [];
|
||||
const collectRois = (node) => {
|
||||
const lods = resolveLods(node, worldPartsMap);
|
||||
if (lods.length > 0) {
|
||||
rois.push({ name: node.name, lods });
|
||||
}
|
||||
for (const child of node.children || []) {
|
||||
collectRois(child);
|
||||
}
|
||||
};
|
||||
collectRois(roi);
|
||||
|
||||
if (rois.length > 0) {
|
||||
vModelsMap.set(modelKey, rois);
|
||||
}
|
||||
if (modelData.textures) {
|
||||
vTextures.push(...modelData.textures);
|
||||
}
|
||||
}
|
||||
}
|
||||
vehiclePartsMap = vModelsMap;
|
||||
vehicleTextures = vTextures;
|
||||
|
||||
renderer = new ActorRenderer(canvas);
|
||||
loadCurrentActor();
|
||||
renderer.start();
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
console.error('ActorEditor initialization error:', e);
|
||||
error = e.message;
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
renderer?.dispose();
|
||||
audioContext?.close();
|
||||
});
|
||||
|
||||
// Reload actor when index, character state, or vehicle toggle changes
|
||||
$: if (renderer && !loading && actorInfo && charState) {
|
||||
if (actorKey(slot?.slotNumber, actorIndex, charState, showVehicle) !== loadedActorKey) {
|
||||
loadCurrentActor();
|
||||
}
|
||||
}
|
||||
|
||||
function loadCurrentActor() {
|
||||
if (!renderer || !globalPartsMap || !slot?.characters) return;
|
||||
|
||||
const activeVehicle = showVehicle ? vehicleInfo : null;
|
||||
renderer.loadActor(
|
||||
actorIndex, slot.characters, globalPartsMap, globalTextures,
|
||||
activeVehicle ? vehiclePartsMap : null,
|
||||
activeVehicle ? vehicleTextures : null,
|
||||
activeVehicle
|
||||
);
|
||||
loadedActorKey = actorKey(slot?.slotNumber, actorIndex, slot.characters[actorIndex], showVehicle);
|
||||
}
|
||||
|
||||
function prevActor() {
|
||||
actorIndex = actorIndex > 0 ? actorIndex - 1 : ActorInfoInit.length - 1;
|
||||
showVehicle = false;
|
||||
loadedActorKey = null;
|
||||
}
|
||||
|
||||
function nextActor() {
|
||||
actorIndex = actorIndex < ActorInfoInit.length - 1 ? actorIndex + 1 : 0;
|
||||
showVehicle = false;
|
||||
loadedActorKey = null;
|
||||
}
|
||||
|
||||
function handleCanvasClick(event) {
|
||||
if (!renderer || !slot?.characters || !charState) return;
|
||||
if (renderer.wasDragged()) return;
|
||||
|
||||
const playerId = slot.header?.actorId;
|
||||
let acted = false;
|
||||
let clickMove = charState.move;
|
||||
|
||||
switch (playerId) {
|
||||
case Actor.PEPPER: switchVariant(); acted = true; break;
|
||||
case Actor.MAMA: switchSound(); acted = true; break;
|
||||
case Actor.PAPA: clickMove = switchMove(); acted = true; break;
|
||||
case Actor.NICK: acted = switchColor(event); break;
|
||||
case Actor.LAURA: switchMood(); acted = true; break;
|
||||
}
|
||||
|
||||
if (!acted) return;
|
||||
|
||||
// Play click sound (Mama plays the *new* sound after cycling)
|
||||
const soundIdx = playerId === Actor.MAMA
|
||||
? (charState.sound + 1) % 9
|
||||
: charState.sound;
|
||||
playSound(`ClickSound${soundIdx}`);
|
||||
|
||||
// Laura additionally plays a mood sound
|
||||
if (playerId === Actor.LAURA) {
|
||||
playSound(`MoodSound${(charState.mood + 1) % 4}`);
|
||||
}
|
||||
|
||||
// Queue click animation — consumed by loadAnimationForActor
|
||||
renderer.queueClickAnimation(clickMove);
|
||||
|
||||
// Sound/move changes don't affect the actorKey, so the reactive block
|
||||
// won't trigger a model reload. Play the click animation directly.
|
||||
// For visual changes (hat/color/mood), the reactive block will call
|
||||
// loadCurrentActor → loadAnimationForActor, which consumes the queue.
|
||||
if (playerId === Actor.MAMA || playerId === Actor.PAPA) {
|
||||
renderer.loadAnimationForActor(actorIndex, charState.mood);
|
||||
}
|
||||
}
|
||||
|
||||
function switchVariant() {
|
||||
const part = actorInfo.parts[ActorPart.INFOHAT];
|
||||
if (!part.partNameIndices) return;
|
||||
|
||||
const maxIdx = part.partNameIndices.length;
|
||||
const nextIdx = (charState.hatPartNameIndex + 1) % maxIdx;
|
||||
|
||||
onUpdate({
|
||||
character: { characterIndex: actorIndex, field: 'hatPartNameIndex', value: nextIdx }
|
||||
});
|
||||
}
|
||||
|
||||
function switchSound() {
|
||||
const nextSound = (charState.sound + 1) % 9;
|
||||
onUpdate({
|
||||
character: { characterIndex: actorIndex, field: 'sound', value: nextSound }
|
||||
});
|
||||
}
|
||||
|
||||
function switchMove() {
|
||||
const nextMove = (charState.move + 1) % 4;
|
||||
onUpdate({
|
||||
character: { characterIndex: actorIndex, field: 'move', value: nextMove }
|
||||
});
|
||||
return nextMove;
|
||||
}
|
||||
|
||||
function switchColor(event) {
|
||||
let partIdx = renderer.getClickedPart(event);
|
||||
if (partIdx < 0) return false;
|
||||
|
||||
// Remap clicked part to the part that owns its color
|
||||
// (matches SwitchColor in legocharactermanager.cpp)
|
||||
if (partIdx === ActorPart.CLAWLFT) partIdx = ActorPart.ARMLFT;
|
||||
else if (partIdx === ActorPart.CLAWRT) partIdx = ActorPart.ARMRT;
|
||||
else if (partIdx === ActorPart.HEAD) partIdx = ActorPart.INFOHAT;
|
||||
else if (partIdx === ActorPart.BODY) partIdx = ActorPart.INFOGRON;
|
||||
|
||||
// Map part index to the save field
|
||||
const fieldMap = {
|
||||
[ActorPart.INFOHAT]: 'hatNameIndex',
|
||||
[ActorPart.INFOGRON]: 'infogronNameIndex',
|
||||
[ActorPart.ARMLFT]: 'armlftNameIndex',
|
||||
[ActorPart.ARMRT]: 'armrtNameIndex',
|
||||
[ActorPart.LEGLFT]: 'leglftNameIndex',
|
||||
[ActorPart.LEGRT]: 'legrtNameIndex'
|
||||
};
|
||||
|
||||
const field = fieldMap[partIdx];
|
||||
if (!field) return false;
|
||||
|
||||
const part = actorInfo.parts[partIdx];
|
||||
if (!part.nameIndices) return false;
|
||||
|
||||
const currentIdx = charState[field] ?? part.nameIndex;
|
||||
const maxIdx = part.nameIndices.length;
|
||||
const nextIdx = (currentIdx + 1) % maxIdx;
|
||||
|
||||
onUpdate({
|
||||
character: { characterIndex: actorIndex, field, value: nextIdx }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function switchMood() {
|
||||
const nextMood = (charState.mood + 1) % 4;
|
||||
onUpdate({
|
||||
character: { characterIndex: actorIndex, field: 'mood', value: nextMood }
|
||||
});
|
||||
}
|
||||
|
||||
function resetActor() {
|
||||
const i = actorIndex;
|
||||
const p = actorInfo.parts;
|
||||
onUpdate({
|
||||
character: [
|
||||
{ characterIndex: i, field: 'sound', value: actorInfo.sound },
|
||||
{ characterIndex: i, field: 'move', value: actorInfo.move },
|
||||
{ characterIndex: i, field: 'mood', value: actorInfo.mood },
|
||||
{ characterIndex: i, field: 'hatPartNameIndex', value: p[1].partNameIndex },
|
||||
{ characterIndex: i, field: 'hatNameIndex', value: p[1].nameIndex },
|
||||
{ characterIndex: i, field: 'infogronNameIndex', value: p[2].nameIndex },
|
||||
{ characterIndex: i, field: 'armlftNameIndex', value: p[4].nameIndex },
|
||||
{ characterIndex: i, field: 'armrtNameIndex', value: p[5].nameIndex },
|
||||
{ characterIndex: i, field: 'leglftNameIndex', value: p[8].nameIndex },
|
||||
{ characterIndex: i, field: 'legrtNameIndex', value: p[9].nameIndex }
|
||||
]
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<EditorTooltip text="Click to customize based on your current character. Navigate between all 66 game actors using the arrows. Changes are automatically saved." onResetCamera={() => renderer?.resetView()}>
|
||||
<div class="preview-container">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
width="190"
|
||||
height="190"
|
||||
class:hidden={loading || error}
|
||||
onclick={handleCanvasClick}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Customize actor"
|
||||
></canvas>
|
||||
|
||||
{#if loading}
|
||||
<div class="preview-overlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="preview-overlay error">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="part-nav-wrapper">
|
||||
<div class="part-nav">
|
||||
<NavButton direction="left" onclick={prevActor} />
|
||||
<div class="part-info">
|
||||
<span class="actor-index">{actorIndex + 1} / {ActorInfoInit.length}</span>
|
||||
<span class="actor-name">{actorName}</span>
|
||||
</div>
|
||||
<NavButton direction="right" onclick={nextActor} />
|
||||
</div>
|
||||
{#if vehicleInfo}
|
||||
<button
|
||||
type="button"
|
||||
class="vehicle-toggle-btn"
|
||||
class:active={showVehicle}
|
||||
onclick={() => { showVehicle = !showVehicle; }}
|
||||
title={showVehicle ? 'Show without vehicle' : `Show with ${vehicleName}`}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm8 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM5.5 11h5V9.5L13 8l-1-3H4L2.5 8l2.5 1.5V11h.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="reset-container">
|
||||
{#if !isDefault && !loading && !error}
|
||||
<ResetButton onclick={resetActor} />
|
||||
{/if}
|
||||
</div>
|
||||
</EditorTooltip>
|
||||
|
||||
<style>
|
||||
.preview-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
canvas:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
canvas.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-input);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-overlay.error {
|
||||
color: var(--color-error, #e74c3c);
|
||||
font-size: 0.75em;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(transparent 55%, transparent 56%),
|
||||
conic-gradient(var(--color-primary, #FFD700) 0deg 90deg, var(--color-border-dark, #333) 90deg 360deg);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.part-nav-wrapper {
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.part-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.part-info {
|
||||
text-align: center;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.actor-index {
|
||||
display: block;
|
||||
font-size: 0.7em;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.vehicle-toggle-btn {
|
||||
position: absolute;
|
||||
right: -36px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: var(--color-bg-input);
|
||||
border: 1px solid var(--color-border-medium);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-light);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.vehicle-toggle-btn.active {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.vehicle-toggle-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-container {
|
||||
height: 1.6em;
|
||||
}
|
||||
</style>
|
||||
@ -1,4 +1,6 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { fetchBitmapAsURL } from '../../core/assetLoader.js';
|
||||
import ResetButton from '../ResetButton.svelte';
|
||||
|
||||
export let slot;
|
||||
@ -14,6 +16,22 @@
|
||||
// Globe images for each position (0-5 map to globe1-globe6)
|
||||
const positions = [0, 1, 2, 3, 4, 5];
|
||||
|
||||
// Blob URLs for globe images, loaded from SI file
|
||||
let globeUrls = new Array(6).fill(null);
|
||||
|
||||
onMount(async () => {
|
||||
const urls = await Promise.all(
|
||||
positions.map(i => fetchBitmapAsURL(`globe${i + 1}`))
|
||||
);
|
||||
globeUrls = urls;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const url of globeUrls) {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
function handleSelect(position) {
|
||||
onUpdate({
|
||||
variable: {
|
||||
@ -40,10 +58,12 @@
|
||||
onclick={() => handleSelect(position)}
|
||||
title="Position {position}"
|
||||
>
|
||||
{#if globeUrls[position]}
|
||||
<img
|
||||
src="images/globe{position + 1}.webp"
|
||||
src={globeUrls[position]}
|
||||
alt="Light position {position}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@ -65,6 +85,8 @@
|
||||
|
||||
.globe-btn {
|
||||
padding: 4px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
background: var(--color-bg-input);
|
||||
border: 2px solid var(--color-border-medium);
|
||||
border-radius: 6px;
|
||||
|
||||
@ -101,6 +101,7 @@
|
||||
|
||||
function handleClick(event) {
|
||||
if (!renderer || loading) return;
|
||||
if (renderer.wasDragged()) return;
|
||||
|
||||
const hit = renderer.raycast(event);
|
||||
if (hit) {
|
||||
@ -121,7 +122,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<EditorTooltip text="Click on the cube to cycle high scores. Changes are automatically saved.">
|
||||
<EditorTooltip text="Click on the cube to cycle high scores. Changes are automatically saved." onResetCamera={() => renderer?.resetView()}>
|
||||
<div class="score-cube-container">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
@ -152,12 +153,16 @@
|
||||
}
|
||||
|
||||
canvas {
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
canvas.hidden {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
} from '../../core/savegame/constants.js';
|
||||
import { squareTexture } from '../../core/savegame/imageQuantizer.js';
|
||||
import { parseTex } from '../../core/formats/TexParser.js';
|
||||
import { fetchTexture } from '../../core/assetLoader.js';
|
||||
import NavButton from '../NavButton.svelte';
|
||||
import ResetButton from '../ResetButton.svelte';
|
||||
import EditorTooltip from '../EditorTooltip.svelte';
|
||||
@ -224,9 +225,8 @@
|
||||
|
||||
async function preloadDefaultTextures(info) {
|
||||
const results = await Promise.all(info.texFiles.map(async (texFile) => {
|
||||
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 @@
|
||||
|
||||
</script>
|
||||
|
||||
<EditorTooltip text="Click on the part to cycle through colors. Use the texture button to customize textures on supported parts (vehicle must be fully built first). Changes are automatically saved.">
|
||||
<EditorTooltip text="Click on the part to cycle through colors. Use the texture button to customize textures on supported parts (vehicle must be fully built first). Changes are automatically saved." onResetCamera={() => renderer?.resetView()}>
|
||||
<!-- 3D Preview (clickable to cycle color) -->
|
||||
<div class="preview-container">
|
||||
<canvas
|
||||
@ -398,10 +399,14 @@
|
||||
canvas {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
canvas:focus {
|
||||
outline: none;
|
||||
}
|
||||
@ -498,10 +503,12 @@
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.texture-btn:hover:not(.disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.texture-btn.disabled {
|
||||
opacity: 0.35;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
globDirectory: 'dist/',
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json,tex}'
|
||||
'**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json,bin}'
|
||||
],
|
||||
swSrc: 'src-sw/sw.js',
|
||||
swDest: 'dist/sw.js',
|
||||
|
||||