diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index 447d701..f773b9d 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -33,7 +33,7 @@ export default function KiraAvatar(props: Props) { const [loadError, setLoadError] = useState(false); const [currentExpression, setCurrentExpression] = useState('Normal'); - // ── Initialize Live2D ── + // Initialize Live2D useEffect(() => { let mounted = true; const container = canvasRef.current; @@ -51,16 +51,13 @@ export default function KiraAvatar(props: Props) { const { Application, Ticker } = await import('pixi.js'); const { Live2DModel } = await import('pixi-live2d-display/cubism4'); - // Register pixi Ticker so Live2DModel can drive animations - // Cast needed due to pixi-live2d-display expecting older Ticker type (Live2DModel as any).registerTicker(Ticker as any); - // Responsive sizing — fill the container, target ~1/3 viewport const containerW = container.clientWidth || 400; - const size = Math.min(containerW, 500); + const containerH = container.clientHeight || 400; const app = new Application({ - width: size, - height: size * 1.25, + width: containerW, + height: containerH, antialias: true, resolution: Math.min(window.devicePixelRatio || 1, 2), backgroundAlpha: 0, @@ -70,26 +67,23 @@ export default function KiraAvatar(props: Props) { container.appendChild(app.view as HTMLCanvasElement); - // Load model with default textures const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { autoInteract: false, }); modelRef.current = model; - // Fit model in canvas - const maxW = size * 0.78; - const maxH = size * 1.1; + // Fit model to fill the canvas + const maxW = containerW * 0.85; + const maxH = containerH * 0.85; const scale = Math.min(maxW / model.width, maxH / model.height); model.scale.set(scale); - model.anchor.set(0.5, 0.5); - model.position.set(app.screen.width / 2, app.screen.height / 2 + 6); + model.anchor.set(0.5, 0.55); + model.position.set(app.screen.width / 2, app.screen.height / 2 + 8); app.stage.addChild(model as any); - // Fix: prevent pixi v7 isInteractive TypeError with Live2D model (model as any).isInteractive = () => false; - // Store reference to texture_02 for outfit swapping try { textureRef.current = { index: 2, @@ -99,11 +93,9 @@ export default function KiraAvatar(props: Props) { if (mounted) setLive2dReady(true); - // Start idle animation try { model.motion('Idle'); } catch { /* no idle */ } try { model.expression('Normal'); } catch { /* no expressions */ } - // Random idle expression changes idleExprRef.current = setInterval(() => { if (props.isSpeaking) return; const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)]; @@ -131,7 +123,7 @@ export default function KiraAvatar(props: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ── Lip sync from TTS ── + // Lip sync from TTS useEffect(() => { const model = modelRef.current; if (!model || !live2dReady) return; @@ -164,7 +156,7 @@ export default function KiraAvatar(props: Props) { return () => cancelAnimationFrame(lipSyncRef.current); }, [props.isSpeaking, live2dReady]); - // ── Outfit texture swapping ── + // Outfit texture swapping useEffect(() => { const model = modelRef.current; if (!model || !live2dReady) return; @@ -176,12 +168,10 @@ export default function KiraAvatar(props: Props) { try { const { Assets } = await import('pixi.js'); const tex = await Assets.load(outfitUrl); - // Swap texture slot 2 (clothing layer) in the model's texture array. - // The render loop automatically binds WebGL textures from PixiJS textures. if ((model as any).textures) { (model as any).textures[2] = tex; } else { - console.warn('[Outfit] cannot swap — no textures array'); + console.warn('[Outfit] cannot swap - no textures array'); } } catch (e) { console.warn('[Outfit] texture swap failed:', e); @@ -189,111 +179,122 @@ export default function KiraAvatar(props: Props) { })(); }, [props.outfit, live2dReady]); - // ── Accessory toggle ── + // Accessory toggle useEffect(() => { const model = modelRef.current; if (!model || !live2dReady) return; - - // The model has hair clips on texture_01 that could be toggled - // For now, the accessory system works on the fallback avatar }, [props.accessory, live2dReady]); + // Inline styles + 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) && ( -
- -
- )} + {/* SVG fallback when Live2D fails */} + {(!live2dReady || loadError) && ( +
+ +
+ )} - {/* Loading spinner */} - {!live2dReady && !loadError && ( -
-
-

loading Kira...

-
- )} + {/* Loading spinner */} + {!live2dReady && !loadError && ( +
+
+

loading Kira...

+
+ )} - {/* Expression indicator + Talk button */} - {live2dReady && ( - <> -
- {EXPRESSIONS.map((expr) => ( + {/* Expression buttons overlay top */} + {live2dReady && ( + <> +
+
+ {EXPRESSIONS.map((expr) => ( + + ))} +
+
+ + {/* Talk button overlay bottom-center */} +
- ))} -
+
- {/* Talk button — mic toggle */} - - - )} - - {/* Status bar */} -
- - - {props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'} - + {/* Status overlay bottom-right */} +
+ + + {props.isSpeaking ? 'speaking...' + : props.isListening ? 'listening...' + : 'here with you'} + +
+ + )}
- +
); } -// ── Helpers ── +// Helpers function loadScript(src: string): Promise { return new Promise((resolve, reject) => { - if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } + 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}`)); + s.onerror = () => reject(new Error('Failed ' + src)); document.head.appendChild(s); }); }