diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index be80a5a..1e078fd 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -23,7 +23,7 @@ const OUTFIT_TEXTURES: Record = { }; export default function KiraAvatar(props: Props) { - const canvasRef = useRef(null); + const wrapRef = useRef(null); const modelRef = useRef(null); const textureRef = useRef(null); const lipSyncRef = useRef(0); @@ -35,21 +35,11 @@ export default function KiraAvatar(props: Props) { // Initialize Live2D useEffect(() => { let mounted = true; - const container = canvasRef.current; + const container = wrapRef.current; if (!container) return; let app: any = null; let model: any = null; - let canvasEl: HTMLCanvasElement | null = null; - - const fitModel = (crW: number, crH: number) => { - if (!model || !app) return; - const maxW = crW * 0.45; - const maxH = crH * 0.45; - 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); - }; const init = async () => { try { @@ -61,14 +51,8 @@ export default function KiraAvatar(props: Props) { const { Live2DModel } = await import('pixi-live2d-display/cubism4'); (Live2DModel as any).registerTicker(Ticker as any); - // 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, + resizeTo: container, antialias: true, resolution: Math.min(window.devicePixelRatio || 1, 2), backgroundAlpha: 0, @@ -76,30 +60,31 @@ export default function KiraAvatar(props: Props) { }); if (!mounted) { app.destroy(true); return; } - // 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); - canvasEl = canvas; + container.appendChild(app.view as HTMLCanvasElement); model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { autoInteract: false, }); modelRef.current = model; - // Fit model with generous margin to avoid clipping - const maxW = w * 0.45; - const maxH = h * 0.45; - const scale = Math.min(maxW / model.width, maxH / model.height); - model.scale.set(scale); - model.anchor.set(0.5, 0.5); - model.position.set(app.screen.width / 2, app.screen.height / 2); + const fit = () => { + const sw = app.screen.width; + const sh = app.screen.height; + // Scale to fit within the container, leaving margin so nothing clips + const margin = 0.9; // 90% of container + const s = Math.min((sw * margin) / model.width, (sh * margin) / model.height); + model.scale.set(s); + model.anchor.set(0.5, 0.5); + model.position.set(sw / 2, sh / 2); + }; + fit(); app.stage.addChild(model as any); (model as any).isInteractive = () => false; + // Re-fit on resize + app.renderer.on('resize', fit); + try { textureRef.current = { index: 2, @@ -125,28 +110,10 @@ export default function KiraAvatar(props: Props) { } }; - // Use ResizeObserver so we init with the real laid-out size - const ro = new ResizeObserver((entries) => { - const cr = entries[0].contentRect; - if (app) { - // Already init'd — handle resize - app.renderer.resize(Math.round(cr.width), Math.round(cr.height)); - // Re-apply CSS 100% because Pixi resize() overwrites inline styles - if (canvasEl) { - canvasEl.style.width = '100%'; - canvasEl.style.height = '100%'; - } - fitModel(cr.width, cr.height); - return; - } - // First measurement — run init - init(); - }); - ro.observe(container); + init(); return () => { mounted = false; - ro.disconnect(); cancelAnimationFrame(lipSyncRef.current); clearInterval(idleExprRef.current ?? undefined); if (app) { app.destroy(true, { children: true }); } @@ -160,9 +127,7 @@ export default function KiraAvatar(props: Props) { useEffect(() => { const model = modelRef.current; if (!model || !live2dReady) return; - cancelAnimationFrame(lipSyncRef.current); - if (props.isSpeaking) { let phase = 0; const animate = () => { @@ -170,8 +135,7 @@ export default function KiraAvatar(props: Props) { const openness = 0.25 + Math.sin(phase) * 0.35; try { model.internalModel.coreModel.setParameterValueByIndex( - findParam(model, 'PARAM_MOUTH_OPEN_Y'), - openness, + findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness, ); } catch { /* */ } lipSyncRef.current = requestAnimationFrame(animate); @@ -180,12 +144,10 @@ export default function KiraAvatar(props: Props) { } else { try { model.internalModel.coreModel.setParameterValueByIndex( - findParam(model, 'PARAM_MOUTH_OPEN_Y'), - 0, + findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0, ); } catch { /* */ } } - return () => cancelAnimationFrame(lipSyncRef.current); }, [props.isSpeaking, live2dReady]); @@ -193,10 +155,8 @@ export default function KiraAvatar(props: Props) { useEffect(() => { const model = modelRef.current; if (!model || !live2dReady) return; - const outfitUrl = OUTFIT_TEXTURES[props.outfit]; if (!outfitUrl) return; - (async () => { try { const { Assets } = await import('pixi.js'); @@ -218,7 +178,6 @@ export default function KiraAvatar(props: Props) { if (!model || !live2dReady) return; }, [props.accessory, live2dReady]); - // Inline styles const pulseStyle = ` @keyframes listening-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); } @@ -232,11 +191,8 @@ export default function KiraAvatar(props: Props) { return (
-
+ {/* Pixi canvas mounts here */} +
{/* SVG fallback when Live2D fails */} {(!live2dReady || loadError) && ( @@ -319,8 +275,6 @@ export default function KiraAvatar(props: Props) { ); } -// Helpers - function loadScript(src: string): Promise { return new Promise((resolve, reject) => { if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; }