Files
kira/frontend/src/components/KiraAvatar.tsx
T
hobokenchicken 9653f80abd feat: Live2D model integration with pixi-live2d-display
- Added Epsilon Live2D model (Cubism 4) with full motion/expression set
- KiraAvatar now loads Live2D via PixiJS + cubism4 renderer
- Idle animation auto-plays on load
- Lip-sync: PARAM_MOUTH_OPEN_Y driven by speaking state
- 8 expressions (Normal, Smile, Sad, Angry, Surprised, Blushing, f01, f02)
- 15 motion files including idle, tap, flick, shake
- Physics, eye blink, and LipSync parameter groups configured
- Falls back to animated SVG placeholder if model isn't available
2026-06-04 11:34:59 -04:00

226 lines
6.8 KiB
TypeScript

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<HTMLDivElement>(null);
const appRef = useRef<any>(null);
const modelRef = useRef<any>(null);
const [live2dReady, setLive2dReady] = useState(false);
const [loadError, setLoadError] = useState(false);
const animFrameRef = useRef<number>(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 (
<div className="glass-card p-4 flex flex-col items-center" style={{ minHeight: 340 }}>
{/* Live2D canvas */}
<div
ref={canvasRef}
className={`relative ${live2dReady ? 'block' : 'hidden'}`}
style={{ width: 220, height: 280 }}
/>
{/* Animated SVG placeholder when Live2D isn't available */}
{(!live2dReady || loadError) && (
<div className={live2dReady ? 'hidden' : 'block'}>
<AnimatedAvatar
isSpeaking={props.isSpeaking}
isListening={props.isListening}
outfit={props.outfit}
accessory={props.accessory}
onTalkToggle={props.onTalkToggle}
/>
</div>
)}
{/* Loading state */}
{!live2dReady && !loadError && (
<div className="flex flex-col items-center gap-2 py-8">
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
<p className="text-[11px] text-kira-plum/40">loading Kira...</p>
</div>
)}
{/* Status bar */}
<div className="mt-2 flex items-center gap-3 text-xs text-kira-plum/40">
<span className={`w-2 h-2 rounded-full ${props.isSpeaking ? 'bg-kira-pink animate-pulse' : props.isListening ? 'bg-red-400 animate-pulse' : 'bg-kira-mint'}`} />
<span>
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
</span>
</div>
{/* Name + outfit indicator */}
<div className="flex gap-2 mt-0.5 text-[10px] text-kira-plum/30">
<span>Kira</span>
{live2dReady && <span>· Live2D</span>}
</div>
</div>
);
}
/** Load a script tag dynamically */
function loadScript(src: string): Promise<void> {
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);
}