mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
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:
parent
c2d7570d41
commit
fe87b3d99f
@ -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 => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user