diff --git a/README.md b/README.md index 95cea95..0856c34 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium - 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。 - 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。 - Argon motion、滚动几何、延迟、RAF 调度、DOM id/selector、hash anchor 和跨组件 ref 必须从 `src/factories/blogAnimationFactory.ts` 与 `src/factories/blogDomFactory.ts` 取值;组件和 Hook 不再散写行为层 id、裸 `requestAnimationFrame`、裸 `setTimeout` 或重复 timing。 -- Live2D 不依赖旧 WordPress `live-2d` 插件、`live2d-widgets`、随机 CDN fallback 模型或插件账号签名;`BlogLive2D` 只在桌面端读取 `VITE_BLOG_LIVE2D_MANIFEST_URL`,默认 `/api/blog/live2d/pio/v1/manifest.json`,并通过自托管 Pio manifest 加载官方 Cubism runtime。 +- Live2D 不依赖旧 WordPress `live-2d` 插件、`live2d-widgets`、随机 CDN fallback 模型或插件账号签名;`BlogLive2D` 只在桌面端读取 `VITE_BLOG_LIVE2D_MANIFEST_URL`,默认 `/api/blog/live2d/pio/v1/manifest.json`,并通过自托管 Pio manifest 加载官方 Cubism runtime。Manifest 已提供源 `Idle` motions 时,前端不得再叠加自动 idle bob/breath/sway,只保留 pointer parallax 兜底;线上 Pio 源目录必须遵守 `docs/live2d-pio-minio-directory-standard.md`,保留 `catalog.json`、`textures/manifest.json` 与完整 source texture catalog。 - Modal 不强求一比一复刻线上 Argon 动画,打开/关闭 motion 与 `centered` 居中定位交给 antdv-next;外层按 `packages/@core/ui-kit/popup-ui/src/modal/modal.vue` 保留 Header/Content/Footer 三段能力、`p-0`、`max-height`、纵向 flex、Content `min-h-40` 滚动和 `px-5 py-4`/`p-3`/`p-2` 间距,但无 footer slot 时不渲染 footer,不搬 draggable/fullscreen/loading/footer 按钮等复杂能力,自有颜色只守 Blog 主题色和暗色可读性。 - Admin 文章预览通过 `VITE_KT_BLOG_WEB_BASE_URL` 打开公开 Blog Web 路由,本地默认 `http://127.0.0.1:5173/#/post/?adminPreview=1&articleId=`。 diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts index 1cb51a7..8582d2c 100644 --- a/src/__tests__/BlogLive2D.spec.ts +++ b/src/__tests__/BlogLive2D.spec.ts @@ -10,8 +10,8 @@ const pioManifest = { character: 'pio', desktopOnly: true, fallback: null, - model3: '/api/blog/live2d/pio/v1/pio.model3.json', - runtimeScript: '/api/blog/live2d/pio/v1/pio-runtime.js', + model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', + runtimeScript: '/api/blog/live2d/pio/v1/assets/runtime/pio-runtime.js', version: 'v1', } as const; @@ -30,7 +30,7 @@ describe('fetchBlogLive2DManifest', () => { expect(manifest.character).toBe('pio'); expect(manifest.fallback).toBeNull(); - expect(manifest.model3).toBe('/api/blog/live2d/pio/v1/pio.model3.json'); + expect(manifest.model3).toBe('/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json'); }); it('rejects non-Pio or fallback manifests before runtime loading', async () => { @@ -97,7 +97,7 @@ describe('mountOfficialPioRuntime', () => { expect(runtime.mount).toHaveBeenCalledTimes(2); expect(runtime.mount).toHaveBeenCalledWith({ canvas, - model3: '/api/blog/live2d/pio/v1/pio.model3.json', + model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', }); }); @@ -132,11 +132,11 @@ describe('mountOfficialPioRuntime', () => { expect(runtime.mount).toHaveBeenCalledTimes(2); expect(runtime.mount).toHaveBeenNthCalledWith(1, { canvas: firstCanvas, - model3: '/api/blog/live2d/pio/v1/pio.model3.json', + model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', }); expect(runtime.mount).toHaveBeenNthCalledWith(2, { canvas: secondCanvas, - model3: '/api/blog/live2d/pio/v1/pio.model3.json', + model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', }); }); }); @@ -192,7 +192,7 @@ describe('BlogLive2D', () => { expect(canvas.exists()).toBe(true); expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({ canvas: canvas.element, - model3: '/api/blog/live2d/pio/v1/pio.model3.json', + model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', }); frames.shift()?.(1000); @@ -203,6 +203,75 @@ describe('BlogLive2D', () => { expect(destroy).toHaveBeenCalledTimes(1); expect(cancelFrame).toHaveBeenCalled(); }); + + it('does not start the canvas idle fallback when source idle motions exist', async () => { + vi.stubGlobal('innerWidth', 1280); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + ...pioManifest, + motionGroups: { + Idle: 6, + }, + }), + { status: 200 }, + ), + ), + ); + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { + const element = Node.prototype.appendChild.call(document.body, node); + if (element instanceof HTMLScriptElement) { + window.KtPioLive2D = { + mount: vi.fn(async () => ({ destroy: vi.fn() })), + }; + element.onload?.(new Event('load')); + } + return element; + }); + + const wrapper = mount(BlogLive2D); + await flushPromises(); + + const canvas = wrapper.find('canvas#live2d'); + expect(canvas.exists()).toBe(true); + const canvasElement = canvas.element as HTMLCanvasElement; + vi.spyOn(canvasElement, 'getBoundingClientRect').mockReturnValue({ + bottom: 420, + height: 320, + left: 100, + right: 320, + toJSON: () => ({}), + top: 100, + width: 220, + x: 100, + y: 100, + } as DOMRect); + + expect(canvasElement.style.transform).toBe(''); + expect(frames).toHaveLength(0); + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 })); + + expect(canvasElement.style.transform).toBe(''); + + canvasElement.dispatchEvent(new MouseEvent('pointermove', { clientX: 210, clientY: 260 })); + + expect(canvasElement.style.transform).toContain('translate3d'); + + canvasElement.dispatchEvent(new MouseEvent('pointerleave')); + + expect(canvasElement.style.transform).toBe(''); + + wrapper.unmount(); + }); }); describe('createBlogLive2DIdleAnimator', () => { diff --git a/src/components/blog/BlogLive2D.tsx b/src/components/blog/BlogLive2D.tsx index 4067b53..156b49d 100644 --- a/src/components/blog/BlogLive2D.tsx +++ b/src/components/blog/BlogLive2D.tsx @@ -39,7 +39,9 @@ export default defineComponent({ return; } runtimeHandle = mountedHandle; - idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value); + idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value, { + enableIdleMotion: !manifest.motionGroups?.Idle, + }); } catch (error: unknown) { console.warn('[KT Blog] Pio Live2D unavailable.', error); } diff --git a/src/components/blog/live2d/types.ts b/src/components/blog/live2d/types.ts index c4905f9..2114cee 100644 --- a/src/components/blog/live2d/types.ts +++ b/src/components/blog/live2d/types.ts @@ -1,12 +1,38 @@ export interface BlogLive2DManifest { + assets?: { + shaderBase?: string; + sourceTextureBase?: string; + textureBase?: string; + }; character: 'pio'; + directoryStandard?: { + catalog: string; + opsRoot: string; + publicRoot: string; + version: string; + }; desktopOnly: boolean; fallback: null; integrity?: { manifestSha256?: string; + mocSha256?: string; + model3Sha256?: string; + motionValidationSha256?: string; + runtimeSha256?: string; + sourceMotionExportSha256?: string; + sourceTextureManifestSha256?: string; }; model3: string; + motionGroups?: Record; + runtimeRig?: { + type: 'moc3-model-motion'; + validation: string; + }; runtimeScript: string; + sourceTextures?: { + count: number; + manifest: string; + }; version: string; } diff --git a/src/factories/blogAnimationFactory.ts b/src/factories/blogAnimationFactory.ts index 2a13cd2..ca6f3e4 100644 --- a/src/factories/blogAnimationFactory.ts +++ b/src/factories/blogAnimationFactory.ts @@ -64,6 +64,11 @@ export interface BlogLive2DIdleAnimatorHandle { destroy: () => void; } +export interface BlogLive2DIdleAnimatorOptions { + enableIdleMotion?: boolean; + enablePointerParallax?: boolean; +} + /** * @returns CSS custom property block generated from the Blog animation factory. */ @@ -116,14 +121,18 @@ export function createBlogFrameScheduler(callback: () => void): BlogFrameSchedul } /** - * Adds a lightweight visual idle loop around the self-hosted Pio canvas. - * The current reconstructed MOC has no shipped motion/physics files, so this - * wrapper-level motion keeps the character alive without claiming Cubism rigging. + * Adds lightweight fallback motion and pointer parallax around the self-hosted Pio canvas. * @param canvas Pio canvas owned by `BlogLive2D`; its inline transform is restored on destroy. + * @param options Flags that disable idle fallback once source model motions are available. * @returns Lifecycle handle that must be destroyed with the runtime mount handle. */ -export function createBlogLive2DIdleAnimator(canvas: HTMLCanvasElement): BlogLive2DIdleAnimatorHandle { +export function createBlogLive2DIdleAnimator( + canvas: HTMLCanvasElement, + options: BlogLive2DIdleAnimatorOptions = {}, +): BlogLive2DIdleAnimatorHandle { const initialTransform = canvas.style.transform; + const enableIdleMotion = options.enableIdleMotion ?? true; + const enablePointerParallax = options.enablePointerParallax ?? true; let frameId = 0; let disposed = false; let startedAt = 0; @@ -131,27 +140,81 @@ export function createBlogLive2DIdleAnimator(canvas: HTMLCanvasElement): BlogLiv let pointerTargetY = 0; let pointerX = 0; let pointerY = 0; + let pointerActive = false; + + /** + * Restores the canvas transform to the value owned before this fallback animator. + */ + const restoreTransform = () => { + canvas.style.transform = initialTransform; + }; /** * Tracks the visitor pointer as a small parallax target around the fixed Pio canvas. - * @param event Pointer movement on the document viewport. + * @param event Pointer movement on the active parallax surface. */ const onPointerMove = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); + const right = rect.left + rect.width; + const bottom = rect.top + rect.height; + const pointerInsideCanvas = + event.clientX >= rect.left && event.clientX <= right && event.clientY >= rect.top && event.clientY <= bottom; + if (!enableIdleMotion && !pointerInsideCanvas) { + return; + } + + pointerActive = true; const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const normalizedX = Math.max(-1, Math.min(1, (event.clientX - centerX) / Math.max(rect.width, 1))); const normalizedY = Math.max(-1, Math.min(1, (event.clientY - centerY) / Math.max(rect.height, 1))); pointerTargetX = normalizedX * BLOG_LIVE2D_IDLE_MOTION.pointerMaxX; pointerTargetY = normalizedY * BLOG_LIVE2D_IDLE_MOTION.pointerMaxY; + if (!enableIdleMotion) { + pointerX = pointerTargetX; + pointerY = pointerTargetY; + writeTransform(0); + } }; /** * Lets the idle loop ease Pio back to neutral when the pointer leaves the document. */ const onPointerLeave = () => { + pointerActive = false; pointerTargetX = 0; pointerTargetY = 0; + if (!enableIdleMotion) { + pointerX = 0; + pointerY = 0; + restoreTransform(); + } + }; + + /** + * Writes the composed fallback transform for either idle frames or pointer-only updates. + * @param elapsedSeconds Elapsed time used by idle bob, breath, and sway terms. + */ + const writeTransform = (elapsedSeconds: number) => { + if (!enableIdleMotion && !pointerActive) { + restoreTransform(); + return; + } + + const bob = enableIdleMotion ? Math.sin(elapsedSeconds * 1.65) * BLOG_LIVE2D_IDLE_MOTION.bobPx : 0; + const scale = enableIdleMotion ? 1 + Math.sin(elapsedSeconds * 2.1) * BLOG_LIVE2D_IDLE_MOTION.breathScale : 1; + const sway = enableIdleMotion ? Math.sin(elapsedSeconds * 0.95) * BLOG_LIVE2D_IDLE_MOTION.swayDeg : 0; + const rotate = + sway + (pointerX / BLOG_LIVE2D_IDLE_MOTION.pointerMaxX) * BLOG_LIVE2D_IDLE_MOTION.pointerMaxRotateDeg; + + canvas.style.transform = [ + initialTransform, + `translate3d(${(pointerX * 0.45).toFixed(2)}px, ${(bob + pointerY * 0.35).toFixed(2)}px, 0)`, + `rotate(${rotate.toFixed(3)}deg)`, + `scale(${scale.toFixed(4)})`, + ] + .filter(Boolean) + .join(' '); }; /** @@ -169,34 +232,37 @@ export function createBlogLive2DIdleAnimator(canvas: HTMLCanvasElement): BlogLiv const elapsedSeconds = (timestamp - startedAt) / 1000; pointerX += (pointerTargetX - pointerX) * BLOG_LIVE2D_IDLE_MOTION.pointerSmoothing; pointerY += (pointerTargetY - pointerY) * BLOG_LIVE2D_IDLE_MOTION.pointerSmoothing; - const bob = Math.sin(elapsedSeconds * 1.65) * BLOG_LIVE2D_IDLE_MOTION.bobPx; - const scale = 1 + Math.sin(elapsedSeconds * 2.1) * BLOG_LIVE2D_IDLE_MOTION.breathScale; - const rotate = - Math.sin(elapsedSeconds * 0.95) * BLOG_LIVE2D_IDLE_MOTION.swayDeg - + (pointerX / BLOG_LIVE2D_IDLE_MOTION.pointerMaxX) * BLOG_LIVE2D_IDLE_MOTION.pointerMaxRotateDeg; - - canvas.style.transform = [ - initialTransform, - `translate3d(${(pointerX * 0.45).toFixed(2)}px, ${(bob + pointerY * 0.35).toFixed(2)}px, 0)`, - `rotate(${rotate.toFixed(3)}deg)`, - `scale(${scale.toFixed(4)})`, - ] - .filter(Boolean) - .join(' '); + writeTransform(elapsedSeconds); frameId = requestBlogFrame(tick); }; - window.addEventListener('pointermove', onPointerMove, { passive: true }); - window.addEventListener('pointerleave', onPointerLeave); - frameId = requestBlogFrame(tick); + if (enablePointerParallax) { + if (enableIdleMotion) { + window.addEventListener('pointermove', onPointerMove, { passive: true }); + window.addEventListener('pointerleave', onPointerLeave); + } else { + canvas.addEventListener('pointermove', onPointerMove, { passive: true }); + canvas.addEventListener('pointerleave', onPointerLeave); + } + } + if (enableIdleMotion) { + frameId = requestBlogFrame(tick); + } return { destroy: () => { disposed = true; cancelBlogFrame(frameId); - window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerleave', onPointerLeave); - canvas.style.transform = initialTransform; + if (enablePointerParallax) { + if (enableIdleMotion) { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerleave', onPointerLeave); + } else { + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerleave', onPointerLeave); + } + } + restoreTransform(); }, }; }