From fe87b3d99fc98acaed1732e1e1e2720c9bc83f24 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Fri, 13 Feb 2026 14:28:35 -0800 Subject: [PATCH] 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. --- scripts/generate-manifest.js | 34 +++++++++- src/core/rendering/ActorRenderer.js | 90 +++++++++++++++++++++++--- src/lib/save-editor/ActorEditor.svelte | 43 ++++++------ 3 files changed, 139 insertions(+), 28 deletions(-) diff --git a/scripts/generate-manifest.js b/scripts/generate-manifest.js index 3e72cfa..bcd46a0 100644 --- a/scripts/generate-manifest.js +++ b/scripts/generate-manifest.js @@ -105,6 +105,15 @@ const ANIMATIONS = [ ['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'], +]; + // [name, siFile, objectId, size, md5] const TEXTURES = [ ['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'], @@ -278,7 +287,28 @@ async function main() { failed++; } } - console.log(` ${found}/${ANIMATIONS.length} animations found\n`); + 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 result = verifyRanges(sndanimSI, clickRanges.get(objectId), size, expectedMd5); + if (result) { + manifest.animations[name] = formatResult('Scripts/SNDANIM.SI', result); + clickFound++; + found++; + } else { + console.error(` FAILED: ${name} (objectId ${objectId})`); + failed++; + } + } + console.log(` ${clickFound}/${CLICK_ANIMATIONS.length} click animations found\n`); // --- Textures (across Build SI files) --- // Group textures by SI file so we scan each file once @@ -345,7 +375,7 @@ async function main() { await fs.writeFile(OUTPUT_PATH, JSON.stringify(manifest)); console.log(`Wrote ${OUTPUT_PATH}`); - console.log(`Total: ${found} assets (${ANIMATIONS.length} animations, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); + console.log(`Total: ${found} assets (${ANIMATIONS.length} walking + ${CLICK_ANIMATIONS.length} click animations, ${TEXTURES.length} textures, ${BITMAPS.length} bitmaps)`); } main().catch(err => { diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js index caeeb35..dfbb267 100644 --- a/src/core/rendering/ActorRenderer.js +++ b/src/core/rendering/ActorRenderer.js @@ -89,6 +89,7 @@ export class ActorRenderer extends BaseRenderer { 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); @@ -370,16 +371,31 @@ export class ActorRenderer extends BaseRenderer { } /** - * Load and start walking animation for the given actor using g_cycles table. - * Loads the secondary (speed 4.0) animation which NPCs typically use in-game, - * falling back to primary (speed 0.7) if unavailable. Matches FUN_10063b90 - * in legoanimationmanager.cpp. Pre-computes world-space transforms by evaluating - * the animation tree hierarchically, then plays via AnimationMixer. + * 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. * Falls back to Y-axis rotation if unavailable. */ async loadAnimationForActor(actorIndex, mood = 0) { if (!this.modelGroup) return; + // If a click animation is queued, play it first, then resume walking + if (this._queuedClickMove !== null) { + const move = this._queuedClickMove; + this._queuedClickMove = null; + await this.playClickAnimation(move, actorIndex, mood); + return; + } + this.stopAnimation(); const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0; @@ -419,6 +435,59 @@ export class ActorRenderer extends BaseRenderer { } } + /** + * 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 = 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) { + nodeToPartGroup.set(animNodeName.toLowerCase(), pg); + } + } + + 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. */ @@ -495,6 +564,11 @@ export class ActorRenderer extends BaseRenderer { 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); @@ -512,9 +586,9 @@ export class ActorRenderer extends BaseRenderer { if (data.translationKeys.length > 0) { const vertex = this.interpolateVertex(data.translationKeys, time, true); if (vertex) { - if (isRoot) { - // Root: only apply vertical (Y) to preserve bounce, - // strip horizontal (XZ) so the actor walks in place + 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; diff --git a/src/lib/save-editor/ActorEditor.svelte b/src/lib/save-editor/ActorEditor.svelte index 4e8c819..0ab2783 100644 --- a/src/lib/save-editor/ActorEditor.svelte +++ b/src/lib/save-editor/ActorEditor.svelte @@ -108,23 +108,28 @@ if (!renderer || !slot?.characters || !charState) return; const playerId = slot.header?.actorId; + let acted = false; + let clickMove = charState.move; switch (playerId) { - case Actor.PEPPER: - switchVariant(); - break; - case Actor.MAMA: - switchSound(); - break; - case Actor.PAPA: - switchMove(); - break; - case Actor.NICK: - switchColor(event); - break; - case Actor.LAURA: - switchMood(); - break; + case Actor.PEPPER: switchVariant(); acted = true; break; + case Actor.MAMA: switchSound(); acted = true; break; + case Actor.PAPA: clickMove = switchMove(); acted = true; break; + case Actor.NICK: acted = switchColor(event); break; + case Actor.LAURA: switchMood(); acted = true; break; + } + + if (!acted) return; + + // Queue click animation — consumed by loadAnimationForActor + renderer.queueClickAnimation(clickMove); + + // Sound/move changes don't affect the actorKey, so the reactive block + // won't trigger a model reload. Play the click animation directly. + // For visual changes (hat/color/mood), the reactive block will call + // loadCurrentActor → loadAnimationForActor, which consumes the queue. + if (playerId === Actor.MAMA || playerId === Actor.PAPA) { + renderer.loadAnimationForActor(actorIndex, charState.mood); } } @@ -152,11 +157,12 @@ onUpdate({ character: { characterIndex: actorIndex, field: 'move', value: nextMove } }); + return nextMove; } function switchColor(event) { let partIdx = renderer.getClickedPart(event); - if (partIdx < 0) return; + if (partIdx < 0) return false; // Remap clicked part to the part that owns its color // (matches SwitchColor in legocharactermanager.cpp) @@ -176,10 +182,10 @@ }; const field = fieldMap[partIdx]; - if (!field) return; + if (!field) return false; const part = actorInfo.parts[partIdx]; - if (!part.nameIndices) return; + if (!part.nameIndices) return false; const currentIdx = charState[field] ?? part.nameIndex; const maxIdx = part.nameIndices.length; @@ -188,6 +194,7 @@ onUpdate({ character: { characterIndex: actorIndex, field, value: nextIdx } }); + return true; } function switchMood() {