import { useEffect, useRef, useState } from 'react'; import AnimatedAvatar from './AnimatedAvatar'; interface Props { isSpeaking: boolean; isListening: boolean; outfit: string; accessory: string | null; onTalkToggle: () => 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']; const OUTFIT_TEXTURES: Record = { 'cozy-hoodie': '/live2d/models/kira/outfits/cozy-hoodie.png', 'girly-dress': '/live2d/models/kira/outfits/girly-dress.png', 'pajama-set': '/live2d/models/kira/outfits/pajama-set.png', 'study-sweater': '/live2d/models/kira/outfits/study-sweater.png', 'going-out': '/live2d/models/kira/outfits/going-out.png', }; export default function KiraAvatar(props: Props) { const canvasRef = useRef(null); const modelRef = useRef(null); const textureRef = useRef(null); const lipSyncRef = useRef(0); const idleExprRef = useRef | null>(null); const [live2dReady, setLive2dReady] = useState(false); const [loadError, setLoadError] = useState(false); const [currentExpression, setCurrentExpression] = useState('Normal'); useEffect(() => { let mounted = true; const canvas = canvasRef.current; if (!canvas) return; let app: any = null; let model: any = null; const init = async () => { try { const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' }); if (!resp.ok) { if (mounted) setLoadError(true); return; } await loadScript('/live2d/cubism/live2dcubismcore.min.js'); const { Application, Ticker } = await import('pixi.js'); const { Live2DModel } = await import('pixi-live2d-display/cubism4'); (Live2DModel as any).registerTicker(Ticker as any); const w = canvas.clientWidth || 260; const h = canvas.clientHeight || 320; app = new Application({ view: canvas, width: w, height: h, antialias: true, resolution: Math.min(window.devicePixelRatio || 1, 2), backgroundAlpha: 0, autoDensity: true, }); if (!mounted) { app.destroy(true); return; } model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { autoInteract: false, }); modelRef.current = model; const fit = () => { const sw = app.screen.width; const sh = app.screen.height; const s = Math.min((sw * 0.82) / model.width, (sh * 0.82) / model.height); model.scale.set(s); model.anchor.set(0.5, 0.5); model.position.set(sw / 2, sh / 2); }; fit(); app.stage.addChild(model as any); (model as any).isInteractive = () => false; try { textureRef.current = { index: 2, original: (model.internalModel.coreModel as any).getTexture(2), }; } catch { /* ignore */ } if (mounted) setLive2dReady(true); try { model.motion('Idle'); } catch { /* no idle */ } try { model.expression('Normal'); } catch { /* no expressions */ } idleExprRef.current = setInterval(() => { if (props.isSpeaking) return; const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)]; try { model.expression(expr); } catch { /* */ } setCurrentExpression(expr); }, 8000 + Math.random() * 7000); } catch (e) { console.warn('[Live2D]', e); if (mounted) setLoadError(true); } }; init(); return () => { mounted = false; cancelAnimationFrame(lipSyncRef.current); clearInterval(idleExprRef.current ?? undefined); if (app) { app.destroy(true, { children: true }); } modelRef.current = null; textureRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Lip sync from TTS useEffect(() => { const model = modelRef.current; if (!model || !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, live2dReady]); // Outfit texture swapping useEffect(() => { const model = modelRef.current; if (!model || !live2dReady) return; const outfitUrl = OUTFIT_TEXTURES[props.outfit]; if (!outfitUrl) return; (async () => { try { const { Assets } = await import('pixi.js'); const tex = await Assets.load(outfitUrl); if ((model as any).textures) { (model as any).textures[2] = tex; } else { console.warn('[Outfit] cannot swap - no textures array'); } } catch (e) { console.warn('[Outfit] texture swap failed:', e); } })(); }, [props.outfit, live2dReady]); // Accessory toggle useEffect(() => { const model = modelRef.current; if (!model || !live2dReady) return; }, [props.accessory, live2dReady]); 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 (
{/* Live2D canvas */} {/* SVG fallback */} {(!live2dReady || loadError) && (
)} {/* Loading */} {!live2dReady && !loadError && (

loading Kira...

)} {/* Expression buttons */} {live2dReady && ( <>
{EXPRESSIONS.map((expr) => ( ))}
{/* Talk button */}
{/* Status */}
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : 'here with you'}
)}
); } function loadScript(src: string): Promise { return new Promise((resolve, reject) => { if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; } const s = document.createElement('script'); s.src = src; s.onload = () => resolve(); s.onerror = () => reject(new Error('Failed ' + src)); document.head.appendChild(s); }); } function findParam(model: any, name: string): number { try { return model.internalModel.coreModel.getParameterIds().indexOf(name); } catch { return 0; } }