fix: 修复Pio Live2D idle动画
This commit is contained in:
parent
37feb7a707
commit
c2cd06ae4a
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user