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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import AnimatedAvatar from './AnimatedAvatar';
|
||||
|
||||
interface Props {
|
||||
@@ -11,80 +11,205 @@ interface Props {
|
||||
|
||||
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(() => {
|
||||
// Try to load Live2D model — if it fails, show animated placeholder
|
||||
let mounted = true;
|
||||
const canvasEl = canvasRef.current;
|
||||
if (!canvasEl) return;
|
||||
|
||||
const tryLoadLive2D = async () => {
|
||||
const init = async () => {
|
||||
try {
|
||||
// Check if model files exist
|
||||
const resp = await fetch('/live2d/models/kira.model3.json', { method: 'HEAD' });
|
||||
// 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
|
||||
// Load Cubism core script
|
||||
await loadScript('/live2d/cubism/live2dcubismcore.min.js');
|
||||
|
||||
// Model exists — ready for Live2D
|
||||
// (full Live2D rendering will require the Cubism4 framework bundle)
|
||||
if (mounted) setLive2dReady(false); // Show placeholder until framework is fully wired
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
tryLoadLive2D();
|
||||
init();
|
||||
|
||||
return () => { mounted = false; };
|
||||
return () => {
|
||||
mounted = false;
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
if (appRef.current) {
|
||||
appRef.current.destroy(true, { children: true });
|
||||
appRef.current = null;
|
||||
}
|
||||
modelRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Show animated fallback while Live2D model isn't available
|
||||
// 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: 290 }}>
|
||||
{/* Live2D canvas area (hidden until model is loaded) */}
|
||||
{live2dReady && (
|
||||
<div ref={canvasRef} className="w-36 h-44 relative" id="live2d-canvas" />
|
||||
)}
|
||||
<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 */}
|
||||
{/* Animated SVG placeholder when Live2D isn't available */}
|
||||
{(!live2dReady || loadError) && (
|
||||
<AnimatedAvatar
|
||||
isSpeaking={props.isSpeaking}
|
||||
isListening={props.isListening}
|
||||
outfit={props.outfit}
|
||||
accessory={props.accessory}
|
||||
onTalkToggle={props.onTalkToggle}
|
||||
/>
|
||||
<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 && (
|
||||
<p className="text-[10px] text-kira-plum/30 mt-1">✨ Live2D model slot ready</p>
|
||||
<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 info */}
|
||||
<div className="mt-3 flex items-center gap-3 text-xs text-kira-plum/40">
|
||||
{/* 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...' : 'here with you'}
|
||||
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Outfit + accessory indicator */}
|
||||
<div className="flex gap-2 mt-1 text-[10px] text-kira-plum/30">
|
||||
<span>{props.outfit.replace('-', ' ')}</span>
|
||||
{props.accessory && <span>· {props.accessory}</span>}
|
||||
{/* 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();
|
||||
@@ -92,3 +217,9 @@ function loadScript(src: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user