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 { fetchBlogLive2DManifest } from '@/api/live2dManifest';
|
||||||
import BlogLive2D from '@/components/blog/BlogLive2D';
|
import BlogLive2D from '@/components/blog/BlogLive2D';
|
||||||
import { mountOfficialPioRuntime } from '@/components/blog/live2d/officialRuntimeBridge';
|
import { mountOfficialPioRuntime } from '@/components/blog/live2d/officialRuntimeBridge';
|
||||||
|
import { createBlogLive2DIdleAnimator } from '@/factories/blogAnimationFactory';
|
||||||
|
|
||||||
const pioManifest = {
|
const pioManifest = {
|
||||||
character: 'pio',
|
character: 'pio',
|
||||||
@ -165,6 +166,13 @@ describe('BlogLive2D', () => {
|
|||||||
'fetch',
|
'fetch',
|
||||||
vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })),
|
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();
|
const destroy = vi.fn();
|
||||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||||
const element = Node.prototype.appendChild.call(document.body, node);
|
const element = Node.prototype.appendChild.call(document.body, node);
|
||||||
@ -186,9 +194,45 @@ describe('BlogLive2D', () => {
|
|||||||
canvas: canvas.element,
|
canvas: canvas.element,
|
||||||
model3: '/api/blog/live2d/pio/v1/pio.model3.json',
|
model3: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||||
});
|
});
|
||||||
|
frames.shift()?.(1000);
|
||||||
|
|
||||||
|
expect((canvas.element as HTMLCanvasElement).style.transform).toContain('translate3d');
|
||||||
|
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
|
|
||||||
expect(destroy).toHaveBeenCalledTimes(1);
|
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 { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { fetchBlogLive2DManifest } from '@/api/live2dManifest';
|
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 { blogDomId } from '@/factories/blogDomFactory';
|
||||||
|
|
||||||
import { mountOfficialPioRuntime } from './live2d/officialRuntimeBridge';
|
import { mountOfficialPioRuntime } from './live2d/officialRuntimeBridge';
|
||||||
@ -19,6 +23,7 @@ export default defineComponent({
|
|||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
const isDesktop = window.innerWidth >= BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx;
|
const isDesktop = window.innerWidth >= BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let idleAnimatorHandle: BlogLive2DIdleAnimatorHandle | null = null;
|
||||||
let runtimeHandle: KtPioLive2DRuntimeHandle | null = null;
|
let runtimeHandle: KtPioLive2DRuntimeHandle | null = null;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -34,6 +39,7 @@ export default defineComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtimeHandle = mountedHandle;
|
runtimeHandle = mountedHandle;
|
||||||
|
idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.warn('[KT Blog] Pio Live2D unavailable.', error);
|
console.warn('[KT Blog] Pio Live2D unavailable.', error);
|
||||||
}
|
}
|
||||||
@ -41,6 +47,8 @@ export default defineComponent({
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
idleAnimatorHandle?.destroy();
|
||||||
|
idleAnimatorHandle = null;
|
||||||
runtimeHandle?.destroy();
|
runtimeHandle?.destroy();
|
||||||
runtimeHandle = null;
|
runtimeHandle = null;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -29,6 +29,16 @@ export const BLOG_VIEWPORT_GEOMETRY = {
|
|||||||
live2dDesktopMinWidthPx: 1200,
|
live2dDesktopMinWidthPx: 1200,
|
||||||
} as const;
|
} 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 = {
|
export const BLOG_MOTION_CSS_VARS = {
|
||||||
backgroundEase: 'background 0.3s ease',
|
backgroundEase: 'background 0.3s ease',
|
||||||
backgroundImageOpacity: 'opacity 0.5s ease',
|
backgroundImageOpacity: 'opacity 0.5s ease',
|
||||||
@ -47,6 +57,13 @@ export interface BlogFrameScheduler {
|
|||||||
schedule: () => void;
|
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.
|
* @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 callback Delayed UI state mutation, usually matching an Argon transition window.
|
||||||
* @param delayMs Delay in milliseconds from `BLOG_ANIMATION_TIMING_MS`.
|
* @param delayMs Delay in milliseconds from `BLOG_ANIMATION_TIMING_MS`.
|
||||||
|
|||||||
@ -282,6 +282,8 @@ html.filter-darkness .kt-blog__primary::after {
|
|||||||
width: 220px;
|
width: 220px;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
transform-origin: 50% 82%;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user