95f97fa897
React was potentially clearing the canvas on re-render because we
appended it manually to a div. Now using a <canvas ref={canvasRef}>
element directly in JSX that React manages. Pixi app uses .
Scale set to 82% of container.
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
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<string, string> = {
|
|
'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<HTMLCanvasElement>(null);
|
|
const modelRef = useRef<any>(null);
|
|
const textureRef = useRef<any>(null);
|
|
const lipSyncRef = useRef<number>(0);
|
|
const idleExprRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const [live2dReady, setLive2dReady] = useState(false);
|
|
const [loadError, setLoadError] = useState(false);
|
|
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('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 (
|
|
<div className="flex flex-col items-center w-full h-full overflow-hidden">
|
|
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
|
|
{/* Live2D canvas */}
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-full block"
|
|
style={{ opacity: live2dReady ? 1 : 0 }}
|
|
/>
|
|
|
|
{/* SVG fallback */}
|
|
{(!live2dReady || loadError) && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<AnimatedAvatar
|
|
isSpeaking={props.isSpeaking}
|
|
isListening={props.isListening}
|
|
outfit={props.outfit}
|
|
accessory={props.accessory}
|
|
onTalkToggle={props.onTalkToggle}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading */}
|
|
{!live2dReady && !loadError && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
|
<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>
|
|
)}
|
|
|
|
{/* Expression buttons */}
|
|
{live2dReady && (
|
|
<>
|
|
<div className="absolute top-1 left-0 right-0 flex gap-1 justify-center flex-wrap pointer-events-none">
|
|
<div className="pointer-events-auto flex gap-1 flex-wrap justify-center">
|
|
{EXPRESSIONS.map((expr) => (
|
|
<button
|
|
key={expr}
|
|
onClick={() => {
|
|
try { modelRef.current?.expression(expr); setCurrentExpression(expr); } catch {}
|
|
}}
|
|
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
|
|
currentExpression === expr
|
|
? 'bg-kira-pink text-white'
|
|
: 'bg-white/70 text-kira-plum/40 hover:text-kira-plum/70'
|
|
}`}
|
|
>
|
|
{expr}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Talk button */}
|
|
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
|
|
<button
|
|
onClick={props.onTalkToggle}
|
|
className={`pointer-events-auto flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all shadow-lg ${
|
|
props.isListening
|
|
? 'bg-red-400 text-white scale-105 animate-listening-pulse'
|
|
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-xl hover:scale-105'
|
|
}`}
|
|
>
|
|
<span className="text-base">{props.isListening ? '⏹️' : '🎤'}</span>
|
|
{props.isListening ? 'Listening...' : 'Talk to Kira'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
|
|
<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'}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<style>{pulseStyle}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function loadScript(src: string): Promise<void> {
|
|
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; }
|
|
}
|