refactor(live2d): single shared stage for both Kira and Mochi

Live2DStage creates ONE full-viewport transparent canvas (z-0, pointer-events:none).
Both Kira and Mochi cat models render on the same Pixi stage and WebGL context.
KiraAvatar is now UI-only (no canvas), receives model ref from stage.
PetZone is label-only. Eliminates all WebGL context conflict errors.
This commit is contained in:
2026-06-05 13:34:51 -04:00
parent 43a392e5f5
commit 5f5127f4fa
4 changed files with 205 additions and 161 deletions
+26 -157
View File
@@ -7,6 +7,10 @@ interface Props {
outfit: string;
accessory: string | null;
onTalkToggle: () => void;
/** The Live2D Kira model instance (from Live2DStage) */
kiraModel: any;
live2dReady: boolean;
onExpressionChange?: (expr: string) => void;
}
type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing';
@@ -14,117 +18,27 @@ type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blus
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');
// Idle expression cycling
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;
if (!mounted) return;
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
}, []);
if (!props.kiraModel || !props.live2dReady) return;
idleExprRef.current = setInterval(() => {
if (props.isSpeaking) return;
const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)];
try { props.kiraModel.expression(expr); } catch {}
setCurrentExpression(expr);
}, 8000 + Math.random() * 7000);
return () => clearInterval(idleExprRef.current ?? undefined);
}, [props.kiraModel, props.live2dReady, props.isSpeaking]);
// Lip sync from TTS
useEffect(() => {
const model = modelRef.current;
if (!model || !live2dReady) return;
const model = props.kiraModel;
if (!model || !props.live2dReady) return;
cancelAnimationFrame(lipSyncRef.current);
if (props.isSpeaking) {
let phase = 0;
@@ -135,7 +49,7 @@ export default function KiraAvatar(props: Props) {
model.internalModel.coreModel.setParameterValueByIndex(
findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness,
);
} catch { /* */ }
} catch {}
lipSyncRef.current = requestAnimationFrame(animate);
};
animate();
@@ -144,37 +58,10 @@ export default function KiraAvatar(props: Props) {
model.internalModel.coreModel.setParameterValueByIndex(
findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0,
);
} catch { /* */ }
} 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]);
}, [props.isSpeaking, props.live2dReady, props.kiraModel]);
const pulseStyle = `
@keyframes listening-pulse {
@@ -187,17 +74,10 @@ export default function KiraAvatar(props: Props) {
`;
return (
<div className="flex flex-col items-center w-full h-full overflow-hidden">
<div className="flex flex-col items-center w-full h-full">
<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) && (
{/* SVG fallback (shows before Live2D is ready) */}
{!props.live2dReady && (
<div className="absolute inset-0 flex items-center justify-center">
<AnimatedAvatar
isSpeaking={props.isSpeaking}
@@ -209,8 +89,8 @@ export default function KiraAvatar(props: Props) {
</div>
)}
{/* Loading */}
{!live2dReady && !loadError && (
{/* Loading spinner */}
{!props.live2dReady && (
<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>
@@ -218,7 +98,7 @@ export default function KiraAvatar(props: Props) {
)}
{/* Expression buttons */}
{live2dReady && (
{props.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">
@@ -226,7 +106,7 @@ export default function KiraAvatar(props: Props) {
<button
key={expr}
onClick={() => {
try { modelRef.current?.expression(expr); setCurrentExpression(expr); } catch {}
try { props.kiraModel?.expression(expr); setCurrentExpression(expr); props.onExpressionChange?.(expr); } catch {}
}}
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
currentExpression === expr
@@ -277,17 +157,6 @@ export default function KiraAvatar(props: Props) {
);
}
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);