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:
@@ -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,111 +179,122 @@ 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}
|
||||||
outfit={props.outfit}
|
outfit={props.outfit}
|
||||||
accessory={props.accessory}
|
accessory={props.accessory}
|
||||||
onTalkToggle={props.onTalkToggle}
|
onTalkToggle={props.onTalkToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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">
|
||||||
{EXPRESSIONS.map((expr) => (
|
<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 overlay bottom-center */}
|
||||||
|
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
|
||||||
<button
|
<button
|
||||||
key={expr}
|
onClick={props.onTalkToggle}
|
||||||
onClick={() => {
|
className={`pointer-events-auto flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all shadow-lg ${
|
||||||
try { modelRef.current?.expression(expr); setCurrentExpression(expr); } catch {}
|
props.isListening
|
||||||
}}
|
? 'bg-red-400 text-white scale-105 animate-listening-pulse'
|
||||||
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
|
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-xl hover:scale-105'
|
||||||
currentExpression === expr
|
|
||||||
? 'bg-kira-pink text-white'
|
|
||||||
: 'text-kira-plum/30 hover:text-kira-plum/60'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{expr}
|
<span className="text-base">{props.isListening ? '⏹️' : '🎤'}</span>
|
||||||
|
{props.isListening ? 'Listening...' : 'Talk to Kira'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Talk button — mic toggle */}
|
{/* Status overlay bottom-right */}
|
||||||
<button
|
<div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
|
||||||
onClick={props.onTalkToggle}
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
className={`mt-2 flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all ${
|
props.isSpeaking ? 'bg-kira-pink animate-pulse'
|
||||||
props.isListening
|
: props.isListening ? 'bg-red-400 animate-pulse'
|
||||||
? 'bg-red-400 text-white shadow-lg scale-105 animate-listening-pulse'
|
: 'bg-kira-mint'
|
||||||
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-lg hover:scale-105'
|
}`} />
|
||||||
}`}
|
<span>
|
||||||
>
|
{props.isSpeaking ? 'speaking...'
|
||||||
<span className="text-base">{props.isListening ? '⏹️' : '🎤'}</span>
|
: props.isListening ? 'listening...'
|
||||||
{props.isListening ? 'Listening...' : 'Talk to Kira'}
|
: 'here with you'}
|
||||||
</button>
|
</span>
|
||||||
</>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{pulseStyle}</style>
|
||||||
@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;
|
|
||||||
}
|
|
||||||
`}</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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user