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:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user