fix: 修复Pio Live2D idle动画

This commit is contained in:
sunlei 2026-07-05 03:36:37 +08:00
parent 37feb7a707
commit c2cd06ae4a
4 changed files with 158 additions and 1 deletions

View File

@ -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();
});
});

View File

@ -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<HTMLCanvasElement | null>(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;
});

View File

@ -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`.

View File

@ -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;
}
}