From 5f5127f4fa651922d5aec0cf5d0456c0e17b198a Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Fri, 5 Jun 2026 13:34:51 -0400 Subject: [PATCH] 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. --- frontend/src/App.tsx | 18 ++- frontend/src/components/KiraAvatar.tsx | 183 ++++-------------------- frontend/src/components/Live2DStage.tsx | 163 +++++++++++++++++++++ frontend/tsconfig.tsbuildinfo | 2 +- 4 files changed, 205 insertions(+), 161 deletions(-) create mode 100644 frontend/src/components/Live2DStage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1960f1c..af4457a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import MusicPlayer from './components/MusicPlayer'; import Timer from './components/Timer'; import Notes from './components/Notes'; @@ -9,6 +9,7 @@ import PetZone from './components/PetZone'; import Wardrobe from './components/Wardrobe'; import Particles from './components/Particles'; import WelcomeScreen from './components/WelcomeScreen'; +import Live2DStage from './components/Live2DStage'; import { SCENES, type Scene } from './components/scenes'; import { useConversation } from './hooks/useConversation'; @@ -33,6 +34,8 @@ export default function App() { const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie'); const [currentAcc, setCurrentAcc] = useState(null); const [textInput, setTextInput] = useState(''); + const [live2dReady, setLive2dReady] = useState(false); + const kiraModelRef = useRef(null); useEffect(() => { if (preferences.scene) setCurrentSceneId(preferences.scene); @@ -93,6 +96,13 @@ export default function App() { return (
+ {/* Single Live2D canvas: both Kira and Mochi share one WebGL context */} + { kiraModelRef.current = model; }} + onReady={() => setLive2dReady(true)} + isSpeaking={isKiraSpeaking} + /> +
{/* ── Top bar: scene selector + clock ── */} @@ -139,14 +149,16 @@ export default function App() {
- {/* CENTER: Avatar (hero) */} -
+ {/* CENTER: Avatar UI overlay (Live2D renders on the background stage) */} +
diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index e91d522..fa9eda2 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -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 = { - '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(null); - const modelRef = useRef(null); - const textureRef = useRef(null); const lipSyncRef = useRef(0); const idleExprRef = useRef | null>(null); - const [live2dReady, setLive2dReady] = useState(false); - const [loadError, setLoadError] = useState(false); const [currentExpression, setCurrentExpression] = useState('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 ( -
+
- {/* Live2D canvas */} - - - {/* SVG fallback */} - {(!live2dReady || loadError) && ( + {/* SVG fallback (shows before Live2D is ready) */} + {!props.live2dReady && (
)} - {/* Loading */} - {!live2dReady && !loadError && ( + {/* Loading spinner */} + {!props.live2dReady && (

loading Kira...

@@ -218,7 +98,7 @@ export default function KiraAvatar(props: Props) { )} {/* Expression buttons */} - {live2dReady && ( + {props.live2dReady && ( <>
@@ -226,7 +106,7 @@ export default function KiraAvatar(props: Props) {