From 9872902e4ddded17ce604feaa0b1b9d034e8ca6a Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Fri, 13 Feb 2026 17:29:55 -0800 Subject: [PATCH] Actor editor (#24) * 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
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 Co-Authored-By: Happy * 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 --- .gitignore | 1 + README.md | 26 +- package.json | 5 +- public/images/globe1.webp | Bin 2194 -> 0 bytes public/images/globe2.webp | Bin 2260 -> 0 bytes public/images/globe3.webp | Bin 2278 -> 0 bytes public/images/globe4.webp | Bin 2264 -> 0 bytes public/images/globe5.webp | Bin 2226 -> 0 bytes public/images/globe6.webp | Bin 2148 -> 0 bytes public/images/laura-selected.webp | Bin 918 -> 0 bytes public/images/laura.webp | Bin 1018 -> 0 bytes public/images/mama-selected.webp | Bin 964 -> 0 bytes public/images/mama.webp | Bin 1016 -> 0 bytes public/images/nick-selected.webp | Bin 876 -> 0 bytes public/images/nick.webp | Bin 986 -> 0 bytes public/images/papa-selected.webp | Bin 948 -> 0 bytes public/images/papa.webp | Bin 980 -> 0 bytes public/images/pepper-selected.webp | Bin 844 -> 0 bytes public/images/pepper.webp | Bin 946 -> 0 bytes public/textures/CHJETL1.tex | Bin 4235 -> 0 bytes public/textures/CHJETL2.tex | Bin 4235 -> 0 bytes public/textures/CHJETL3.tex | Bin 4235 -> 0 bytes public/textures/CHJETL4.tex | Bin 4235 -> 0 bytes public/textures/CHJETR1.tex | Bin 4235 -> 0 bytes public/textures/CHJETR2.tex | Bin 4235 -> 0 bytes public/textures/CHJETR3.tex | Bin 4235 -> 0 bytes public/textures/CHJETR4.tex | Bin 4235 -> 0 bytes public/textures/CHWIND1.tex | Bin 4235 -> 0 bytes public/textures/CHWIND2.tex | Bin 4235 -> 0 bytes public/textures/CHWIND3.tex | Bin 4235 -> 0 bytes public/textures/CHWIND4.tex | Bin 4235 -> 0 bytes public/textures/Dbfrfn1.tex | Bin 16524 -> 0 bytes public/textures/Dbfrfn2.tex | Bin 16524 -> 0 bytes public/textures/Dbfrfn3.tex | Bin 16524 -> 0 bytes public/textures/Dbfrfn4.tex | Bin 16524 -> 0 bytes public/textures/JSWNSH1.tex | Bin 16511 -> 0 bytes public/textures/JSWNSH2.tex | Bin 16484 -> 0 bytes public/textures/JSWNSH3.tex | Bin 16484 -> 0 bytes public/textures/JSWNSH4.tex | Bin 16511 -> 0 bytes public/textures/jsfrnt1.tex | Bin 8325 -> 0 bytes public/textures/jsfrnt2.tex | Bin 8331 -> 0 bytes public/textures/jsfrnt3.tex | Bin 8325 -> 0 bytes public/textures/jsfrnt4.tex | Bin 8331 -> 0 bytes public/textures/rcback1.tex | Bin 16524 -> 0 bytes public/textures/rcback2.tex | Bin 16524 -> 0 bytes public/textures/rcback3.tex | Bin 16524 -> 0 bytes public/textures/rcback4.tex | Bin 16512 -> 0 bytes public/textures/rcfrnt1.tex | Bin 16524 -> 0 bytes public/textures/rcfrnt2.tex | Bin 16524 -> 0 bytes public/textures/rcfrnt3.tex | Bin 16524 -> 0 bytes public/textures/rcfrnt4.tex | Bin 16512 -> 0 bytes public/textures/rctail1.tex | Bin 4227 -> 0 bytes public/textures/rctail2.tex | Bin 4236 -> 0 bytes public/textures/rctail3.tex | Bin 4230 -> 0 bytes public/textures/rctail4.tex | Bin 4236 -> 0 bytes scripts/generate-save-editor-assets.js | 429 +++++++++ src/core/assetLoader.js | 139 +++ src/core/formats/AnimationParser.js | 212 ++++ src/core/formats/SaveGameParser.js | 29 +- src/core/formats/SaveGameSerializer.js | 60 +- src/core/formats/WdbParser.js | 41 +- src/core/formats/index.js | 5 +- src/core/rendering/ActorRenderer.js | 903 ++++++++++++++++++ src/core/rendering/BaseRenderer.js | 258 +++++ src/core/rendering/VehiclePartRenderer.js | 195 +--- src/core/rendering/WdbModelRenderer.js | 240 +---- src/core/savegame/actorConstants.js | 715 ++++++++++++++ src/core/savegame/constants.js | 4 +- src/core/savegame/index.js | 19 + src/lib/EditorTooltip.svelte | 63 +- src/lib/ReadMePage.svelte | 8 +- src/lib/SaveEditorPage.svelte | 76 +- src/lib/save-editor/ActorEditor.svelte | 512 ++++++++++ .../save-editor/LightPositionEditor.svelte | 30 +- src/lib/save-editor/ScoreCube.svelte | 9 +- src/lib/save-editor/VehicleEditor.svelte | 23 +- workbox-config.cjs | 2 +- 77 files changed, 3516 insertions(+), 488 deletions(-) delete mode 100644 public/images/globe1.webp delete mode 100644 public/images/globe2.webp delete mode 100644 public/images/globe3.webp delete mode 100644 public/images/globe4.webp delete mode 100644 public/images/globe5.webp delete mode 100644 public/images/globe6.webp delete mode 100644 public/images/laura-selected.webp delete mode 100644 public/images/laura.webp delete mode 100644 public/images/mama-selected.webp delete mode 100644 public/images/mama.webp delete mode 100644 public/images/nick-selected.webp delete mode 100644 public/images/nick.webp delete mode 100644 public/images/papa-selected.webp delete mode 100644 public/images/papa.webp delete mode 100644 public/images/pepper-selected.webp delete mode 100644 public/images/pepper.webp delete mode 100644 public/textures/CHJETL1.tex delete mode 100644 public/textures/CHJETL2.tex delete mode 100644 public/textures/CHJETL3.tex delete mode 100644 public/textures/CHJETL4.tex delete mode 100644 public/textures/CHJETR1.tex delete mode 100644 public/textures/CHJETR2.tex delete mode 100644 public/textures/CHJETR3.tex delete mode 100644 public/textures/CHJETR4.tex delete mode 100644 public/textures/CHWIND1.tex delete mode 100644 public/textures/CHWIND2.tex delete mode 100644 public/textures/CHWIND3.tex delete mode 100644 public/textures/CHWIND4.tex delete mode 100644 public/textures/Dbfrfn1.tex delete mode 100644 public/textures/Dbfrfn2.tex delete mode 100644 public/textures/Dbfrfn3.tex delete mode 100644 public/textures/Dbfrfn4.tex delete mode 100644 public/textures/JSWNSH1.tex delete mode 100644 public/textures/JSWNSH2.tex delete mode 100644 public/textures/JSWNSH3.tex delete mode 100644 public/textures/JSWNSH4.tex delete mode 100644 public/textures/jsfrnt1.tex delete mode 100644 public/textures/jsfrnt2.tex delete mode 100644 public/textures/jsfrnt3.tex delete mode 100644 public/textures/jsfrnt4.tex delete mode 100644 public/textures/rcback1.tex delete mode 100644 public/textures/rcback2.tex delete mode 100644 public/textures/rcback3.tex delete mode 100644 public/textures/rcback4.tex delete mode 100644 public/textures/rcfrnt1.tex delete mode 100644 public/textures/rcfrnt2.tex delete mode 100644 public/textures/rcfrnt3.tex delete mode 100644 public/textures/rcfrnt4.tex delete mode 100644 public/textures/rctail1.tex delete mode 100644 public/textures/rctail2.tex delete mode 100644 public/textures/rctail3.tex delete mode 100644 public/textures/rctail4.tex create mode 100644 scripts/generate-save-editor-assets.js create mode 100644 src/core/assetLoader.js create mode 100644 src/core/formats/AnimationParser.js create mode 100644 src/core/rendering/ActorRenderer.js create mode 100644 src/core/rendering/BaseRenderer.js create mode 100644 src/core/savegame/actorConstants.js create mode 100644 src/lib/save-editor/ActorEditor.svelte diff --git a/.gitignore b/.gitignore index 224c138..d867904 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ isle.wasm isle.wasm.map isle.js LEGO +save-editor.bin diff --git a/README.md b/README.md index 22bf53f..d1c56a9 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,25 @@ A custom web frontend for the Emscripten port of [isle-portable](https://github. npm run prepare:assets -- -p /path/to/your/LEGO ``` -5. Start the development server: +5. Generate the save editor asset bundle (requires game assets from step 4): + ```bash + npm run generate:save-editor-assets + ``` + This extracts animations, sounds, textures, and character icons from the game files into `save-editor.bin`, used by the save editor's 3D previews. + +6. Start the development server: ```bash npm run dev ``` -6. Open the URL shown in the terminal (usually `http://localhost:5173`). +7. Open the URL shown in the terminal (usually `http://localhost:5173`). ## Scripts | Command | Description | |---------|-------------| | `npm run prepare:assets` | Set up LEGO Island game assets via symlinks | +| `npm run generate:save-editor-assets` | Extract save editor assets (animations, sounds, textures, icons) into `save-editor.bin` | | `npm run dev` | Start development server with hot reload | | `npm run build` | Build for production (outputs to `dist/`) | | `npm run preview` | Preview the production build locally | @@ -56,14 +63,20 @@ isle.pizza/ │ ├── App.svelte # Main application component │ ├── app.css # Global styles │ ├── stores.js # Svelte stores for state management -│ ├── core/ # Core modules (audio, OPFS, service worker, etc.) -│ └── lib/ # UI components -├── public/ # Static assets (images, fonts, PDFs) -├── scripts/ # Build scripts +│ ├── core/ +│ │ ├── formats/ # Binary file parsers/serializers (WDB, save games, animations, textures) +│ │ ├── rendering/ # Three.js renderers (BaseRenderer, VehiclePartRenderer, ActorRenderer, etc.) +│ │ ├── savegame/ # Save game constants, actor data, color tables +│ │ └── ... # Audio, OPFS, service worker, asset loading +│ └── lib/ # UI components and pages (save editor, configure, etc.) +├── public/ +│ └── images/ # UI images (menu buttons, tab icons) +├── scripts/ # Build and asset generation scripts ├── src-sw/ # Service worker source ├── index.html # HTML entry point ├── isle.js # Emscripten JS (not in repo, build from isle-portable) ├── isle.wasm # Emscripten WASM (not in repo, build from isle-portable) +├── save-editor.bin # Packed save editor assets: animations, sounds, textures, icons (not in repo, generated) └── LEGO/ # Game data directory (not in repo) ``` @@ -79,6 +92,7 @@ Alternatively, a [Docker image that bundles the runtime with this frontend](http ## Tech Stack - [Svelte 5](https://svelte.dev/) - UI framework +- [Three.js](https://threejs.org/) - 3D rendering for save editor previews - [Vite](https://vitejs.dev/) - Build tool and dev server - [Workbox](https://developer.chrome.com/docs/workbox/) - Service worker and offline support diff --git a/package.json b/package.json index b3b2200..ab6e622 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && cp isle.js isle.wasm dist/ && node scripts/workbox-inject.js", + "build": "vite build && cp isle.js isle.wasm save-editor.bin dist/ && node scripts/workbox-inject.js", "build:ci": "vite build && node scripts/workbox-inject.js", "check": "svelte-check --fail-on-warnings", "preview": "vite preview", - "prepare:assets": "node scripts/prepare.js" + "prepare:assets": "node scripts/prepare.js", + "generate:save-editor-assets": "node scripts/generate-save-editor-assets.js" }, "dependencies": { "@floating-ui/dom": "^1.7.5", diff --git a/public/images/globe1.webp b/public/images/globe1.webp deleted file mode 100644 index cef69ca80bcf3abe3413af0ac69cafabfa9c05a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2194 zcmV;D2yORLNk&GB2mkA!_rgX102Pk(7ohf(nQka(8$4`41hI0~4Vn z2o8(|Ew7koX;ozm*ej$ z`5(*H9kV!hdy-3S>XoG(5KXlfu z#t^jD)+Htk1z}p_U;$4F8vn7S3`c|`l_ne2_RdZUWuMz#9w~=ZtCnP37n=~f)%@q5 z|NIk|2xP7q|f?! z-y`}D!Hwjw%}KZ3Tipq2U>Q=M@X!CAcJ&{%|3}ZO|MLUaw`dFVFSXV6ZQ9K!?#w3U zEMb^%=0@RQWOjqA6f=$Yg=D0&QgW=0Y*s_7l%*rSGaVBvL_#_XfLoW$5<#K?@iwq) z#FE2=Aj33FNTg%}q(kLlvN5ZXKE^k3NRnV;(~QhXTjaLPm}g8a62t^&$!0MOBOWp# zXQ`O40LtN30Hn=Q8OUx)B7ECC>t6R5$ z@)9}iY6KQ>UO0@0oG8k}7!U+yrzi6*kvWeyZ4IZ)SCc+ zvduKGhg)eZ^M#RhtX(iaR=FP>>sXlx;OWr3QurUr1PQR&CE zm}Dp6AJRqZ&1*aTLgVb|v%Th-cH5b=gvzS0dW6CaMJ#&v>`Z9zp!dqw8c+8wyTBf> zjSUsSW%Z~o%7NmWdH@cv-OOI!gMFo@v$a+hVTZGDT4}iig7B+O?xFdj1F{=&A@Cf7 zuTcl}w5kuXH?-TKG1`V4$} zk@i)EfC?1wv+7j~5S}V=5U{v$0DbuCfLSY=AJBuZbWj6ZoU3i;z}}*nDY1lLoysq` zU5cHVmk^u)j@|_GZ@-$u^QpAbs5H(hD9oVtRiA5(s%;cR&7rq65)&2D2m_MPWT}q`YTdT@KA*%EsTsP%VKl+nz-#vLl_M}s7U7=6t^EGW_D}l;r zWF>MY(ZE*<4jg^&_51JMzQ&s;p1KI8Mp?t!YA9mAl}c^F^T%~j=7mkV_mR9dMcKO` z*5{LVf6QOMeR5w_TKNIC6;Nv^Knq2I*zy5JQ z@N0dhA%aGupGT%{HS*HtHr|Qty~;{J79PEpUq5;C`YrDN@&4_}JW#U0DuimUMFtw+ zT=zz%#`*F$;&GG)&Y!%-+mmm9J$|PmXa&raX=*B60nh+!rBSmRNn<5H`iu&cGN9M` zb8r6rlOJ*U$FJhkf*M4nF9_`Mi)6pm1@e+MotbM1tF%D>B#*7{P1t@lKLMhXzu9f& z{K?ftt({=|xke_>7ICt0fa0@ClK0xKlWj2f`PCelstZiXtyZ;#t`tC2&?rjeeYV+k zOq^I3SzaEFU3mY?mh5cq1*G2wRGPjb^i-KZSD|V^=&BZ;Cb1eg@m|KcE&JHs_57Ay zEx+R2Z#}4L?UP#_P(|8(Ru^TFtebQee=~G!j?%m+kNxiU$<`0PX2-yYb>(}2s#L&8 zH#4JZT|u$Trv@=`O_H+jnlvwu2T&Tn`r4a&?L1O7mXYUg5h!0k4KFfvQzVKE#m00D zh!Rn93|hZqtq*!_(5e87t@0=5l~3_%8aqLx&NI7dOgH{T?{w}SG-=k8BmB09ULz-1 zo~t@qb~^AeV8H)0XH8 zJfAD`d4+9cwxKVe{JtBRt9Ozu>V3ynIHi6G-jIREW>q6s_k+MnC-GNe%eJq zXCL9tgXO78XR%1)K(lt{AJNc0GY?MV za@{!xIWXK8;;v&;W}CH%)5P{`$VmVeh#789S1^}1PK8Vv>Fz~@32eLx9v zA#Y;bte2ea`(lkPtkq_6{#P`HK zQQq{L<*wtS6<4B<7d5eS1>DS%m||WsG0~7Z>{kvQBP)Y2kwjKCRQ($?u3&(U2owT$ zi^u?~F$AK^oqT^>g+pU-DMpLvY#UO{M_vy)9CvDOM*)ofJHTxqIP$yoFhqxh+fD}% Uzz7?dWn=;41n!DQ#)jAx09eB~-2eap diff --git a/public/images/globe2.webp b/public/images/globe2.webp deleted file mode 100644 index 5bafe2c99df25dce80ff5a1469d65881185c9569..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2260 zcmV;_2rKteNk&G@2mkA!KjSt4z@)+5{R6mz@2QO4TsPd} zdBc%x+orR>ZU2}+S(w?43$j390aPpvODCXWeCPt4aASrDJzmy;JUk{jigE++xbkm-%?g@7rv zp$X)a%gF!#FU%YOnEH9&Bl_<^lGM1704-;HNs+$r(Lbhtyu*e3O@`gB;BOi~{ySCa z`)}{JcIXGqpXx`qcIh{J<*eD{92Y%W(5W@Eo45X?GIgx(u>8_>=6c zV{wHjz@P(-Np@XE*lXFXA>m^78{ z%6;l2oLyW3APuRmOcCVA!q+Vha+c5(7U%2gkAaR<00OWqn&V#cY!g&4($c=(=7U_PE%X#RP10nwi>o7`Wz1%sW@c5|s`D7h z$Y-e?z)r*!AwjCcPV-Ue@KeF7Y75Wjs44a8PY5I>nPS%U3z@^M*RK5m08bf=%BQ;t z!5OPnqMHses~ld*?@6 zy&CK}2DhJo7BOaKQaLMAu8oz5zO<5Y^z}NSQ>FMrPDH5>-3SaL^2^^pJBjdRM5~dM zxg<>tKmK8zu3CkK&elYJj6~=dBD$?I6Y=+xsMVkGgftmcpp5e4uhwZo>P8B52(Yqf z=*T~b0yXv{Wtu<*Y8?2dkx_v_7<;ibyOzy_NRyQ?Tfe_oi%7tW82jrWUZ0$2V>JSZ z6Qjn)vdNUUMk;2gxhVd9bjPB!+I~xY^<7bto&^u z*IsidW~NT0>Zr1TL(=j7%f8UUg>mGBvCUDIuq$~091d&8PbNRk7jG660SrygE6HTTHc%kS02<#`1qGOQplH z*&87IruL^#9xm^FKY0?oS}Ym62GGT>&8Z>OX|&c>uZm;2=IaiX+nPgowmn+jTg)F~ z!n?ox_A_v96uyj1YoIx^g49)ZwY6d=tpTXXE98y+>BBqUPaZy5PJX<*n7;ugYTTUI z<^;u3N1f7?O>4Exbkc@$B&Sc7_s+q|{O$uAH{sc{%qk=qkuf8#;i~4!A_v3NxhcdD zm=5T1`S3pP-6bg!!`thgf59^fwC{B-ZlV)<>vXY1H1 z${<$wDykAXf6k8GTBg&)5&cW9@rF;&Uq0c(<@w^l1ZIBsJa%a?r^>ADAhra?>Mm03 zSi{mJg0?kDkM&f{ERe6hUvh%^j}xPsNX+e1P9G0?afj$2^ru1<9YL3NZx{sHUuNX!E z5R>b(EYYKeIh$J~DZwl=+JRCw-nc%T)*fEY7t0CM{&Jx_xYoph7pgT<^ZYE^1R|O( z<6%*yWP-g59$C}7!C~#+*q=`3z=Ss4V-u+c@o;dUpsP*BoS9(CqYK&OMl(ruZqd}M zLBhMSPj$!o)XV=NB)?WbGpw8NG|Mxalh4F|Xj||gFDtRf+ziFs@}?wSPcdKInaAXs zARDUfunuiAmOH-1Rv;K|%1Se(9_`gWtq;@5s|n_Kg^-7$AL0F)dk4cND=jxO!L5c{ zB*Wa4M%#pK?a{Zj@AA$ZIObKpb4m1fE?mA~+mXtOpxC(GYAd9r@n)Eps4RHCR}UE8 zRr44?c!}iuBrej@+xXy8c(KXk-kmLC2fW#AtWlHOLCxly^HrkFd7%@gI%TD^=i5ts z>%yg@Fx!mjcK;pRMKhI?h0!drMbqB%Cuzczq-L3I-+kxeQP^zc`9X4r04UBu<03Cj zrHN5tDrnML#VMpR70P0ZBOGowcCie=Mjs=`d@ZA~&rQ>R3EhWvwkspS1=1 zz<~(oxM1s;mo;J6Nl+r9oBES@Q@BW~tjQXN$L`U$@+IoVlF*w(@wJF9){-4+ch=~H iI~d50yI@bA!XX7}MZB=>y*}dOg_GkhWiCDB` z!rI-x-!$CDwyi4ryW{Q-Aq~(rMUXONp1HeT*R^e%&UyFT^XwNp1!ScA^x*v2w(a?k z5HW&mhy#Zp0GS{g&Vd7v1JN+OzhJbOo7`O`?WHg;gOnT4xmUmY&G&XisTT;7@I!tH z;4xDkzG>Pdk}m$p^G$U`bf+#Xj96TEySh z(OGd>_{~05?PB1xyW1#9yfae0o}$=a_p|BP!1FM*?zjB%1&ir~!Me6>Zrest14u5x zVUbOy7ZE+{-fg<>3T_})NPsP1c7=h967KmQz~Gw!)Be9-ME@N~k{UN$S|O0}k;LQ) zum33gYR6)J$D_RZ1BCx>;E%H3pP%3Cz?1A(@$Ahm{I=81v!#oH*UtJXPn(%4$>wk0 z{7Q?-($#7NjCrir;G&uRN}Xp4xu8Y%J(>r%-%$z1oz5;L)eT6VNAf-2g$M0?NF{x5 znRPiku<5En`T>V6%P0gh%Yda%PNeU3Ms;F>{6K~gBSsXPI0C>@g_3JAF`5qSxgLC} zRlEg*4XlTT9LV;rOm;C)*o6$jm?$at7X|}hVhy2D7_jd)iyh3wg^!S905sRcFX#cZ zB?6@HW{XZUU-ko*)T39dGpRsN)zZU2X66mR$XpW9dN_u@*5v?+-neSJEJti zV8p%>e{Pb7D99BG?Lz>TkZk)M@E6UX#f?-FW`<{R9<-}e&_6mviX$PL9?R_x!r(Y; zGL(^83=GTf4H-b}DmucUEm>sP-gpT!-f|2-5_yTD;TWdnhWfxSz@BvoGURe?uN%2# z?e51MA%-uSvLM{V3^e*YA|tm~IJO8nOvl%*7bww4RTKb3Xe4qJl*{7BJO(0t`VZqr zsimV-`SSwb-@JaM;O`5a^$H{gqWo_j#inVaN8<&@hX7jo>lGpM0ynSd=m%T@WW#;I zfL#`O)GlPymiLZUDF5CglsBE>+o%6+R;ZUFixgFen=v=3A2 zhE*W9(JS+9F=&D4p=gPTwQ@2dlE=h^2E*(9gBGNKRa`-7GlcBf%#4A^uAom*(>uyy z`XToh2#n`s8sbb^ zwikn#aceGztI)W}Sr6k|K5-@H>T#2|<48wVr%AQBwMuF{{HUlQOX94^qd1HLZ$V7f zCW1~qMc5>iqiKHN)jz_4t7 zerr<=ljpad{GT5`Tvhc4SUJOPPlD7!pgb*tlT0DaD{Pb4+ z{Ml*p_*q@?s!F($?^sb3BV_gm*<6LaZh7p>n8Py0b1RV!ug+@+ zLY@_6q4Y0-RHNaIci{&l1#Sv}$mbYY7^2=dLuXj69G1zday|%@D9Rt=$je;cU4CkK zZB|1_gj?Fj;b%OuQvUn`m7)==JZ8D7xmD!vnmFub+;@e_W~MT4BoRNQy!h<%VLCce znm8YZqok#3mDElttTFN?k|*yx?M;t0=hO>igg7|ZBLfZ&)#P*Ku8<6=Nmlj6htfKn zh<9PIYyekWZk1{K@rE-mWR4?t*K|`HSv<0b7ynUfNp)IZB-KVh;xNs|XU(ATy}mc2 z$I@daOGzM34${4^)dtom9sN8)vN_#UykcflrO3vIDX-u8K`2enq86;g^#cwg@S1#K z9{;=;ITs_Y@Zmz|*)jGH((zO@zKf;uQKKPdOkBv2Rl-p#Q*l;qPAl;E^92BWvA`wf zulLe}$t+{J?CWpddI%BW`ZADVv^?MxA^Vn;z$M!>Mh6hwnosLtS|9bpB&G|&9d*Bv;)q^Zr}F;4t<2?lFj*>XKKhLN1E zE>+t?*?|k`x$7@4tx02srQWDz^PTinYmLrD*67XKa?MM3Kw2!VshrMt342APGilOj zeVsy{3$4L)#pJRfqQk2}BLL>pjLFu9mbAfmX6TxX$N)PWZxrtQ<6WIRguekNp?nIU(J{x!D4nhCuE0D`G; AiU0rr diff --git a/public/images/globe4.webp b/public/images/globe4.webp deleted file mode 100644 index 12272ab4ac00db654ddceb8b791227d2f4e48bbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2264 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 AApigX diff --git a/public/images/globe6.webp b/public/images/globe6.webp deleted file mode 100644 index 6f83bd2cae4f9f6c8495e0d7ab4491cb27ffb779..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2148 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 diff --git a/public/images/mama.webp b/public/images/mama.webp deleted file mode 100644 index 55c51bfba5ee7af364b1c2d6dc3e2f06e29b0989..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1016 zcmVJDZ_W|$Q)Nb0}F@AOagM9!$Vm%qXiai8BRed-ASUwql zUB7C*sB0AR+6{FVIq|?!SN}WVTYG=Se$E{vFlA<3qOgfk6!BHJoZdkPRQW7|Nb2c~ zv%}=7;Wlpz8!l_baJ=E90~;#{fhRZHsHc|z0RH)ww)YdqP^eLddYvc^ZVnv^x8xE_ zFoI7?R$GyNr);EOa62~9PuxaSI!?Is@u$#5p1}Is_(?Z>LtTYQ?%oD|6d6~+{Dl52 zfMM;1Djh;<|2$QM)OVPH*$POB5wteCKaS_js#uJKJiekXK3>n#KWe!^fmK`Dw+d@R z%R8fT3s<}=Q|LAR#FgegyK76;YUBPyv0HTgh)5sOpprLI#scaHpM#uzf#;gJ0a3!u z!o%<`Ni+STZxssUrRLu>|Hb+%H(c-!m$QfoOJr_nlOsEUR~wYh!2Mlk}Nzauvsp!=ucM}1gl5#B%_*1P!n#= z^Tx4cSk`ZpJCq2K=WZ~tED#N21{ZiMqG1t^ z-i>2V#`B>~nMw5hgTLT0b&ma+O`|wwA8OP4CG#Wq>%Z-fzA(o#`-8L0XHD+5QyL)_ zVh`uMtET6_)PgO6k&80_UI>bDJ}`5l4Zcq~iucX1GFTED!y`ddF(;KY6b@W82jzCH zsp`Vr$r#S8_tM5L?Jy_CkYR2@ubq0_ih1F>wuC>xUh@k#V&%cu#|t>IoU;YjeuVsR z^}n%*K0JdyJYv@8|MLSpRH}`k>awlVXAl03{v5DW`mx zwZHN<_nZ~+C7#j>D`otIM>f50UW_Jl+|wkdQ&fl!oxTxnfs>cWs(oftO&_F)&MxS; z>(0dJ#Wgi?yNBV+rj-)_%?irgdeW5x5@NiXp1+y-Q9P1@AGUU2>?R_l&gM(eX)J$G z9$>>8m9ZO}H}guNyBuUTw+8T3>}4$;`vzf@mc}wDnyp_7M?f&@G~Zvb1`Oq#0Hn4` mz{zv{ZRa5FH4lU$Rft7&v<03zYy+-cnU1C;$M6Xzh*w diff --git a/public/images/nick-selected.webp b/public/images/nick-selected.webp deleted file mode 100644 index d6e626c307de86107b1d8180fc56b39855ea7040..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 876 zcmV-y1C#txNk&Fw0{{S5MM6+kP&go10{{RJ5CEM4DklIg06uLlkVYgTp`k7~3ow8U ziEIF!IYCqYK>fP)J7UJeY~MV0_zzUO&>o4uupXK|%AQacarmO?uXr+n;0GI?0_Jbd zqwasW1kivE`ZieXbk3W{Z@UXxm0+IAI$^`vi8p#tJ^VI7Apkh_QkE z44OEeVj2aQgYIPr?$I!Ud3XwMMr z@-3Qb1GC5pD+F=l8$MgLMNvco5N(C{P;pmvtJT-OLSgxDBc?Q+i@EFhqgc=Kczf`K zv_4YLOBk<(oPyVz{s18!A|Lz4wBJuAwwLZedjGE}t3xZIU-9dA-woFjp(NPczg4&p zBbq9e0Pq^Z%Mr8ry=VX>`PQw$SG%&d>^6$pEBVaQ##f~#TN!o7V$}I%>h)y~&&FgK z6sd`}^);jhmf+Zd$5p4KJ##dIG%&f-8l#87b(hllO`Eoa-)bnAjZyG&vfMlP5R;D}e3S30Ux znu86a3T?Z2doxlz}k&w=U=QoJzgC95nZ!fjL&s;lGGf8_@GFiw}BfGclH>-CWRqU$KpZ#Kz7M3yw-QmY|4KxQ#IcI?*mWF@M)D&I{KRuvjnd#A#{1M``{ zX&~-kufWe(TN2z@Ww}wOai=T>j+I0!u5M_&{ZQ2_V&fW+46=y|B z($nqYEdhU~tl7tc`xmqIxhkYkPZMGnaRBmC)6}1qlLQx;%2B8_dcB!ObhFRw-|Kf$ zt44^q1E^L*U)_i}^*jgJfdhnubt}{h@8sk`vNre)7Lj(}e3;rbCZfK)?VF2Li8_-$ zB`Hfz0pYk0b92%2F1RFomK0l&9SFU0I-R=zfffv_iZYb`T)qoH3f8NC0oFXwbw%d~BYBnXjelhfg(o{(Qi}!fLtA$LZ056s&5WVjhJe$y zB)lNGyGKtWJ~3oR%c5Uq@KYts>6}Sq1Eu+a#Wq)?f5T$^0RsUGozT`irH9n2o2f{3 z6xcSY`}{80Gd+kd3POi=mM6F*GI-3F-LSV^B~K2E`<3C!u*-e@JV1A_xr<|>^090^ zA0Zhq%%C5h=FlH|w$#ukBvv)@z2BSA(qT&EFgKq`-SJrbjsZR4lU@eyb!o)*DvCQZ z(ehY?_c+pIeu)Cb{vJ2pFuOofH|%7DnW~gJk6tL0**a#hl~^GG{AU(O`Le`Lgtt|a z>!Hs$oz_>wLa}9oHoXk~AHcCdd4rjDIR@x+L(|m>L*TB?T>-RN#`X==+c3`B)G(PFjdwEE#U_~5QN-s#);S^AfN`meZL&sS#u z-#F}MdjsvMv4Ilm+P08qckd^OqQ!}v?Z!l*Ud4tf(h0(E)@!6PhK{?jDf%(>#u-=M z5VpjCcgM{Gui0auu>vX*y_XENS*TYM0B#w@S-)CrqIOw_GyviJv1D7<8>uiID zytwR<8V46C%0>kgv|g;v75g8LOi^-u=D+qs|Bd_KR^s!2ocA;{mT?M3<=^_}^uizA IF~wj20AD`xzyJUM diff --git a/public/images/papa-selected.webp b/public/images/papa-selected.webp deleted file mode 100644 index cc6b2213e83ab4bb63ba554049fdd71f55cab51c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 948 zcmV;l155l;Nk&Gj0{{S5MM6+kP&go<0{{SU5CEM4DklIg06uLjkwv5;p_;0!06+%B zwg66X z1GAb}WszNPzR16nzO)Nnb7KY!Xv-&v`g(GQo>0HOU9(ff+2$4cEeS%0Sp%AlMWuW$rG!wZq zF$gR_DBzjXYu~pn?-PEKg6k^+PCeacWHQ$Y3;SlT8x2J}U1v*0d9$nE|&vv3W^Lj^~{6jMD^~s9Ma9N-jm$a}5mF@(0=nuOJVfPJu4DnIP zRF_T*5UG|$mDBniaM$NSn2!bDckG|>-2=EkFYD{}k&jLB@1uvAo)hzB7RhTZfUV0K zq(98IAMdcvFU{)T=RUkcVZH#^Nm*w-HisDP?cUt1SNjIJBlKqJP)ufaM@ZjcOc$4R zKry}VMWc*IUx(+i;=$HG&|!i%v6w!ZSGJGmDCW1109lENsdNaiSr`-YC=^m)B zBCE|+2JuIaIb{&Z2QM*^!A2E=9pl=rJSSe&9yshAjk38IY54#5M_WJJ?R0k4DjuIw zI87*UR}>1EQ4lFT)({frUkFi1a)RijN-Z}1RCm14>Vazi W|0!}?kGe^x+}gj_&$m1aNB{u*cGB|z diff --git a/public/images/papa.webp b/public/images/papa.webp deleted file mode 100644 index 83935244cb85a6d520aef00a290e76be825c4c14..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 980 zcmV;_11tPeNk&G@0{{S5MM6+kP&gpK0{{RJ5&)e6DklIg06uLjkwzpUp`kI>Y5;%@ ziEIF!GQD#T%6XAepS9}|FvGgAFiI3zRn(0m5jN~^hA)# zE_G{*BX zcnE#3Vqg9C}aGj^)+Wy=FlZ7;$&VL-d04%?jIrfB?I9+q4 zM5EI!|EDwPP~9TsIZ@@vVc;QaS8M>lBpjD0oYTlt-RT4lUZs!7{P1nXURyJ^tW z?G6rrVC>ukamnZY5674Am(+rW$h?STeEe)=l+k>)$mnb-uxh2Gca%&ql3M6GNM_Fm zE2dJgAx)>i0VJ@Tepxx%jy7r407)VzA(cuiyAmY_eQd@$WlhwN93sNw zCDC00R5R(I*&qL~_+=5QwV>&y1j@#$U$Kco%83TWzX}c}(|NF~0FW9cROmlz+>FOd>B%4W_zSGn zV-^1eN}quy`#@}K_ua&FHnc(iK89U4%CScbgn7p?%W~Rc9wfWFAvGDDQ~pxy;yXR7 z;hah+2#08rR+2EZzx27huaGN2Vv)qG7t{YF1?++aMc!5265bS>F8?hoe>kxmy>C;T zI!zRk<<;v53szn;FEBHe5EW)Ua(*wwF`VZMIEzC4mg<;Df^J56VmhxCm>RWiAOHYN CH{0g` diff --git a/public/images/pepper-selected.webp b/public/images/pepper-selected.webp deleted file mode 100644 index 1cacc1402e2a0f219218462bc9c6e6f90a5e19b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 844 zcmV-S1GD^6Nk&FQ0{{S5MM6+kP&gns0{{T94*;D3DklIg06uLhk42;+p%)fpKnBFN z08X5ssNWF2@8(5aYPu8dz;Y|hy?W0092<#_i|rV)S+=^Y~m6^yxpRnGqa8yW>gVPEw&8dliGJW1TzhotmtrMlA<& z*=kE4jA(boq7pn5pa3mM96)vcBmgTX(2*as?uyI_NmgMtCUw7kA#KiYobY-7a`u-L z>wi$49|TwbsIK-h{gX6awJ1{!HOQ={)M@KiMit;KiIw3$ium>nZuOE&mBb&VWB_tn z!4wZns(acD7IJ018sYIb=q|I{X=2i}hdHAP`Uqm$ETf|O;Ha;$(suM}61SsoM!ZWxE{49|m9RZV$UxX$_8OvNlkGp5ATVmeaP9+coW zaR)(=a-KZDVJ-=gxl~q$4}Dvt#3gtAY#7{=*iRkTRi6@qa90=duf+!%?lw>)qW2${BQ)DM`u&zlHtc?(5i}V#u z{_9>B968gsgM&Yyg?x*(>vQPfP9~4yrA||DOdMf#7emKWCC;R+i`NkAq8nqjs4%WE W$WSr6nvtmsg?QXDu>)HH@&EwzvzC4U diff --git a/public/images/pepper.webp b/public/images/pepper.webp deleted file mode 100644 index fdf9fa102d05805d9e3bde725a93067a1ee47623..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 946 zcmV;j15Nx=Nk&Gh0{{S5MM6+kP&go-0{{T<5CEM4DklIg06uLjk42;+p&0IE06+%B zwg66?psJq${>{ci%1;$>-qCs0^&hsk%$J;xNC!yQ><6$1><6ZQvS;lN?60&0PeTCg ztuQ4(_NV)|s5h1Gv2jAu$uXFA$p1lF0g>TPmSfrwSh59*9_PLAeC5Mj|C}n}pTv~+ z{X)=UfLcB(RzSK+I}`u_{D>WMD{eUS!nQz@I`GC4uG0OhsPhXAV|2C9`PewXD=~CR6Y5Sh};&jMWwEn|704hJ5%n_CN1W9mv zoB2uvzPEm(?hnNo3EVv_xg~e#gIde_E=mWt7IPtnOZ|2%06lf)vBbc*^Nx}VlQUn% z$}CY@a4myyleoePQfWMo5OzNcYQI+*DJ+YuvZ=a8gXkJWYi@U$$d>?*? zx+VXIseW}#{*Wf2*uDU}D-X{@3^$_YlHBonOYb_CZ(=l5b}QO+nMVMm&?NgcD?5S{ zA9bTLkww5j;oyiV_Em~Fup++!YdFGa;$G|Vzg<0E%oi750G%oFbg6XgvW%3?!y&5* zDjub<;?~CY_ZOQ|M^4+`(^@TK+zy2aq}Nm5X|T$Z-Oiwf-MX+7mhGj@+^v?J>fb~k z;7vLgqWkL0dOzLnDk!4o>J-`4tSe?i`NZDG*#fi;_UsexWAr|D++S~aH?KOM(ffVs zkrC%Q|HA|@^%KO7rTT1B<}Pb@t82t{1dg~~yCbx_-YHzIt7tUd0--O9Um0ne4Y*K5 z*J9^Pl=nAxi6cX6Nv25cCDhf+1B~X_0rs}1fF&5IJ+pmf2WT8lWdGRp!UpZ8GWrbb z`90>A$!Sup;v_@1Uu3#MqNcz$oq2szbFb`!{SaLar&mKm{`R)6M%JFG!^m`%`s!pN zpSpf{gp@FqwV2_fqG&>pzjb`oUiw9^>7r|Gl;M&}JZBvqkwt2!2PX=TU)pg=?%@X7 zDP0Lvty-hTG`&EtE0?m!FUgA9$rK z{4oax1`DE9ud2IiW@q-sVA0?jhUu=Zey=`z=A3ix9z9RzFK2IF-F*JynLDm%J^wj( z*XaF--aMZCl6Ot>BS;+fFP*%j)ge9m^sH8^-%Gb#E(_yJdLUwJ?m9z&09M1QG?n$jZ{@Ks&vLDiK~=|3%0`X2Ad)dES4e0r4_x^ z+Fr_*FqlJtHK?=&n_#1t3~+xqAQUr6!qyd6oJ$dEarG@M3WHdCn2LcQ)9B<-6z5ru z9kxgNsCs39sT3?WicwJ-0Sm`W%y*S*S5MU5=d(~lK~$2gUc!!$Pm^{8N|X?C+9}#% z0r`!IoPz?1kOp>|8b@rd8inc*Y+k0+YF)vKTst74yc=L}r7%S)i6+8ntG1cc>;f+k z4mhwXv4e56Ofjc(8VcCisj=jWK|gh>ZbvD4Nu_}m#|2ohL;^dtAu@*;k5J?Y=FOoA z9Fa<+W;2fBfMGM`U)xKd086<5tAwy@_s=YU0KXCye#@qz5W6~vQj*{w?|*Wg-rf=c zTQh3d)IvVB#$jBsK6I>XFTnsCsOQ%Bff!9)Ck;Na7Gnsd{o5oPR>+wTK@66aG!=EW z6qH=6T5K8$6OjxKSGbe@W=K1wLrN$BdKGG~oAM*a5tv;6_~b<#HoY^-jE*Ck3D_g$ z?Auvk1>%*xK$j%lelW(Rn_(1fR${z5uN2p@L%btNMqNu=ON{fEuJWPW{xCz*zgC)s zp*@oVH!|@>xRqXl4Ux|=#w4rJ%^{bxN^)ila_Ok&3|21q<=r6r_`zhKYS>0zsIj4hhC-0 zHfp2O&kU*eGCxh?U;sPXRg={j82r#mj6ct F+#jaJ=0^Yk diff --git a/public/textures/CHJETL2.tex b/public/textures/CHJETL2.tex deleted file mode 100644 index 64183a41b7625ae842520e915d686018bc5500e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4235 zcmeHJJ*yNk6pacSXJnFz^B!1OJmFc13SuEv3l{za#Y&LXRcr)>SIS~-V{awaw)+Qc z{4s)s#e(R$Uo)B68F^2z5ck58nIw~Q?ztz)o^sCJ#_!Sc+2ZB%Yfqj&c1Nf2zWly) zm(kuwOY!iBv}f>q$UTn7>#wizc7Wd=ew)qa=i05;>vx|&etUcN)B78%)oQU=ym{b0 zTsd?{S6|%Odv){Pm-7en`TXvF<8JL=LR@g};`P}sb_)D63iuF$|4VFrh;595U_wC= zM@Nm7|Lv&F+G>4hQmiaSFnl%~!`AoK5$d|O=aaS}6-J<{23h)itSZt(6+&H>e}GSQ zrJO=Wpoig8$BdqeG?tuCCE~>Ro>-T44F)-ZDofI`FQPImtOQ<6LwfKsv`||EPmg{^ z*!?d2SAaapdcn+Y3yuCBmtjii+#s@j4^FBACc(;UAH zvwi^tKMJC$Az4S8DvqMsv`D1BprTi30ZsEy=2NNeaqy)kL;PM}!HpMLUIuo#rg_AKEY9;k zHvX*14A?ioaQ`uu!sA)CzHuDnFT0utx!#lc_xC9jYaQ67W?e;0b^xMM;T(x1s_?ZZ z`NN5LoATe5{(}mTJqVe5v2M`XDysX{%H-_IQP>=nC$gh8c?+6ehqOAh(Rnoom&Yw|y zszLiL^lSK}x8$XT#+*8!y3y`016Qj(%LzrURe>0hk~{Q_cy#bY@uDdQ{X>T!2JSE!l{D* diff --git a/public/textures/CHJETL3.tex b/public/textures/CHJETL3.tex deleted file mode 100644 index e1e7196823d613c40d1e5e808714eb88edbc3c97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4235 zcmeHKJxc>Y5M8yARC1Sqg++u|i3(yNRsjotf?_4eX%rhlA*BRs8+$9Uw!J@KxDR`$*~QuF@yU_sPlMlk>xm^;_h7~A z;9agWV0S1YC_ndd1zsEOCfvi}@FN#_p5Hz_yxz<{-mMP?gDlIg_r?8kNAy?Dx0{z6 zyU$6x+wJbOQnA@uglk@ig|)`-zY^;jLv6L{Fkq4tH4?kQZTiuGV@3A)=R?GF!A_(C8MCQ@xfS={vx~|GU(4&bvVB3r+0zbo(j^r-=x|Y78YDmk3uG!O+?p0mKy;M=l&~oCBe5BDEEPs?H2w+_Y^gE%l>|C(WxIgf zHm7Zu^?V&?a8ybhWe*4J`~2SPhYTbC0eCbML<%{>Z7rmPGEEU&)F_BsH= NYU9uc82@-$d;$#R&Jq9s diff --git a/public/textures/CHJETL4.tex b/public/textures/CHJETL4.tex deleted file mode 100644 index 5a887db55701711a72163c0f4d4c1acfcb05a300..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4235 zcmc(hy-EW?5P-*c#DE~)UIG>t5n?4Oh=o`MEPMpTN|4hiHiAM*3D!3DR$^^?FJR-t z2o@Fr(b+%uE;qT|P0n-SZgyvP_M4ggxtjriU3^Y@7xDGw#@YEP^lRAeefD4#87O1`hxOf(m9o#Tl|^9x*-?BGJ(xKi@E4# zCe=}-4N^+2qGZW@v+MqWA4OphgenCU^Xs9=WDp2}`>|TSUPi^|O|vPeb=Nv=IF%Gf zEl3r~pK4BJto1ssWPPs6_|;vX z=GV+sq}Pd(Dp_L%Ws}pX4KUA7pFV+Z;g|D~QhbS)Z+RV!4a>PB7b~S;F!%f2XRLwW zAoPeDnAh1H$y-nwfn)017cu@nP%)n;z!c~3t?O^*EUfVzUVZHfyGBeWbY9{%G?^p8;$(>-V6|Nk{F fe0lL>e8O7{XiTW2y1e*>1DQg2Z9}(#bno9n!N;&uX>$y>!dv^5eHJKR%rQ`swy!v6#(f?;pF* z*N@!sjaT>g-`;uny*Zptrw+>G4DVs*wEgi)9U`wI7-Fo?B>sTc?{jZO|lah}!K zVSBWXs#gY>O2J~I7!{=vuyD-8d{?=4^+fG`J_|JzL?y}UCF}_KG-*emLwY>xiu#^k1N(jq#|IG3S@GDW_w`>{;v8#h9B?Uq=W*XSE2U0DL--?fywocPhP}f(>tTg=s2R8fIU*q zzMU0TAYR!EbV<_f2V-2i8Aj1&CC01sN^u=K#5KhFYo%Ej z+A}F|BNJbQTj?d(5cwQqOtKo?9CAskBxlAT_f7$0o2B#vLx~@SCP=w3l*UK)_ugN6LccSb|KdO9pIq3==tTK!`F}*f F{Q(Kc=1l+q diff --git a/public/textures/CHJETR2.tex b/public/textures/CHJETR2.tex deleted file mode 100644 index 04fa132e495d4ebec5302d7e5508009966f6c75e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4235 zcmeHJJ*yNk6pacSXJnFz^B!1OJmFc13SuEv3l{za#Y&LX)lN}(r7YGq_EutTyMMsO zA0t>;EQp@_HItd0k@o}(aW5>HNisR-o_mt)Dd*g6{2nczEndF3_T=efcXS%>%kN8f z8SQ{`wkk2l(ybx7loduHAaQe)sv~x3_0My}z+qtrm;Ln+NX0 zl|y%Q_4%E>S2yo{Ie##p&+qOx?$-V##0BRrUZ3q^r@%j>fDa+~zr@yu*v2RbCKMEL zbktb+-;Ua>t=5Mo#mZs?!)L=WY<+JXp{{FtK4}|LVFapbkfqPZsv=EPA=G902l!N1 z$|+<7dKf-+%;>2|W6AkcB2J9&iFH}mV2~52vLr40A}YheO5nvbqz5lU3$-=y^yp`l z9p-bEWRJd$J)u(!33N#Yu||=wj=Cgr1;~@EM@$AUOOZT2@ya!Mf?OeOkZ#b@V9|_# z?@R~}*qk8hvmiQy1;|_x+cY>8tgUkJ%3Kx6v(a>oKt7GM$}CWs$U;i#=nN7xb4Bz` zZ9<|17;N86k$y+1779v`X@{BV%Na+JNK@9gnXtj<>I&@ENLnePsx4^rGJ&r?&GE}H z>lZ-qqad0Zl6ADH;wY+3i$v-RDtd($&@}&KK9%Yo2VZJ3#P3yw7Td<>PZ*)ahi=M9 z*6ZP8P*xSgjq0xoC7Ic7!~s5Y5sxvP;nOA)arkLqQ_NDYGXcw3I4M-?Kkm)X;ynLj z^Jf3Ci8^=NZva5NJ>phu&f1g6J)`4AW)>Xu02OugH&XGu>3SWDY zKb(lSDgSNhKd1oNgFuSdW={pnPX$6HZo2&o9Eyo&3qBvf!8T(sx2ruz@cRnTMzZ?| z$P(E>N9YU>R_fIP?1*i40O26X%i5iPWB7dn_1Hg%{Q5WejsP(t+A08FCEZK!{29fk z8noX+zlKkGOI})N%&7ya8}0rwaJAaAoKWOi1voD~?LU(km6DkHkB7T2MO=Y&)L7{C lYi0?Fm(VF-fCvFML)}n$C}>M?f5V%@7TUEt1^zY5M8yARC1Sqg++u|i3(yNRsjotf?_4eX|z)mQcAG4v9}Uy+xr7H{usf+ zA|N_5`*AzFIX8E1P{D({ozM5)Z1zGjC4|_5`>=PKU7W8TpB#z)H2A%@o>+o)4_3Sm z-sL(2c84N@@^dd&;I-jy!aW=gKXQ@h`R&ug>&@)r-TGiK$g=EuU)(QuM1SRMyLq{> z`<%49-R@2+6`QR^xaNgeSZnWFCzU_*veq>#D340>TGdxrT6k2v8%8MXtaKow_^3 zQl2M-D&z#E8{+VnZ9B4P2HTd?U`l7p=L=;LJnyW?FeM3m_o}3A>*`qi8c(gn3N})v zN`P&faujNux`vMd5rLxtJ({=!w#|ql@H0H=NdAJkVX5Hd6Xpjpq?Qk5GXeknY&&W= zqju1I{y)9{>f}%K{CV#%y?^iszkg6uW5;<5j_5?-TaOMs2tWhVC`Q|+8Zu6+NsVs+ zf-?q=4j09xK>}2;K&Fzyt!dE&L{|ww35zm65}QHCQepH)|yQ+@o-0%E~~%^6HyxuLCfw MHV%D&@sFp)CvVx#7ytkO diff --git a/public/textures/CHJETR4.tex b/public/textures/CHJETR4.tex deleted file mode 100644 index 17cbca8d1a3319f370e712f9e74c02d8cb9103ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4235 zcmc(hy-EW?5P-*c#DE~)UIG>t5n?4Oh=o`MEPMpTN|4iNrzoV9U~OY>CDyk00yaL3 zU||suo&9s~a+BNLHf5g;Brk*IAh)hIb=rRHyzSF9mv3lh7%v{#5nO* zRmV&a$c)N`k3?w$bxu`vw@hSUlQtlfZc4hWFDO4Iom1Jl#h)3h8*(8m6WH9dn2T;^ zQXNIwAf?nQN|wwwyY3(OQ4|J2s8UcdzaENA27wT`AFJi-WmJ6LG@F82cdgTgQ%P~u zf>e?GspeG1+Rq}1p<+ql#Uhjwe-T&rNj__652Wjr^K}Yz=Bm7ZBvU3p*5|5>U)}X- ze$8A(dYve#k~LOPHaVTz0Q3Cx=@aM{emNg0#g}OLme@74vxlOmPn1y8dR)!W!QJW&m?Md}vWl{&}{U`U&H6 zX1p3!`hHY25g zKFo=_UAIcNITR4hm~7xMBw@v9PD5Gc!N70Q$OADYhK-4_K@gKs(V z=!HVtz>+Y)kX5LJ=mk=-V8(h>1!QJ{4v}VhFj`LJS202#II@A50fr^aPzf%?6-DU< zoy=@OFm!gea^oY?D{;^vM+KNF(E*`Q5Jr^cG-WDYoF;hiEN9k0rlQ7jBTc3BGZ4AN zUs5I)L8dtFkftP+oit@&{PyGmo5S`x14$wC29k;=2O}Qhr#Vk{OC?La3Sf?GtBCET zNrg$gvqmr*B%K`YJAJg4WcL5Je-|V;1Fe~U(+jM1cHS(6uG?0rwjQXeLeXh%mB{Lm ze3D+<-`kf2IDf*>Q_OVF*%(_oka#%@{F?*ya)`$DB~5$72SRSO_Y#Rv{C`kbtJ_A zciL{fiH!tNt5sh3sr|z}6}^oZ!JiwoY*^RdF9S45{5gml5(5IP+ZQAF{eIm!Ga@lQ zYr6PXhiDj*To&Pz0Tm)Bi)VWM@y*gTDp_7%89R16e%Z|U8I)EHWa&~7WJJazAfwu7 t6ljR7*xRaH9OCQix6I$&$v)W`@0Yil{@!GM-YZ*_Gcw#Q?JA%O`~#<(VO{_L diff --git a/public/textures/CHWIND2.tex b/public/textures/CHWIND2.tex deleted file mode 100644 index 622ec2fa0b5671750e17bdd3503beec2c5e648de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4235 zcmeHJJ&P1U5N#Evot5nm=MD@EPBY?)>HYx| ze~e&Yups)Xs%yGt_vVxn19AJ{4UQkc;Dp_$MfmSEA)2o*}`YJT>e_-7RGAq4-I*!tMFqd|O# zEwy+o1%K@9D0d&a9H-ij!0=`|hS4}7rSZwpU&%zstrD0{SF-eWZrbLNDx@?y2|m@8 zXa_@JfZ=mxMsICCDi@#AQEC+?<^bnh$L55RLAj?mL zfUhQm2XsvtWI@Ub7Qnb7_GwVK1bEAbM*;(NC0!+8mto8bFjbKSztAZwNKoU7=sTEC z8}J8w48AUz6blI}8ASzDU-nxh8Jk8WmtN`O=fxE*4i3`Fb11F9kV$A98;mRx#z-Ty z!DwiP1&}2x_>y(hrDP=^s5XKxqDfd1S%j$gxhVd}_(3!hlhF4%K68;_%@n%zjMnnU zWJ%WQUobp2 ztXV^ny!fA@4+zkfK}e%3L7AHgEJPYTD_Tqv+k#KAQ60jlZPb69o+SY8G&XGZ`d4@W z*3ClrC1-#Fzjm0!vG^oGrI1o*u0G`fjf?!T&ene!xLUoCRAsqVi42AQ1wLy|g+sHK tqoUIjcVEVz^##mYnrIfXM35dBa5&ryspA}}OOa?CBb&BS;Qv;@{REA7sOkU! diff --git a/public/textures/CHWIND3.tex b/public/textures/CHWIND3.tex deleted file mode 100644 index e732ddb6288328d0faddb25fc58b60cafd345878..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4235 zcmeHKJxc>Y5M8yARC1Sqg++u|i3(yNRsjotf?_3TG-(72(Nco7jlGpv+uk3r@y7@j z76H+j*^k@V&AE5y1{FNG+xdL&&1NqoQ$mO>xDWdmgVUqclfkjLoCd%D))z~#?!k)J z!Mj{%!0u2)P=4;^3cNPlO}Iy+(MKk-EW3Src)gi@yjvd*hrM3!dSBcxcf{q&*>>}M zWA`~}ce~x4Rw_1Ii*U^gv9Q+o{a0dLCDc}{4g)4hUL&z{pIMq%2kk~3tGoiJ+mmV{ zXHDHgztyb>`yPA4VYOTjErI~fPh=jA4)|H#t?R0+76QTtTe*gCeF#t^lpDwsmzZevPMAVg(y1 zQzgK*O*slRPF=&tfQZ1+fF4cU0o!Io5%?LNbR>Vy+^|&e@(J?;8B)uKvYCK?ezqMo zoN+s7KL4NIe|7RFdj7n3nBG5lgx^1?sj=g{1xIuu@U2IO9t5C)X%wSvQwiIg(;HZ>1iXINw_xZio4;jY(1Mq(SQBE(8m5DT#iSojEvm7vk25iG=%60B|Pt;E{)UcknO z5iBeMqO*VQU2bx_o1Evu-R#cp>^C#}b2kS7d-$C8Zu*yJn-~3a7*w#``|QCQ$|ID# zoP0?+kM*|DfaqU(zr|Jyp9Vgo(davdIF27)pFi&FFOS>9;jr87-W|i!dK(5CSNo0Y zox?ZQ>U25>%?Ngzt5}u+R<_t!!Rh|AjNvj(PdH=V1{q{Z;Wr)9Jsn8Fh=vm%?Zi0o zR#nGL5Xh9ug^xsO1658{b+=5UVAD1rlx|A8tS>4*rkzvSxy7FvtQ&G6EECw=vzUu+ zW>OtR+909SDoPg2H@og1_)!!FL8wwtGQS#%Oa_4vxF5^q>t$4Y-ZYznT6e9}hBHZV z)Ph8j{F&xd#@f$3iJ@Xi;Kd>o6Mr68_enmhYY(LBrSo+Pbmq#uePIFOW+edx5+OwjL>qpc=(Sm(LX_zPxqXO|NmFK f@WsWC@df+)T4tUb->u>X~=zjPCUZ$J^ diff --git a/public/textures/Dbfrfn1.tex b/public/textures/Dbfrfn1.tex deleted file mode 100644 index dcda861dd192ce07dc4c263f5ca7051168a0c81b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16524 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 XI%a{rwErpc5ta8R-Yf8btH6H&qud2_ diff --git a/public/textures/Dbfrfn2.tex b/public/textures/Dbfrfn2.tex deleted file mode 100644 index 1c594eaa53419880fadc42a09baac7fe630ed63a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16524 zcmeHNJ+C846?J>;_xxsYPa7}cN(e0?KoFqKNG98m1PMZfc81-A010UY37QxY5F&&K zASay-_yhb9M1-^f!ANL1=YCY(>aPCs^K61^o?YMfoO|xAa{Ik$Hk-}&@%reeFMsj! z=imL=CoeajZqfhvKOb+tiTcN=S$^_gt)HO%zKm>SzWeu+-=NFayLi2M^X7|x{_%^~ zo7b;j|LYHb{{7!S`}}u@zy0ZZzxwNYuU@@+@#4j=fAI7#-+q7d>34ql!H@p&)vs@U z_}k~tpMUtZ^E==A2DabIO@W&NHwA7AtWjXU-*;Nj+OOI9*tH4R@V)vnSd3wRDIDg& zJsgh}xW3=R?XFJLZy_C4pp)2@8LULVGJ_#Hk6f<|!*MMQdX{^*MYmjf6#_ly3%E+B zWKhrKHKPT51+Ov`DLht!!d3;VJ`{JYakjrUI$uW`8x0IDmmt88;f#-G2A2|e6nwm^ zFC&oQt4^M)qs3s7;1U8e@cZ+5p1~ss%)!G^Fqq?TF@XeM2-@q+p=0@l1j4=^=IX99 z=%4M$zHeHMR@vaTCIQPy-;%7&r&?M%Y`C=&W1{Ek2U9WB$77gS&~*I8r!Z~E#v}gz zQUVh$$EgZ@@30+@M?7LJFK#iSjJADoXCVAk{n>dGX(O^!QV&bZ~GCMtfglGeCE zrcue}h4{G2Ca?1XGIXjpAdwXv4JA-d03*j$M_4lc!aKeNJa3AV`|dn@1uwy;^FZg% zm6=XnAX{u?(11~l;p3^t;K<9e<5Exrz8w-)>|Jc{OR)3_D@!>8`_Uf~M@ zb#N?&b*w+1V$Nf%!eAaB`&6ZjHnM8WPsKsCL z@et)oiw1fOWR85Ix)d6pB{IShtKj}V>%Ztl%lK+bx_qMmN

@KjJ2M zhQG!AH^kQrar8<51p-K>mbCaLEaW7}(cm#KuXBaHB!Ki2Z3+$CqS&4AU-6m2v@2P? z5h#S){I(F(@LL=JTMZ9#4r&Z&RQ@f&CjpS*yC}?9;P|uB3t<#r1mV7bk8MQekC-Kc zp@BT-3k_OLau2NFjg8@U#2;+C|U6yblcMj0ytsf#~>tIEATVA=q_E_sp7XS zFaN|@?uQ}?)S6_FY8=daGyCuI+l-zCU*qt4 zqJ0?Rq%FtF{T{a;K7R57w2B0<8QYTAWsq!;mM!~KYr6@vyt*snZ|FXbe`zfwp#x6i z+W}zah%0*7NXMO}+y>&Il=CS5=kzyzAqh+W3!b54(J}W00eH!Mg;G=QJhxz>xbM`Q zAw~2gl(`xAJ)$1{lw9N>B_Qa4ze9cb@C(jJ5+;1T1O`^(dKVdYY{H=jkUxnHCb}j1 z536na(8lnGIO!>nxEb3LH1^z`0zdsGgR>x@Dd0=B?(1(C7Hsn>00mlf&JnR}4`i0` z7f>lE#VGLO@ng=oP&AyH8+36Qt(lg5psK?P8mg*4+ULTyWI zB!xX<*V+g_;y-R!2)H8wZqO_w(vCiR)KK%7`2TcZEes%Rwmb}0nt740&nV-qhqm2C z|7%LPy$Wa;vQmaX)n%lU4fd9aOyWQQ+4hg## z!WRU9zsHL|G+8OQ|AF7`w!6Cm{c`x6gZ@`dK1U)8D(FoEYzolcp;#0)&8BHw`-w-B zVer6xrM3V`?_1NO0=*B zcaoFNf^{ZSE`Ox+N~n;41l)V^ItjsEi5|a^R()GI*t3dZ69zEU_`zIm2 z_j^xVg6BPWVz~cDJtq*~l5d2}pZ}b^ge3mX!f&-&9e#gzxD?Cf^2gf`Z@zx{_4?H3 z_t&3)I)89*u(PxC;`YYJD_dg!>a!bnzb!6^JFm94w{M+mPhY+W)j6;NtN<&(3a|pK z04u->umY?AE5Hh{0;~WluwDh4c{6N4Gmqy+stz|wa{w{Kz^r&~A$|y=;nIS>fpOEQ zhw&8UG#C`K|5s*`+;aZ+X(@01fBuPBi^GKT?NMZ@x{M8|K+T6{&r)4rwSndh=Hts?PL;B0Pv7+2OmTV^V_Kq!v-K; zh^cQtS$?~TS~>$@*}u)i>=S0#L5fHRzyS2M(zowqSfHTfQvtvNbK|8I`rn^aZCWZX zrCR725U?Qd{Y_Vn6Lq1H2a#2Ef-gew`{PKHdD&e9@A1&Q$U#bAh z0fbQAXoI;0LXhEixveLZpA6HwIqs^E&~ADWx)5}sHtn6$(%VienV>->->rQUvL9Hd zWn@|#Zmp0JZHc@0(ClMKq1VTN8AhxbFPe&jeb{TxVFg$LR)7^?1y})AfE8c`SOHdm N6<`He0ahSKfxn%uIHdpp diff --git a/public/textures/Dbfrfn4.tex b/public/textures/Dbfrfn4.tex deleted file mode 100644 index d93381c135c867591c9dc986e09c36ae4e74e21f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16524 zcmeHMJF62x6kffJL?oJMDTrLL5G};cCY_~?prwe3*TzB+F4{RS^V&1B^E$H=v6z8mXEKlPJKs5HX0v;&>$;opdH7)UboJ!gqsOc6@Hn&& zejjv~;Cc_PSnmIk*FC83i(fj?fBNU%OK9TfEPS@x?a}vlN1JZ5+5C9>;my}Czh0mC z{Qmm$Pv_U`^>Vpt1=KarLYwf7f+0BLZ%FrC(B(4jv#PEZVb8^+d90ycxH`+;QO`a0I0 z!HzW6ZaQCtK@8CCLjna1V3H{mzr!%#B*Ii|iS`ny79xwJ7hl+-GnjdRVQ4Qv7xUCD za7n{rAfnKfIojqB(CDX3Q6ltAaR|!=;DbyB6uQAy6`)`(uqy)6?JhdDos*p_K$Zg1 zcpthTKqX)ckU>BgA3z@xU`7C(2XiZn`fyUU1iWC-2SI%SAmhWv%N`nqwF#j4DSQ!y zS~zwHMm>-PFVmp+7jF}YgK%D?&Ca3+Z~kZ8cn$2A zM*uqkeEUD&ev^Rg1n}bX36LF_7XUtdFrqvFc{_m5{qRJg1mD~Pa0Kw0Uw}^nfF(cx zzeoUej&tz+@)tTl=WmIt2`HiYOK0K{Bw6Og0R9 zm2gxVz@kZDJpc*7G*`{i_QY(aDGJK+s_4ji0@kQ}2heW60)t`6le-G1)2g)fu=xHpefK4XbMzO;4ijGm45&L diff --git a/public/textures/JSWNSH1.tex b/public/textures/JSWNSH1.tex deleted file mode 100644 index 4061a659ce4a4e1e12bd5c4ca94efc8d3bf46e36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16511 zcmdU0y^1bH5Sz>ASA#wNak`VRX7CO(W{ zV6Y%qRbAEpU0w6de7!iZO!rK6ol~c(dwS-(cb=x{Q~3M#<@evcy!-gYH(yV$7Uqfyt z{82S{k*?W5&Ew;mST)}gH*?6QV{OE_*CYXqKU#iY0E~Zba29V#0*1>k6HrQUu>Nk% z5pX;YRUj)q$77rXy!gf8V|x&RQhOmXBnMUPg~9bDXlOr*z#JbQG`?tAJ5I7;{1YGj z`|=ky0Vf3ntJ)q0T#9>!bcjMkd%RHs_Q*eP`W%Ib;(cw0t$PUUAUyZpX2}tSnwy45qKo|?K1%0t!%Tp5p8kV$=`jWh{jnb zOEe^H!g3rACqIZ9AF2K6J&swR^RqQ|m-FJJ=fJLSo{N{`%Nu%)!1bdi-i;hso-GIB zj)qoqFhYCbaM8v_2A^`yaxhMNVIOv

JK=4yqfvm0hXSQFmIU*em#EYbM9nZs*e{&8$E4GvC$++WWf+izhu9Lhi*ez|eU1mh+GGFofCr_YW{LaqB%!`sHKxf-0;p3`LYff(m;;cL(F;I42VD4>;;30g9 z##KBgv@@XzKdDhjL&Y;+0&`7J^yVi!6VUj&>8B<<<%030SCU?Y4%eL;UHl#JPOSt| z>kxq+{tkGzSZWE#*dPHNf9cdm>d;M=^qSi1)QdW{yh-x+B1(tNYQ3hm-5%rbNC3Fh z_3vWF22Or;2E^uPv~j2G54WO00^;Kg6vYQ?F3|aTUoI4!<}H=LiOFpeD7OeA!mhd7 z_TRnJkl^~-3$rSLA=(Rrd|Q=&4-&hpoiho>X)h5a3Mg9yu|NAPMeoi-laHZf`VYrX z#(g-|(` zB40T5?|eCbRGT)&m>YLJFJE7li|fay2lLjt{^E0Cu3UZR>b~564fTm@Uv5Xt_U~sO z@2=Nhr|#NpHtY3zwOY;R^Vw`Rolb8r&fU1meGH zkd2X_C#F8KR=ONJ}Q8;j>*+l6TDmG6a0 zc@zjYo5~3%M2}}Lk1Bp0Q1QpS%1*-y`497C`y%-d^TO^bkIH|Tr~HS#qx{2u!L-Vw z%0JBWxAM=B@GO3%JQ$#X#T{JR2`IKRSP#_`+cj9f_xV5D(CoecsRYO3hYeLg>sm}6 zmaF)fK!P!J7*O#KjG`99CI5Ly*nK*G^n9|K8A|z=ff~9Dm-5f!QvP8Ao=;XYLn;3< zP(zpDI=cJ|H$^V#u=abdUP|Cjy z)X-(Plz$!JbnV-e-)^I diff --git a/public/textures/JSWNSH3.tex b/public/textures/JSWNSH3.tex deleted file mode 100644 index 784375e0d9544919102770b8724f409cc488fd7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16484 zcmcIryRzIw5S13)B_$?v0Vy9q8{GWb)LG5p4 zWRv-?{`s4F{Qpb(|MBC;4Vjtzkc=V)sNqNP2&$7`+mFKj^lW-gW9*- zzCQqUdC7h|?nYaGl8Vo(VZYUsP5(1WxJAGl+B9(ED*4n$cAfHF625Oz4N1Vu@u?M{ z@AqD*?K{*Xi8(%DB_Q-Hzqvf_Co$Mh=1T&p;3KwYm|M!yBnuR-m*ccVD6>HR^0J_q zaPi=cfhvVECI_|XIP?@IejDF5P8XFDA8f+gOGcuUp6V5YeYiiMFlrNCO;K5-?t=40 z;36skrgSB?rgNp9*I=*k->=Xw`bGSEPl<-i`yQ%oR1UVG^;INJ(5AM^ybB_ z{Bs-9@=)=q%6R}3`1AyLH2W-J>YM_f@jsk>=AQ;Y`KKn&g`Wh}wS@f=SRCrKp9%kb zd*cAn(cy%lr^3I)Kdq5xs-K;kWY;{K7vOW@pG9|#pPhK-7)RIkmAU`VaP!)8{z;0K zZ+TTE-99&R$KILkl>a3G%6IS47a(P-zji&#Zz@@bV_E7zT-DJ*8k8KG5megFKQ0lr3pBK=1c0>af{DVCgg`*tWFnmiXR-@f>_=jW6*Q9; zA$stq#b}~!k5ih8LP!oP%&?rNhVRgcPw*cVk>bvl8Diubu3jVQ; zE-=eS{2%)k1$4%50Z3Sj@lTqQ|G}TDCmE#xtnvf$#5K!mUA-Ot;%kTB9`vO}z@L^R zBiz`>K`KjBYjQ@4g5b3{5<+;ER%Ck2Us|2bv{yXBDduH(Ee`b4%9RWzz{roQk;B`C zAM9kg^y>h?gA%ZJC~I8ew}`qwvbsc9#rafZe-eGmn*z^tvR;pw< zQ^5DE4fKiWFaQ>`xk}+rrXj$gZGVm_Z@I?Cvjp1Arh$Qff$elvqk0sbKoh^al1#jV z381Ibg~j=8fX022ZStdYN7Kd?e)??+n-jGPEo+Nv2oFng+lsL2vOqsh{I8bUEvbYz z{ffauQm<(H&drSr>ln#nN53n06z9=(%%9d`XX*#-TTl_WY{~!lbqFLpp;#R6{M!@O ziAUKPeLSt4>Mkf;<+`Cmy(bjqKO`qLqG|T=wDM#F6a~u4`se%#Tp{{J`2znz*BXeR zdce5KZ?|6P2%~G?c@)9R)W>mITuOpZ6{7%ML6=lja+HuNw5FHP%P1~1#SGy5u82pg zXt9bi@AfuA1G?WArd=J@9!-|yB>tj_nS6YaKLi&(7Q`Gk?+el)GMqm<1YCXo&Nl!S zD+d$Gp>7K@J!r8=AL{WT0V8zf@=Dw&O0nHE`2R5a(5R`=H=boAumNR~sH%8@mITBayktzFi@byS# zhmB4XvmP;lNrAE~>Qd4?Xrd_~n3(Me$_5LnDHgPdWY{HqlIFDDz?-Ust`XN;8@nIy z8y$_B3ud(BXb406p8qA15;nh&7gw~@69>h9=Yhgydc_%ZKTdxu)RGsm%sBK+ryY5ge z*WX>k7^8{83sRkspDy5xA6x~{7*)>1EClF&Scd`=fc&_|LV@$N+7ed{2d=f^6t8N} z3+s{Q%`4rc_1J*r)7)!HKdjW`gukJ2U@P+ggx1aCGl3SfC*3-6UHcGh+H zn8ye*oN?_k0s^fGE_$l!MjaXlryYaedb0!gV^AhX?vslAhMz&fXU&dD12bxl`LQ(3 zbN`$wsGomY*LC;W1AfyEYL3kgmVSRJF4N^!ZPc%pj{uCfk-5cx996WYtQ&wo)X_)VOZ#;%r8s0<86ZRx7sW-^_iLXc=olQht?zE z(3j`^1TFj>FQcp=H+OSAW*Mq84BHa==Ie{RF~3EInCJq`2faccwAM0Da#+eNK5P~h zfl&C%1`IB`+76kK`e!P&?%yS_c7t^1X?+=+@W-{m>8gf5lQ4q*`9SgG0EX`b;|?pN zOLQb^Kn5ye?hbalOw(B>Ls4%U55qy~_XV^qu>ZlzzDbt443Ue$Ai?2GmmKr-{lG0!KIND@2#Rl)80TvOLZ_MWZ@c_eZ diff --git a/public/textures/JSWNSH4.tex b/public/textures/JSWNSH4.tex deleted file mode 100644 index f08f0ead94fe77dead7c4ccc80233b5ac00aa6ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16511 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 diff --git a/public/textures/jsfrnt3.tex b/public/textures/jsfrnt3.tex deleted file mode 100644 index ea167676cb4b1faf8c1d8237f48d965466fe8504..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8325 zcmd5>JBwsR6u#ZlYI&}7!tK=1_{%ETgwnTQyOv5CR_ z0Tc0eh|z+9!GNsiJLlAS)P3~r2Wr7gz0dd6SLamKotZUF^9KEV@X6zkKm7F7_uhZp zJe^bh$zM;JmneUSa-?_vmi&y$kGQ4b{s+%Kr`i?$JfxqyySwMVfBXElxxKyp`j;R7 z_~!ese*W#xFIPW(_u9?P&Gq&54=+Dzp1$(Yn=gF!`rE%ge6e}!i>s@v_CHTVecyCa z&ijJX_~P?1=04_CqPSg{m}PZZ*v_yyGJxD1j#p z*=yN0SWI+(&*uexiJ*+B%+_I_wjQHc~H*(VO1Xdr2+oecw#`tKM zN}&gTTz$bmXM8)N^aLPnfURi~g%U zt;6v9)mH-Mg&F?@wSqsWJ{b5_a00|^q6A=E{*t4=I2Q<%FtqT?MvkbJ01UgYAvcSZ zGzL8Uj(|*4H2^G>#GPeV{3TQbFtsa>Q-yE}z?z)?v3lNBGkj1=J|zO|jl z^_w`VmF@aM<6%c#<`&X^6jJ|WK7kEiUy>q9!p9L1PTziGW35Bq>cBGh%ktM9^7tA9 z8R`r7Og$vyqZX10!^G;Tlj~{`hw-eb4iWVE`U5r>ivt{muSV^mns`H+yv)GCiSZSm zCQhytdx&wlNz&AaU;Cfq)2K9SdZoZaN)4}cj-kKETn5BBd;nnLbjUceJTXjt*p36R z0gi81Er!{?;QTu<`t*ilCCzxU1$;k$fvd^j`GFZZPb<@7tHnzd2>|I^-Azz$K+-MZ z#>C1eOe$UQQ?JzvRv)YOl87dF0}P7ab71V8_yLk6i_`ncj19+WMSk<+@~xgGWiUeH zWQ^ointu2hIPkF&{Z;@UdQ5RcvIdUs0WvK9hHBNRmH5X>1ScD^epa83FwWTt6Fx#D zs2!nE=1of>8v$dv0#EqtR~CeEmiXu(0da^tp|92j-)=W8|A_(NEwM+N+Vkg9zMjH! z3#%?k;CH?JI^di8`jx|*OU4p@y_@V4^FANYaOhjGwPkQc#+NwxF*^WcPxv-f3oHQn zfSrS7_4@5I5_|!Cv*9(b1zDnKg$Uqn2Lagcb!HZT`jZ6y{ay1d^#V@Fm()hcf*O9e z@NSM}|5h*4J-5F^;Xe;ccnMAc(JWsVK1aZZk(ESC;&R}LrexDic&U7dKrL{f|G%gv z{WlMTW6e=)Mar2qf` diff --git a/public/textures/jsfrnt4.tex b/public/textures/jsfrnt4.tex deleted file mode 100644 index d80c799b1166a65c5fb567519e8be8487627c680..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8331 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 diff --git a/public/textures/rcback2.tex b/public/textures/rcback2.tex deleted file mode 100644 index 07055997c1ac38df068dbf26812b31e9e2a60cfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16524 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 bdCFS?s0iC-V_;)oV_;)oV_;+8R1EwF>mYFh diff --git a/public/textures/rcback4.tex b/public/textures/rcback4.tex deleted file mode 100644 index 76dc7891a0bb720206adb52f5a5ff0ab27114c3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16512 zcmeHNv5wR*5Dj<5CPlO-M2kdmR0s-MN`8V8I4vBag6c$t_5+X*9YTl#sA(wn2WSxA zfvAuuC{99mZ^p?cYmc3c?RY^r((Yy*d)}KjoF8&a!PgId?mx#@dL7_(dU|^H{mt2`SglsCzI^=g`rXUV z-+sL)KfS&A?8D{da=BP6-d{T`POd+>ed+7PD|J4y0$G8qKvv+NRAA3`r17S-!B71il9RY_1y_59F6JJ7vU4RpViOmYYHUIeRJvqKIeXl+9yu^to|;S zy{!H&)lawSpg3z)cRo)CPg_0%p7uOh{RD+Kt8XSB<^GO0-2X@J|E;$>S^ZsB9?U*E z%892SAk6(fP5s_6AqQ*j_u%PtqFj{g{(RO+GHbP%gaJJ3pv>jJX=y0L&jeY0iH8bD zBIw~C`p9o$u$r6|Z(0deQzcWml?pxsNnh1#9`zA-7OX0V|D*}c9 zkjseGwj_Y1+avX@!zl-Z0aCQAHu@I?b7*q7BMhKLpl@@`xnRz1O%lL8!bL|&zoBT3 zW;_yq$QdDHhlbc0co?HOsAb@gWKMj|7LsWonWx1c_GQ=jh?%C@FlSo?d~ErQ$@sDY z;`~dP|3dv6EGB0D4-S!?N2>?EP=EaK>EAsgCzBYmi0dCFv(uW%IR5(USpNn2!FV8L zt?{n~HQfTheSHUrCI?|VHeC@C(0NY_zIwuh$X^Qy==xbhP(Sdwte~fz0Kx0(Q*ZC^ z*OUN;proi2QtE5KhsiGnK>fg~JH_{)CcHac-PuQ1g&BZ~L<($3pzeT8cOKcRu-gz&xXrsjGu(Yk2COA$8Au$X{Qe|Q0p)A^#6<>2 zAFvCHKBWMRSOFx>yEcr}rwQlOxHqY)br_Db@Z?iODaH;M+a-bzSDz^O5x}Sj>Jz?L z*lljN?L?Xe$2dF)#DrJt(5Oie7hVzAJ_)%z#}K}=!8LwM2_}G7Nw`%ZV)SUK*D1i$ zaF_u_KGwwRfzv7Na0ZI8az0Mk(a`BD6R>Kaz$bEEvEei%5sXx97sE(3PjG&d0h3@B zvlbdnQ=>Bnl?N&o8}F0F-B6=>Gji7n;uR$$ZI%vjSOxtUz)F FigQ0gN&f%< diff --git a/public/textures/rcfrnt1.tex b/public/textures/rcfrnt1.tex deleted file mode 100644 index 1c44503231be98ca5f8eff6b58b42cef8b6f678a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16524 zcmeHOzl$YB5FVXnk@p_E@5dPmA}0o-fvL%Xf&YMkp&)yniQr*y7|F&Wh?$5OsIiH` z{sSiB-yueVfx&{DUv*Ere)Y{)_0?C^J^f~$>ALPs{5^jE;p2}! zdHsVAA9hck#qp!RA9XKd`!2TB-uuUId+gtLXF7L%`)B_d4(ab6{;sdDpZ@;M)2r_4 z>guase*EL>@4o!`x4*u4?x%0x`1FStE-x=HE-t=*^?vu{wU6Jr_v`+}?w!vcJb3W- z^M~~-FKv(Q$iR_-?G|}-hfOkmhv0xZ;&TR${I@dD_bX~WjZUqNv)`J3{c3lZ+HQT) zXuyr|Q}DrzT@wvA!0*=%alhYe&Z%uSXM1;Mx2h*$KK_cZ_8!{ddn23)^&HH_?|D+6$Y^XC} z20q2U;60H611dr=10Tw-fcFLzv!ERxRi_I6{2Ur6R-$Hnu)k+o%O1jjk_Mac!G0Ex zUw{Nkbx7i$nlXd>R^l@YiUgdt;z9WPtzTm z6!79;2cQvu&2P_(yfk2MmVicl;hq>{M1F!GBLg*jBVN4t;O4BiD9>OhkKb$f`2r~9 zr<4~APl*Ig0V?Ug-PYr4_vSWgeMUl5{ColA_r^SPyw$kFV{|y=pNX{cV?2TJ^sB_r zJEVpGKa7uwXHx!DZ8QGO`0M^>1<2#C-Mgkw(2AOVMgs7AYr}g>{;BfcZnJs;emcNa z)E`x&0|Bl0omPJXnSQI(-~0?nPd_68#P|d6lH1Gi$C`rb@-y&x0dRBt=_yAUe<9I) z`N`p136KiF8F1L0&?w~AL-_D-4SyN=8wt>f@cIYMasI{Oks={&k%q{0ESyEJ)Nl&a3;?P9J{&jTlgpe(#vGPj}K&hh%LzD=Q-<8 z->MCO`18^4vhuS8*ii(MK`8b&-3NS99Ae+GpOv2_K%^O@R%HO-4e@ndUVfGUstqW? z8}hU9NdoB{Go^hvLMJ2mruBulT^C6mL zK#lu}03<%#PY4>(u_FnvLy@dt zE5fgHKh6M&Z_rNx;PZr}doBJH0D+%6G zpC`x@AO(xV{LDp%OhBBpqyXHyry>Es^TCHq^#W~oKNMjZ0QZU!pMoh% z;FybrP_;*&IB}*c0dNLPzkX9U^pf6FKTm&#;lgRB00cnLI;_60d}{apg+#TNSL!_X z2LhBCIG7FZUchhHwAC57?{!k}Gke+nZ=HbD0u9}f z@ID6+0a02LFnO>D`;G#V03x8Iw?rM!5WK4n#RwNW1PQbl0C@l0GpFuzi$(+Z=r(%G zcWC+7d4JR3dsCbSslrzp0O>6WKa7NAKusD>?;tPZJ#pX|px1x(LL2i>+}~`};qf00 zsQ3lO4NFci*3WDjLgAdhC&j?vAYPVSj{LzgsssMFcHII$B8O?o5{btsaUvKjIE-IE zxY%zz`1nfToarV*Qx1I$y6Ec84$fB zM_>QY^80$7d;)Z<=wwOY!0D0ybL;4ii;M}Uw;=kM`*?qK8pP%(=P# diff --git a/public/textures/rcfrnt2.tex b/public/textures/rcfrnt2.tex deleted file mode 100644 index 799409e95f9ae2b79a90987e919d60b4aedb9a44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16524 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 LtGAT`|6YM#nUj5F diff --git a/public/textures/rctail3.tex b/public/textures/rctail3.tex deleted file mode 100644 index d07c5e812cc11fe83f4b751f1d060da859e36e7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4230 zcmcgvyNVP+6z!g#qPwPMx}C)j*kFxB12HpM@D~IP6m{qb^-5Oc(dL9W$yz09Z53HpZi`s#c&({TlhadK0f*S`eczU7K@jk-hX@b_Ql69 zKb}_~-ds64I@;UYdv|dsJG}Jp`uQhUZ+_m|&Tc&0-QC5)wOWogLQ78!*Rr$5qKHiN zvLt@VF53U#K&MM`q6CLCs0G9|ubL(hD%i6|*EzyR!A$a|K_P6Ifok1lt_eUp_1<*mLnN#q6!GF!dG;i3zh|gXZ}N|QG_Y-fmOf^ zC<0tqg%w|Y9k&4N5A_fRLN)IF$ zdGHC?fEbdRcH$;7kfJZ6uD}t{ct7!g&4`(E1H__)VXqU_GRt776V})2pVm^ba)1 z3fQJufgt)cAtDL`=JIyVhy{T#JXpL6u$^l`k>DO0W1#rfJa{;T3Q!TSF9fLQRDb~2 zg|wiNbZkir)bD^FiUu4#)Yv%9`)yQ4G(U#-@*~0H9ZQdLtRZ~dnh1o<2tQ-lQm4i2 z`gCb2iBwrynB^n;g+Y3f!;H=I4Q(66sivumD3CVJDz<1seLXm7Q)#&?;zHz9GMjGK zQWlD__bdT4+xjg&R>60KJj8iZisjcc*itq+dBnv?lqt2vHgA1cK%87LmMMiBLqt20 zRyE?{jv3OBA4=n~%q&-zMcb>o_T#K$cD$T9IT9!5as5*Pl6ksu}j zfGK1!VLo^H5gc!D-s#kf`nnwA-Wow1^ zI0_3KM?4o#X>f6r_Zbt-0+CF1_`QTDJ*t5M_I!a|FscHtJ=x^x7OZUa`xhe}cn!|o z8dhDpd$AM-dZ81j9Sz#z=5*R)k&@1*-U=AQ%Ap9q~=5C$Btwh@G~6S*Elgkjt_GeNn*IR|I7IlP?hQ%Y*0x44KMs h&CVn#sM$wg&dQ0v#9qf_XMA9f-#3v(+gbPP_cw|$c4+_r diff --git a/public/textures/rctail4.tex b/public/textures/rctail4.tex deleted file mode 100644 index ba43831859635c84672a7ac94c318446d8079bde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4236 zcmeHJJ&P1U5N+)ZoLQE&Z_fOX69dse%uE&x`~!l9imcv&iK1|!Y%GG9iHLz1n;7gL zFcE);Ty$VyupnpEtLpBWcII{h4=%XoZnr;PzxS%DduPr#=Wf#fvxm^`~nc=wUp zJ4@r8-#hLSecz*RZukGlx2OI#W*pYfANZFv+@k+Y`d_V9hdMF1}hfw5|!jyT=QFm0n=y>cOP z&_+?KBL{!W)QEakUuMltS23-b$SErIiZS9*MM$ugX^>)KC^QYb1I}*LWuq?+3$3nU z8Li5DuVm0-KN?X?g~}67fuS*uaxJ?w?VOf2fTeDvgc7UtNf*Q#5#y-1q9rmCf=6no zU<4c-z0`~ki)i38nTG*dOgSeU>yste)si$YFZy~)Y=lSmnTUB1gk&MR=S5=F95Iom z0_Z5u)BG1p!?>YM`7{IsY>uu9vkkpaViV#26Dr*DwJJOo*~?4OUtV?W@2?E zj^Nt(6cs{gvm8e|K*YLh1R;}EO?=D|Gf73%SDP9zq0(fv*E>h%wtFeA4J)hZ50N_T ztTa+7`j(?pHchgEG>W@StB&;)@E)BJUx`FI2!($Se7ZmdqGQO~qgo#|U^n@PxtMco zMvQXOUCGAi;fa^sP;B3~%!{)@c9>XQX)0hNC7B+3fgX4aOLL{Uo^f%I5O9n^@~Jsj zk6L)xjz>c39*R(U1dC}?T3!(Li4zshx zNK3sf^CgI((KDB4YNSG-IV(`EJiLPug z!_(hmjE-N=N_(1c!+`B=c{CY!w#lStp+FO^3pmLFYkK49akPy!s(JZ1)Th(o`b9qf O(=f+(+B-oRaDM?ku?SoM diff --git a/scripts/generate-save-editor-assets.js b/scripts/generate-save-editor-assets.js new file mode 100644 index 0000000..2f5b932 --- /dev/null +++ b/scripts/generate-save-editor-assets.js @@ -0,0 +1,429 @@ +#!/usr/bin/env node +// Scans LEGO Island SI files to extract embedded assets into a packed binary bundle. +// Writes save-editor.bin: [U32LE index length][JSON index][fragment data]. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +// [name, objectId, size, md5] +const ANIMATIONS = [ + ['CNs001Bd', 223, 657, 'eec976da0035968ee65cde2444d66fdd'], + ['CNs001Br', 207, 1617, 'ca07df295c5146da01efd57aa42e6f43'], + ['CNs001La', 179, 1335, '2f3c2d17a404e3ee06e24c9b79a5bc93'], + ['CNs001Ma', 117, 1545, '6d7eb4527cab9d589f61e931f76a7ccf'], + ['CNs001Ni', 145, 1233, '8777efe8556288cedba7ac67ae5b8e75'], + ['CNs001Pa', 131, 1277, '222c7c997e79d77228c3607ed6ff9754'], + ['CNs001Pe', 107, 1209, 'f0057004a852fb8a6385c075bcb425f8'], + ['CNs001Pg', 224, 657, 'c9293ae1cadcd769c57f16d721189cbe'], + ['CNs001Rd', 225, 657, 'cb6c0ec1203b644f16451f7caf5538e9'], + ['CNs001Sk', 227, 1590, 'b9e19a58b4a6d8bb9e63a12af08ce5ee'], + ['CNs001Sy', 226, 657, '986645d7598ba921113aef9d72ff74fd'], + ['CNs001xx', 80, 1101, '79a32fb54e881403cf785caf2f07fb99'], + ['CNs002Br', 208, 1393, 'f489e8ae4ed591f35738a9f4d1259450'], + ['CNs002La', 196, 1447, '0738817ecc1ed6afc8c605537afbcbc9'], + ['CNs002Ma', 118, 1461, 'b8b1a40dd9ee102de0724fa4f0fa4047'], + ['CNs002Ni', 146, 1201, '6e761fa60aea9e5c8d37ffa39a3aaf24'], + ['CNs002Pa', 132, 1149, '7774a58da0fc3d3dd9bdaec4a4feebcb'], + ['CNs002Pe', 108, 1065, '1bcb02590f35c2fc7a248fcfb00e6e58'], + ['CNs002xx', 87, 1472, '9541d8c34615d28998985de01cbc7dfd'], + ['CNs003Br', 209, 1497, 'a2d6094d7f4da36772d7d4a739a2831c'], + ['CNs003La', 197, 1675, '7687af38a09b16be8028e73b39b99981'], + ['CNs003Ma', 119, 1749, '5ca15ea1f1fd84a775b044c6ca068ba1'], + ['CNs003Ni', 147, 1369, 'd2533306f8ce771d958e2839a0fc0ae9'], + ['CNs003Pa', 133, 1369, '1fef78d548e65839093d2ff0b1a4460f'], + ['CNs003Pe', 109, 1373, 'f9815a0266a1c63a3f3518cca85beb49'], + ['CNs003xx', 88, 1377, 'f61d8fd473121bface739c7a557c05ec'], + ['CNs004Br', 210, 1741, 'e777c1dabff4f0c756445c588b7c2ef2'], + ['CNs004La', 198, 2139, '8dc93c005ebcedecbea0db2bdf351434'], + ['CNs004Ma', 120, 2597, '332eaac68b2fb4244fd62789bc2c5508'], + ['CNs004Ni', 148, 1853, '8a209f3790a1d9b7b58f8909151de2bf'], + ['CNs004Pa', 134, 1961, '80bb689a754f174ead41f5b01019a00e'], + ['CNs004Pe', 110, 1577, '70bf03ce873583554dcc8955ff307975'], + ['CNs004xx', 89, 1581, '65d27ca748b437676c4a162e7d5a599c'], + ['CNs005Br', 211, 1373, '837455903a2d23ea77833e9d96697b18'], + ['CNs005La', 199, 1427, '9b4d20d1bbca5a81ec5805ac768b3f70'], + ['CNs005Ma', 121, 2117, '8d5bb9ec4905efbc0a0d29b700eeee0d'], + ['CNs005Ni', 149, 1245, '2696c22fa69a403e5a07406d9425400a'], + ['CNs005Pa', 135, 1545, '8217a905ce20b4958695c9e5de3fc372'], + ['CNs005Pe', 111, 1249, 'ed5a06a87baf507ccd35c96407af1da7'], + ['CNs005xx', 90, 1253, 'bfe52167e229966987552da768ab5e44'], + ['CNs006Br', 213, 1269, '1d451aee9a06118c5a944750ddf4fedc'], + ['CNs006La', 201, 1587, '99f0f187b6128626ac2106e010a30373'], + ['CNs006Ma', 123, 1445, '13f5e003ff05a3d87a7866bcb195fbfc'], + ['CNs006Ni', 157, 1365, 'e76dbff6c14206cc20bbe02be377ee2f'], + ['CNs006Pa', 137, 1665, '831badb8a5065d1f64b6123891b7907e'], + ['CNs006Pe', 113, 1369, '619630dec24433c8f4863c16d09ebc14'], + ['CNs006xx', 101, 1373, '18a8f17d235c89b5365e6f7a83dc9311'], + ['CNs007Br', 212, 1353, 'b7ae9c9eb305e75572a472d65683f040'], + ['CNs007La', 200, 1567, '0a0da9bf5043c1dcb537069f9febbeae'], + ['CNs007Ma', 122, 1501, '81f94684fb3f508b697ba47af1c6079a'], + ['CNs007Ni', 156, 1205, 'b228c9b6fce5ce909963b614cd2e4163'], + ['CNs007Pa', 136, 1293, '2708a4b6e72f62ae96da476e6d67210e'], + ['CNs007Pe', 112, 1249, '2b2aba18edb8945800203a1c4d985c34'], + ['CNs007xx', 100, 2144, 'b4be30f4c60a1ecc8b01f4414af14455'], + ['CNs008Br', 214, 1113, 'b11de30162588dfcc0bf1512469dd72a'], + ['CNs008La', 202, 1047, '1bad27d6ff48c93bf5c9d476e5d64fbb'], + ['CNs008Ma', 124, 1045, '4a12974aee2e9f81aa995120b5efd54b'], + ['CNs008Ni', 158, 1005, 'ad3f7af37af89fe1b29175943cee52c2'], + ['CNs008Pa', 138, 1305, '075566a5c2905b0126761750488a5f8d'], + ['CNs008Pe', 114, 1009, 'ea176943a17569bea30695e10e6277f4'], + ['CNs008xx', 102, 1013, 'b5610e2318eb760a9d6ecd915026edfa'], + ['CNs009Br', 215, 1005, '4e8633be87a0d3e86018bb79d6df6647'], + ['CNs009La', 203, 1179, '172d6534981dc3c82a6edf46b0a46a90'], + ['CNs009Ma', 125, 917, '122d10aceea520188e614f4100cff493'], + ['CNs009Ni', 159, 1269, 'd075ca06d9bca5509cd01367282d6322'], + ['CNs009Pa', 139, 1757, '85ee3e3915ed3a81470b30da9985ee94'], + ['CNs009Pe', 115, 941, '368209e2db8b682b9f5ee8c2449b3c14'], + ['CNs009xx', 103, 945, '8a15d1553d6d81a0d741c1e11adb5b29'], + ['CNs010Br', 216, 1469, 'b7c1a5844f0a4710d15c67eed8c19418'], + ['CNs010La', 204, 1543, '77347221f51e10470ea06d514430663a'], + ['CNs010Ma', 126, 1493, '846a971fe79f314ef9eb4788776d337d'], + ['CNs010Ni', 168, 1541, '6e25bd94e5cb76e0acb642d4a52a922f'], + ['CNs010Pa', 140, 1637, '94be270722ab8a69c6c81c9e0467a97c'], + ['CNs010Pe', 116, 1465, 'ba3abad56fb12c025840df86ea5ea7b1'], + ['CNs010xx', 104, 1469, 'aebf4eb621c5eb4b202751c11383d3c9'], + ['CNs011Br', 217, 1161, '2e0e6495387746460c66a37f6ea4d9b3'], + ['CNs011La', 205, 798, 'ce98ce48a5396559ee55fb786f122fcc'], + ['CNs011Ma', 127, 1561, '1a4f6b4d89c9bd867d4433a81423d178'], + ['CNs011Ni', 169, 699, '0b24eb57d4225b8737bf959aecf14430'], + ['CNs011Pa', 141, 3747, '94cb14868ed2957db9a156f832221637'], + ['CNs011xx', 105, 990, '5b09efc169a758325b76e6654f9777e0'], + ['CNs012Br', 218, 2245, 'b83f53d4361a980de9abb186c3a2806b'], + ['CNs012Ma', 128, 1593, '17e75f084d52d712012adeb92f7cda54'], + ['CNs012Pa', 142, 6900, 'e61e78454d7ff3e7bae7def8b3dfa9d6'], + ['CNs012xx', 106, 1830, '17ab918c42f311064f4e4c3560c93f51'], + ['CNs013Br', 219, 3417, '8b7482f00475111add6829dcb6434d96'], + ['CNs013Ma', 129, 3169, '8f017d5092a216d2a32a33e1de4ac118'], + ['CNs013Pa', 143, 3647, '3e3cf7409b766b80653f8571801ebf71'], + ['CNs014Br', 220, 3174, '1a47cb0ebd2c812273a5338b8495204f'], + ['CNs0x4Ma', 130, 2657, '81e016edfb3acdd25fe4cb3a1b74d376'], + ['CNs0x4Pa', 144, 2005, 'a7272eb818e1cd00d9b5ee65f5b592f4'], + ['CNs900Br', 221, 3617, 'd9d4c57e6ec4061a464b45bb0560fd47'], + ['CNs901BR', 222, 3917, '6378d58ab123dd09a0ca5460ef2a1112'], + ['CNsx11La', 206, 881, 'b1ac6017e17a0f93e6af3962a8c3ae66'], + ['CNsx11Ni', 178, 879, '0e09f9119f37308af94956c38527e758'], +]; + +// Click animations from SNDANIM.SI (objectId = m_move + 10) +// [name, objectId, size, md5] +const CLICK_ANIMATIONS = [ + ['ClickAnim0', 10, 1898, 'e8bb524cc29c6bdc9416ae3a95727dd1'], + ['ClickAnim1', 11, 2038, '21444b8952df188cb338e830a8ee1e00'], + ['ClickAnim2', 12, 2606, '5b49aeb7dcd7e52f22febc6502b9f8a2'], + ['ClickAnim3', 13, 4218, 'e25f074d7012f89868011dc2bd5c0586'], +]; + +// Click sounds from SNDANIM.SI (objectId = m_sound + 50) +// [name, objectId, size, md5] +const CLICK_SOUNDS = [ + ['ClickSound0', 50, 10078, '928eeb70f8dadbc400f5c150727fde69'], + ['ClickSound1', 51, 15988, '9c8aa04b0e4683976c3f2c2be868b37e'], + ['ClickSound2', 52, 4114, 'a94a6dc7ae24fc42b1b9be962bbf3bf1'], + ['ClickSound3', 53, 7741, '96bd26dc212ffd31da365ea1d088bfa3'], + ['ClickSound4', 54, 23705, 'ca79cc736729c12aed6da018725fb0e3'], + ['ClickSound5', 55, 24179, 'b7c97cb776f0afbba40f2e21fc0b309d'], + ['ClickSound6', 56, 17675, 'b69b07bba21c6667d0af651c89828815'], + ['ClickSound7', 57, 18953, '65d9cc0d09e3bfb831cee014a84085f7'], + ['ClickSound8', 58, 7344, '7bbc41251b750835989cb3b35c8546a4'], +]; + +// Mood sounds from SNDANIM.SI (objectId = m_mood + 66) +// [name, objectId, size, md5] +const MOOD_SOUNDS = [ + ['MoodSound0', 66, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound1', 67, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound2', 68, 11534, '91379f36012f600a4b7432e003e16c3a'], + ['MoodSound3', 69, 11534, '91379f36012f600a4b7432e003e16c3a'], +]; + +// [name, siFile, objectId, size, md5] +const TEXTURES = [ + ['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'], + ['CHJETL2', 'Scripts/Build/COPTER.SI', 118, 4235, '130322a91a293b85551f59e1b5fb1c6f'], + ['CHJETL3', 'Scripts/Build/COPTER.SI', 115, 4235, 'a922a1cf56da0ab47426cc3d0f581339'], + ['CHJETL4', 'Scripts/Build/COPTER.SI', 121, 4235, '624b3aa949f2e2db5c8820caffbe8f58'], + ['CHJETR1', 'Scripts/Build/COPTER.SI', 127, 4235, '924b8ae4db6c60003aba9994720ad0d6'], + ['CHJETR2', 'Scripts/Build/COPTER.SI', 133, 4235, 'b4edeba59b44b2b37124a8da02693a10'], + ['CHJETR3', 'Scripts/Build/COPTER.SI', 130, 4235, '18b50ad01a7aee7cf47f378d278be9eb'], + ['CHJETR4', 'Scripts/Build/COPTER.SI', 136, 4235, 'f6874aec4931186782aa2e360fe1861f'], + ['CHWIND1', 'Scripts/Build/COPTER.SI', 97, 4235, '860a0c8cacf27d3e2faed9030cc1be69'], + ['CHWIND2', 'Scripts/Build/COPTER.SI', 103, 4235, 'b99fec10adf4660aa19b067483970f8f'], + ['CHWIND3', 'Scripts/Build/COPTER.SI', 100, 4235, '05d7068d58105292632cdab56d3f67e4'], + ['CHWIND4', 'Scripts/Build/COPTER.SI', 106, 4235, '24eb83a84cad5e5926fc2db010bd93d8'], + ['Dbfrfn1', 'Scripts/Build/DUNECAR.SI', 96, 16524, '255fd145075b02d16fee2ac8bfeab3de'], + ['Dbfrfn2', 'Scripts/Build/DUNECAR.SI', 99, 16524, 'a6b3e5a02bb1ab0b139cb76b8cc00e9b'], + ['Dbfrfn3', 'Scripts/Build/DUNECAR.SI', 102, 16524, '60b6758fd34a74abf868fca98be3a3fa'], + ['Dbfrfn4', 'Scripts/Build/DUNECAR.SI', 105, 16524, 'b57ff13872e67c8e52953b943d4244a8'], + ['JSWNSH1', 'Scripts/Build/JETSKI.SI', 124, 16511, 'faf25d963756e335bd3e97b5383ed3a4'], + ['JSWNSH2', 'Scripts/Build/JETSKI.SI', 130, 16484, 'bce7b364358238f8adf15a2f7242ed1b'], + ['JSWNSH3', 'Scripts/Build/JETSKI.SI', 136, 16484, 'c502a5ca2f43f73320960c201ecef96a'], + ['JSWNSH4', 'Scripts/Build/JETSKI.SI', 142, 16511, '9dbddced239fe2e6f04704116a6dc98c'], + ['jsfrnt1', 'Scripts/Build/JETSKI.SI', 100, 8325, '3bc6cee56e1b282e1271d823c932b140'], + ['jsfrnt2', 'Scripts/Build/JETSKI.SI', 106, 8331, 'f0b3ba901b7302d6ea72fdadaee5def0'], + ['jsfrnt3', 'Scripts/Build/JETSKI.SI', 112, 8325, '13431821186bb466fce71c34ecc008e7'], + ['jsfrnt4', 'Scripts/Build/JETSKI.SI', 118, 8331, 'a173f79e05be78fba888d89aa5ee5ed1'], + ['rcback1', 'Scripts/Build/RACECAR.SI', 110, 16524, '97c1c6f3673bcceb340149627a5d656c'], + ['rcback2', 'Scripts/Build/RACECAR.SI', 113, 16524, 'd730986dc5a0b4f3199dcd31246c32a3'], + ['rcback3', 'Scripts/Build/RACECAR.SI', 116, 16524, 'a2574a6c6d9c16d41f001fb0eb908726'], + ['rcback4', 'Scripts/Build/RACECAR.SI', 119, 16512, '37291480ea6c96145c99e659d4d6cbd4'], + ['rcfrnt1', 'Scripts/Build/RACECAR.SI', 95, 16524, '8962ce972e6122ab7f7b87efa40591c2'], + ['rcfrnt2', 'Scripts/Build/RACECAR.SI', 98, 16524, 'ca235a1cdd432f6cc5b64610e62eb94f'], + ['rcfrnt3', 'Scripts/Build/RACECAR.SI', 101, 16524, '017301e33ff6dc8dfe41afc6558055f1'], + ['rcfrnt4', 'Scripts/Build/RACECAR.SI', 104, 16512, '3064a4627325d2325ee07b98b49f4a58'], + ['rctail1', 'Scripts/Build/RACECAR.SI', 125, 4227, '5c28aa88d5971f73575315b09359ce57'], + ['rctail2', 'Scripts/Build/RACECAR.SI', 128, 4236, 'a69ae67432ecccfd88a567ce2d8973c0'], + ['rctail3', 'Scripts/Build/RACECAR.SI', 131, 4230, '55d628507bf0968037422aefb3494184'], + ['rctail4', 'Scripts/Build/RACECAR.SI', 134, 4236, '614cb9aa532ee85c119cc1432e6d65e9'], +]; + +// [name, siFile, objectId, size, md5] +const BITMAPS = [ + ['globe1', 'Scripts/Isle/ISLE.SI', 1130, 5824, '12554d2a7d38bdc0e6bc1709f8404293'], + ['globe2', 'Scripts/Isle/ISLE.SI', 1131, 5824, 'b0b39a4b959b4bf1605a6c695d9e3dd0'], + ['globe3', 'Scripts/Isle/ISLE.SI', 1132, 5824, '71672bff19044f7df059c87a0759d950'], + ['globe4', 'Scripts/Isle/ISLE.SI', 1133, 5824, 'f5421e06ae9997d9cbfc774942f097d4'], + ['globe5', 'Scripts/Isle/ISLE.SI', 1134, 5824, '47974b0577cab1c3775175eab074e5b5'], + ['globe6', 'Scripts/Isle/ISLE.SI', 1135, 5824, '65237421063fa993167ed4af9be9180c'], + ['pepper', 'Scripts/Infocntr/INFOMAIN.SI', 80, 2904, '4143f58632135089b7bc695bff406077'], + ['pepper-selected', 'Scripts/Infocntr/INFOMAIN.SI', 81, 2904, '01f57a3a32f7aea6bc48a78a8c95ee1b'], + ['mama', 'Scripts/Infocntr/INFOMAIN.SI', 76, 2904, 'ad68ae8fe78c368cac026ae09f1ad8c4'], + ['mama-selected', 'Scripts/Infocntr/INFOMAIN.SI', 77, 2904, '72f041d1080b20f713d39f7ae00d966d'], + ['papa', 'Scripts/Infocntr/INFOMAIN.SI', 78, 2904, '0cd5ef1c6d68198862c102b36b2f04fe'], + ['papa-selected', 'Scripts/Infocntr/INFOMAIN.SI', 79, 2904, 'ec7d3d87d796dd824dfd5110911d4aa4'], + ['nick', 'Scripts/Infocntr/INFOMAIN.SI', 82, 2904, 'c29ecdce0ffae81a71b973b8af26c26c'], + ['nick-selected', 'Scripts/Infocntr/INFOMAIN.SI', 83, 2904, '8db4e63632c6f90e543832e6c92c89fa'], + ['laura', 'Scripts/Infocntr/INFOMAIN.SI', 84, 2904, '99abd3415870285d487da20882f3bbf3'], + ['laura-selected', 'Scripts/Infocntr/INFOMAIN.SI', 85, 2904, 'f56c2efb4f744d306d5a3d4ac8d332ca'], +]; + +const MXCH_SIGNATURE = Buffer.from('MxCh'); +const MXCH_HEADER_SIZE = 22; // MxCh(4) + chunkSize(4) + flags(2) + objectId(4) + time(4) + dataSize(4) + +const LEGO_DIR = path.join(process.cwd(), 'LEGO'); +const BIN_PATH = path.join(process.cwd(), 'save-editor.bin'); + +const siCache = new Map(); + +async function loadSI(siRelPath) { + if (siCache.has(siRelPath)) return siCache.get(siRelPath); + const buf = await fs.readFile(path.join(LEGO_DIR, siRelPath)); + siCache.set(siRelPath, buf); + return buf; +} + +function md5(buf) { + return crypto.createHash('md5').update(buf).digest('hex'); +} + +/** + * Scan a SI buffer for all MxCh chunks and group data ranges by objectId. + * Clips each chunk's data to the physical space before the next MxCh header, + * since interleaving can split a logical chunk across sector boundaries. + * Returns Map. + */ +function findMxChByObjectId(siBuf, targetIds) { + // First pass: collect all MxCh header positions + const allPositions = []; + let pos = 0; + while (pos <= siBuf.length - MXCH_HEADER_SIZE) { + const idx = siBuf.indexOf(MXCH_SIGNATURE, pos); + if (idx === -1) break; + allPositions.push(idx); + pos = idx + 4; + } + + // Second pass: extract data ranges for target objectIds + const result = new Map(); + for (const id of targetIds) result.set(id, []); + + for (let i = 0; i < allPositions.length; i++) { + const idx = allPositions[i]; + const dataSize = siBuf.readUInt32LE(idx + 18); + const objectId = siBuf.readUInt32LE(idx + 10); + + if (dataSize > 0 && result.has(objectId)) { + const dataStart = idx + MXCH_HEADER_SIZE; + const physicalEnd = i + 1 < allPositions.length ? allPositions[i + 1] : siBuf.length; + const actualSize = Math.min(dataSize, physicalEnd - dataStart); + if (actualSize > 0) { + result.get(objectId).push([dataStart, actualSize]); + } + } + } + + return result; +} + +/** + * Assemble MxCh data from ranges, verify against expected size and md5. + * Only assembles up to `size` bytes (objectIds can be reused across streams). + * Returns the assembled Buffer, or null on failure. + */ +function extractAndVerify(siBuf, ranges, size, expectedMd5) { + if (!ranges || ranges.length === 0) return null; + + const assembled = Buffer.alloc(size); + let writePos = 0; + for (const [rOff, rLen] of ranges) { + if (writePos >= size) break; + const take = Math.min(rLen, size - writePos); + siBuf.copy(assembled, writePos, rOff, rOff + take); + writePos += take; + } + + if (writePos !== size || md5(assembled) !== expectedMd5) return null; + return assembled; +} + +async function main() { + console.log('Generating asset fragment bundle...\n'); + + const fragments = []; // [{type, name, data: Buffer}, ...] + let found = 0; + let failed = 0; + + // --- Animations (all in ISLE.SI) --- + const isleSI = await loadSI('Scripts/Isle/ISLE.SI'); + console.log(`Loaded ISLE.SI (${(isleSI.length / 1024 / 1024).toFixed(1)} MB)`); + + const aniObjectIds = new Set(ANIMATIONS.map(([, objectId]) => objectId)); + const aniRanges = findMxChByObjectId(isleSI, aniObjectIds); + + for (const [name, objectId, size, expectedMd5] of ANIMATIONS) { + const data = extractAndVerify(isleSI, aniRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'animations', name, data }); + found++; + } else { + console.error(` FAILED: ${name}.ani (objectId ${objectId})`); + failed++; + } + } + console.log(` ${found}/${ANIMATIONS.length} walking animations found\n`); + + // --- Click Animations (in SNDANIM.SI) --- + const sndanimSI = await loadSI('Scripts/SNDANIM.SI'); + console.log(`Loaded SNDANIM.SI (${(sndanimSI.length / 1024 / 1024).toFixed(1)} MB)`); + + const clickObjectIds = new Set(CLICK_ANIMATIONS.map(([, objectId]) => objectId)); + const clickRanges = findMxChByObjectId(sndanimSI, clickObjectIds); + + let clickFound = 0; + for (const [name, objectId, size, expectedMd5] of CLICK_ANIMATIONS) { + const data = extractAndVerify(sndanimSI, clickRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'animations', name, data }); + clickFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); + + // --- Sounds (in SNDANIM.SI) --- + const allSounds = [...CLICK_SOUNDS, ...MOOD_SOUNDS]; + const soundObjectIds = new Set(allSounds.map(([, objectId]) => objectId)); + const soundRanges = findMxChByObjectId(sndanimSI, soundObjectIds); + + let soundFound = 0; + for (const [name, objectId, size, expectedMd5] of allSounds) { + const data = extractAndVerify(sndanimSI, soundRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'sounds', name, data }); + soundFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${soundFound}/${allSounds.length} sounds found\n`); + + // --- Textures (across Build SI files) --- + const texBySI = new Map(); + for (const entry of TEXTURES) { + const siFile = entry[1]; + if (!texBySI.has(siFile)) texBySI.set(siFile, []); + texBySI.get(siFile).push(entry); + } + + let texFound = 0; + for (const [siFile, entries] of texBySI) { + const siBuf = await loadSI(siFile); + const objectIds = new Set(entries.map(([, , objectId]) => objectId)); + const texRanges = findMxChByObjectId(siBuf, objectIds); + console.log(`Loaded ${siFile} (${(siBuf.length / 1024).toFixed(0)} KB)`); + + for (const [name, , objectId, size, expectedMd5] of entries) { + const data = extractAndVerify(siBuf, texRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'textures', name, data }); + texFound++; + found++; + } else { + console.error(` FAILED: ${name}.tex in ${siFile} (objectId ${objectId})`); + failed++; + } + } + } + console.log(` ${texFound}/${TEXTURES.length} textures found\n`); + + // --- Bitmaps (across SI files) --- + const bmpBySI = new Map(); + for (const entry of BITMAPS) { + const siFile = entry[1]; + if (!bmpBySI.has(siFile)) bmpBySI.set(siFile, []); + bmpBySI.get(siFile).push(entry); + } + + let bmpFound = 0; + for (const [siFile, entries] of bmpBySI) { + const siBuf = await loadSI(siFile); + const objectIds = new Set(entries.map(([, , objectId]) => objectId)); + const bmpRanges = findMxChByObjectId(siBuf, objectIds); + + for (const [name, , objectId, size, expectedMd5] of entries) { + const data = extractAndVerify(siBuf, bmpRanges.get(objectId), size, expectedMd5); + if (data) { + fragments.push({ type: 'bitmaps', name, data }); + bmpFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + } + console.log(` ${bmpFound}/${BITMAPS.length} bitmaps found\n`); + + if (failed > 0) { + console.error(`Failed to find ${failed} assets. Bundle not written.`); + process.exit(1); + } + + // --- Write single bundle: [U32LE indexLen][JSON index][data] --- + const index = {}; + let offset = 0; + for (const { type, name, data } of fragments) { + index[`${type}/${name}`] = [offset, data.length]; + offset += data.length; + } + + const indexBuf = Buffer.from(JSON.stringify(index)); + const header = Buffer.alloc(4); + header.writeUInt32LE(indexBuf.length); + const dataBuf = Buffer.concat(fragments.map(f => f.data)); + const bundle = Buffer.concat([header, indexBuf, dataBuf]); + await fs.writeFile(BIN_PATH, bundle); + console.log(`Wrote ${BIN_PATH} (${(bundle.length / 1024).toFixed(1)} KB, ${Object.keys(index).length} entries)`); + + console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${allSounds.length} sounds, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); +} + +main().catch(err => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/src/core/assetLoader.js b/src/core/assetLoader.js new file mode 100644 index 0000000..3119c3f --- /dev/null +++ b/src/core/assetLoader.js @@ -0,0 +1,139 @@ +// Loads assets from a packed binary bundle generated by scripts/generate-save-editor-assets.js +// Format: [U32LE indexLen][JSON index][fragment data] + +let bundleIndex = null; +let dataOffset = 0; +let bundleBuffer = null; +let bundlePromise = null; + +async function loadBundle() { + if (!bundlePromise) { + bundlePromise = fetch('/save-editor.bin').then(async (resp) => { + bundleBuffer = await resp.arrayBuffer(); + const indexLen = new DataView(bundleBuffer).getUint32(0, true); + const indexJson = new TextDecoder().decode(new Uint8Array(bundleBuffer, 4, indexLen)); + bundleIndex = JSON.parse(indexJson); + dataOffset = 4 + indexLen; + }); + } + await bundlePromise; +} + +function getAsset(type, name) { + const entry = bundleIndex[`${type}/${name}`]; + if (!entry) return null; + const [offset, size] = entry; + return bundleBuffer.slice(dataOffset + offset, dataOffset + offset + size); +} + +export async function fetchAnimation(name) { + await loadBundle(); + return getAsset('animations', name); +} + +export async function fetchTexture(name) { + await loadBundle(); + return getAsset('textures', name); +} + +async function fetchBitmap(name) { + await loadBundle(); + return getAsset('bitmaps', name); +} + +async function fetchSound(name) { + await loadBundle(); + return getAsset('sounds', name); +} + +/** + * Build a WAV file from raw MxCh sound data. + * Layout: bytes 0-15 = PCMWAVEFORMAT, 16-19 = m_dataSize, 20-23 = m_flags, 24+ = PCM data. + * Uses actual available size since sector interleaving may clip the last chunk. + */ +function buildWav(buffer) { + const dataSize = buffer.byteLength - 24; + const wavSize = 44 + dataSize; + const wav = new ArrayBuffer(wavSize); + const view = new DataView(wav); + const bytes = new Uint8Array(wav); + + // RIFF header + bytes.set([0x52, 0x49, 0x46, 0x46]); // "RIFF" + view.setUint32(4, wavSize - 8, true); + bytes.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE" + + // fmt chunk — copy PCMWAVEFORMAT (16 bytes) directly from source header + bytes.set([0x66, 0x6D, 0x74, 0x20], 12); // "fmt " + view.setUint32(16, 16, true); + bytes.set(new Uint8Array(buffer, 0, 16), 20); + + // data chunk + bytes.set([0x64, 0x61, 0x74, 0x61], 36); // "data" + view.setUint32(40, dataSize, true); + bytes.set(new Uint8Array(buffer, 24, dataSize), 44); + + return wav; +} + +export async function fetchSoundAsWav(name) { + const buffer = await fetchSound(name); + if (!buffer) return null; + return buildWav(buffer); +} + +/** + * Decode a raw Windows DIB (no BM file header) into RGBA ImageData. + * Supports 8-bit indexed color only. + */ +function decodeDib(buffer) { + const view = new DataView(buffer); + const width = view.getInt32(4, true); + const height = view.getInt32(8, true); + const bpp = view.getUint16(14, true); + if (bpp !== 8) return null; + + // Palette: 256 BGRA entries starting at offset 40 + const palette = new Uint8Array(buffer, 40, 1024); + + // Pixel data starts after header + palette + const pixelOffset = 40 + 1024; + const rowStride = (width + 3) & ~3; // rows padded to 4-byte boundary + const absHeight = Math.abs(height); + const bottomUp = height > 0; + + const imageData = new ImageData(width, absHeight); + const pixels = new Uint8Array(buffer, pixelOffset); + + for (let y = 0; y < absHeight; y++) { + const srcRow = bottomUp ? (absHeight - 1 - y) : y; + for (let x = 0; x < width; x++) { + const idx = pixels[srcRow * rowStride + x] * 4; + const dst = (y * width + x) * 4; + imageData.data[dst] = palette[idx + 2]; // R (from BGR) + imageData.data[dst + 1] = palette[idx + 1]; // G + imageData.data[dst + 2] = palette[idx]; // B + imageData.data[dst + 3] = 255; + } + } + + return imageData; +} + +/** + * Fetch a bitmap from an SI file and return a blob URL for use in tags. + */ +export async function fetchBitmapAsURL(name) { + const buffer = await fetchBitmap(name); + if (!buffer) return null; + + const imageData = decodeDib(buffer); + if (!imageData) return null; + + const canvas = new OffscreenCanvas(imageData.width, imageData.height); + const ctx = canvas.getContext('2d'); + ctx.putImageData(imageData, 0, 0); + + const blob = await canvas.convertToBlob({ type: 'image/png' }); + return URL.createObjectURL(blob); +} diff --git a/src/core/formats/AnimationParser.js b/src/core/formats/AnimationParser.js new file mode 100644 index 0000000..8970269 --- /dev/null +++ b/src/core/formats/AnimationParser.js @@ -0,0 +1,212 @@ +/** + * Parser for LEGO Island .ani animation files. + * Format spec: isle/docs/animation.ksy + * + * Binary layout: + * S32 magic (0x11) + * F32 boundingRadius, F32 centerX, F32 centerY, F32 centerZ + * S32 hasCameraAnim, S32 unused + * U32 numActors + * For each actor: U32 nameLen, char[nameLen] name, U32 actorType (if nameLen > 0) + * S32 duration (ms) + * [optional camera_anim if hasCameraAnim != 0] + * tree_node root + * + * tree_node: + * node_data data + * U32 numChildren + * tree_node[numChildren] children + * + * node_data: + * U32 nameLen, char[nameLen] name (if nameLen > 0) + * U16 numTranslationKeys, translation_key[...] + * U16 numRotationKeys, rotation_key[...] + * U16 numScaleKeys, scale_key[...] + * U16 numMorphKeys, morph_key[...] + * + * Keys share packed time_and_flags (S32): bits 0-23 = time ms, bits 24-31 = flags + * translation_key: anim_key + F32 x,y,z + * rotation_key: anim_key + F32 angle(w), F32 x, F32 y, F32 z (quaternion) + * scale_key: anim_key + F32 x,y,z + * morph_key: anim_key + U8 visible + */ + +import { BinaryReader } from './BinaryReader.js'; + +export class AnimationParser { + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + this.reader = new BinaryReader(buffer); + } + + parse() { + const magic = this.reader.readS32(); + if (magic !== 0x11) { + throw new Error(`Invalid animation magic: 0x${magic.toString(16)}, expected 0x11`); + } + + const boundingRadius = this.reader.readF32(); + const centerX = this.reader.readF32(); + const centerY = this.reader.readF32(); + const centerZ = this.reader.readF32(); + const hasCameraAnim = this.reader.readS32(); + const unused = this.reader.readS32(); + + const numActors = this.reader.readU32(); + const actors = []; + for (let i = 0; i < numActors; i++) { + actors.push(this.parseActorEntry()); + } + + const duration = this.reader.readS32(); + + let cameraAnim = null; + if (hasCameraAnim !== 0) { + cameraAnim = this.parseCameraAnim(); + } + + const rootNode = this.parseTreeNode(); + + return { + boundingRadius, + center: { x: centerX, y: centerY, z: centerZ }, + actors, + duration, + cameraAnim, + rootNode + }; + } + + parseActorEntry() { + const nameLen = this.reader.readU32(); + if (nameLen === 0) { + return { name: '', actorType: 0 }; + } + const name = this.reader.readString(nameLen); + const actorType = this.reader.readU32(); + return { name, actorType }; + } + + parseCameraAnim() { + const numTranslationKeys = this.reader.readU16(); + const translationKeys = []; + for (let i = 0; i < numTranslationKeys; i++) { + translationKeys.push(this.parseTranslationKey()); + } + + const numTargetKeys = this.reader.readU16(); + const targetKeys = []; + for (let i = 0; i < numTargetKeys; i++) { + targetKeys.push(this.parseTranslationKey()); + } + + const numRotationKeys = this.reader.readU16(); + const rotationKeys = []; + for (let i = 0; i < numRotationKeys; i++) { + rotationKeys.push(this.parseRotationZKey()); + } + + return { translationKeys, targetKeys, rotationKeys }; + } + + parseTreeNode() { + const data = this.parseNodeData(); + const numChildren = this.reader.readU32(); + const children = []; + for (let i = 0; i < numChildren; i++) { + children.push(this.parseTreeNode()); + } + return { data, children }; + } + + parseNodeData() { + const nameLen = this.reader.readU32(); + let name = ''; + if (nameLen > 0) { + name = this.reader.readString(nameLen); + } + + const numTranslationKeys = this.reader.readU16(); + const translationKeys = []; + for (let i = 0; i < numTranslationKeys; i++) { + translationKeys.push(this.parseTranslationKey()); + } + + const numRotationKeys = this.reader.readU16(); + const rotationKeys = []; + for (let i = 0; i < numRotationKeys; i++) { + rotationKeys.push(this.parseRotationKey()); + } + + const numScaleKeys = this.reader.readU16(); + const scaleKeys = []; + for (let i = 0; i < numScaleKeys; i++) { + scaleKeys.push(this.parseScaleKey()); + } + + const numMorphKeys = this.reader.readU16(); + const morphKeys = []; + for (let i = 0; i < numMorphKeys; i++) { + morphKeys.push(this.parseMorphKey()); + } + + return { name, translationKeys, rotationKeys, scaleKeys, morphKeys }; + } + + parseAnimKey() { + const timeAndFlags = this.reader.readS32(); + return { + time: timeAndFlags & 0xFFFFFF, + flags: (timeAndFlags >>> 24) & 0xFF + }; + } + + parseTranslationKey() { + const key = this.parseAnimKey(); + const x = this.reader.readF32(); + const y = this.reader.readF32(); + const z = this.reader.readF32(); + return { ...key, x, y, z }; + } + + parseRotationKey() { + const key = this.parseAnimKey(); + const w = this.reader.readF32(); // angle/scalar component + const x = this.reader.readF32(); + const y = this.reader.readF32(); + const z = this.reader.readF32(); + return { ...key, w, x, y, z }; + } + + parseScaleKey() { + const key = this.parseAnimKey(); + const x = this.reader.readF32(); + const y = this.reader.readF32(); + const z = this.reader.readF32(); + return { ...key, x, y, z }; + } + + parseMorphKey() { + const key = this.parseAnimKey(); + const visible = this.reader.readU8(); + return { ...key, visible: visible !== 0 }; + } + + parseRotationZKey() { + const key = this.parseAnimKey(); + const z = this.reader.readF32(); + return { ...key, z }; + } +} + +/** + * Parse an animation buffer. + * @param {ArrayBuffer} buffer + * @returns {Object} Parsed animation data + */ +export function parseAnimation(buffer) { + const parser = new AnimationParser(buffer); + return parser.parse(); +} diff --git a/src/core/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js index f3e71fd..494f219 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -100,10 +100,31 @@ export class SaveGameParser { } /** - * Skip character manager data (66 characters * 16 bytes = 1056 bytes) + * Parse character manager data (66 characters * 16 bytes = 1056 bytes) + * Each character: sound(S32) + move(S32) + mood(U8) + * + hatPartNameIndex(U8) + hatNameIndex(U8) + infogronNameIndex(U8) + * + armlftNameIndex(U8) + armrtNameIndex(U8) + leglftNameIndex(U8) + legrtNameIndex(U8) */ - skipCharacters() { - this.reader.skip(66 * 16); + parseCharacters() { + this.parsed.charactersOffset = this.reader.tell(); + const characters = []; + + for (let i = 0; i < 66; i++) { + characters.push({ + sound: this.reader.readS32(), + move: this.reader.readS32(), + mood: this.reader.readU8(), + hatPartNameIndex: this.reader.readU8(), + hatNameIndex: this.reader.readU8(), + infogronNameIndex: this.reader.readU8(), + armlftNameIndex: this.reader.readU8(), + armrtNameIndex: this.reader.readU8(), + leglftNameIndex: this.reader.readU8(), + legrtNameIndex: this.reader.readU8() + }); + } + + this.parsed.characters = characters; } /** @@ -403,7 +424,7 @@ export class SaveGameParser { parse() { this.parseHeader(); this.parseVariables(); - this.skipCharacters(); + this.parseCharacters(); this.skipPlants(); this.skipBuildings(); this.parseGameStates(); diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index c39110e..5c0aa5c 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -5,6 +5,7 @@ import { SaveGameParser } from './SaveGameParser.js'; import { BinaryWriter } from './BinaryWriter.js'; import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js'; +import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js'; /** * Offsets for header fields @@ -208,41 +209,9 @@ export class SaveGameSerializer { return null; } - const view = new DataView(workingBuffer); - - // Calculate offset based on mission type - let offset; - - if (missionType === 'pizza') { - // Pizza: 5 actors * 8 bytes (unk(2) + counter(2) + score(2) + hiScore(2)) - const actorIndex = actorId - Actor.PEPPER; // 0-4 - const entryOffset = stateLocation.dataOffset + (actorIndex * 8); - if (scoreType === 'score') { - offset = entryOffset + 4; // Skip unk + counter - } else { - offset = entryOffset + 6; // Skip unk + counter + score - } - } else if (missionType === 'carRace' || missionType === 'jetskiRace') { - // Race: 5 actors * 5 bytes (id(1) + lastScore(2) + highScore(2)) - const actorIndex = actorId - Actor.PEPPER; - const entryOffset = stateLocation.dataOffset + (actorIndex * 5); - if (scoreType === 'score') { - offset = entryOffset + 1; // Skip id - } else { - offset = entryOffset + 3; // Skip id + lastScore - } - } else if (missionType === 'towTrack' || missionType === 'ambulance') { - // Score mission: 5 scores then 5 high scores (all S16) - const actorIndex = actorId - Actor.PEPPER; - if (scoreType === 'score') { - offset = stateLocation.dataOffset + (actorIndex * 2); - } else { - offset = stateLocation.dataOffset + 10 + (actorIndex * 2); // Skip 5 scores - } - } - - if (offset !== undefined) { - view.setInt16(offset, value, true); + const offset = this.getMissionScoreOffset(missionType, actorId, scoreType); + if (offset !== null) { + new DataView(workingBuffer).setInt16(offset, value, true); } return workingBuffer; @@ -461,6 +430,27 @@ export class SaveGameSerializer { return newBuffer; } + /** + * Update a character field in the save file + * @param {number} characterIndex - Character index (0-65) + * @param {string} field - Field name from CharacterFieldOffsets + * @param {number} value - New value + * @returns {ArrayBuffer} - Modified buffer + */ + updateCharacter(characterIndex, field, value) { + const workingBuffer = this.createCopy(); + const view = new DataView(workingBuffer); + const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + CharacterFieldOffsets[field]; + + if (field === 'sound' || field === 'move') { + view.setInt32(offset, value, true); + } else { + view.setUint8(offset, value); + } + + return workingBuffer; + } + /** * Get the byte offset for a mission score * @param {string} missionType diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js index 3e08deb..ba1f351 100644 --- a/src/core/formats/WdbParser.js +++ b/src/core/formats/WdbParser.js @@ -23,14 +23,18 @@ export class WdbParser { } const globalTexturesSize = this.reader.readU32(); - // Skip global textures for now - BIGCUBE.GIF is in model_data - this.reader.skip(globalTexturesSize); + let globalTextures = []; + if (globalTexturesSize > 0) { + globalTextures = this.parseTextureInfo(); + } const globalPartsSize = this.reader.readU32(); - // Skip global parts - this.reader.skip(globalPartsSize); + let globalParts = null; + if (globalPartsSize > 0) { + globalParts = this.parseGlobalParts(globalPartsSize); + } - return { worlds, globalTexturesSize, globalPartsSize }; + return { worlds, globalTexturesSize, globalPartsSize, globalParts, globalTextures }; } parseWorldEntry() { @@ -125,6 +129,18 @@ export class WdbParser { return { parts, textures }; } + /** + * Parse global parts block (same structure as parsePartData) + * @param {number} size - Size of global parts block + * @returns {{ parts: Array, textures: Array }} + */ + parseGlobalParts(size) { + const startOffset = this.reader.tell(); + const result = this.parsePartData(startOffset); + this.reader.seek(startOffset + size); + return result; + } + /** * Parse model_data blob at specified offset * @param {number} offset - Absolute file offset @@ -510,6 +526,21 @@ export function resolveLods(roi, partsMap) { return []; } +/** + * Build a parts lookup map from global parts + * @param {{ parts: Array }} globalParts - Parsed global parts from WdbParser + * @returns {Map} - Map of part name (lowercase) -> part data + */ +export function buildGlobalPartsMap(globalParts) { + const partsMap = new Map(); + if (!globalParts || !globalParts.parts) return partsMap; + + for (const part of globalParts.parts) { + partsMap.set(part.name.toLowerCase(), part); + } + return partsMap; +} + /** * Build a parts lookup map from a world's parts array * @param {WdbParser} parser - Parser instance for reading part data diff --git a/src/core/formats/index.js b/src/core/formats/index.js index a6d3ca4..aaddc97 100644 --- a/src/core/formats/index.js +++ b/src/core/formats/index.js @@ -7,7 +7,7 @@ export { BinaryReader } from './BinaryReader.js'; export { BinaryWriter } from './BinaryWriter.js'; // WDB format -export { WdbParser, findRoi } from './WdbParser.js'; +export { WdbParser, findRoi, buildGlobalPartsMap } from './WdbParser.js'; // Save game format export { SaveGameParser, parseSaveGame } from './SaveGameParser.js'; @@ -19,3 +19,6 @@ export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer. // Texture format export { parseTex } from './TexParser.js'; + +// Animation format +export { AnimationParser, parseAnimation } from './AnimationParser.js'; diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js new file mode 100644 index 0000000..34de012 --- /dev/null +++ b/src/core/rendering/ActorRenderer.js @@ -0,0 +1,903 @@ +import * as THREE from 'three'; +import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js'; +import { LegoColors } from '../savegame/constants.js'; +import { parseAnimation } from '../formats/AnimationParser.js'; +import { fetchAnimation } from '../assetLoader.js'; +import { BaseRenderer } from './BaseRenderer.js'; + +/** + * Map actor index to animation suffix index (from g_characters[].m_unk0x16). + * This maps the 66 ActorInfoInit indices to the g_cycles row index. + */ +const ACTOR_SUFFIX_INDEX = (() => { + // Default all to 0 (xx) + const map = new Array(66).fill(0); + map[0] = 1; // pepper → Pe + map[1] = 2; // mama → Ma + map[2] = 3; // papa → Pa + map[3] = 4; // nick → Ni + map[4] = 5; // laura → La + map[5] = 0; // infoman → xx (not in g_characters, uses default) + map[6] = 6; // brickstr → Br + // 7-35: all 0 (xx) — generic NPCs + // Note: g_characters indices don't perfectly align with ActorInfoInit indices + // for the NPCs after brickstr. The g_characters array has 47 entries + // matching by name. We map the special ones: + map[37] = 9; // rd → Rd (g_characters index 36) + map[38] = 8; // pg → Pg (g_characters index 37) + map[39] = 7; // bd → Bd (g_characters index 38) + map[40] = 10; // sy → Sy (g_characters index 39) + map[56] = 1; // pep → Pe (same as pepper) + return map; +})(); + +/** + * g_cycles[11][17] — animation name table from legoanimationmanager.cpp. + * Rows = character type suffix index, columns = mood (0-3) for walking, higher indices for other animations. + */ +const G_CYCLES = [ + // 0: xx + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs011xx','CNs012xx',null,null,null,null,null], + // 1: Pe + ['CNs001Pe','CNs002Pe','CNs003Pe','CNs004Pe','CNs005Pe','CNs007Pe','CNs006Pe','CNs008Pe','CNs009Pe','CNs010Pe','CNs001sk',null,null,null,null,null,null], // CNs001sk = skateboard + // 2: Ma + ['CNs001Ma','CNs002Ma','CNs003Ma','CNs004Ma','CNs005Ma','CNs007Ma','CNs006Ma','CNs008Ma','CNs009Ma','CNs010Ma','CNs0x4Ma',null,null,'CNs011Ma','CNs012Ma','CNs013Ma',null], + // 3: Pa + ['CNs001Pa','CNs002Pa','CNs003Pa','CNs004Pa','CNs005Pa','CNs007Pa','CNs006Pa','CNs008Pa','CNs009Pa','CNs010Pa','CNs0x4Pa',null,null,'CNs011Pa','CNs012Pa','CNs013Pa',null], + // 4: Ni + ['CNs001Ni','CNs002Ni','CNs003Ni','CNs004Ni','CNs005Ni','CNs007Ni','CNs006Ni','CNs008Ni','CNs009Ni','CNs010Ni','CNs011Ni','CNsx11Ni',null,null,null,null,null], + // 5: La + ['CNs001La','CNs002La','CNs003La','CNs004La','CNs005La','CNs007La','CNs006La','CNs008La','CNs009La','CNs010La','CNs011La','CNsx11La',null,null,null,null,null], + // 6: Br + ['CNs001Br','CNs002Br','CNs003Br','CNs004Br','CNs005Br','CNs007Br','CNs006Br','CNs008Br','CNs009Br','CNs010Br','CNs011Br','CNs900Br','CNs901Br','CNs011Br','CNs012Br','CNs013Br','CNs014Br'], + // 7: Bd + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Bd','CNs012xx',null,null,null,null,null], + // 8: Pg + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Pg','CNs012xx',null,null,null,null,null], + // 9: Rd + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Rd','CNs012xx',null,null,null,null,null], + // 10: Sy + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Sy','CNs012xx',null,null,null,null,null], +]; + +/** + * Map ActorLOD names (used in our part hierarchy) to animation node names. + * Animation files use uppercase names like "BODY", "HEAD", "LEG-RT", etc. + */ +const PART_NAME_TO_ANIM_NODE = { + 'body': 'BODY', + 'infohat': 'INFOHAT', + 'infogron': 'INFOGRON', + 'head': 'HEAD', + 'arm-lft': 'ARM-LFT', + 'arm-rt': 'ARM-RT', + 'claw-lft': 'CLAW-LFT', + 'claw-rt': 'CLAW-RT', + 'leg-lft': 'LEG-LFT', + 'leg-rt': 'LEG-RT' +}; + +/** + * Renderer for full LEGO characters assembled from WDB global parts. + * Mirrors the game's LegoCharacterManager::CreateActorROI logic. + */ +export class ActorRenderer extends BaseRenderer { + constructor(canvas) { + super(canvas); + this.partGroups = []; // 10 part groups for click targeting + this.clock = new THREE.Clock(); + this.mixer = null; + this.currentAction = null; + this.animationCache = new Map(); // suffix → parsed animation data + this._queuedClickMove = null; // queued click animation move index (0-3) + + this.camera.position.set(2, 0.8, 3.5); + this.camera.lookAt(0, 0.2, 0); + + this.setupControls(new THREE.Vector3(0, 0.2, 0)); + this.controls.autoRotate = false; + this._initialAutoRotate = false; + + this.raycaster = new THREE.Raycaster(); + } + + /** + * Load a full actor from global parts, optionally with a vehicle. + * @param {number} actorIndex - Index into ActorInfoInit (0-65) + * @param {Array} characters - Parsed character state from save file (66 entries) + * @param {Map} globalPartsMap - Name→part lookup for global parts + * @param {Array} globalTextures - Global texture list from WDB + * @param {Map|null} vehiclePartsMap - Name→part lookup for vehicle parts (null if no vehicle) + * @param {Array|null} vehicleTextures - Vehicle texture list (null if no vehicle) + * @param {object|null} vehicleInfo - { vehicleModel, vehicleAnim } or null + */ + loadActor(actorIndex, characters, globalPartsMap, globalTextures, vehiclePartsMap, vehicleTextures, vehicleInfo) { + this.clearModel(); + + const actorInfo = ActorInfoInit[actorIndex]; + const charState = characters[actorIndex]; + + // Build texture lookup + this.textures.clear(); + for (const tex of globalTextures) { + if (tex.name) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); + } + } + + // Merge vehicle textures (if present) + if (vehicleInfo && vehicleTextures) { + for (const tex of vehicleTextures) { + if (tex.name && !this.textures.has(tex.name.toLowerCase())) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); + } + } + } + + this.modelGroup = new THREE.Group(); + this.partGroups = []; + this.vehicleGroup = null; + this.vehicleInfo = vehicleInfo || null; + + // Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10]) + for (let i = 0; i < 10; i++) { + const actorLOD = ActorLODs[i + 1]; + const part = actorInfo.parts[i]; + + // Resolve part name for body (i=0) and hat (i=1) + let partName; + if (i === 0 || i === 1) { + partName = this.resolvePartName(part, charState, i); + } else { + partName = actorLOD.parentName; + } + + if (!partName) continue; + + // Find the part's LOD data in global parts + const partData = globalPartsMap.get(partName.toLowerCase()); + if (!partData) continue; + + const partGroup = new THREE.Group(); + partGroup.userData.partIndex = i; + partGroup.userData.partName = partName; + partGroup.userData.lodName = actorLOD.name; // for animation matching + // Name used by Three.js PropertyBinding to match animation tracks + partGroup.name = `part_${actorLOD.name}`; + + // Resolve color/texture for this part + const resolvedName = this.resolveNameValue(part, charState, i); + + // Create meshes from LODs + const lods = partData.lods || []; + if (lods.length > 0) { + const lod = lods[lods.length - 1]; // Highest quality + this.createPartMeshes(lod, actorLOD, part, resolvedName, i, partGroup); + } + + // Position the part using ActorLOD transform + this.applyPartTransform(partGroup, actorLOD); + + this.modelGroup.add(partGroup); + this.partGroups[i] = partGroup; + } + + // Create vehicle mesh if vehicle info is provided + if (vehicleInfo && vehiclePartsMap) { + this.createVehicleMesh(vehicleInfo, vehiclePartsMap); + } + + this.centerAndScaleModel(1.8); + // Rotate 180° around Y so actor faces the camera (negating X for + // left-to-right-handed conversion flips the facing direction) + this.modelGroup.rotation.y = Math.PI; + // Shift model up in vehicle mode so it's better framed + if (this.vehicleGroup) { + this.modelGroup.position.y += 0.2; + } + this.scene.add(this.modelGroup); + + // Load and start walking/vehicle animation based on mood + const mood = charState?.mood ?? 0; + this.loadAnimationForActor(actorIndex, mood, vehicleInfo); + + this.renderer.render(this.scene, this.camera); + } + + /** + * Resolve which part geometry to use (body variant or hat type). + */ + resolvePartName(part, charState, partIdx) { + if (!part.partNameIndices || !part.partNames) return null; + + let nameIdx = part.partNameIndex; + if (partIdx === 1 && charState) { + nameIdx = charState.hatPartNameIndex; + } + + return part.partNames[part.partNameIndices[nameIdx]]; + } + + /** + * Resolve the color or texture name for a part. + */ + resolveNameValue(part, charState, partIdx) { + if (!part.nameIndices || !part.names) return null; + + let nameIdx = part.nameIndex; + + if (charState) { + switch (partIdx) { + case 1: nameIdx = charState.hatNameIndex; break; + case 2: nameIdx = charState.infogronNameIndex; break; + case 4: nameIdx = charState.armlftNameIndex; break; + case 5: nameIdx = charState.armrtNameIndex; break; + case 8: nameIdx = charState.leglftNameIndex; break; + case 9: nameIdx = charState.legrtNameIndex; break; + } + } + + return part.names[part.nameIndices[nameIdx]]; + } + + /** + * Create meshes for a single body part. + */ + createPartMeshes(lod, actorLOD, part, resolvedName, partIdx, group) { + const useTexture = (actorLOD.flags & ActorLODFlags.USE_TEXTURE) !== 0; + const useColor = (actorLOD.flags & ActorLODFlags.USE_COLOR) !== 0; + + // Special case: body part (i=0) with partNameIndex 0 uses color instead of texture + // (matches the C++ condition: i != 0 || part.m_partNameIndices[part.m_partNameIndex] != 0) + const bodyUsesDefaultGeom = partIdx === 0 && part.partNameIndices && + part.partNameIndices[part.partNameIndex] === 0; + + let partColor = null; + let partTexture = null; + + if (useTexture && !bodyUsesDefaultGeom) { + // Look up texture by resolved name + const texName = resolvedName?.toLowerCase(); + if (texName && this.textures.has(texName)) { + partTexture = this.textures.get(texName); + } + } + + if ((useColor || bodyUsesDefaultGeom) && !partTexture) { + // Resolve LEGO color + const colorEntry = LegoColors[resolvedName] || LegoColors['lego white']; + partColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255); + } + + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + + // Check for per-mesh texture from the WDB geometry + let meshTexture = null; + const meshTexName = mesh.properties?.textureName?.toLowerCase(); + if (meshTexName && this.textures.has(meshTexName)) { + meshTexture = this.textures.get(meshTexName); + } + + let material; + if (partTexture) { + material = new THREE.MeshLambertMaterial({ + map: partTexture, + side: THREE.DoubleSide, + color: 0xffffff + }); + } else if (meshTexture) { + material = new THREE.MeshLambertMaterial({ + map: meshTexture, + side: THREE.DoubleSide, + color: 0xffffff + }); + } else if (partColor) { + material = new THREE.MeshLambertMaterial({ + color: partColor, + side: THREE.DoubleSide + }); + } else { + const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), + side: THREE.DoubleSide + }); + } + + const threeMesh = new THREE.Mesh(geometry, material); + group.add(threeMesh); + } + } + + /** + * Create vehicle mesh from WDB model ROIs and add to modelGroup. + * vehiclePartsMap maps model name → array of { name, lods }. + */ + createVehicleMesh(vehicleInfo, vehiclePartsMap) { + const rois = vehiclePartsMap.get(vehicleInfo.vehicleModel.toLowerCase()); + if (!rois || rois.length === 0) return; + + this.vehicleGroup = new THREE.Group(); + this.vehicleGroup.name = `vehicle_${vehicleInfo.vehicleModel}`; + + for (const roi of rois) { + const lods = roi.lods || []; + if (lods.length === 0) continue; + + const lod = lods[lods.length - 1]; // Highest quality + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + + let material; + const meshTexName = mesh.properties?.textureName?.toLowerCase(); + if (meshTexName && this.textures.has(meshTexName)) { + material = new THREE.MeshLambertMaterial({ + map: this.textures.get(meshTexName), + side: THREE.DoubleSide, + color: 0xffffff + }); + } else { + const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), + side: THREE.DoubleSide + }); + } + + this.vehicleGroup.add(new THREE.Mesh(geometry, material)); + } + } + + this.modelGroup.add(this.vehicleGroup); + } + + /** + * Center and scale the actor, excluding the hat from the bounding box + * so that changing hats doesn't shift the actor's position. + */ + centerAndScaleModel(scaleFactor) { + if (!this.modelGroup) return; + + const box = new THREE.Box3(); + for (let i = 0; i < this.partGroups.length; i++) { + if (i === 1 || !this.partGroups[i]) continue; // skip hat + box.expandByObject(this.partGroups[i]); + } + if (this.vehicleGroup) { + box.expandByObject(this.vehicleGroup); + } + + if (box.isEmpty()) { + super.centerAndScaleModel(scaleFactor); + return; + } + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + + this.modelGroup.position.sub(center); + + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim > 0) { + const scale = scaleFactor / maxDim; + this.modelGroup.scale.setScalar(scale); + } + } + + /** + * Apply position/direction/up transform from ActorLOD data. + * The game uses CalcLocalTransform with direction/up vectors. + */ + applyPartTransform(group, actorLOD) { + const pos = actorLOD.position; + + // Negate X for our coordinate system (matching VehiclePartRenderer's -v.x) + group.position.set(-pos[0], pos[1], pos[2]); + } + + /** + * Get which body part was clicked + * @returns {number} Part index (0-9) or -1 if nothing hit + */ + getClickedPart(mouseEvent) { + if (!this.modelGroup) return -1; + + const rect = this.canvas.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1, + -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1 + ); + + this.raycaster.setFromCamera(mouse, this.camera); + + for (let i = 0; i < this.partGroups.length; i++) { + const partGroup = this.partGroups[i]; + if (!partGroup) continue; + + const meshes = []; + partGroup.traverse((child) => { + if (child instanceof THREE.Mesh) meshes.push(child); + }); + + const intersects = this.raycaster.intersectObjects(meshes); + if (intersects.length > 0) return i; + } + + return -1; + } + + // ─── Animation System ──────────────────────────────────────────── + + /** + * Compute secondary animation column index from mood, matching FUN_10063b90. + * Primary: columns 0-3 (speed 0.7), Secondary: columns 4-6 (speed 4.0). + * NPCs walk at speed 0.6-2.0, so most use the secondary animation which has + * independent head/hat movement. Mood adjustment: if (mood >= 2) mood--. + */ + static getSecondaryAnimColumn(mood) { + let adjMood = mood; + if (adjMood >= 2) adjMood--; + return adjMood + 4; + } + + /** + * Queue a click animation to play after the next model load/reload. + * @param {number} move - The actor's m_move value (0-3) + */ + queueClickAnimation(move) { + this._queuedClickMove = move; + } + + /** + * Load and start the animation for the given actor. If a click animation + * is queued, plays it first (one-shot), then resumes the walking loop. + * Otherwise loads the walking animation from the g_cycles table using the + * secondary (speed 4.0) variant which NPCs typically use in-game. + * When vehicleInfo is provided, uses the vehicle animation instead. + * Falls back to Y-axis rotation if unavailable. + */ + async loadAnimationForActor(actorIndex, mood = 0, vehicleInfo = undefined) { + if (!this.modelGroup) return; + + // Use stored vehicleInfo when not explicitly provided (e.g. resuming after click anim) + if (vehicleInfo === undefined) { + vehicleInfo = this.vehicleInfo; + } + + // If a click animation is queued (skip in vehicle mode), play it first + if (this._queuedClickMove !== null && !vehicleInfo) { + const move = this._queuedClickMove; + this._queuedClickMove = null; + await this.playClickAnimation(move, actorIndex, mood); + return; + } + this._queuedClickMove = null; + + this.stopAnimation(); + + let animName; + if (vehicleInfo) { + // Vehicle mode: use the vehicle animation name + animName = vehicleInfo.vehicleAnim; + } else { + const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0; + // Use secondary animation (speed 4.0 threshold) — this is what NPCs use in-game + // since their walking speed (0.6-2.0) exceeds the 0.7 primary threshold + const secondaryCol = ActorRenderer.getSecondaryAnimColumn(mood); + const primaryCol = mood; + animName = G_CYCLES[suffixIdx]?.[secondaryCol] ?? G_CYCLES[suffixIdx]?.[primaryCol]; + } + + if (!animName) return; // null entry in g_cycles — no animation for this combo + + try { + const animData = await this.fetchAnimationByName(animName); + if (!animData || !this.modelGroup) return; + + const nodeToPartGroup = this.buildNodeToPartGroupMap(); + + // Map vehicle animation nodes if in vehicle mode + if (vehicleInfo && this.vehicleGroup) { + this.mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup); + } + + const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup); + if (tracks.length === 0) return; + + const clip = new THREE.AnimationClip('walk', -1, tracks); + this.mixer = new THREE.AnimationMixer(this.modelGroup); + this.currentAction = this.mixer.clipAction(clip); + this.currentAction.play(); + } catch (e) { + // Animation unavailable — fall back to rotation (handled in updateAnimation()) + } + } + + /** + * Build a lookup mapping animation node names to part groups. + */ + buildNodeToPartGroupMap() { + const map = new Map(); + for (let i = 0; i < this.partGroups.length; i++) { + const pg = this.partGroups[i]; + if (!pg) continue; + const lodName = pg.userData.lodName; + const animNodeName = PART_NAME_TO_ANIM_NODE[lodName]; + if (animNodeName) { + map.set(animNodeName.toLowerCase(), pg); + } + } + return map; + } + + /** + * Map vehicle animation tree nodes to the vehicle group. + * Scans the animation tree for nodes whose name (stripped of trailing + * digits/underscores) matches the vehicle model name, and maps them + * to the vehicleGroup so buildHierarchicalTracks can drive the vehicle. + */ + mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup) { + const vehicleName = vehicleInfo.vehicleModel.toLowerCase(); + + const scanTree = (node) => { + const name = node.data.name?.toLowerCase(); + if (name) { + // Strip trailing digits and underscores to get base name + const baseName = name.replace(/[\d_]+$/, ''); + if (baseName === vehicleName) { + nodeToPartGroup.set(name, this.vehicleGroup); + } + } + for (const child of node.children) { + scanTree(child); + } + }; + + scanTree(animData.rootNode); + } + + /** + * Play a one-shot click animation (pose/gesture) determined by the actor's + * m_move value (0-3). After it finishes, the walking animation resumes. + * Matches LegoEntity::ClickAnimation which uses objectId = m_move + 10. + */ + async playClickAnimation(move, actorIndex, mood) { + if (!this.modelGroup) return; + + this.stopAnimation(); + + const animName = `ClickAnim${move}`; + try { + const animData = await this.fetchAnimationByName(animName); + if (!animData || !this.modelGroup) { + this.loadAnimationForActor(actorIndex, mood); + return; + } + + const nodeToPartGroup = this.buildNodeToPartGroupMap(); + + const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup); + if (tracks.length === 0) { + this.loadAnimationForActor(actorIndex, mood); + return; + } + + const clip = new THREE.AnimationClip('click', -1, tracks); + this.mixer = new THREE.AnimationMixer(this.modelGroup); + const action = this.mixer.clipAction(clip); + action.setLoop(THREE.LoopOnce); + action.clampWhenFinished = true; + this.currentAction = action; + action.play(); + + // When click animation finishes, resume walking + this.mixer.addEventListener('finished', () => { + this.loadAnimationForActor(actorIndex, mood); + }); + } catch (e) { + console.error('ActorRenderer: click animation error', e); + this.loadAnimationForActor(actorIndex, mood); + } + } + + /** + * Fetch and parse an animation file by name (e.g. "CNs001xx"), with caching. + */ + async fetchAnimationByName(animName) { + if (this.animationCache.has(animName)) { + return this.animationCache.get(animName); + } + + const buffer = await fetchAnimation(animName); + if (!buffer) return null; + const animData = parseAnimation(buffer); + this.animationCache.set(animName, animData); + return animData; + } + + /** + * Build world-space keyframe tracks by evaluating the animation tree + * hierarchically. At each unique keyframe time, walks the tree composing + * parent * child transforms via matrix multiplication, then decomposes + * to world-space position/quaternion for each part. + */ + buildHierarchicalTracks(animData, nodeToPartGroup) { + const duration = animData.duration; + + // Collect all unique keyframe times from the tree + const timesSet = new Set([0]); + this.collectKeyframeTimes(animData.rootNode, timesSet); + const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b); + + // For each time, evaluate the full tree and store world-space transforms + const valueMap = new Map(); + const identity = new THREE.Matrix4(); + + for (const time of times) { + this.evaluateNode(animData.rootNode, time, identity, nodeToPartGroup, valueMap, true); + } + + // Convert to Three.js KeyframeTracks + const timesSec = times.map(t => t / 1000); + const tracks = []; + for (const [name, values] of valueMap) { + if (name.endsWith('.position')) { + tracks.push(new THREE.VectorKeyframeTrack(name, timesSec, values)); + } else if (name.endsWith('.quaternion')) { + tracks.push(new THREE.QuaternionKeyframeTrack(name, timesSec, values)); + } else if (name.endsWith('.visible')) { + tracks.push(new THREE.BooleanKeyframeTrack(name, timesSec, values)); + } + } + return tracks; + } + + /** + * Recursively collect all unique keyframe times from the animation tree. + */ + collectKeyframeTimes(node, timesSet) { + const data = node.data; + for (const key of data.translationKeys) timesSet.add(key.time); + for (const key of data.rotationKeys) timesSet.add(key.time); + for (const key of data.scaleKeys) timesSet.add(key.time); + for (const key of data.morphKeys) timesSet.add(key.time); + for (const child of node.children) { + this.collectKeyframeTimes(child, timesSet); + } + } + + /** + * Evaluate a single animation node at a given time, composing its local + * transform with the parent's world matrix. If the node maps to a part + * group, stores the decomposed world-space position and quaternion. + * Recurses into children with the composed matrix. + */ + evaluateNode(node, time, parentMatrix, nodeToPartGroup, valueMap, isRoot = false) { + const data = node.data; + let mat = new THREE.Matrix4(); + + // Strip XZ translation on the actor root to keep the actor in place (treadmill fix). + // Walking anims: the root node IS the actor (named "pepper", "mama", "actor_01", etc.) + // Click anims: actor_01 is nested under wrapper nodes like "-NPa001ns" + const isActorRoot = isRoot || data.name?.toLowerCase() === 'actor_01'; + + // 1. Scale (applied first) + if (data.scaleKeys.length > 0) { + const scale = this.interpolateVertex(data.scaleKeys, time, false); + if (scale) { + mat.scale(scale); + } + if (data.rotationKeys.length > 0) { + mat = this.evaluateRotation(data.rotationKeys, time).multiply(mat); + } + } else if (data.rotationKeys.length > 0) { + mat = this.evaluateRotation(data.rotationKeys, time); + } + + // 2. Translation + if (data.translationKeys.length > 0) { + const vertex = this.interpolateVertex(data.translationKeys, time, true); + if (vertex) { + if (isActorRoot) { + // Actor_01: only apply vertical (Y) to preserve bounce, + // strip horizontal (XZ) so the actor animates in place + mat.elements[13] += vertex.y; + } else { + mat.elements[12] += vertex.x; + mat.elements[13] += vertex.y; + mat.elements[14] += vertex.z; + } + } + } + + // 3. Compose with parent: world = parent * local + mat = parentMatrix.clone().multiply(mat); + + // 4. If this node maps to a part group, decompose and store + const nodeName = data.name?.toLowerCase(); + if (nodeName) { + const partGroup = nodeToPartGroup.get(nodeName); + if (partGroup) { + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + mat.decompose(position, quaternion, scale); + + if (Math.abs(scale.x) < 1e-8 || Math.abs(scale.y) < 1e-8 || Math.abs(scale.z) < 1e-8) { + quaternion.identity(); + } + + const trackName = partGroup.name; + this.pushValues(valueMap, `${trackName}.position`, position.toArray()); + this.pushValues(valueMap, `${trackName}.quaternion`, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]); + + // Evaluate visibility from morph keys (matches game's SetVisibility(data->GetVisibility(p_time))) + if (data.morphKeys.length > 0) { + const visible = this.getVisibility(data.morphKeys, time); + this.pushValues(valueMap, `${trackName}.visible`, [visible]); + } + } + } + + // 5. Recurse into children + for (const child of node.children) { + this.evaluateNode(child, time, mat, nodeToPartGroup, valueMap); + } + } + + /** + * Evaluate rotation keyframes at a given time. + * Handles slerp interpolation between keyframes with flag-based control. + * Coordinate conversion: game (w,x,y,z) → Three.js with X negated. + */ + evaluateRotation(keys, time) { + const { before, after } = this.getBeforeAndAfter(keys, time); + + const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w); + + if (!after) { + if (before.flags & 0x01) { + return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before)); + } + return new THREE.Matrix4(); + } + + if ((before.flags & 0x01) || (after.flags & 0x01)) { + const beforeQ = toQuat(before); + + // Flag 0x04: skip interpolation, use before value + if (after.flags & 0x04) { + return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ); + } + + let afterQ = toQuat(after); + // Flag 0x02: negate the after quaternion before slerp + if (after.flags & 0x02) { + afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w); + } + + const t = (time - before.time) / (after.time - before.time); + const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t); + return new THREE.Matrix4().makeRotationFromQuaternion(result); + } + + return new THREE.Matrix4(); + } + + /** + * Interpolate translation or scale keyframes at a given time. + * For translation: negates X for coordinate system conversion. + * For scale: no negation. + */ + interpolateVertex(keys, time, isTranslation) { + const { before, after } = this.getBeforeAndAfter(keys, time); + + const toVec = (key) => isTranslation + ? new THREE.Vector3(-key.x, key.y, key.z) + : new THREE.Vector3(key.x, key.y, key.z); + + if (!after) { + if (isTranslation && !(before.flags & 0x01)) { + // Check if vertex is non-zero (matching reference behavior) + if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) { + return null; + } + } + return toVec(before); + } + + if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) { + // Both inactive — check if vertices are non-zero + const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5; + const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5; + if (!bNonZero && !aNonZero) return null; + } + + const t = (time - before.time) / (after.time - before.time); + return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t); + } + + /** + * Find the keyframes immediately before and after the given time. + */ + getBeforeAndAfter(keys, time) { + let idx = keys.findIndex(k => k.time > time); + if (idx < 0) idx = keys.length; + const before = keys[Math.max(0, idx - 1)]; + return { before, after: keys[idx] || null }; + } + + /** + * Evaluate visibility from morph keys at a given time. + * Matches game's GetVisibility: returns true (visible) by default, + * or the last morph key's visible flag at or before the given time. + */ + getVisibility(morphKeys, time) { + let lastKey = null; + for (const key of morphKeys) { + if (key.time <= time) { + lastKey = key; + } else { + break; + } + } + return lastKey ? lastKey.visible : true; + } + + /** + * Append values to a named entry in the value map. + */ + pushValues(map, key, values) { + const existing = map.get(key); + if (!existing) { + map.set(key, [...values]); + } else { + existing.push(...values); + } + } + + stopAnimation() { + if (this.currentAction) { + this.currentAction.stop(); + this.currentAction = null; + } + if (this.mixer) { + this.mixer.stopAllAction(); + this.mixer = null; + } + } + + // ─── Scene Management ──────────────────────────────────────────── + + clearModel() { + this.stopAnimation(); + super.clearModel(); + this.partGroups = []; + this.vehicleGroup = null; + this.vehicleInfo = null; + } + + start() { + this.animating = true; + this.clock.start(); + this.animate(); + } + + updateAnimation() { + const delta = this.clock.getDelta(); + + if (this.mixer) { + this.mixer.update(delta); + } + this.controls?.update(); + } + + dispose() { + this.stopAnimation(); + super.dispose(); + this.animationCache.clear(); + } +} diff --git a/src/core/rendering/BaseRenderer.js b/src/core/rendering/BaseRenderer.js new file mode 100644 index 0000000..4cb55ad --- /dev/null +++ b/src/core/rendering/BaseRenderer.js @@ -0,0 +1,258 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +/** + * Base renderer providing shared Three.js setup, lighting, texture creation, + * geometry building, and animation loop for LEGO model viewers. + */ +export class BaseRenderer { + constructor(canvas) { + this.canvas = canvas; + this.animating = false; + this.modelGroup = null; + this.textures = new Map(); + + this.scene = new THREE.Scene(); + + this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true + }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x000000, 0); + + this.setupLighting(); + + this.controls = null; + this._didDrag = false; + } + + setupLighting() { + const ambient = new THREE.AmbientLight(0xffffff, 0.8); + this.scene.add(ambient); + + const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); + sunLight.position.set(1, 2, 3); + this.scene.add(sunLight); + } + + setupControls(target) { + this.controls = new OrbitControls(this.camera, this.canvas); + this.controls.enableZoom = true; + this.controls.enablePan = true; + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.1; + this.controls.autoRotate = true; + this.controls.autoRotateSpeed = 4.0; + this.controls.target.copy(target); + + this.controls.addEventListener('start', () => { + this.controls.autoRotate = false; + }); + + this._onPointerDown = (e) => { + this._didDrag = false; + this._pointerStart = { x: e.clientX, y: e.clientY }; + }; + this._onPointerMove = (e) => { + if (!this._pointerStart) return; + const dx = e.clientX - this._pointerStart.x; + const dy = e.clientY - this._pointerStart.y; + if (dx * dx + dy * dy > 9) this._didDrag = true; + }; + + this.canvas.addEventListener('pointerdown', this._onPointerDown); + this.canvas.addEventListener('pointermove', this._onPointerMove); + + this._initialAutoRotate = this.controls.autoRotate; + this.controls.saveState(); + } + + resetView() { + if (!this.controls) return; + this.controls.reset(); + this.controls.autoRotate = this._initialAutoRotate; + } + + wasDragged() { + return this._didDrag; + } + + /** + * Create a Three.js texture from parsed texture data + */ + createTexture(textureData) { + const canvas = document.createElement('canvas'); + canvas.width = textureData.width; + canvas.height = textureData.height; + const ctx = canvas.getContext('2d'); + + const imageData = ctx.createImageData(textureData.width, textureData.height); + for (let i = 0; i < textureData.pixels.length; i++) { + const colorIdx = textureData.pixels[i]; + const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 }; + imageData.data[i * 4 + 0] = color.r; + imageData.data[i * 4 + 1] = color.g; + imageData.data[i * 4 + 2] = color.b; + imageData.data[i * 4 + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + return texture; + } + + /** + * Create a single geometry from mesh data + */ + createGeometry(mesh, lod) { + if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) { + return null; + } + + const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; + + const vertexIndicesPacked = []; + for (const poly of mesh.polygonIndices) { + vertexIndicesPacked.push(poly.a, poly.b, poly.c); + } + + const textureIndicesFlat = []; + if (hasTexture) { + for (const texPoly of mesh.textureIndices) { + textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); + } + } + + const meshVertices = []; + const meshNormals = []; + const meshUvs = []; + const indices = []; + + for (let i = 0; i < vertexIndicesPacked.length; i++) { + const packed = vertexIndicesPacked[i]; + + if ((packed & 0x80000000) !== 0) { + indices.push(meshVertices.length); + + const gv = packed & 0xFFFF; + const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; + meshVertices.push([-v.x, v.y, v.z]); + + const gn = (packed >>> 16) & 0x7fff; + const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; + meshNormals.push([-n.x, n.y, n.z]); + + if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) { + const tex = textureIndicesFlat[i]; + const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; + meshUvs.push([uv.u, 1 - uv.v]); + } + } else { + indices.push(packed & 0xFFFF); + } + } + + // Reverse face winding + for (let i = 0; i < indices.length; i += 3) { + const temp = indices[i]; + indices[i] = indices[i + 2]; + indices[i + 2] = temp; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3)); + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3)); + geometry.setIndex(indices); + + if (hasTexture && meshUvs.length > 0) { + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2)); + } + + return geometry; + } + + centerAndScaleModel(scaleFactor) { + if (!this.modelGroup) return; + + const box = new THREE.Box3().setFromObject(this.modelGroup); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + + this.modelGroup.position.sub(center); + + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim > 0) { + const scale = scaleFactor / maxDim; + this.modelGroup.scale.setScalar(scale); + } + } + + clearModel() { + if (this.modelGroup) { + this.modelGroup.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry?.dispose(); + child.material?.dispose(); + } + }); + this.scene.remove(this.modelGroup); + this.modelGroup = null; + } + + for (const texture of this.textures.values()) { + texture.dispose(); + } + this.textures.clear(); + } + + start() { + this.animating = true; + this.animate(); + } + + stop() { + this.animating = false; + } + + animate = () => { + if (!this.animating) return; + requestAnimationFrame(this.animate); + + this.updateAnimation(); + + this.renderer.render(this.scene, this.camera); + } + + /** + * Override in subclasses for custom animation logic. + * Called each frame before rendering. + */ + updateAnimation() { + this.controls?.update(); + } + + resize(width, height) { + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + } + + dispose() { + this.animating = false; + if (this.controls) { + this.controls.dispose(); + this.canvas.removeEventListener('pointerdown', this._onPointerDown); + this.canvas.removeEventListener('pointermove', this._onPointerMove); + } + this.clearModel(); + this.renderer?.dispose(); + } +} diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js index 7a872bf..36f4293 100644 --- a/src/core/rendering/VehiclePartRenderer.js +++ b/src/core/rendering/VehiclePartRenderer.js @@ -1,43 +1,21 @@ import * as THREE from 'three'; import { LegoColors } from '../savegame/constants.js'; import { resolveLods } from '../formats/WdbParser.js'; +import { BaseRenderer } from './BaseRenderer.js'; /** * Specialized renderer for LEGO vehicle parts * Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName */ -export class VehiclePartRenderer { +export class VehiclePartRenderer extends BaseRenderer { constructor(canvas) { - this.canvas = canvas; - this.animating = false; - this.modelGroup = null; + super(canvas); this.colorableMeshes = []; // Meshes with INH prefix - this.textures = new Map(); // Cache for loaded textures - this.scene = new THREE.Scene(); - - this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); this.camera.position.set(0, 0, 3); this.camera.lookAt(0, 0, 0); - this.renderer = new THREE.WebGLRenderer({ - canvas, - antialias: true, - alpha: true - }); - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.renderer.setClearColor(0x000000, 0); - - this.setupLighting(); - } - - setupLighting() { - const ambient = new THREE.AmbientLight(0xffffff, 0.8); - this.scene.add(ambient); - - const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); - sunLight.position.set(1, 2, 3); - this.scene.add(sunLight); + this.setupControls(new THREE.Vector3(0, 0, 0)); } /** @@ -50,34 +28,6 @@ export class VehiclePartRenderer { return texName.startsWith('inh') || matName.startsWith('inh'); } - /** - * Create a Three.js texture from parsed texture data - */ - createTexture(textureData) { - const canvas = document.createElement('canvas'); - canvas.width = textureData.width; - canvas.height = textureData.height; - const ctx = canvas.getContext('2d'); - - const imageData = ctx.createImageData(textureData.width, textureData.height); - for (let i = 0; i < textureData.pixels.length; i++) { - const colorIdx = textureData.pixels[i]; - const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 }; - imageData.data[i * 4 + 0] = color.r; - imageData.data[i * 4 + 1] = color.g; - imageData.data[i * 4 + 2] = color.b; - imageData.data[i * 4 + 3] = 255; - } - ctx.putImageData(imageData, 0, 0); - - const texture = new THREE.CanvasTexture(canvas); - texture.minFilter = THREE.NearestFilter; - texture.magFilter = THREE.NearestFilter; - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - return texture; - } - /** * Load part geometry with proper textures and colorable mesh detection * @param {object} roiData - Parsed ROI data with lods @@ -105,9 +55,10 @@ export class VehiclePartRenderer { this.createMeshesFromROI(roiData, threeLegoColor); - this.centerAndScaleModel(); + this.centerAndScaleModel(1.5); this.scene.add(this.modelGroup); + this.controls.autoRotate = true; this.renderer.render(this.scene, this.camera); } @@ -187,76 +138,6 @@ export class VehiclePartRenderer { } } - /** - * Create a single geometry from mesh data - */ - createGeometry(mesh, lod) { - if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) { - return null; - } - - const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; - - const vertexIndicesPacked = []; - for (const poly of mesh.polygonIndices) { - vertexIndicesPacked.push(poly.a, poly.b, poly.c); - } - - const textureIndicesFlat = []; - if (hasTexture) { - for (const texPoly of mesh.textureIndices) { - textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); - } - } - - const meshVertices = []; - const meshNormals = []; - const meshUvs = []; - const indices = []; - - for (let i = 0; i < vertexIndicesPacked.length; i++) { - const packed = vertexIndicesPacked[i]; - - if ((packed & 0x80000000) !== 0) { - indices.push(meshVertices.length); - - const gv = packed & 0xFFFF; - const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; - meshVertices.push([-v.x, v.y, v.z]); - - const gn = (packed >>> 16) & 0x7fff; - const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; - meshNormals.push([-n.x, n.y, n.z]); - - if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) { - const tex = textureIndicesFlat[i]; - const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; - meshUvs.push([uv.u, 1 - uv.v]); - } - } else { - indices.push(packed & 0xFFFF); - } - } - - // Reverse face winding - for (let i = 0; i < indices.length; i += 3) { - const temp = indices[i]; - indices[i] = indices[i + 2]; - indices[i + 2] = temp; - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3)); - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3)); - geometry.setIndex(indices); - - if (hasTexture && meshUvs.length > 0) { - geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2)); - } - - return geometry; - } - /** * Update texture on meshes matching a given texture name * @param {string} textureName - Texture name to match (case-insensitive) @@ -301,70 +182,8 @@ export class VehiclePartRenderer { this.renderer.render(this.scene, this.camera); } - centerAndScaleModel() { - if (!this.modelGroup) return; - - const box = new THREE.Box3().setFromObject(this.modelGroup); - const center = box.getCenter(new THREE.Vector3()); - const size = box.getSize(new THREE.Vector3()); - - this.modelGroup.position.sub(center); - - const maxDim = Math.max(size.x, size.y, size.z); - if (maxDim > 0) { - const scale = 1.5 / maxDim; - this.modelGroup.scale.setScalar(scale); - } - } - clearModel() { - if (this.modelGroup) { - this.modelGroup.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry?.dispose(); - child.material?.dispose(); - } - }); - this.scene.remove(this.modelGroup); - this.modelGroup = null; - } + super.clearModel(); this.colorableMeshes = []; - - for (const texture of this.textures.values()) { - texture.dispose(); - } - this.textures.clear(); - } - - start() { - this.animating = true; - this.animate(); - } - - stop() { - this.animating = false; - } - - animate = () => { - if (!this.animating) return; - requestAnimationFrame(this.animate); - - if (this.modelGroup) { - this.modelGroup.rotation.y += 0.01; - } - - this.renderer.render(this.scene, this.camera); - } - - resize(width, height) { - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(width, height, false); - } - - dispose() { - this.animating = false; - this.clearModel(); - this.renderer?.dispose(); } } diff --git a/src/core/rendering/WdbModelRenderer.js b/src/core/rendering/WdbModelRenderer.js index 26f47c9..c3da856 100644 --- a/src/core/rendering/WdbModelRenderer.js +++ b/src/core/rendering/WdbModelRenderer.js @@ -1,46 +1,23 @@ import * as THREE from 'three'; +import { BaseRenderer } from './BaseRenderer.js'; /** - * Generic Three.js renderer for LEGO Island WDB models - * Handles D3DRM packed vertex format and paletted textures + * Renderer for LEGO Island WDB models with mutable canvas textures. + * Extends BaseRenderer with model loading, canvas-based texture painting, + * and UV raycasting for click interaction. */ -export class WdbModelRenderer { +export class WdbModelRenderer extends BaseRenderer { constructor(canvas) { - this.canvas = canvas; - this.animating = false; - this.modelGroup = null; + super(canvas); this.texturedMesh = null; this.texture = null; this.textureCanvas = null; this.baseImageData = null; this.palette = null; - this.scene = new THREE.Scene(); - - this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); this.camera.position.set(0, 0.2, 7); - this.renderer = new THREE.WebGLRenderer({ - canvas, - antialias: true, - alpha: true - }); - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.renderer.setClearColor(0x000000, 0); - - this.setupLighting(); - } - - /** - * Setup scene lighting - override to customize - */ - setupLighting() { - const ambient = new THREE.AmbientLight(0xffffff, 0.8); - this.scene.add(ambient); - - const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); - sunLight.position.set(1, 2, 3); - this.scene.add(sunLight); + this.setupControls(new THREE.Vector3(0, 0.2, 0)); } /** @@ -52,29 +29,39 @@ export class WdbModelRenderer { this.palette = textureData.palette; this.modelGroup = new THREE.Group(); - const { texturedGeometry, nonTexturedGeometries } = this.createGeometries(roiData); + if (!roiData.lods || roiData.lods.length === 0) { + this.scene.add(this.modelGroup); + return; + } + + const lod = roiData.lods[0]; this.textureCanvas = this.createTextureCanvas(textureData); this.texture = new THREE.CanvasTexture(this.textureCanvas); this.texture.minFilter = THREE.LinearFilter; this.texture.magFilter = THREE.LinearFilter; - if (texturedGeometry) { - const texturedMaterial = new THREE.MeshLambertMaterial({ - map: this.texture, - side: THREE.DoubleSide - }); - this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial); - this.modelGroup.add(this.texturedMesh); - } + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; - for (const { geometry, color } of nonTexturedGeometries) { - const material = new THREE.MeshLambertMaterial({ - color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255), - side: THREE.DoubleSide - }); - const mesh = new THREE.Mesh(geometry, material); - this.modelGroup.add(mesh); + const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; + + if (hasTexture) { + const material = new THREE.MeshLambertMaterial({ + map: this.texture, + side: THREE.DoubleSide + }); + this.texturedMesh = new THREE.Mesh(geometry, material); + this.modelGroup.add(this.texturedMesh); + } else { + const color = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + const material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255), + side: THREE.DoubleSide + }); + this.modelGroup.add(new THREE.Mesh(geometry, material)); + } } this.scene.add(this.modelGroup); @@ -82,107 +69,9 @@ export class WdbModelRenderer { } /** - * Create Three.js BufferGeometries from ROI LOD data - * - * D3DRM packed polygon index format (32-bit): - * - Bits 0-15: vertex index (16 bits) into positions array, OR destination index when reusing - * - Bits 16-30: normal index into normals array - * - Bit 31: "create new vertex" flag - when set, create a new mesh vertex; - * when clear, bits 0-15 is the INDEX into the created mesh vertices array - * - * @param {object} roiData - ROI with lods array - * @returns {{ texturedGeometry: THREE.BufferGeometry|null, nonTexturedGeometries: Array }} - */ - createGeometries(roiData) { - if (!roiData.lods || roiData.lods.length === 0) { - console.warn('ROI has no LODs'); - return { texturedGeometry: null, nonTexturedGeometries: [] }; - } - - const lod = roiData.lods[0]; - let texturedGeometry = null; - const nonTexturedGeometries = []; - - for (const mesh of lod.meshes) { - const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; - - // Flatten polygon indices - const vertexIndicesPacked = []; - for (const poly of mesh.polygonIndices) { - vertexIndicesPacked.push(poly.a, poly.b, poly.c); - } - - // Flatten texture indices if present - const textureIndicesFlat = []; - if (hasTexture) { - for (const texPoly of mesh.textureIndices) { - textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); - } - } - - const meshVertices = []; - const meshNormals = []; - const meshUvs = []; - const indices = []; - - for (let i = 0; i < vertexIndicesPacked.length; i++) { - const packed = vertexIndicesPacked[i]; - - if ((packed & 0x80000000) !== 0) { - // Create flag is set - create new mesh vertex - indices.push(meshVertices.length); - - const gv = packed & 0xFFFF; // Vertex index (16 bits) - const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; - // Negate X for coordinate system conversion - meshVertices.push([-v.x, v.y, v.z]); - - const gn = (packed >>> 16) & 0x7fff; // Normal index (15 bits) - const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; - meshNormals.push([-n.x, n.y, n.z]); - - if (hasTexture && lod.textureVertices.length > 0) { - const tex = textureIndicesFlat[i]; - const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; - meshUvs.push([uv.u, 1 - uv.v]); - } - } else { - // Create flag NOT set - reuse existing mesh vertex by index - indices.push(packed & 0xFFFF); - } - } - - // Reverse face winding (swap indices 0 and 2 of each triangle) - for (let i = 0; i < indices.length; i += 3) { - const temp = indices[i]; - indices[i] = indices[i + 2]; - indices[i + 2] = temp; - } - - // Create geometry - const geometry = new THREE.BufferGeometry(); - const vertices = meshVertices.flat(); - const normals = meshNormals.flat(); - - geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); - geometry.setIndex(indices); - - if (hasTexture) { - const uvs = meshUvs.flat(); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); - texturedGeometry = geometry; - } else { - const color = mesh.properties?.color || { r: 128, g: 128, b: 128 }; - nonTexturedGeometries.push({ geometry, color }); - } - } - - return { texturedGeometry, nonTexturedGeometries }; - } - - /** - * Create canvas texture from paletted LEGO texture data + * Create canvas texture from paletted LEGO texture data. + * Unlike BaseRenderer.createTexture(), this keeps a reference to the + * canvas and base image data so subclasses can paint over it (e.g. scores). * @param {object} textureData - { width, height, palette, pixels } * @returns {HTMLCanvasElement} */ @@ -234,63 +123,8 @@ export class WdbModelRenderer { return null; } - /** - * Start animation loop - */ - start() { - this.animating = true; - this.animate(); - } - - /** - * Stop animation loop - */ - stop() { - this.animating = false; - } - - /** - * Animation loop - override to customize animation - */ - animate = () => { - if (!this.animating) return; - requestAnimationFrame(this.animate); - - if (this.modelGroup) { - this.modelGroup.rotation.y += 0.008; - } - - this.renderer.render(this.scene, this.camera); - } - - /** - * Resize renderer to match canvas size - * @param {number} width - * @param {number} height - */ - resize(width, height) { - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(width, height, false); - } - - /** - * Clean up resources - */ dispose() { - this.animating = false; - - if (this.modelGroup) { - this.modelGroup.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry?.dispose(); - child.material?.dispose(); - } - }); - this.scene.remove(this.modelGroup); - } - this.texture?.dispose(); - this.renderer?.dispose(); + super.dispose(); } } diff --git a/src/core/savegame/actorConstants.js b/src/core/savegame/actorConstants.js new file mode 100644 index 0000000..1615040 --- /dev/null +++ b/src/core/savegame/actorConstants.js @@ -0,0 +1,715 @@ +/** + * Actor data constants ported from LEGO1 source: + * isle/LEGO1/lego/legoomni/src/common/legoactors.cpp + * isle/LEGO1/lego/legoomni/include/legoactors.h + */ + +// LegoActorLOD flags +export const ActorLODFlags = Object.freeze({ + USE_TEXTURE: 0x01, + USE_COLOR: 0x02 +}); + +// LegoActorParts enum — indices into the 10-part array on each actor +export const ActorPart = Object.freeze({ + BODY: 0, + INFOHAT: 1, + INFOGRON: 2, + HEAD: 3, + ARMLFT: 4, + ARMRT: 5, + CLAWLFT: 6, + CLAWRT: 7, + LEGLFT: 8, + LEGRT: 9 +}); + +/** + * g_actorLODs[11] — transform/bounding data for each body part position. + * Fields: name, parentName, flags, boundingSphere[4], boundingBox[6], + * position[3], direction[3], up[3] + */ +export const ActorLODs = Object.freeze([ + { name: 'top', parentName: 'top', flags: 0, + boundingSphere: [0.000267, 0.780808, -0.01906, 0.951612], + boundingBox: [-0.461166, -0.002794, -0.299442, 0.4617, 1.56441, 0.261321], + position: [0, 0, 0], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'body', parentName: 'body', flags: ActorLODFlags.USE_TEXTURE, + boundingSphere: [0.00158332, 0.401828, -0.00048697, 0.408071], + boundingBox: [-0.287507, 0.150419, -0.147452, 0.289219, 0.649774, 0.14258], + position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'infohat', parentName: 'infohat', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0, -0.00938, -0.01955, 0.35], + boundingBox: [-0.231822, -0.140237, -0.320954, 0.234149, 0.076968, 0.249083], + position: [0.000191, 1.519793, 0.001767], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'infogron', parentName: 'infogron', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0, 0.11477, 0.00042, 0.26], + boundingBox: [-0.285558, -0.134391, -0.142231, 0.285507, 0.152986, 0.143071], + position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'head', parentName: 'head', flags: ActorLODFlags.USE_TEXTURE, + boundingSphere: [0, -0.03006, 0, 0.3], + boundingBox: [-0.189506, -0.209665, -0.189824, 0.189532, 0.228822, 0.194945], + position: [-0.00105, 1.293115, 0.001781], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'arm-lft', parentName: 'arm-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [-0.06815, -0.0973747, 0.0154655, 0.237], + boundingBox: [-0.137931, -0.282775, -0.105316, 0.000989, 0.100221, 0.140759], + position: [-0.225678, 0.963312, 0.023286], + direction: [-0.003031, -0.017187, 0.999848], up: [0.173622, 0.984658, 0.017453] }, + { name: 'arm-rt', parentName: 'arm-rt', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.0680946, -0.097152, 0.0152722, 0.237], + boundingBox: [0.00141, -0.289604, -0.100831, 0.138786, 0.09291, 0.145437], + position: [0.223494, 0.963583, 0.018302], + direction: [0, 0, 1], up: [-0.173648, 0.984808, 0] }, + { name: 'claw-lft', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15], + boundingBox: [-0.089838, -0.246208, -0.117735, 0.091275, 0.000263, 0.07215], + position: [-0.341869, 0.700355, 0.092779], + direction: [0.000001, 0.000003, 1], up: [0.190812, 0.981627, -0.000003] }, + { name: 'claw-rt', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15], + boundingBox: [-0.095016, -0.245349, -0.117979, 0.086528, 0.00067, 0.069743], + position: [0.343317, 0.69924, 0.096123], + direction: [0.00606, -0.034369, 0.999391], up: [-0.190704, 0.981027, 0.034894] }, + { name: 'leg-lft', parentName: 'leg', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33], + boundingBox: [-0.129782, -0.440428, -0.184207, 0.13817, 0.118415, 0.122607], + position: [-0.156339, 0.436087, 0.006822], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'leg-rt', parentName: 'leg', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33], + boundingBox: [-0.132864, -0.437138, -0.183944, 0.134614, 0.12043, 0.121888], + position: [0.151154, 0.436296, 0.007373], direction: [0, 0, 1], up: [0, 1, 0] } +]); + +// Index arrays — 0xff marks end-of-list sentinel +export const hatPartIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +export const pepperHatPartIndices = [21, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +export const infomanHatPartIndices = [22]; +export const ghostHatPartIndices = [20]; +export const bodyPartIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const hatColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const faceTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; +export const chestTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 27]; +export const armColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const clawRightColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const clawLeftColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const gronColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const legColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; + +// Name arrays +export const hatPartNames = [ + 'baseball', 'chef', 'cap', 'cophat', 'helmet', 'ponytail', 'pageboy', 'shrthair', + 'bald', 'flower', 'cboyhat', 'cuphat', 'cathat', 'backbcap', 'pizhat', 'caprc', + 'capch', 'capdb', 'capjs', 'capmd', 'sheet', 'phat', 'icap' +]; + +export const bodyPartNames = [ + 'body', 'bodyred', 'bodyblck', 'bodywhte', 'bodyyllw', 'bodyblue', 'bodygren', 'bodybrwn' +]; + +export const chestTextures = [ + 'peprchst.gif', 'mamachst.gif', 'papachst.gif', 'nickchst.gif', 'norachst.gif', + 'infochst.gif', 'shftchst.gif', 'rac1chst.gif', 'rac2chst.gif', 'bth1chst.gif', + 'bth2chst.gif', 'mech.gif', 'polkadot.gif', 'bowtie.gif', 'postchst.gif', + 'vest.gif', 'doctor.gif', 'copchest.gif', 'l.gif', 'e.gif', + 'g.gif', 'o.gif', 'fruit.gif', 'flowers.gif', 'construct.gif', + 'paint.gif', 'l6.gif', 'unkchst.gif' +]; + +export const faceTextures = [ + 'peprface.gif', 'mamaface.gif', 'papaface.gif', 'nickface.gif', 'noraface.gif', + 'infoface.gif', 'shftface.gif', 'dogface.gif', 'womanshd.gif', 'smileshd.gif', + 'woman.gif', 'smile.gif', 'mustache.gif', 'black.gif' +]; + +export const colorAliases = [ + 'lego white', 'lego black', 'lego yellow', 'lego red', 'lego blue', 'lego brown', 'lego lt grey', 'lego green' +]; + +// Reference names for the index arrays (used to build part configs) +const HP = 'hatPartIndices'; +const PHP = 'pepperHatPartIndices'; +const IHP = 'infomanHatPartIndices'; +const GHP = 'ghostHatPartIndices'; +const BP = 'bodyPartIndices'; +const HC = 'hatColorIndices'; +const FT = 'faceTextureIndices'; +const CT = 'chestTextureIndices'; +const AC = 'armColorIndices'; +const CRC = 'clawRightColorIndices'; +const CLC = 'clawLeftColorIndices'; +const GC = 'gronColorIndices'; +const LC = 'legColorIndices'; + +// Lookup tables for the index array references +const indexArrays = { + [HP]: hatPartIndices, + [PHP]: pepperHatPartIndices, + [IHP]: infomanHatPartIndices, + [GHP]: ghostHatPartIndices, + [BP]: bodyPartIndices, + [HC]: hatColorIndices, + [FT]: faceTextureIndices, + [CT]: chestTextureIndices, + [AC]: armColorIndices, + [CRC]: clawRightColorIndices, + [CLC]: clawLeftColorIndices, + [GC]: gronColorIndices, + [LC]: legColorIndices +}; + +const nameArrays = { + hatPartNames, + bodyPartNames, + chestTextures, + faceTextures, + colorAliases +}; + +/** + * Helper to build a Part entry from compact form. + * C++ Part has: { partNameIndices, partName, partNameIndex, nameIndices, names, nameIndex } + * We store references to our JS arrays. + */ +function P(partNameIndicesRef, partNamesRef, partNameIndex, nameIndicesRef, namesRef, nameIndex) { + return { + partNameIndices: partNameIndicesRef ? indexArrays[partNameIndicesRef] : null, + partNames: partNamesRef ? nameArrays[partNamesRef] : null, + partNameIndex, + nameIndices: indexArrays[nameIndicesRef], + names: nameArrays[namesRef], + nameIndex + }; +} + +// Short aliases for name array refs +const HPN = 'hatPartNames'; +const BPN = 'bodyPartNames'; +const CTN = 'chestTextures'; +const FTN = 'faceTextures'; +const CA = 'colorAliases'; + +/** + * g_actorInfoInit[66] — All 66 game actors. + * Each entry: { name, sound, move, mood, parts[10] } + * Parts order: body, infohat, infogron, head, armlft, armrt, clawlft, clawrt, leglft, legrt + */ +export const ActorInfoInit = Object.freeze([ + /* 0 */ { name: 'pepper', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 1 */ { name: 'mama', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 1), P(HP, HPN, 1, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 1), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 2 */ { name: 'papa', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 2), P(HP, HPN, 1, HC, CA, 0), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 2), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 3 */ { name: 'nick', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 3), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 3), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 4 */ { name: 'laura', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 4), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 4), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 5 */ { name: 'infoman', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 5), P(IHP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 5), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 6 */ { name: 'brickstr', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 6), P(HP, HPN, 13, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 6), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 4), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 7 */ { name: 'studs', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 7), P(HP, HPN, 4, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 8 */ { name: 'rhoda', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 8), P(HP, HPN, 4, HC, CA, 3), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 9 */ { name: 'valerie', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 10 */ { name: 'snap', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 11 */ { name: 'pt', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 6, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 12 */ { name: 'mg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 13 */ { name: 'bu', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] }, + /* 14 */ { name: 'ml', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 14), P(HP, HPN, 2, HC, CA, 4), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 15 */ { name: 'nu', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 16 */ { name: 'na', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 15), P(HP, HPN, 10, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 17 */ { name: 'cl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 19, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 18 */ { name: 'en', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 19 */ { name: 're', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 20 */ { name: 'ro', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 21 */ { name: 'd1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 15, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 22 */ { name: 'd2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 16, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 23 */ { name: 'd3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 17, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 24 */ { name: 'd4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 18, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 25 */ { name: 'l1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 18), P(HP, HPN, 5, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 26 */ { name: 'l2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 19), P(HP, HPN, 6, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 27 */ { name: 'l3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 20), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 28 */ { name: 'l4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 21), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 29 */ { name: 'l5', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 30 */ { name: 'l6', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 0, HC, CA, 1), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 31 */ { name: 'b1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 32 */ { name: 'b2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 5, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 33 */ { name: 'b3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 34 */ { name: 'b4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 35 */ { name: 'cm', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 36 */ { name: 'gd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 37 */ { name: 'rd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] }, + /* 38 */ { name: 'pg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 39 */ { name: 'bd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 40 */ { name: 'sy', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 41 */ { name: 'gn', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 6, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] }, + /* 42 */ { name: 'df', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 23), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 43 */ { name: 'bs', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 44 */ { name: 'lt', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 45 */ { name: 'st', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 5), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 46 */ { name: 'bm', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 47 */ { name: 'jk', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 48 */ { name: 'ghost', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 49 */ { name: 'ghost01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 50 */ { name: 'ghost02', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 51 */ { name: 'ghost03', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 52 */ { name: 'ghost04', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 53 */ { name: 'ghost05', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 54 */ { name: 'hg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 8, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 55 */ { name: 'pntgy', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 56 */ { name: 'pep', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 57 */ { name: 'cop01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 58 */ { name: 'actor_01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 59 */ { name: 'actor_02', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 60 */ { name: 'actor_03', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 61 */ { name: 'actor_04', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 62 */ { name: 'actor_05', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 63 */ { name: 'btmncycl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 64 */ { name: 'cboycycl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 65 */ { name: 'boatman', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] } +]); + +/** + * Display names for the 66 actors, from savegame.ksy doc comments. + * Falls back to the internal name (ActorInfoInit[i].name) when not listed. + */ +export const ActorDisplayNames = Object.freeze([ + /* 0 */ 'Pepper Roni', + /* 1 */ 'Mama Brickolini', + /* 2 */ 'Papa Brickolini', + /* 3 */ 'Nick Brick', + /* 4 */ 'Laura Brick', + /* 5 */ 'Infomaniac', + /* 6 */ 'Brickster', + /* 7 */ 'Studs Linkin', + /* 8 */ 'Rhoda Hogg', + /* 9 */ 'Valerie Stubbins', + /* 10 */ 'Snap Lockitt', + /* 11 */ 'pt', + /* 12 */ 'Maggie Post', + /* 13 */ 'Buck Pounds', + /* 14 */ 'Ed Mail', + /* 15 */ 'Nubby Stevens', + /* 16 */ 'Nancy Nubbins', + /* 17 */ 'Dr. Clickitt', + /* 18 */ 'Enter', + /* 19 */ 'Return', + /* 20 */ 'Captain D. Rom', + /* 21 */ 'Bill Ding (Race Car)', + /* 22 */ 'Bill Ding (Helicopter)', + /* 23 */ 'Bill Ding (Dune Buggy)', + /* 24 */ 'Bill Ding (Jetski)', + /* 25 */ 'Flying Legandos #1', + /* 26 */ 'Flying Legandos #2', + /* 27 */ 'Flying Legandos #3', + /* 28 */ 'Flying Legandos #4', + /* 29 */ 'Flying Legandos #5', + /* 30 */ 'Flying Legandos #6', + /* 31 */ 'Legobobs #1', + /* 32 */ 'Legobobs #2', + /* 33 */ 'Legobobs #3', + /* 34 */ 'Legobobs #4', + /* 35 */ 'Brazilian Carmen', + /* 36 */ 'Gideon Worse', + /* 37 */ 'Red Greenbase', + /* 38 */ 'Polly Gone', + /* 39 */ 'Bradford Brickford', + /* 40 */ 'Shiney Doris', + /* 41 */ 'Glen Funberg', + /* 42 */ 'Dorothy Funberg', + /* 43 */ 'Brian Shrimp', + /* 44 */ 'Luke Tepid', + /* 45 */ 'Shorty Tails', + /* 46 */ 'Bumpy Kindergreen', + /* 47 */ "Jack O'Trades", + /* 48 */ 'Ghost #1', + /* 49 */ 'Ghost #2', + /* 50 */ 'Ghost #3', + /* 51 */ 'Ghost #4', + /* 52 */ 'Ghost #5', + /* 53 */ 'Ghost #6', + /* 54 */ 'hg', + /* 55 */ 'pntgy', + /* 56 */ 'pep', + /* 57 */ 'cop01', + /* 58 */ 'actor_01', + /* 59 */ 'actor_02', + /* 60 */ 'actor_03', + /* 61 */ 'actor_04', + /* 62 */ 'actor_05', + /* 63 */ 'btmncycl', + /* 64 */ 'cboycycl', + /* 65 */ 'boatman' +]); + +/** + * Vehicle associations for actors. Maps ActorInfoInit index -> vehicle info. + * From g_characters[].m_vehicleId and g_vehicles[] in legoanimationmanager.cpp. + * vehicleAnim = g_cycles[row][10] for each character. + */ +export const ActorVehicles = Object.freeze({ + 0: { vehicleModel: 'board', vehicleAnim: 'CNs001Sk' }, // pepper -> skateboard + 3: { vehicleModel: 'motoni', vehicleAnim: 'CNs011Ni' }, // nick -> motorcycle + 4: { vehicleModel: 'motola', vehicleAnim: 'CNs011La' }, // laura -> motorcycle + 37: { vehicleModel: 'bikerd', vehicleAnim: 'CNs001Rd' }, // rd -> bicycle + 38: { vehicleModel: 'bikepg', vehicleAnim: 'CNs001Pg' }, // pg -> bicycle + 39: { vehicleModel: 'bikebd', vehicleAnim: 'CNs001Bd' }, // bd -> bicycle + 40: { vehicleModel: 'bikesy', vehicleAnim: 'CNs001Sy' }, // sy -> bicycle + 56: { vehicleModel: 'board', vehicleAnim: 'CNs001Sk' }, // pep (pepper alias) +}); + +export const VehicleDisplayNames = Object.freeze({ + 'board': 'Skateboard', + 'motoni': 'Motorcycle', + 'motola': 'Motorcycle', + 'bikebd': 'Bicycle', + 'bikepg': 'Bicycle', + 'bikerd': 'Bicycle', + 'bikesy': 'Bicycle', +}); + +/** + * Save file field offsets within the 16-byte character record. + * The save file stores these per-character values that override defaults: + * sound(S32) + move(S32) + mood(U8) + hatPartNameIndex(U8) + hatNameIndex(U8) + * + infogronNameIndex(U8) + armlftNameIndex(U8) + armrtNameIndex(U8) + * + leglftNameIndex(U8) + legrtNameIndex(U8) + */ +export const CharacterFieldOffsets = Object.freeze({ + sound: 0, // S32 + move: 4, // S32 + mood: 8, // U8 + hatPartNameIndex: 9, // U8 + hatNameIndex: 10, // U8 + infogronNameIndex: 11, // U8 + armlftNameIndex: 12, // U8 + armrtNameIndex: 13, // U8 + leglftNameIndex: 14, // U8 + legrtNameIndex: 15 // U8 +}); + +export const CHARACTER_RECORD_SIZE = 16; + diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js index eb6c8cf..e168c0f 100644 --- a/src/core/savegame/constants.js +++ b/src/core/savegame/constants.js @@ -197,7 +197,9 @@ export const LegoColors = Object.freeze({ 'lego green': { r: 0x00, g: 0x78, b: 0x2d }, 'lego red': { r: 0xcb, g: 0x12, b: 0x20 }, 'lego white': { r: 0xfa, g: 0xfa, b: 0xfa }, - 'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 } + 'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 }, + 'lego brown': { r: 0x4a, g: 0x23, b: 0x00 }, + 'lego lt grey': { r: 0xc0, g: 0xc0, b: 0xc0 } }); // LEGO color display names and order diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index 14e2e7c..8dd334c 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -91,6 +91,8 @@ export async function listSaveSlots() { missions: null, variables: null, act1State: null, + characters: null, + charactersOffset: null, playerName: null, buffer: null }; @@ -104,6 +106,8 @@ export async function listSaveSlots() { slot.missions = parsed.missions; slot.variables = parsed.variables; slot.act1State = parsed.act1State || null; + slot.characters = parsed.characters || null; + slot.charactersOffset = parsed.charactersOffset || null; slot.buffer = buffer; // Try to get player name @@ -168,6 +172,8 @@ export async function loadSaveSlot(slotNumber) { missions: parsed.missions, variables: parsed.variables, act1State: parsed.act1State || null, + characters: parsed.characters || null, + charactersOffset: parsed.charactersOffset || null, playerName, buffer }; @@ -238,6 +244,19 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply character update(s) + if (updates.character) { + const entries = Array.isArray(updates.character) ? updates.character : [updates.character]; + for (const { characterIndex, field, value } of entries) { + const charSerializer = createSerializer(newBuffer); + const result = charSerializer.updateCharacter(characterIndex, field, value); + if (result) { + newBuffer = result; + modified = true; + } + } + } + // Apply texture update if (updates.texture) { const { textureName, textureData } = updates.texture; diff --git a/src/lib/EditorTooltip.svelte b/src/lib/EditorTooltip.svelte index c178b3d..4918415 100644 --- a/src/lib/EditorTooltip.svelte +++ b/src/lib/EditorTooltip.svelte @@ -1,11 +1,26 @@

- ? - {text} - +
+ {#if onResetCamera} + + {/if} + ? + {text} + +
@@ -17,11 +32,51 @@ width: 100%; } - .tooltip-trigger { + .tooltip-icons { position: absolute; top: 0; right: 0; z-index: 1; + display: flex; + align-items: center; + gap: 8px; + } + + .reset-camera-btn { + position: relative; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--color-border-medium); + color: #eee; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + padding: 0; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + /* Expand touch target on mobile */ + @media (pointer: coarse) { + .reset-camera-btn::before { + content: ''; + position: absolute; + inset: -10px; + } + } + + @media (hover: hover) { + .reset-camera-btn:hover { + background-color: var(--color-primary); + color: var(--color-bg-panel); + } + } + + .reset-camera-btn:active { + background-color: var(--color-primary); + color: var(--color-bg-panel); } .editor-tooltip-content { diff --git a/src/lib/ReadMePage.svelte b/src/lib/ReadMePage.svelte index 320b48a..1473d45 100644 --- a/src/lib/ReadMePage.svelte +++ b/src/lib/ReadMePage.svelte @@ -37,7 +37,13 @@ { type: 'New', text: 'Save Editor lets you view and modify save files — change your player name, character, and high scores directly from the browser' }, { type: 'New', text: 'Sky Color Editor allows customizing the island sky gradient colors in your save file' }, { type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' }, - { type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' } + { type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' }, + { type: 'New', text: 'Actor Editor with animated 3D character preview — customize hats, colors, moods, sounds, and moves for all 66 game actors' }, + { type: 'New', text: 'Vehicle rendering in Actor Editor — toggle to see actors with their assigned vehicles' }, + { type: 'New', text: 'Click animations and sound effects in Actor Editor matching the original game behavior' }, + { type: 'New', text: 'Drag-to-orbit, zoom, and pan controls on all 3D previews (vehicle, actor, and score cube editors)' }, + { type: 'New', text: 'Camera reset button on 3D editors to restore the default view' }, + { type: 'Fixed', text: 'Sticky hover highlights on touch devices for editor buttons' } ]}, { id: 'cl1', title: 'January 2026', items: [ { type: 'New', text: 'Debug menu for developers and power users. Tap the LEGO Island logo 5 times to unlock OGEL mode and access debug features like teleporting to locations, switching acts, and playing animations' }, diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index 8970c86..b1ff811 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -1,11 +1,13 @@ + + renderer?.resetView()}> +
+ + + {#if loading} +
+
+
+ {:else if error} +
{error}
+ {/if} +
+ +
+
+ +
+ {actorIndex + 1} / {ActorInfoInit.length} + {actorName} +
+ +
+ {#if vehicleInfo} + + {/if} +
+ +
+ {#if !isDefault && !loading && !error} + + {/if} +
+
+ + diff --git a/src/lib/save-editor/LightPositionEditor.svelte b/src/lib/save-editor/LightPositionEditor.svelte index 21fd7d7..9957cab 100644 --- a/src/lib/save-editor/LightPositionEditor.svelte +++ b/src/lib/save-editor/LightPositionEditor.svelte @@ -1,4 +1,6 @@ - + renderer?.resetView()}>
{ - const response = await fetch(`/textures/${texFile}.tex`); - if (!response.ok) return null; - const buffer = await response.arrayBuffer(); + const buffer = await fetchTexture(texFile); + if (!buffer) return null; const parsed = parseTex(buffer); if (parsed.textures.length > 0) { return { name: texFile, ...parsed.textures[0] }; @@ -251,6 +251,7 @@ function cycleColor() { if (!currentPart || partError) return; + if (renderer?.wasDragged()) return; // Find current color index and cycle to next const currentIdx = LegoColorNames.indexOf(currentColorValue); @@ -322,7 +323,7 @@ - + renderer?.resetView()}>