diff --git a/frontend/src/components/Live2DStage.tsx b/frontend/src/components/Live2DStage.tsx index a87de65..d16aa75 100644 --- a/frontend/src/components/Live2DStage.tsx +++ b/frontend/src/components/Live2DStage.tsx @@ -8,12 +8,54 @@ interface Props { /** * Single full-viewport Live2D stage. One WebGL context, two models: - * - Kira (center) - * - Mochi the cat (bottom-right, PetZone area) + * - Kira (center panel) + * - Mochi the cat (PetZone, bottom of right sidebar) * - * The canvas sits behind the UI panels (z-0, pointer-events: none). + * Canvas sits behind UI panels (z-0, pointer-events: none). */ -export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props) { + +// --- Layout constants (must match Tailwind in App.tsx) --- +// Left sidebar: w-72 = 288px, gap-4 = 16px, px-4 = 16px +// Right sidebar: w-64 = 256px, bottom bar: ~40px +const PAD = 16; +const LEFT_W = 288; +const GAP = 16; +const RIGHT_W = 256; +const BOTTOM_BAR_H = 40; + +function positionModels( + kira: any, cat: any, + appW: number, appH: number, +) { + // Center panel center + const centerLeft = PAD + LEFT_W + GAP; + const centerRight = appW - GAP - RIGHT_W - PAD; + const centerMidX = (centerLeft + centerRight) / 2; + const centerMidY = appH * 0.46; + + if (kira) { + const s = Math.min(((centerRight - centerLeft) * 0.78) / kira.width, (appH * 0.78) / kira.height); + kira.scale.set(s); + kira.anchor.set(0.5, 0.5); + kira.position.set(centerMidX, centerMidY); + } + + // Right sidebar center + const rightLeft = appW - RIGHT_W - PAD; + const rightMidX = rightLeft + RIGHT_W / 2; + // PetZone sits near the bottom of the sidebar, above the status bar + const catTargetH = 120; + const catY = appH - BOTTOM_BAR_H - catTargetH / 2 - 18; + + if (cat) { + const cs = catTargetH / cat.height; + cat.scale.set(cs); + cat.anchor.set(0.5, 0.5); + cat.position.set(rightMidX, catY); + } +} + +export default function Live2DStage({ onKiraReady, onReady }: Props) { const canvasRef = useRef(null); useEffect(() => { @@ -32,7 +74,6 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props) const { Live2DModel } = await import('pixi-live2d-display/cubism4'); (Live2DModel as any).registerTicker(Ticker as any); - // Full viewport canvas const w = window.innerWidth; const h = window.innerHeight; @@ -47,30 +88,9 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props) }); if (!mounted) { app.destroy(true); return; } - // Resize handler const onResize = () => { app.renderer.resize(window.innerWidth, window.innerHeight); - if (kiraModel) { - const sw = app.screen.width; - const sh = app.screen.height; - const centerX = sw * 0.42; // center column offset - const centerY = sh * 0.48; - const s = Math.min((sw * 0.28) / kiraModel.width, (sh * 0.7) / kiraModel.height); - kiraModel.scale.set(s); - kiraModel.anchor.set(0.5, 0.5); - kiraModel.position.set(centerX, centerY); - } - if (catModel) { - const sw = app.screen.width; - const sh = app.screen.height; - // Position in right sidebar area - const catX = sw * 0.88; - const catY = sh * 0.88; - const cs = Math.min((sw * 0.08) / catModel.width, (sh * 0.12) / catModel.height); - catModel.scale.set(cs); - catModel.anchor.set(0.5, 0.5); - catModel.position.set(catX, catY); - } + positionModels(kiraModel, catModel, app.screen.width, app.screen.height); }; window.addEventListener('resize', onResize); @@ -80,17 +100,10 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props) }); if (!mounted) { app.destroy(true); return; } - const sw = app.screen.width; - const sh = app.screen.height; - const centerX = sw * 0.42; - const centerY = sh * 0.48; - const s = Math.min((sw * 0.28) / kiraModel.width, (sh * 0.7) / kiraModel.height); - kiraModel.scale.set(s); - kiraModel.anchor.set(0.5, 0.5); - kiraModel.position.set(centerX, centerY); + positionModels(kiraModel, null, app.screen.width, app.screen.height); (kiraModel as any).isInteractive = () => false; - app.stage.addChild(kiraModel as any); + try { kiraModel.motion('Idle'); } catch {} try { kiraModel.expression('Normal'); } catch {} @@ -103,15 +116,10 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props) }); if (!mounted) return; - const catX = sw * 0.88; - const catY = sh * 0.88; - const cs = Math.min((sw * 0.08) / catModel.width, (sh * 0.12) / catModel.height); - catModel.scale.set(cs); - catModel.anchor.set(0.5, 0.5); - catModel.position.set(catX, catY); + positionModels(kiraModel, catModel, app.screen.width, app.screen.height); (catModel as any).isInteractive = () => false; - app.stage.addChild(catModel as any); + try { catModel.motion('Idle'); } catch {} } catch (e) { console.warn('[Live2DStage] cat load failed:', e); @@ -133,15 +141,6 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props) // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Lip sync for Kira - const kiraRef = useRef(null); - const lipSyncRef = useRef(0); - - useEffect(() => { - // Store kira model from onKiraReady callback - // We need to capture the model for lip sync - }, []); - return (