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:
@@ -145,6 +145,130 @@ export default function Live2DStage({ onKiraReady, onReady, outfit, celebrateTri
|
||||
// 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
|
||||
//
|
||||
// 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;
|
||||
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 = {
|
||||
tail0: 'Param_Angle_Rotation2',
|
||||
tail1: 'Param_Angle_Rotation3',
|
||||
@@ -306,6 +435,8 @@ export default function Live2DStage({ onKiraReady, onReady, outfit, celebrateTri
|
||||
if (t < 1) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Resume idle animations
|
||||
if (celebratingRef) celebratingRef.current = false;
|
||||
// Reset scale
|
||||
positionCat(cat);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user