feat(animations): idle animations for Kira and Mochi

Kira: breathing, body sway, head tilt, eye wander, hair sway, periodic blink
Mochi: breathing, body sway, tail sway (6 segments), ear twitches, blink
Idle loop pauses during celebration animation then resumes.
This commit is contained in:
2026-06-06 00:40:39 -04:00
parent 358538299e
commit 2ffd5b2fa5
+131
View File
@@ -145,6 +145,130 @@ export default function Live2DStage({ onKiraReady, onReady, outfit, celebrateTri
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Idle animation loop — gentle breathing, blinking, sway for both models
useEffect(() => {
let animId: number;
const celebratingRef = { current: false };
// Expose to celebration effect via a data attribute on canvas
const canvas = canvasRef.current;
if (canvas) (canvas as any).__celebrating = celebratingRef;
const blinkSchedule = () => 3000 + Math.random() * 4000; // 3-7s between blinks
let nextBlink = blinkSchedule();
let lastTs = 0;
const tick = (ts: number) => {
if (!lastTs) lastTs = ts;
const dt = (ts - lastTs) / 1000; // seconds
lastTs = ts;
const elapsed = ts / 1000; // total seconds
const celebrating = celebratingRef.current;
// ── Kira idle ──
const kira = kiraModelRef.current;
if (kira) {
try {
const core = kira.internalModel?.coreModel;
if (core && !celebrating) {
// Breathing (PARAM_BREATH: 0-1, sinusoidal)
const breath = 0.5 + Math.sin(elapsed * 1.2) * 0.5;
core.setParameterValueById('PARAM_BREATH', breath);
// Gentle body sway
const swayX = Math.sin(elapsed * 0.4) * 3;
const swayZ = Math.sin(elapsed * 0.3 + 1) * 1.5;
core.setParameterValueById('PARAM_BODY_ANGLE_X', swayX);
core.setParameterValueById('PARAM_BODY_ANGLE_Z', swayZ);
// Subtle head tilt
const headX = Math.sin(elapsed * 0.5 + 0.5) * 2;
const headY = Math.cos(elapsed * 0.35) * 1.5;
core.setParameterValueById('PARAM_ANGLE_X', headX);
core.setParameterValueById('PARAM_ANGLE_Y', headY);
// Eye tracking (subtle wander)
const eyeX = Math.sin(elapsed * 0.25 + 2) * 0.3;
const eyeY = Math.cos(elapsed * 0.2) * 0.2;
core.setParameterValueById('PARAM_EYE_BALL_X', eyeX);
core.setParameterValueById('PARAM_EYE_BALL_Y', eyeY);
// Hair sway
const hairSway = Math.sin(elapsed * 0.6) * 0.3;
core.setParameterValueById('PARAM_HAIR_SIDE', hairSway);
core.setParameterValueById('PARAM_HAIR_BACK', Math.sin(elapsed * 0.5 + 1) * 0.2);
// Periodic blink
nextBlink -= dt * 1000;
if (nextBlink <= 0) {
// Quick blink (close for ~120ms)
core.setParameterValueById('PARAM_EYE_L_OPEN', 0);
core.setParameterValueById('PARAM_EYE_R_OPEN', 0);
setTimeout(() => {
if (kiraModelRef.current?.internalModel?.coreModel) {
kiraModelRef.current.internalModel.coreModel.setParameterValueById('PARAM_EYE_L_OPEN', 1);
kiraModelRef.current.internalModel.coreModel.setParameterValueById('PARAM_EYE_R_OPEN', 1);
}
}, 120);
nextBlink = blinkSchedule();
}
}
} catch {}
}
// ── Mochi idle ──
const cat = catModelRef.current;
if (cat) {
try {
const core = cat.internalModel?.coreModel;
if (core && !celebrating) {
// Breathing
const catBreath = 0.5 + Math.sin(elapsed * 1.5) * 0.5;
core.setParameterValueById('ParamBreath', catBreath);
// Gentle body sway
const catSway = Math.sin(elapsed * 0.6) * 4;
core.setParameterValueById('ParamBodyAngleX', catSway);
// Tail sway (gentle idle, all 6 segments)
const tailBase = Math.sin(elapsed * 1.0) * 0.25;
core.setParameterValueById('Param_Angle_Rotation2', tailBase);
core.setParameterValueById('Param_Angle_Rotation3', tailBase * 0.8);
core.setParameterValueById('Param_Angle_Rotation4', tailBase * 0.6);
core.setParameterValueById('Param_Angle_Rotation5', tailBase * 0.4);
core.setParameterValueById('Param_Angle_Rotation6', tailBase * 0.3);
core.setParameterValueById('Param_Angle_Rotation7', tailBase * 0.2);
// Ear twitches (occasional)
const earTwitch = Math.sin(elapsed * 2.5 + 3) > 0.9 ? 0.3 : 0;
core.setParameterValueById('Param_Angle_Rotation10', earTwitch);
core.setParameterValueById('Param_Angle_Rotation13', earTwitch * 0.5);
// Eye blink (cat)
nextBlink -= dt * 1000;
if (nextBlink <= 0) {
core.setParameterValueById('ParamEyeLOpen', 0);
core.setParameterValueById('ParamEyeROpen', 0);
setTimeout(() => {
if (catModelRef.current?.internalModel?.coreModel) {
catModelRef.current.internalModel.coreModel.setParameterValueById('ParamEyeLOpen', 1);
catModelRef.current.internalModel.coreModel.setParameterValueById('ParamEyeROpen', 1);
}
}, 100);
nextBlink = blinkSchedule();
}
}
} catch {}
}
animId = requestAnimationFrame(tick);
};
animId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animId);
}, []);
// Outfit swap effect // Outfit swap effect
// //
// The cubism4 _render loop (cubism4.es.js:4955-4965) iterates model.textures[] // The cubism4 _render loop (cubism4.es.js:4955-4965) iterates model.textures[]
@@ -224,6 +348,11 @@ export default function Live2DStage({ onKiraReady, onReady, outfit, celebrateTri
const coreModel = cat.internalModel?.coreModel; const coreModel = cat.internalModel?.coreModel;
if (!coreModel) return; if (!coreModel) return;
// Tell idle loop to pause
const canvas = canvasRef.current;
const celebratingRef = canvas ? (canvas as any).__celebrating : null;
if (celebratingRef) celebratingRef.current = true;
const PARAMS = { const PARAMS = {
tail0: 'Param_Angle_Rotation2', tail0: 'Param_Angle_Rotation2',
tail1: 'Param_Angle_Rotation3', tail1: 'Param_Angle_Rotation3',
@@ -306,6 +435,8 @@ export default function Live2DStage({ onKiraReady, onReady, outfit, celebrateTri
if (t < 1) { if (t < 1) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
// Resume idle animations
if (celebratingRef) celebratingRef.current = false;
// Reset scale // Reset scale
positionCat(cat); positionCat(cat);
} }