diff --git a/frontend/src/components/Live2DStage.tsx b/frontend/src/components/Live2DStage.tsx index 0cfc3d0..7db8bea 100644 --- a/frontend/src/components/Live2DStage.tsx +++ b/frontend/src/components/Live2DStage.tsx @@ -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); }