diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index 530b09e..90bf3a4 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -39,49 +39,55 @@ export default function KiraAvatar(props: Props) { const container = canvasRef.current; if (!container) return; - (async () => { + 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; - } + 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 containerW = container.clientWidth || 400; - const containerH = container.clientHeight || 400; - const app = new Application({ - width: containerW, - height: containerH, + // Measure real container size (flex layout may not be ready on first paint) + const rect = container.getBoundingClientRect(); + const w = Math.max(Math.round(rect.width), 260); + const h = Math.max(Math.round(rect.height), 260); + + app = new Application({ + width: w, + height: h, antialias: true, resolution: Math.min(window.devicePixelRatio || 1, 2), backgroundAlpha: 0, + autoDensity: true, }); - appRef.current = app; if (!mounted) { app.destroy(true); return; } - container.appendChild(app.view as HTMLCanvasElement); + // Force canvas to fill container via CSS so it never overflows + const canvas = app.view as HTMLCanvasElement; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + canvas.style.display = 'block'; + container.appendChild(canvas); - const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { + model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { autoInteract: false, }); modelRef.current = model; - // Fit model to fill the canvas — leave generous margin to avoid clipping - const maxW = containerW * 0.72; - const maxH = containerH * 0.72; + // Fit model with generous margin to avoid clipping + const maxW = w * 0.68; + const maxH = h * 0.68; const scale = Math.min(maxW / model.width, maxH / model.height); model.scale.set(scale); - model.anchor.set(0.5, 0.52); - model.position.set(app.screen.width / 2, app.screen.height / 2 + 6); + model.anchor.set(0.5, 0.5); + model.position.set(app.screen.width / 2, app.screen.height / 2); app.stage.addChild(model as any); - (model as any).isInteractive = () => false; try { @@ -107,16 +113,34 @@ export default function KiraAvatar(props: Props) { console.warn('[Live2D]', e); if (mounted) setLoadError(true); } - })(); + }; + + // Use ResizeObserver so we init with the real laid-out size + const ro = new ResizeObserver((entries) => { + if (app) { + // Already init'd — handle resize + const cr = entries[0].contentRect; + app.renderer.resize(Math.round(cr.width), Math.round(cr.height)); + if (model) { + const maxW = cr.width * 0.68; + const maxH = cr.height * 0.68; + const s = Math.min(maxW / model.width, maxH / model.height); + model.scale.set(s); + model.position.set(app.screen.width / 2, app.screen.height / 2); + } + return; + } + // First measurement — run init + init(); + }); + ro.observe(container); return () => { mounted = false; + ro.disconnect(); cancelAnimationFrame(lipSyncRef.current); clearInterval(idleExprRef.current ?? undefined); - if (appRef.current) { - appRef.current.destroy(true, { children: true }); - appRef.current = null; - } + if (app) { app.destroy(true, { children: true }); } modelRef.current = null; textureRef.current = null; };