import { useEffect, useRef, useState } from 'react'; import AnimatedAvatar from './AnimatedAvatar'; interface Props { isSpeaking: boolean; isListening: boolean; outfit: string; accessory: string | null; onTalkToggle: () => void; /** The Live2D Kira model instance (from Live2DStage) */ kiraModel: any; live2dReady: boolean; onExpressionChange?: (expr: string) => void; } type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing'; const EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Sad', 'Angry', 'Surprised', 'Blushing']; const IDLE_EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Blushing']; export default function KiraAvatar(props: Props) { const lipSyncRef = useRef(0); const idleExprRef = useRef | null>(null); const [currentExpression, setCurrentExpression] = useState('Normal'); // Idle expression cycling useEffect(() => { if (!props.kiraModel || !props.live2dReady) return; idleExprRef.current = setInterval(() => { if (props.isSpeaking) return; const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)]; try { props.kiraModel.expression(expr); } catch {} setCurrentExpression(expr); }, 8000 + Math.random() * 7000); return () => clearInterval(idleExprRef.current ?? undefined); }, [props.kiraModel, props.live2dReady, props.isSpeaking]); // Lip sync from TTS useEffect(() => { const model = props.kiraModel; if (!model || !props.live2dReady) return; cancelAnimationFrame(lipSyncRef.current); if (props.isSpeaking) { let phase = 0; const animate = () => { phase += 0.12; const openness = 0.25 + Math.sin(phase) * 0.35; try { model.internalModel.coreModel.setParameterValueByIndex( findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness, ); } catch {} lipSyncRef.current = requestAnimationFrame(animate); }; animate(); } else { try { model.internalModel.coreModel.setParameterValueByIndex( findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0, ); } catch {} } return () => cancelAnimationFrame(lipSyncRef.current); }, [props.isSpeaking, props.live2dReady, props.kiraModel]); const pulseStyle = ` @keyframes listening-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); } 50% { box-shadow: 0 0 0 12px rgba(248, 113, 113, 0); } } .animate-listening-pulse { animation: listening-pulse 1.5s infinite; } `; return (
{/* SVG fallback (shows before Live2D is ready) */} {!props.live2dReady && (
)} {/* Loading spinner */} {!props.live2dReady && (

loading Kira...

)} {/* Expression buttons */} {props.live2dReady && ( <>
{EXPRESSIONS.map((expr) => ( ))}
{/* Talk button */}
{/* Status */}
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : 'here with you'}
)}
); } function findParam(model: any, name: string): number { try { return model.internalModel.coreModel.getParameterIds().indexOf(name); } catch { return 0; } }