import { useEffect, useRef, useState, type ReactNode } from "react"; import { installLive2DLoader } from "@/services/live2d-loader"; import { adaptLive2DRenderOrders } from "@/lib/canvas/live2d-core-adapter"; let corePromise: Promise | undefined; function loadCore() { if ("Live2DCubismCore" in window) return Promise.resolve(); if (corePromise) return corePromise; corePromise = new Promise((resolve, reject) => { const script = document.createElement("script"); // Bundled same-origin runtime only. Uploaded models may never provide executable code. script.src = `${import.meta.env.BASE_URL}live2d/live2dcubismcore.min.js`; const timer = window.setTimeout(() => fail(), 15000); const fail = () => { clearTimeout(timer); script.remove(); reject(new Error("Live2D 运行库加载失败,请确认部署产物包含 Cubism Core 且静态资源可访问后重试")); }; script.onload = () => { if (!("Live2DCubismCore" in window)) { fail(); return; } clearTimeout(timer); resolve(); }; script.onerror = fail; document.head.append(script); }).catch((error) => { corePromise = undefined; throw error; }); return corePromise; } export function Live2DAvatar({ url, width, height, reducedMotion = false, fallback, onReady, onError, }: { url: string; width: number; height: number; reducedMotion?: boolean; fallback?: ReactNode; onReady?: () => void; onError?: (message: string) => void; }) { const host = useRef(null); const callbacks = useRef({ onReady, onError }); callbacks.current = { onReady, onError }; const [failed, setFailed] = useState(false); const [ready, setReady] = useState(false); useEffect(() => { let disposed = false; let cleanup: (() => void) | undefined; let timer: ReturnType | undefined; let deadline: ReturnType | undefined; let cancelLoading: (() => void) | undefined; setFailed(false); setReady(false); const init = async () => { await loadCore(); const [PIXI, { Live2DModel, Live2DFactory, Live2DLoader, MotionPreloadStrategy, Cubism4InternalModel }] = await Promise.all([import("pixi.js"), import("pixi-live2d-display/cubism4")]); if (disposed) return; installLive2DLoader(Live2DLoader); const app = new PIXI.Application({ width, height, backgroundAlpha: 0, resolution: Math.min(window.devicePixelRatio || 1, 1.5), autoDensity: true, autoStart: false, antialias: true }); const canvas = app.view as HTMLCanvasElement; canvas.style.width = "100%"; canvas.style.height = "100%"; canvas.setAttribute("aria-hidden", "true"); host.current?.append(canvas); let model: InstanceType | undefined; let visible = true; let lost = false; let destroyed = false; let elapsed = performance.now(); const failRendering = (message: string) => { lost = true; clearTimeout(deadline); cancelLoading?.(); cleanup?.(); setFailed(true); callbacks.current.onError?.(message); }; const draw = () => { if (document.hidden || disposed || lost || !visible || !model) return; try { const now = performance.now(); model.update(Math.min(now - elapsed, 100)); elapsed = now; app.render(); } catch { failRendering("Live2D 渲染失败,已回退默认形象"); } }; const sync = () => { clearInterval(timer); if (!document.hidden && visible && !lost && !reducedMotion && model) { elapsed = performance.now(); timer = setInterval(draw, 1000 / 30); } }; const observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; sync(); }); observer.observe(canvas); const contextLost = (event: Event) => { event.preventDefault(); failRendering("Live2D 渲染上下文丢失,已回退默认形象"); }; canvas.addEventListener("webglcontextlost", contextLost); document.addEventListener("visibilitychange", sync); cleanup = () => { if (destroyed) return; destroyed = true; clearInterval(timer); observer.disconnect(); document.removeEventListener("visibilitychange", sync); canvas.removeEventListener("webglcontextlost", contextLost); app.destroy(true, { children: true, texture: true, baseTexture: true }); }; const loaded = new Live2DModel({ autoUpdate: false, autoInteract: false }); const releaseLoaded = () => { // Cubism's destroy() assumes internalModel exists, even after failed setup. if (loaded.internalModel) loaded.destroy({ children: true, texture: true, baseTexture: true }); else { loaded.emit("destroy"); loaded.textures.forEach((texture) => texture.destroy(true)); } }; cancelLoading = () => loaded.emit("destroy"); deadline = setTimeout(() => { disposed = true; cancelLoading?.(); cleanup?.(); setFailed(true); callbacks.current.onError?.("Live2D 加载超时,请检查模型和网络后重试"); }, 30000); try { await Live2DFactory.setupLive2DModel(loaded, url, { autoUpdate: false, autoInteract: false, crossOrigin: "use-credentials", motionPreload: MotionPreloadStrategy.IDLE }); } catch (error) { releaseLoaded(); throw error; } finally { clearTimeout(deadline); cancelLoading = undefined; } if (disposed || lost) { releaseLoaded(); cleanup?.(); return; } model = loaded; app.stage.addChild(loaded); if (!(loaded.internalModel instanceof Cubism4InternalModel)) throw new Error("不支持的 Live2D 模型运行时"); adaptLive2DRenderOrders(loaded.internalModel.coreModel); if (!(loaded.width > 0 && loaded.height > 0)) throw new Error("Live2D 模型尺寸无效"); const scale = Math.min(width / loaded.width, height / loaded.height); loaded.scale.set(scale); loaded.anchor.set(0.5, 1); loaded.position.set(width / 2, height); loaded.update(0); app.render(); sync(); setReady(true); callbacks.current.onReady?.(); }; void init().catch((error) => { cleanup?.(); cleanup = undefined; if (!disposed) { setFailed(true); callbacks.current.onError?.(error instanceof Error ? error.message : "Live2D 加载失败"); } }); return () => { disposed = true; clearTimeout(deadline); cancelLoading?.(); cleanup?.(); }; }, [url, width, height, reducedMotion]); return ( {failed || !ready ? {fallback} : null} ); }