fix(avatar): center Live2D model in card, overlay controls on canvas; scale model to 85% of container; remove card padding; clean template literals to avoid TS parsing issues

This commit is contained in:
2026-06-05 09:16:16 -04:00
parent baaa89756f
commit f5930d6190
+62 -61
View File
@@ -33,7 +33,7 @@ export default function KiraAvatar(props: Props) {
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal'); const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
// ── Initialize Live2D ── // Initialize Live2D
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const container = canvasRef.current; const container = canvasRef.current;
@@ -51,16 +51,13 @@ export default function KiraAvatar(props: Props) {
const { Application, Ticker } = await import('pixi.js'); const { Application, Ticker } = await import('pixi.js');
const { Live2DModel } = await import('pixi-live2d-display/cubism4'); 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); (Live2DModel as any).registerTicker(Ticker as any);
// Responsive sizing — fill the container, target ~1/3 viewport
const containerW = container.clientWidth || 400; const containerW = container.clientWidth || 400;
const size = Math.min(containerW, 500); const containerH = container.clientHeight || 400;
const app = new Application({ const app = new Application({
width: size, width: containerW,
height: size * 1.25, height: containerH,
antialias: true, antialias: true,
resolution: Math.min(window.devicePixelRatio || 1, 2), resolution: Math.min(window.devicePixelRatio || 1, 2),
backgroundAlpha: 0, backgroundAlpha: 0,
@@ -70,26 +67,23 @@ export default function KiraAvatar(props: Props) {
container.appendChild(app.view as HTMLCanvasElement); container.appendChild(app.view as HTMLCanvasElement);
// Load model with default textures
const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
autoInteract: false, autoInteract: false,
}); });
modelRef.current = model; modelRef.current = model;
// Fit model in canvas // Fit model to fill the canvas
const maxW = size * 0.78; const maxW = containerW * 0.85;
const maxH = size * 1.1; const maxH = containerH * 0.85;
const scale = Math.min(maxW / model.width, maxH / model.height); const scale = Math.min(maxW / model.width, maxH / model.height);
model.scale.set(scale); model.scale.set(scale);
model.anchor.set(0.5, 0.5); model.anchor.set(0.5, 0.55);
model.position.set(app.screen.width / 2, app.screen.height / 2 + 6); model.position.set(app.screen.width / 2, app.screen.height / 2 + 8);
app.stage.addChild(model as any); app.stage.addChild(model as any);
// Fix: prevent pixi v7 isInteractive TypeError with Live2D model
(model as any).isInteractive = () => false; (model as any).isInteractive = () => false;
// Store reference to texture_02 for outfit swapping
try { try {
textureRef.current = { textureRef.current = {
index: 2, index: 2,
@@ -99,11 +93,9 @@ export default function KiraAvatar(props: Props) {
if (mounted) setLive2dReady(true); if (mounted) setLive2dReady(true);
// Start idle animation
try { model.motion('Idle'); } catch { /* no idle */ } try { model.motion('Idle'); } catch { /* no idle */ }
try { model.expression('Normal'); } catch { /* no expressions */ } try { model.expression('Normal'); } catch { /* no expressions */ }
// Random idle expression changes
idleExprRef.current = setInterval(() => { idleExprRef.current = setInterval(() => {
if (props.isSpeaking) return; if (props.isSpeaking) return;
const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)]; 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// ── Lip sync from TTS ── // Lip sync from TTS
useEffect(() => { useEffect(() => {
const model = modelRef.current; const model = modelRef.current;
if (!model || !live2dReady) return; if (!model || !live2dReady) return;
@@ -164,7 +156,7 @@ export default function KiraAvatar(props: Props) {
return () => cancelAnimationFrame(lipSyncRef.current); return () => cancelAnimationFrame(lipSyncRef.current);
}, [props.isSpeaking, live2dReady]); }, [props.isSpeaking, live2dReady]);
// ── Outfit texture swapping ── // Outfit texture swapping
useEffect(() => { useEffect(() => {
const model = modelRef.current; const model = modelRef.current;
if (!model || !live2dReady) return; if (!model || !live2dReady) return;
@@ -176,12 +168,10 @@ export default function KiraAvatar(props: Props) {
try { try {
const { Assets } = await import('pixi.js'); const { Assets } = await import('pixi.js');
const tex = await Assets.load(outfitUrl); 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) { if ((model as any).textures) {
(model as any).textures[2] = tex; (model as any).textures[2] = tex;
} else { } else {
console.warn('[Outfit] cannot swap no textures array'); console.warn('[Outfit] cannot swap - no textures array');
} }
} catch (e) { } catch (e) {
console.warn('[Outfit] texture swap failed:', e); console.warn('[Outfit] texture swap failed:', e);
@@ -189,27 +179,35 @@ export default function KiraAvatar(props: Props) {
})(); })();
}, [props.outfit, live2dReady]); }, [props.outfit, live2dReady]);
// ── Accessory toggle ── // Accessory toggle
useEffect(() => { useEffect(() => {
const model = modelRef.current; const model = modelRef.current;
if (!model || !live2dReady) return; 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]); }, [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 ( return (
<div className="glass-card p-6 flex flex-col items-center w-full" style={{ minHeight: '33vh' }}> <div className="glass-card flex flex-col items-center w-full overflow-hidden" style={{ minHeight: '33vh' }}>
{/* Live2D canvas */} <div className="relative w-full flex-1" style={{ minHeight: 250 }}>
<div <div
ref={canvasRef} ref={canvasRef}
className={`relative w-full ${live2dReady ? 'block' : 'hidden'}`} className="w-full h-full"
style={{ maxWidth: 500, height: '30vh', minHeight: 250 }} style={{ display: live2dReady ? 'block' : 'none' }}
/> />
{/* SVG fallback */} {/* SVG fallback when Live2D fails */}
{(!live2dReady || loadError) && ( {(!live2dReady || loadError) && (
<div className={live2dReady ? 'hidden' : 'block'}> <div className="absolute inset-0 flex items-center justify-center">
<AnimatedAvatar <AnimatedAvatar
isSpeaking={props.isSpeaking} isSpeaking={props.isSpeaking}
isListening={props.isListening} isListening={props.isListening}
@@ -222,16 +220,17 @@ export default function KiraAvatar(props: Props) {
{/* Loading spinner */} {/* Loading spinner */}
{!live2dReady && !loadError && ( {!live2dReady && !loadError && (
<div className="flex flex-col items-center gap-2 py-10"> <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" /> <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> <p className="text-[11px] text-kira-plum/40">loading Kira...</p>
</div> </div>
)} )}
{/* Expression indicator + Talk button */} {/* Expression buttons overlay top */}
{live2dReady && ( {live2dReady && (
<> <>
<div className="mt-1 flex gap-1.5 flex-wrap justify-center"> <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) => ( {EXPRESSIONS.map((expr) => (
<button <button
key={expr} key={expr}
@@ -241,59 +240,61 @@ export default function KiraAvatar(props: Props) {
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${ className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
currentExpression === expr currentExpression === expr
? 'bg-kira-pink text-white' ? 'bg-kira-pink text-white'
: 'text-kira-plum/30 hover:text-kira-plum/60' : 'bg-white/70 text-kira-plum/40 hover:text-kira-plum/70'
}`} }`}
> >
{expr} {expr}
</button> </button>
))} ))}
</div> </div>
</div>
{/* Talk button — mic toggle */} {/* Talk button overlay bottom-center */}
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
<button <button
onClick={props.onTalkToggle} onClick={props.onTalkToggle}
className={`mt-2 flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all ${ 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 props.isListening
? 'bg-red-400 text-white shadow-lg scale-105 animate-listening-pulse' ? 'bg-red-400 text-white scale-105 animate-listening-pulse'
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-lg hover:scale-105' : '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> <span className="text-base">{props.isListening ? '⏹️' : '🎤'}</span>
{props.isListening ? 'Listening...' : 'Talk to Kira'} {props.isListening ? 'Listening...' : 'Talk to Kira'}
</button> </button>
</>
)}
{/* 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> </div>
<style>{` {/* Status overlay bottom-right */}
@keyframes listening-pulse { <div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); } <span className={`w-2 h-2 rounded-full ${
50% { box-shadow: 0 0 0 12px rgba(248, 113, 113, 0); } props.isSpeaking ? 'bg-kira-pink animate-pulse'
} : props.isListening ? 'bg-red-400 animate-pulse'
.animate-listening-pulse { : 'bg-kira-mint'
animation: listening-pulse 1.5s infinite; }`} />
} <span>
`}</style> {props.isSpeaking ? 'speaking...'
: props.isListening ? 'listening...'
: 'here with you'}
</span>
</div>
</>
)}
</div>
<style>{pulseStyle}</style>
</div> </div>
); );
} }
// ── Helpers ── // Helpers
function loadScript(src: string): Promise<void> { function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => { 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'); const s = document.createElement('script');
s.src = src; s.src = src;
s.onload = () => resolve(); s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed ${src}`)); s.onerror = () => reject(new Error('Failed ' + src));
document.head.appendChild(s); document.head.appendChild(s);
}); });
} }