diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts index 46fec27..1cb51a7 100644 --- a/src/__tests__/BlogLive2D.spec.ts +++ b/src/__tests__/BlogLive2D.spec.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchBlogLive2DManifest } from '@/api/live2dManifest'; import BlogLive2D from '@/components/blog/BlogLive2D'; import { mountOfficialPioRuntime } from '@/components/blog/live2d/officialRuntimeBridge'; +import { createBlogLive2DIdleAnimator } from '@/factories/blogAnimationFactory'; const pioManifest = { character: 'pio', @@ -165,6 +166,13 @@ describe('BlogLive2D', () => { 'fetch', vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })), ); + const frames: FrameRequestCallback[] = []; + const cancelFrame = vi.fn(); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(cancelFrame); const destroy = vi.fn(); vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { const element = Node.prototype.appendChild.call(document.body, node); @@ -186,9 +194,45 @@ describe('BlogLive2D', () => { canvas: canvas.element, model3: '/api/blog/live2d/pio/v1/pio.model3.json', }); + frames.shift()?.(1000); + + expect((canvas.element as HTMLCanvasElement).style.transform).toContain('translate3d'); wrapper.unmount(); expect(destroy).toHaveBeenCalledTimes(1); + expect(cancelFrame).toHaveBeenCalled(); + }); +}); + +describe('createBlogLive2DIdleAnimator', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('keeps the Pio canvas visibly moving between animation frames', () => { + const frames: FrameRequestCallback[] = []; + const cancelFrame = vi.fn(); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(cancelFrame); + const canvas = document.createElement('canvas'); + canvas.style.transform = 'translateX(1px)'; + + const handle = createBlogLive2DIdleAnimator(canvas); + frames.shift()?.(1000); + const firstTransform = canvas.style.transform; + frames.shift()?.(2200); + const secondTransform = canvas.style.transform; + + expect(firstTransform).not.toBe('translateX(1px)'); + expect(secondTransform).not.toBe(firstTransform); + + handle.destroy(); + + expect(canvas.style.transform).toBe('translateX(1px)'); + expect(cancelFrame).toHaveBeenCalled(); }); }); diff --git a/src/components/blog/BlogLive2D.tsx b/src/components/blog/BlogLive2D.tsx index 308fc30..4067b53 100644 --- a/src/components/blog/BlogLive2D.tsx +++ b/src/components/blog/BlogLive2D.tsx @@ -1,7 +1,11 @@ import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue'; import { fetchBlogLive2DManifest } from '@/api/live2dManifest'; -import { BLOG_VIEWPORT_GEOMETRY } from '@/factories/blogAnimationFactory'; +import { + BLOG_VIEWPORT_GEOMETRY, + createBlogLive2DIdleAnimator, + type BlogLive2DIdleAnimatorHandle, +} from '@/factories/blogAnimationFactory'; import { blogDomId } from '@/factories/blogDomFactory'; import { mountOfficialPioRuntime } from './live2d/officialRuntimeBridge'; @@ -19,6 +23,7 @@ export default defineComponent({ const canvasRef = ref(null); const isDesktop = window.innerWidth >= BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx; let disposed = false; + let idleAnimatorHandle: BlogLive2DIdleAnimatorHandle | null = null; let runtimeHandle: KtPioLive2DRuntimeHandle | null = null; onMounted(async () => { @@ -34,6 +39,7 @@ export default defineComponent({ return; } runtimeHandle = mountedHandle; + idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value); } catch (error: unknown) { console.warn('[KT Blog] Pio Live2D unavailable.', error); } @@ -41,6 +47,8 @@ export default defineComponent({ onBeforeUnmount(() => { disposed = true; + idleAnimatorHandle?.destroy(); + idleAnimatorHandle = null; runtimeHandle?.destroy(); runtimeHandle = null; }); diff --git a/src/factories/blogAnimationFactory.ts b/src/factories/blogAnimationFactory.ts index 820b14a..2a13cd2 100644 --- a/src/factories/blogAnimationFactory.ts +++ b/src/factories/blogAnimationFactory.ts @@ -29,6 +29,16 @@ export const BLOG_VIEWPORT_GEOMETRY = { live2dDesktopMinWidthPx: 1200, } as const; +export const BLOG_LIVE2D_IDLE_MOTION = { + bobPx: 5, + breathScale: 0.018, + pointerMaxRotateDeg: 1.8, + pointerMaxX: 8, + pointerMaxY: 5, + pointerSmoothing: 0.1, + swayDeg: 1.15, +} as const; + export const BLOG_MOTION_CSS_VARS = { backgroundEase: 'background 0.3s ease', backgroundImageOpacity: 'opacity 0.5s ease', @@ -47,6 +57,13 @@ export interface BlogFrameScheduler { schedule: () => void; } +export interface BlogLive2DIdleAnimatorHandle { + /** + * Stops the frame loop, removes pointer listeners, and restores the canvas transform owned before animation. + */ + destroy: () => void; +} + /** * @returns CSS custom property block generated from the Blog animation factory. */ @@ -98,6 +115,92 @@ 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. + * @param canvas Pio canvas owned by `BlogLive2D`; its inline transform is restored on destroy. + * @returns Lifecycle handle that must be destroyed with the runtime mount handle. + */ +export function createBlogLive2DIdleAnimator(canvas: HTMLCanvasElement): BlogLive2DIdleAnimatorHandle { + const initialTransform = canvas.style.transform; + let frameId = 0; + let disposed = false; + let startedAt = 0; + let pointerTargetX = 0; + let pointerTargetY = 0; + let pointerX = 0; + let pointerY = 0; + + /** + * Tracks the visitor pointer as a small parallax target around the fixed Pio canvas. + * @param event Pointer movement on the document viewport. + */ + const onPointerMove = (event: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + 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; + }; + + /** + * Lets the idle loop ease Pio back to neutral when the pointer leaves the document. + */ + const onPointerLeave = () => { + pointerTargetX = 0; + pointerTargetY = 0; + }; + + /** + * Applies one visual idle frame and schedules the next frame until teardown. + * @param timestamp Browser animation timestamp supplied by requestAnimationFrame. + */ + const tick = (timestamp: number) => { + if (disposed) { + return; + } + if (!startedAt) { + startedAt = timestamp; + } + + 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(' '); + frameId = requestBlogFrame(tick); + }; + + window.addEventListener('pointermove', onPointerMove, { passive: true }); + window.addEventListener('pointerleave', onPointerLeave); + frameId = requestBlogFrame(tick); + + return { + destroy: () => { + disposed = true; + cancelBlogFrame(frameId); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerleave', onPointerLeave); + canvas.style.transform = initialTransform; + }, + }; +} + /** * @param callback Delayed UI state mutation, usually matching an Argon transition window. * @param delayMs Delay in milliseconds from `BLOG_ANIMATION_TIMING_MS`. diff --git a/src/styles/base.scss b/src/styles/base.scss index 7a6f1f0..279cad1 100644 --- a/src/styles/base.scss +++ b/src/styles/base.scss @@ -282,6 +282,8 @@ html.filter-darkness .kt-blog__primary::after { width: 220px; height: 320px; pointer-events: auto; + transform-origin: 50% 82%; + will-change: transform; } }