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'],
|
['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 => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user