diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1960f1c..8a4b51f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ export default function App() { const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie'); const [currentAcc, setCurrentAcc] = useState(null); const [textInput, setTextInput] = useState(''); + const [pixiApp, setPixiApp] = useState(null); useEffect(() => { if (preferences.scene) setCurrentSceneId(preferences.scene); @@ -147,6 +148,7 @@ export default function App() { outfit={currentOutfit} accessory={currentAcc} onTalkToggle={handleTalkToggle} + onAppReady={setPixiApp} /> @@ -155,7 +157,7 @@ export default function App() { - + diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index f90a16f..8327b75 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -7,6 +7,7 @@ interface Props { outfit: string; accessory: string | null; onTalkToggle: () => void; + onAppReady?: (app: any) => void; } type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing'; @@ -82,6 +83,8 @@ export default function KiraAvatar(props: Props) { app.stage.addChild(model as any); (model as any).isInteractive = () => false; + if (props.onAppReady) props.onAppReady(app); + try { textureRef.current = { index: 2, diff --git a/frontend/src/components/Live2DCat.tsx b/frontend/src/components/Live2DCat.tsx index 913b941..e4b5e3f 100644 --- a/frontend/src/components/Live2DCat.tsx +++ b/frontend/src/components/Live2DCat.tsx @@ -1,58 +1,42 @@ import { useEffect, useRef } from 'react'; interface Props { + app: any; className?: string; } -export default function Live2DCat({ className }: Props) { - const canvasRef = useRef(null); +export default function Live2DCat({ app, className }: Props) { + const modelRef = useRef(null); useEffect(() => { + if (!app) return; let mounted = true; - const canvas = canvasRef.current; - if (!canvas) return; - - let app: any = null; const init = async () => { try { - 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 || 120; - const h = canvas.clientHeight || 120; - - 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; } const model = await Live2DModel.from('/live2d/models/little-cat/LittleCat.model3.json', { autoInteract: false, }); - if (!mounted) { app.destroy(true); return; } + if (!mounted) return; - // Scale to fit the small canvas + // Scale to fit ~120x120 area on the shared stage const sw = app.screen.width; const sh = app.screen.height; - const s = Math.min((sw * 0.85) / model.width, (sh * 0.85) / model.height); + const targetW = 120; + const targetH = 120; + const s = Math.min(targetW / model.width, targetH / model.height); model.scale.set(s); model.anchor.set(0.5, 0.5); - model.position.set(sw / 2, sh / 2); + // Position near top-left corner with some padding + model.position.set(80, 80); app.stage.addChild(model as any); (model as any).isInteractive = () => false; + modelRef.current = model; try { model.motion('Idle'); } catch { /* */ } - } catch (e) { console.warn('[Live2DCat]', e); } @@ -62,26 +46,14 @@ export default function Live2DCat({ className }: Props) { return () => { mounted = false; - if (app) { app.destroy(true, { children: true }); } + if (modelRef.current) { + app.stage.removeChild(modelRef.current); + modelRef.current.destroy?.(); + modelRef.current = null; + } }; - }, []); + }, [app]); - return ( - - ); -} - -function loadScript(src: string): Promise { - 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); - }); + // Live2DCat no longer renders its own canvas — it shares the KiraAvatar canvas + return null; } diff --git a/frontend/src/components/PetZone.tsx b/frontend/src/components/PetZone.tsx index 826a4f7..c648f82 100644 --- a/frontend/src/components/PetZone.tsx +++ b/frontend/src/components/PetZone.tsx @@ -1,6 +1,10 @@ import Live2DCat from './Live2DCat'; -export default function PetZone() { +interface Props { + app: any; +} + +export default function PetZone({ app }: Props) { return (

@@ -8,7 +12,8 @@ export default function PetZone() {

- + {/* Cat model is rendered on the shared KiraAvatar canvas */} + Mochi