import { useEffect, useRef, useState, useCallback } from 'react'; import AnimatedAvatar from './AnimatedAvatar'; interface Props { isSpeaking: boolean; isListening: boolean; outfit: string; accessory: string | null; onTalkToggle: () => void; } export default function KiraAvatar(props: Props) { const canvasRef = useRef(null); const appRef = useRef(null); const modelRef = useRef(null); const [live2dReady, setLive2dReady] = useState(false); const [loadError, setLoadError] = useState(false); const animFrameRef = useRef(0); // Initialize Live2D useEffect(() => { let mounted = true; const canvasEl = canvasRef.current; if (!canvasEl) return; const init = async () => { try { // Check if model exists const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' }); if (!resp.ok) { if (mounted) setLoadError(true); return; } // Load Cubism core script await loadScript('/live2d/cubism/live2dcubismcore.min.js'); // Dynamic imports const { Application } = await import('pixi.js'); const { Live2DModel } = await import('pixi-live2d-display/cubism4'); // Create Pixi app const app = new Application({ width: 220, height: 280, transparent: true, antialias: true, resolution: 2, backgroundAlpha: 0, }); appRef.current = app; if (!mounted) { app.destroy(true); return; } // Append canvas canvasEl.appendChild(app.view as HTMLCanvasElement); // Load model const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { autoInteract: false, }); modelRef.current = model; // Scale and center const scale = Math.min(220 / model.width, 280 / model.height) * 0.85; model.scale.set(scale); model.anchor.set(0.5, 0.5); model.position.set(app.screen.width / 2, app.screen.height / 2 + 10); app.stage.addChild(model); if (mounted) setLive2dReady(true); // Start idle animation try { model.motion('Idle'); } catch { // Some models may not have Idle motion } // Set default expression try { model.expression('Normal'); } catch { // Expressions are optional } } catch (e) { console.warn('[Live2D] Failed to load:', e); if (mounted) setLoadError(true); } }; init(); return () => { mounted = false; cancelAnimationFrame(animFrameRef.current); if (appRef.current) { appRef.current.destroy(true, { children: true }); appRef.current = null; } modelRef.current = null; }; }, []); // Handle speaking → lip sync useEffect(() => { if (!modelRef.current || !live2dReady) return; const model = modelRef.current; const core = (window as any).Live2DCubismCore; if (props.isSpeaking) { // Animate mouth while speaking let mouthPhase = 0; const animateMouth = () => { mouthPhase += 0.15; const openness = 0.3 + Math.sin(mouthPhase) * 0.35; try { model.internalModel.coreModel.setParameterValueByIndex( findParameterIndex(model, 'PARAM_MOUTH_OPEN_Y'), openness, ); } catch { /* ignore */ } animFrameRef.current = requestAnimationFrame(animateMouth); }; animateMouth(); } else { // Close mouth when not speaking cancelAnimationFrame(animFrameRef.current); try { model.internalModel.coreModel.setParameterValueByIndex( findParameterIndex(model, 'PARAM_MOUTH_OPEN_Y'), 0, ); } catch { /* ignore */ } } return () => cancelAnimationFrame(animFrameRef.current); }, [props.isSpeaking, live2dReady]); // Handle outfit changes useEffect(() => { if (!modelRef.current || !live2dReady) return; // Texture swapping for outfits would go here // For now, the outfit system works on the fallback avatar }, [props.outfit, live2dReady]); // Handle listening pulse effect useEffect(() => { if (!modelRef.current || !live2dReady) return; // Could add a glow/breathing effect when listening }, [props.isListening, live2dReady]); return (
{/* Live2D canvas */}
{/* Animated SVG placeholder when Live2D isn't available */} {(!live2dReady || loadError) && (
)} {/* Loading state */} {!live2dReady && !loadError && (

loading Kira...

)} {/* Status bar */}
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
{/* Name + outfit indicator */}
Kira {live2dReady && · Live2D}
); } /** Load a script tag dynamically */ function loadScript(src: string): Promise { return new Promise((resolve, reject) => { // Check if already loaded if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } const script = document.createElement('script'); script.src = src; script.onload = () => resolve(); script.onerror = () => reject(new Error(`Failed to load ${src}`)); document.head.appendChild(script); }); } /** Find a parameter index by name in the Live2D model */ function findParameterIndex(model: any, name: string): number { const parameters = model.internalModel.coreModel.getParameterIds(); return parameters.indexOf(name); }