fix(live2d): precise cat positioning and sizing

- Extract layout constants matching Tailwind config (PAD, LEFT_W, GAP, RIGHT_W)
- positionModels() helper computes exact pixel positions from layout
- Kira: centered in center panel at 78% of available space
- Mochi: 120px tall, centered in right sidebar, above status bar
- Both models reposition on window resize
This commit is contained in:
2026-06-05 13:42:21 -04:00
parent 5f5127f4fa
commit 9d2ba052f4
+51 -52
View File
@@ -8,12 +8,54 @@ interface Props {
/** /**
* Single full-viewport Live2D stage. One WebGL context, two models: * Single full-viewport Live2D stage. One WebGL context, two models:
* - Kira (center) * - Kira (center panel)
* - Mochi the cat (bottom-right, PetZone area) * - 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<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => { useEffect(() => {
@@ -32,7 +74,6 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props)
const { Live2DModel } = await import('pixi-live2d-display/cubism4'); const { Live2DModel } = await import('pixi-live2d-display/cubism4');
(Live2DModel as any).registerTicker(Ticker as any); (Live2DModel as any).registerTicker(Ticker as any);
// Full viewport canvas
const w = window.innerWidth; const w = window.innerWidth;
const h = window.innerHeight; const h = window.innerHeight;
@@ -47,30 +88,9 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props)
}); });
if (!mounted) { app.destroy(true); return; } if (!mounted) { app.destroy(true); return; }
// Resize handler
const onResize = () => { const onResize = () => {
app.renderer.resize(window.innerWidth, window.innerHeight); app.renderer.resize(window.innerWidth, window.innerHeight);
if (kiraModel) { positionModels(kiraModel, catModel, app.screen.width, app.screen.height);
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);
}
}; };
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
@@ -80,17 +100,10 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props)
}); });
if (!mounted) { app.destroy(true); return; } if (!mounted) { app.destroy(true); return; }
const sw = app.screen.width; positionModels(kiraModel, null, app.screen.width, app.screen.height);
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);
(kiraModel as any).isInteractive = () => false; (kiraModel as any).isInteractive = () => false;
app.stage.addChild(kiraModel as any); app.stage.addChild(kiraModel as any);
try { kiraModel.motion('Idle'); } catch {} try { kiraModel.motion('Idle'); } catch {}
try { kiraModel.expression('Normal'); } catch {} try { kiraModel.expression('Normal'); } catch {}
@@ -103,15 +116,10 @@ export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props)
}); });
if (!mounted) return; if (!mounted) return;
const catX = sw * 0.88; positionModels(kiraModel, catModel, app.screen.width, app.screen.height);
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);
(catModel as any).isInteractive = () => false; (catModel as any).isInteractive = () => false;
app.stage.addChild(catModel as any); app.stage.addChild(catModel as any);
try { catModel.motion('Idle'); } catch {} try { catModel.motion('Idle'); } catch {}
} catch (e) { } catch (e) {
console.warn('[Live2DStage] cat load failed:', 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Lip sync for Kira
const kiraRef = useRef<any>(null);
const lipSyncRef = useRef<number>(0);
useEffect(() => {
// Store kira model from onKiraReady callback
// We need to capture the model for lip sync
}, []);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}