From c2d7570d4108fcae1981cfef865455e0ae9146b9 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Fri, 13 Feb 2026 13:51:10 -0800 Subject: [PATCH] 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. --- src/core/rendering/ActorRenderer.js | 84 ++++++++++++++++++++------ src/lib/save-editor/ActorEditor.svelte | 15 +++-- 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js index 6b67a10..caeeb35 100644 --- a/src/core/rendering/ActorRenderer.js +++ b/src/core/rendering/ActorRenderer.js @@ -33,13 +33,13 @@ const ACTOR_SUFFIX_INDEX = (() => { /** * g_cycles[11][17] — animation name table from legoanimationmanager.cpp. - * Rows = character type suffix index, columns = sound + 4 * move (0-16). + * 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 + ['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 @@ -49,7 +49,7 @@ const G_CYCLES = [ // 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'], + ['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 @@ -169,10 +169,9 @@ export class ActorRenderer extends BaseRenderer { this.modelGroup.rotation.y = Math.PI; this.scene.add(this.modelGroup); - // Load and start animation based on move/sound - const move = charState?.move ?? 0; - const sound = charState?.sound ?? 0; - this.loadAnimationForActor(actorIndex, move, sound); + // Load and start walking animation based on mood + const mood = charState?.mood ?? 0; + this.loadAnimationForActor(actorIndex, mood); this.renderer.render(this.scene, this.camera); } @@ -359,19 +358,36 @@ export class ActorRenderer extends BaseRenderer { // ─── Animation System ──────────────────────────────────────────── /** - * Load and start animation for the given actor using g_cycles table. - * Animation index = sound + 4 * move. Pre-computes world-space transforms - * by evaluating the animation tree hierarchically, then plays via AnimationMixer. + * 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; + } + + /** + * 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. * Falls back to Y-axis rotation if unavailable. */ - async loadAnimationForActor(actorIndex, move = 0, sound = 0) { + async loadAnimationForActor(actorIndex, mood = 0) { if (!this.modelGroup) return; this.stopAnimation(); const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0; - const animIdx = sound + 4 * move; - const animName = G_CYCLES[suffixIdx]?.[animIdx]; + // 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; + const animName = G_CYCLES[suffixIdx]?.[secondaryCol] ?? G_CYCLES[suffixIdx]?.[primaryCol]; if (!animName) return; // null entry in g_cycles — no animation for this combo @@ -448,6 +464,8 @@ export class ActorRenderer extends BaseRenderer { 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; @@ -461,6 +479,7 @@ export class ActorRenderer extends BaseRenderer { 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); } @@ -489,13 +508,19 @@ export class ActorRenderer extends BaseRenderer { mat = this.evaluateRotation(data.rotationKeys, time); } - // 2. Translation (skip on root node so the actor walks in place) - if (!isRoot && data.translationKeys.length > 0) { + // 2. Translation + if (data.translationKeys.length > 0) { const vertex = this.interpolateVertex(data.translationKeys, time, true); if (vertex) { - mat.elements[12] += vertex.x; - mat.elements[13] += vertex.y; - mat.elements[14] += vertex.z; + if (isRoot) { + // Root: only apply vertical (Y) to preserve bounce, + // strip horizontal (XZ) so the actor walks in place + mat.elements[13] += vertex.y; + } else { + mat.elements[12] += vertex.x; + mat.elements[13] += vertex.y; + mat.elements[14] += vertex.z; + } } } @@ -519,6 +544,12 @@ export class ActorRenderer extends BaseRenderer { 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]); + } } } @@ -610,6 +641,23 @@ export class ActorRenderer extends BaseRenderer { 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. */ diff --git a/src/lib/save-editor/ActorEditor.svelte b/src/lib/save-editor/ActorEditor.svelte index 7afff68..4e8c819 100644 --- a/src/lib/save-editor/ActorEditor.svelte +++ b/src/lib/save-editor/ActorEditor.svelte @@ -40,7 +40,7 @@ charState.legrtNameIndex === actorInfo.parts[9].nameIndex; function actorKey(slotNumber, idx, cs) { - return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.move}-${cs.sound}`; + return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.mood}`; } onMount(async () => { @@ -141,7 +141,7 @@ } function switchSound() { - const nextSound = (charState.sound + 1) % 4; + const nextSound = (charState.sound + 1) % 9; onUpdate({ character: { characterIndex: actorIndex, field: 'sound', value: nextSound } }); @@ -155,9 +155,16 @@ } function switchColor(event) { - const partIdx = renderer.getClickedPart(event); + let partIdx = renderer.getClickedPart(event); if (partIdx < 0) return; + // Remap clicked part to the part that owns its color + // (matches SwitchColor in legocharactermanager.cpp) + if (partIdx === ActorPart.CLAWLFT) partIdx = ActorPart.ARMLFT; + else if (partIdx === ActorPart.CLAWRT) partIdx = ActorPart.ARMRT; + else if (partIdx === ActorPart.HEAD) partIdx = ActorPart.INFOHAT; + else if (partIdx === ActorPart.BODY) partIdx = ActorPart.INFOGRON; + // Map part index to the save field const fieldMap = { [ActorPart.INFOHAT]: 'hatNameIndex', @@ -169,7 +176,7 @@ }; const field = fieldMap[partIdx]; - if (!field) return; // Body (0) and Head (3) don't have color fields + if (!field) return; const part = actorInfo.parts[partIdx]; if (!part.nameIndices) return;