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.
This commit is contained in:
Christian Semmler 2026-02-13 14:28:35 -08:00
parent c2d7570d41
commit fe87b3d99f
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
3 changed files with 139 additions and 28 deletions

View File

@ -105,6 +105,15 @@ const ANIMATIONS = [
['CNsx11Ni', 178, 879, '0e09f9119f37308af94956c38527e758'], ['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] // [name, siFile, objectId, size, md5]
const TEXTURES = [ const TEXTURES = [
['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'], ['CHJETL1', 'Scripts/Build/COPTER.SI', 112, 4235, 'af5010e9de08240c1ff7ad08ae90087e'],
@ -278,7 +287,28 @@ async function main() {
failed++; 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) --- // --- Textures (across Build SI files) ---
// Group textures by SI file so we scan each file once // 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)); await fs.writeFile(OUTPUT_PATH, JSON.stringify(manifest));
console.log(`Wrote ${OUTPUT_PATH}`); 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 => { main().catch(err => {

View File

@ -89,6 +89,7 @@ export class ActorRenderer extends BaseRenderer {
this.mixer = null; this.mixer = null;
this.currentAction = null; this.currentAction = null;
this.animationCache = new Map(); // suffix → parsed animation data 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.position.set(2, 0.8, 3.5);
this.camera.lookAt(0, 0.2, 0); 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. * Queue a click animation to play after the next model load/reload.
* Loads the secondary (speed 4.0) animation which NPCs typically use in-game, * @param {number} move - The actor's m_move value (0-3)
* falling back to primary (speed 0.7) if unavailable. Matches FUN_10063b90 */
* in legoanimationmanager.cpp. Pre-computes world-space transforms by evaluating queueClickAnimation(move) {
* the animation tree hierarchically, then plays via AnimationMixer. 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. * Falls back to Y-axis rotation if unavailable.
*/ */
async loadAnimationForActor(actorIndex, mood = 0) { async loadAnimationForActor(actorIndex, mood = 0) {
if (!this.modelGroup) return; 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(); this.stopAnimation();
const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0; 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. * 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; const data = node.data;
let mat = new THREE.Matrix4(); 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) // 1. Scale (applied first)
if (data.scaleKeys.length > 0) { if (data.scaleKeys.length > 0) {
const scale = this.interpolateVertex(data.scaleKeys, time, false); const scale = this.interpolateVertex(data.scaleKeys, time, false);
@ -512,9 +586,9 @@ export class ActorRenderer extends BaseRenderer {
if (data.translationKeys.length > 0) { if (data.translationKeys.length > 0) {
const vertex = this.interpolateVertex(data.translationKeys, time, true); const vertex = this.interpolateVertex(data.translationKeys, time, true);
if (vertex) { if (vertex) {
if (isRoot) { if (isActorRoot) {
// Root: only apply vertical (Y) to preserve bounce, // Actor_01: only apply vertical (Y) to preserve bounce,
// strip horizontal (XZ) so the actor walks in place // strip horizontal (XZ) so the actor animates in place
mat.elements[13] += vertex.y; mat.elements[13] += vertex.y;
} else { } else {
mat.elements[12] += vertex.x; mat.elements[12] += vertex.x;

View File

@ -108,23 +108,28 @@
if (!renderer || !slot?.characters || !charState) return; if (!renderer || !slot?.characters || !charState) return;
const playerId = slot.header?.actorId; const playerId = slot.header?.actorId;
let acted = false;
let clickMove = charState.move;
switch (playerId) { switch (playerId) {
case Actor.PEPPER: case Actor.PEPPER: switchVariant(); acted = true; break;
switchVariant(); case Actor.MAMA: switchSound(); acted = true; break;
break; case Actor.PAPA: clickMove = switchMove(); acted = true; break;
case Actor.MAMA: case Actor.NICK: acted = switchColor(event); break;
switchSound(); case Actor.LAURA: switchMood(); acted = true; break;
break; }
case Actor.PAPA:
switchMove(); if (!acted) return;
break;
case Actor.NICK: // Queue click animation — consumed by loadAnimationForActor
switchColor(event); renderer.queueClickAnimation(clickMove);
break;
case Actor.LAURA: // Sound/move changes don't affect the actorKey, so the reactive block
switchMood(); // won't trigger a model reload. Play the click animation directly.
break; // 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({ onUpdate({
character: { characterIndex: actorIndex, field: 'move', value: nextMove } character: { characterIndex: actorIndex, field: 'move', value: nextMove }
}); });
return nextMove;
} }
function switchColor(event) { function switchColor(event) {
let partIdx = renderer.getClickedPart(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 // Remap clicked part to the part that owns its color
// (matches SwitchColor in legocharactermanager.cpp) // (matches SwitchColor in legocharactermanager.cpp)
@ -176,10 +182,10 @@
}; };
const field = fieldMap[partIdx]; const field = fieldMap[partIdx];
if (!field) return; if (!field) return false;
const part = actorInfo.parts[partIdx]; const part = actorInfo.parts[partIdx];
if (!part.nameIndices) return; if (!part.nameIndices) return false;
const currentIdx = charState[field] ?? part.nameIndex; const currentIdx = charState[field] ?? part.nameIndex;
const maxIdx = part.nameIndices.length; const maxIdx = part.nameIndices.length;
@ -188,6 +194,7 @@
onUpdate({ onUpdate({
character: { characterIndex: actorIndex, field, value: nextIdx } character: { characterIndex: actorIndex, field, value: nextIdx }
}); });
return true;
} }
function switchMood() { function switchMood() {