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.
This commit is contained in:
Christian Semmler 2026-02-13 13:51:10 -08:00
parent 3b925adafd
commit c2d7570d41
2 changed files with 77 additions and 22 deletions

View File

@ -33,13 +33,13 @@ const ACTOR_SUFFIX_INDEX = (() => {
/** /**
* g_cycles[11][17] animation name table from legoanimationmanager.cpp. * 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 = [ const G_CYCLES = [
// 0: xx // 0: xx
['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs011xx','CNs012xx',null,null,null,null,null], ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs011xx','CNs012xx',null,null,null,null,null],
// 1: Pe // 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 // 2: Ma
['CNs001Ma','CNs002Ma','CNs003Ma','CNs004Ma','CNs005Ma','CNs007Ma','CNs006Ma','CNs008Ma','CNs009Ma','CNs010Ma','CNs0x4Ma',null,null,'CNs011Ma','CNs012Ma','CNs013Ma',null], ['CNs001Ma','CNs002Ma','CNs003Ma','CNs004Ma','CNs005Ma','CNs007Ma','CNs006Ma','CNs008Ma','CNs009Ma','CNs010Ma','CNs0x4Ma',null,null,'CNs011Ma','CNs012Ma','CNs013Ma',null],
// 3: Pa // 3: Pa
@ -49,7 +49,7 @@ const G_CYCLES = [
// 5: La // 5: La
['CNs001La','CNs002La','CNs003La','CNs004La','CNs005La','CNs007La','CNs006La','CNs008La','CNs009La','CNs010La','CNs011La','CNsx11La',null,null,null,null,null], ['CNs001La','CNs002La','CNs003La','CNs004La','CNs005La','CNs007La','CNs006La','CNs008La','CNs009La','CNs010La','CNs011La','CNsx11La',null,null,null,null,null],
// 6: Br // 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 // 7: Bd
['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Bd','CNs012xx',null,null,null,null,null], ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Bd','CNs012xx',null,null,null,null,null],
// 8: Pg // 8: Pg
@ -169,10 +169,9 @@ export class ActorRenderer extends BaseRenderer {
this.modelGroup.rotation.y = Math.PI; this.modelGroup.rotation.y = Math.PI;
this.scene.add(this.modelGroup); this.scene.add(this.modelGroup);
// Load and start animation based on move/sound // Load and start walking animation based on mood
const move = charState?.move ?? 0; const mood = charState?.mood ?? 0;
const sound = charState?.sound ?? 0; this.loadAnimationForActor(actorIndex, mood);
this.loadAnimationForActor(actorIndex, move, sound);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
@ -359,19 +358,36 @@ export class ActorRenderer extends BaseRenderer {
// ─── Animation System ──────────────────────────────────────────── // ─── Animation System ────────────────────────────────────────────
/** /**
* Load and start animation for the given actor using g_cycles table. * Compute secondary animation column index from mood, matching FUN_10063b90.
* Animation index = sound + 4 * move. Pre-computes world-space transforms * Primary: columns 0-3 (speed 0.7), Secondary: columns 4-6 (speed 4.0).
* by evaluating the animation tree hierarchically, then plays via AnimationMixer. * 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. * Falls back to Y-axis rotation if unavailable.
*/ */
async loadAnimationForActor(actorIndex, move = 0, sound = 0) { async loadAnimationForActor(actorIndex, mood = 0) {
if (!this.modelGroup) return; if (!this.modelGroup) return;
this.stopAnimation(); this.stopAnimation();
const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0; const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0;
const animIdx = sound + 4 * move; // Use secondary animation (speed 4.0 threshold) — this is what NPCs use in-game
const animName = G_CYCLES[suffixIdx]?.[animIdx]; // 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 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)); tracks.push(new THREE.VectorKeyframeTrack(name, timesSec, values));
} else if (name.endsWith('.quaternion')) { } else if (name.endsWith('.quaternion')) {
tracks.push(new THREE.QuaternionKeyframeTrack(name, timesSec, values)); tracks.push(new THREE.QuaternionKeyframeTrack(name, timesSec, values));
} else if (name.endsWith('.visible')) {
tracks.push(new THREE.BooleanKeyframeTrack(name, timesSec, values));
} }
} }
return tracks; 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.translationKeys) timesSet.add(key.time);
for (const key of data.rotationKeys) 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.scaleKeys) timesSet.add(key.time);
for (const key of data.morphKeys) timesSet.add(key.time);
for (const child of node.children) { for (const child of node.children) {
this.collectKeyframeTimes(child, timesSet); this.collectKeyframeTimes(child, timesSet);
} }
@ -489,13 +508,19 @@ export class ActorRenderer extends BaseRenderer {
mat = this.evaluateRotation(data.rotationKeys, time); mat = this.evaluateRotation(data.rotationKeys, time);
} }
// 2. Translation (skip on root node so the actor walks in place) // 2. Translation
if (!isRoot && 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) {
mat.elements[12] += vertex.x; if (isRoot) {
mat.elements[13] += vertex.y; // Root: only apply vertical (Y) to preserve bounce,
mat.elements[14] += vertex.z; // 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; const trackName = partGroup.name;
this.pushValues(valueMap, `${trackName}.position`, position.toArray()); this.pushValues(valueMap, `${trackName}.position`, position.toArray());
this.pushValues(valueMap, `${trackName}.quaternion`, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]); 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 }; 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. * Append values to a named entry in the value map.
*/ */

View File

@ -40,7 +40,7 @@
charState.legrtNameIndex === actorInfo.parts[9].nameIndex; charState.legrtNameIndex === actorInfo.parts[9].nameIndex;
function actorKey(slotNumber, idx, cs) { 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 () => { onMount(async () => {
@ -141,7 +141,7 @@
} }
function switchSound() { function switchSound() {
const nextSound = (charState.sound + 1) % 4; const nextSound = (charState.sound + 1) % 9;
onUpdate({ onUpdate({
character: { characterIndex: actorIndex, field: 'sound', value: nextSound } character: { characterIndex: actorIndex, field: 'sound', value: nextSound }
}); });
@ -155,9 +155,16 @@
} }
function switchColor(event) { function switchColor(event) {
const partIdx = renderer.getClickedPart(event); let partIdx = renderer.getClickedPart(event);
if (partIdx < 0) return; 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 // Map part index to the save field
const fieldMap = { const fieldMap = {
[ActorPart.INFOHAT]: 'hatNameIndex', [ActorPart.INFOHAT]: 'hatNameIndex',
@ -169,7 +176,7 @@
}; };
const field = fieldMap[partIdx]; 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]; const part = actorInfo.parts[partIdx];
if (!part.nameIndices) return; if (!part.nameIndices) return;