fix: 切回Pio WordPress MOC运行时
This commit is contained in:
parent
c824b1a42e
commit
445a09782b
@ -42,7 +42,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium
|
|||||||
- 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。
|
- 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。
|
||||||
- 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。
|
- 左栏文章目录/站点概览切换必须保留 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。
|
- 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/v2/manifest.json`,并通过自托管 Pio manifest 加载官方 Cubism runtime。Manifest 声明 `wordpressParity` 时,Idle/Sleepy、呼吸、鼠标视差、头部点击和身体点击都由生成 runtime 接管,前端不得再叠加 bob/breath/sway/parallax 兜底;线上 Pio 源目录必须遵守 `docs/live2d-pio-minio-directory-standard.md`,保留 `catalog.json`、`textures/manifest.json` 与完整 source texture catalog。
|
- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 链路,静态运行时固定在 `public/live2d/wordpress-moc/live2d.min.js`;`BlogLive2D` 只在桌面端创建页面级单例 canvas,写入 `window.LAppDefine.MODELS = [['/api/blog/live2d/pio/moc/index.json']]` 并调用一次 `window.InitLive2D()`,hash 路由切换必须复用该实例。线上 MinIO Pio 公共根只允许 `catalog.json`、`moc/`、`moc3/` 三个入口,`moc/` 是当前 WordPress 同款源,`moc3/` 只保留重建模型资产;前端不得再接入自研 `KtPioLive2D`、manifest runtime、canvas transform fallback、pointer parallax fallback、`live2d-widgets` 或随机 CDN 模型。
|
||||||
- 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 主题色和暗色可读性。
|
- 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/<slug>?adminPreview=1&articleId=<id>`。
|
- Admin 文章预览通过 `VITE_KT_BLOG_WEB_BASE_URL` 打开公开 Blog Web 路由,本地默认 `http://127.0.0.1:5173/#/post/<slug>?adminPreview=1&articleId=<id>`。
|
||||||
|
|
||||||
@ -51,3 +51,4 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium
|
|||||||
| 一级来源 | 使用方式 | License |
|
| 一级来源 | 使用方式 | License |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [Argon Theme](https://github.com/solstice23/argon-theme) | 博客视觉资产、主题样式、滚动/搜索/过渡参考和页脚主题署名 | GPL-3.0 |
|
| [Argon Theme](https://github.com/solstice23/argon-theme) | 博客视觉资产、主题样式、滚动/搜索/过渡参考和页脚主题署名 | GPL-3.0 |
|
||||||
|
| Pio Cubism2 MOC runtime/model assets | `public/live2d/wordpress-moc/live2d.min.js` 与 MinIO `moc/` 源用于旧 WordPress Live2D 行为还原 | 上游本地包未显式附带 license,外部分发前必须复核 |
|
||||||
|
|||||||
8908
public/live2d/wordpress-moc/live2d.min.js
vendored
Normal file
8908
public/live2d/wordpress-moc/live2d.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,153 +1,51 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
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 {
|
||||||
import { createBlogLive2DIdleAnimator } from '@/factories/blogAnimationFactory';
|
DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY,
|
||||||
|
DEFAULT_WORDPRESS_LIVE2D_SCRIPT,
|
||||||
|
DEFAULT_WORDPRESS_LIVE2D_SETTINGS,
|
||||||
|
mountWordPressLive2DRuntime,
|
||||||
|
} from '@/components/blog/live2d/wordpressRuntimeBridge';
|
||||||
|
|
||||||
const pioManifest = {
|
describe('mountWordPressLive2DRuntime', () => {
|
||||||
character: 'pio',
|
|
||||||
desktopOnly: true,
|
|
||||||
fallback: null,
|
|
||||||
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
||||||
motionGroups: {
|
|
||||||
FlickHead: 6,
|
|
||||||
Idle: 6,
|
|
||||||
Sleepy: 1,
|
|
||||||
TapBody: 9,
|
|
||||||
},
|
|
||||||
runtimeScript: '/api/blog/live2d/pio/v1/assets/runtime/pio-runtime.js',
|
|
||||||
version: 'v1',
|
|
||||||
wordpressParity: {
|
|
||||||
hitAreasCustom: {
|
|
||||||
body_x: [-0.3, -0.25],
|
|
||||||
body_y: [0.3, -0.9],
|
|
||||||
head_x: [-0.35, 0.6],
|
|
||||||
head_y: [0.19, -0.2],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
center_x: 0,
|
|
||||||
center_y: -0.05,
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
motionGroups: {
|
|
||||||
FlickHead: {
|
|
||||||
legacyName: 'flick_head',
|
|
||||||
sourceFiles: ['motions/Touch Dere1.mtn'],
|
|
||||||
},
|
|
||||||
Idle: {
|
|
||||||
legacyName: 'idle',
|
|
||||||
sourceFiles: ['motions/Breath1.mtn'],
|
|
||||||
},
|
|
||||||
Sleepy: {
|
|
||||||
legacyName: 'sleepy',
|
|
||||||
sourceFiles: ['motions/Sleeping.mtn'],
|
|
||||||
},
|
|
||||||
TapBody: {
|
|
||||||
legacyName: 'tap_body',
|
|
||||||
sourceFiles: ['motions/Touch1.mtn'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parameterRig: {
|
|
||||||
breath: [{ cycle: 3.2345, id: 'ParamBreath', offset: 0.5, peak: 0.5, weight: 1 }],
|
|
||||||
look: [{ factorX: 30, factorXY: 0, factorY: 0, id: 'ParamAngleX' }],
|
|
||||||
},
|
|
||||||
version: '2026-07-05-wordpress-v1',
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
describe('fetchBlogLive2DManifest', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts only the Pio manifest without fallback model chains', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const manifest = await fetchBlogLive2DManifest('/manifest.json');
|
|
||||||
|
|
||||||
expect(manifest.character).toBe('pio');
|
|
||||||
expect(manifest.fallback).toBeNull();
|
|
||||||
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 () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(
|
|
||||||
async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
...pioManifest,
|
|
||||||
character: 'haru',
|
|
||||||
fallback: 'https://cdn.example.com/model.json',
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow(
|
|
||||||
'Only Pio Live2D manifest is allowed.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed manifest payloads before runtime loading', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(async () => new Response(JSON.stringify(null), { status: 200 })),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow(
|
|
||||||
'Only Pio Live2D manifest is allowed.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mountOfficialPioRuntime', () => {
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
delete window.KtPioLive2D;
|
delete window.InitLive2D;
|
||||||
|
delete window.LAppDefine;
|
||||||
|
delete window.LAppLive2DManager;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads official runtime script once and mounts with the manifest model URL', async () => {
|
it('loads the WordPress Cubism2 MOC runtime once and keeps the SPA singleton mounted', async () => {
|
||||||
const destroy = vi.fn();
|
const initLive2D = vi.fn();
|
||||||
const runtime = {
|
|
||||||
mount: vi.fn(async () => ({ destroy })),
|
|
||||||
};
|
|
||||||
const originalAppendChild = document.body.appendChild.bind(document.body);
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||||
const element = originalAppendChild(node);
|
const element = originalAppendChild(node);
|
||||||
if (element instanceof HTMLScriptElement) {
|
if (element instanceof HTMLScriptElement) {
|
||||||
window.KtPioLive2D = runtime;
|
window.InitLive2D = initLive2D;
|
||||||
|
window.LAppLive2DManager = function LAppLive2DManager() {};
|
||||||
|
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
|
||||||
element.onload?.(new Event('load'));
|
element.onload?.(new Event('load'));
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
await mountWordPressLive2DRuntime();
|
||||||
await mountOfficialPioRuntime(canvas, pioManifest);
|
await mountWordPressLive2DRuntime();
|
||||||
await mountOfficialPioRuntime(canvas, pioManifest);
|
|
||||||
|
|
||||||
expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1);
|
const runtimeScript = document.querySelector('script#kt-blog-pio-wordpress-live2d-runtime');
|
||||||
expect(runtime.mount).toHaveBeenCalledTimes(2);
|
expect(runtimeScript?.getAttribute('src')).toBe(DEFAULT_WORDPRESS_LIVE2D_SCRIPT);
|
||||||
expect(runtime.mount).toHaveBeenCalledWith({
|
expect(document.querySelectorAll('script#kt-blog-pio-wordpress-live2d-runtime')).toHaveLength(1);
|
||||||
canvas,
|
expect(document.querySelectorAll('canvas#live2d.kt-blog__live2d-canvas')).toHaveLength(1);
|
||||||
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
expect(initLive2D).toHaveBeenCalledTimes(1);
|
||||||
wordpressParity: pioManifest.wordpressParity,
|
expect(window.LAppDefine).toMatchObject(DEFAULT_WORDPRESS_LIVE2D_SETTINGS);
|
||||||
});
|
expect(window.LAppDefine?.MODELS).toEqual([[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('coalesces concurrent runtime script loads before mounting', async () => {
|
it('coalesces concurrent WordPress runtime script loads before calling InitLive2D', async () => {
|
||||||
const runtime = {
|
const initLive2D = vi.fn();
|
||||||
mount: vi.fn(async () => ({ destroy: vi.fn() })),
|
|
||||||
};
|
|
||||||
const runtimeScripts: HTMLScriptElement[] = [];
|
const runtimeScripts: HTMLScriptElement[] = [];
|
||||||
const originalAppendChild = document.body.appendChild.bind(document.body);
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||||
@ -158,184 +56,151 @@ describe('mountOfficialPioRuntime', () => {
|
|||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstCanvas = document.createElement('canvas');
|
const firstMount = mountWordPressLive2DRuntime();
|
||||||
const secondCanvas = document.createElement('canvas');
|
const secondMount = mountWordPressLive2DRuntime();
|
||||||
const firstMount = mountOfficialPioRuntime(firstCanvas, pioManifest);
|
|
||||||
const secondMount = mountOfficialPioRuntime(secondCanvas, pioManifest);
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1);
|
expect(document.querySelectorAll('script#kt-blog-pio-wordpress-live2d-runtime')).toHaveLength(1);
|
||||||
|
expect(document.querySelectorAll('canvas#live2d.kt-blog__live2d-canvas')).toHaveLength(1);
|
||||||
|
|
||||||
window.KtPioLive2D = runtime;
|
window.InitLive2D = initLive2D;
|
||||||
|
window.LAppLive2DManager = function LAppLive2DManager() {};
|
||||||
|
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
|
||||||
expect(runtimeScripts).toHaveLength(1);
|
expect(runtimeScripts).toHaveLength(1);
|
||||||
runtimeScripts[0]?.onload?.(new Event('load'));
|
runtimeScripts[0]?.onload?.(new Event('load'));
|
||||||
|
|
||||||
await Promise.all([firstMount, secondMount]);
|
await Promise.all([firstMount, secondMount]);
|
||||||
|
|
||||||
expect(runtime.mount).toHaveBeenCalledTimes(2);
|
expect(initLive2D).toHaveBeenCalledTimes(1);
|
||||||
expect(runtime.mount).toHaveBeenNthCalledWith(1, {
|
expect(window.LAppDefine?.MODELS).toEqual([[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]]);
|
||||||
canvas: firstCanvas,
|
});
|
||||||
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
||||||
wordpressParity: pioManifest.wordpressParity,
|
it('maps WordPress Pio custom hit areas to source motion groups', async () => {
|
||||||
});
|
const initLive2D = vi.fn();
|
||||||
expect(runtime.mount).toHaveBeenNthCalledWith(2, {
|
const originalTapEvent = vi.fn(() => false);
|
||||||
canvas: secondCanvas,
|
const startRandomMotion = vi.fn();
|
||||||
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||||
wordpressParity: pioManifest.wordpressParity,
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||||
|
const element = originalAppendChild(node);
|
||||||
|
if (element instanceof HTMLScriptElement) {
|
||||||
|
window.InitLive2D = initLive2D;
|
||||||
|
window.LAppLive2DManager = function LAppLive2DManager() {};
|
||||||
|
window.LAppLive2DManager.prototype.tapEvent = originalTapEvent;
|
||||||
|
element.onload?.(new Event('load'));
|
||||||
|
}
|
||||||
|
return element;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await mountWordPressLive2DRuntime();
|
||||||
|
|
||||||
|
const tapEvent = window.LAppLive2DManager?.prototype.tapEvent;
|
||||||
|
const manager = {
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelSetting: {
|
||||||
|
json: {
|
||||||
|
hit_areas_custom: {
|
||||||
|
body_x: [-0.3, -0.25] as [number, number],
|
||||||
|
body_y: [0.3, -0.9] as [number, number],
|
||||||
|
head_x: [-0.35, 0.6] as [number, number],
|
||||||
|
head_y: [0.19, -0.2] as [number, number],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startRandomMotion,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(tapEvent?.call(manager, 0, 0)).toBe(true);
|
||||||
|
expect(startRandomMotion).toHaveBeenLastCalledWith('flick_head', 2);
|
||||||
|
expect(tapEvent?.call(manager, -0.275, -0.3)).toBe(true);
|
||||||
|
expect(startRandomMotion).toHaveBeenLastCalledWith('tap_body', 2);
|
||||||
|
expect(tapEvent?.call(manager, 0.9, 0.9)).toBe(false);
|
||||||
|
expect(originalTapEvent).toHaveBeenCalledWith(0.9, 0.9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('BlogLive2D', () => {
|
describe('BlogLive2D', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
delete window.InitLive2D;
|
||||||
|
delete window.LAppDefine;
|
||||||
|
delete window.LAppLive2DManager;
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not fetch the manifest below the desktop viewport', async () => {
|
it('does not load the WordPress runtime below the desktop viewport', async () => {
|
||||||
vi.stubGlobal('innerWidth', 1199);
|
vi.stubGlobal('innerWidth', 1199);
|
||||||
const fetchMock = vi.fn();
|
const appendChild = vi.spyOn(document.body, 'appendChild');
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
mount(BlogLive2D);
|
mount(BlogLive2D);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(appendChild).not.toHaveBeenCalled();
|
||||||
|
expect(document.querySelector('canvas#live2d')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts the WordPress Cubism2 MOC runtime on desktop without manifest fetch or canvas fallback motion', async () => {
|
||||||
|
vi.stubGlobal('innerWidth', 1280);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const requestFrame = vi.spyOn(window, 'requestAnimationFrame');
|
||||||
|
const initLive2D = vi.fn();
|
||||||
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||||
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||||
|
const element = originalAppendChild(node);
|
||||||
|
if (element instanceof HTMLScriptElement) {
|
||||||
|
window.InitLive2D = initLive2D;
|
||||||
|
window.LAppLive2DManager = function LAppLive2DManager() {};
|
||||||
|
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
|
||||||
|
element.onload?.(new Event('load'));
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(BlogLive2D);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const canvas = document.querySelector('canvas#live2d.kt-blog__live2d-canvas') as HTMLCanvasElement | null;
|
||||||
|
expect(wrapper.find('canvas#live2d').exists()).toBe(false);
|
||||||
|
expect(canvas).not.toBeNull();
|
||||||
|
expect(canvas?.width).toBe(DEFAULT_WORDPRESS_LIVE2D_SETTINGS.canvasSize.width);
|
||||||
|
expect(canvas?.height).toBe(DEFAULT_WORDPRESS_LIVE2D_SETTINGS.canvasSize.height);
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
expect(document.querySelector('#live2d')).toBeNull();
|
expect(initLive2D).toHaveBeenCalledTimes(1);
|
||||||
|
expect(canvas?.style.transform).toBe('');
|
||||||
|
expect(requestFrame).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mounts Pio runtime on desktop and destroys it when unmounted', async () => {
|
it('reuses the page-level WordPress runtime after route component remounts', async () => {
|
||||||
vi.stubGlobal('innerWidth', 1280);
|
vi.stubGlobal('innerWidth', 1280);
|
||||||
vi.stubGlobal(
|
const initLive2D = vi.fn();
|
||||||
'fetch',
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||||
vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })),
|
|
||||||
);
|
|
||||||
const requestFrame = vi.spyOn(window, 'requestAnimationFrame');
|
|
||||||
const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame');
|
|
||||||
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 = originalAppendChild(node);
|
||||||
if (element instanceof HTMLScriptElement) {
|
if (element instanceof HTMLScriptElement) {
|
||||||
window.KtPioLive2D = {
|
window.InitLive2D = initLive2D;
|
||||||
mount: vi.fn(async () => ({ destroy })),
|
window.LAppLive2DManager = function LAppLive2DManager() {};
|
||||||
};
|
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
|
||||||
element.onload?.(new Event('load'));
|
element.onload?.(new Event('load'));
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
|
|
||||||
const wrapper = mount(BlogLive2D);
|
const firstWrapper = mount(BlogLive2D);
|
||||||
|
await flushPromises();
|
||||||
|
firstWrapper.unmount();
|
||||||
|
const secondWrapper = mount(BlogLive2D);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
const canvas = wrapper.find('canvas#live2d');
|
expect(document.querySelectorAll('canvas#live2d.kt-blog__live2d-canvas')).toHaveLength(1);
|
||||||
expect(canvas.exists()).toBe(true);
|
expect(document.querySelectorAll('script#kt-blog-pio-wordpress-live2d-runtime')).toHaveLength(1);
|
||||||
expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({
|
expect(initLive2D).toHaveBeenCalledTimes(1);
|
||||||
canvas: canvas.element,
|
|
||||||
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
||||||
wordpressParity: pioManifest.wordpressParity,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((canvas.element as HTMLCanvasElement).style.transform).toBe('');
|
secondWrapper.unmount();
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
|
|
||||||
expect(destroy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(requestFrame).not.toHaveBeenCalled();
|
|
||||||
expect(cancelFrame).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not start canvas transform fallback when WordPress parity owns motion', async () => {
|
|
||||||
vi.stubGlobal('innerWidth', 1280);
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(
|
|
||||||
async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
...pioManifest,
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const requestFrame = vi.spyOn(window, 'requestAnimationFrame');
|
|
||||||
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(requestFrame).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
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).toBe('');
|
|
||||||
|
|
||||||
canvasElement.dispatchEvent(new MouseEvent('pointerleave'));
|
|
||||||
|
|
||||||
expect(canvasElement.style.transform).toBe('');
|
|
||||||
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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,26 +0,0 @@
|
|||||||
import type { BlogLive2DManifest } from '@/components/blog/live2d/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the Pio-only Live2D manifest used by the official runtime bridge.
|
|
||||||
* @param manifestUrl Runtime manifest URL configured for the current Blog deployment.
|
|
||||||
* @returns Validated Pio manifest consumed by `BlogLive2D`.
|
|
||||||
*/
|
|
||||||
export async function fetchBlogLive2DManifest(manifestUrl: string): Promise<BlogLive2DManifest> {
|
|
||||||
const response = await fetch(manifestUrl, { credentials: 'omit' });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Live2D manifest failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = (await response.json()) as Partial<BlogLive2DManifest> | null;
|
|
||||||
if (
|
|
||||||
!manifest ||
|
|
||||||
manifest.character !== 'pio' ||
|
|
||||||
manifest.fallback !== null ||
|
|
||||||
typeof manifest.model3 !== 'string' ||
|
|
||||||
typeof manifest.runtimeScript !== 'string'
|
|
||||||
) {
|
|
||||||
throw new Error('Only Pio Live2D manifest is allowed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifest as BlogLive2DManifest;
|
|
||||||
}
|
|
||||||
@ -1,49 +1,35 @@
|
|||||||
import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { defineComponent, onBeforeUnmount, onMounted } from 'vue';
|
||||||
|
|
||||||
|
import { BLOG_VIEWPORT_GEOMETRY } from '@/factories/blogAnimationFactory';
|
||||||
|
|
||||||
import { fetchBlogLive2DManifest } from '@/api/live2dManifest';
|
|
||||||
import {
|
import {
|
||||||
BLOG_VIEWPORT_GEOMETRY,
|
mountWordPressLive2DRuntime,
|
||||||
createBlogLive2DIdleAnimator,
|
type WordPressLive2DRuntimeHandle,
|
||||||
type BlogLive2DIdleAnimatorHandle,
|
} from './live2d/wordpressRuntimeBridge';
|
||||||
} from '@/factories/blogAnimationFactory';
|
|
||||||
import { blogDomId } from '@/factories/blogDomFactory';
|
|
||||||
|
|
||||||
import { mountOfficialPioRuntime } from './live2d/officialRuntimeBridge';
|
|
||||||
import type { KtPioLive2DRuntimeHandle } from './live2d/types';
|
|
||||||
|
|
||||||
const LIVE2D_MANIFEST_URL = import.meta.env.VITE_BLOG_LIVE2D_MANIFEST_URL || '/api/blog/live2d/pio/v2/manifest.json';
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'BlogLive2D',
|
name: 'BlogLive2D',
|
||||||
/**
|
/**
|
||||||
* Owns the Pio canvas lifecycle and defers all drawing to the official runtime bridge.
|
* Owns the Pio canvas lifecycle and defers drawing to the WordPress-exported runtime.
|
||||||
* @returns Render function that only creates the canvas on desktop viewports.
|
* @returns Render function that only creates the canvas on desktop viewports.
|
||||||
*/
|
*/
|
||||||
setup() {
|
setup() {
|
||||||
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: WordPressLive2DRuntimeHandle | null = null;
|
||||||
let runtimeHandle: KtPioLive2DRuntimeHandle | null = null;
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!isDesktop || !canvasRef.value) {
|
if (!isDesktop) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const manifest = await fetchBlogLive2DManifest(LIVE2D_MANIFEST_URL);
|
const mountedHandle = await mountWordPressLive2DRuntime();
|
||||||
const mountedHandle = await mountOfficialPioRuntime(canvasRef.value, manifest);
|
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
mountedHandle.destroy();
|
mountedHandle.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtimeHandle = mountedHandle;
|
runtimeHandle = mountedHandle;
|
||||||
if (!manifest.wordpressParity) {
|
|
||||||
idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value, {
|
|
||||||
enableIdleMotion: !manifest.motionGroups?.Idle,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.warn('[KT Blog] Pio Live2D unavailable.', error);
|
console.warn('[KT Blog] Pio Live2D unavailable.', error);
|
||||||
}
|
}
|
||||||
@ -51,15 +37,10 @@ export default defineComponent({
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
idleAnimatorHandle?.destroy();
|
|
||||||
idleAnimatorHandle = null;
|
|
||||||
runtimeHandle?.destroy();
|
runtimeHandle?.destroy();
|
||||||
runtimeHandle = null;
|
runtimeHandle = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return () =>
|
return () => null;
|
||||||
isDesktop ? (
|
|
||||||
<canvas id={blogDomId('live2dCanvas')} ref={canvasRef} class="kt-blog__live2d-canvas" />
|
|
||||||
) : null;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
import type {
|
|
||||||
BlogLive2DManifest,
|
|
||||||
KtPioLive2DRuntime,
|
|
||||||
KtPioLive2DRuntimeHandle,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
const RUNTIME_SCRIPT_ID = 'kt-blog-pio-live2d-runtime';
|
|
||||||
let runtimeScriptPromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
KtPioLive2D?: KtPioLive2DRuntime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the generated official Cubism runtime script and mounts the Pio model.
|
|
||||||
* @param canvas Canvas element owned by the Vue component.
|
|
||||||
* @param manifest Validated Pio manifest with runtime script and model URL.
|
|
||||||
* @returns Runtime handle used by the component teardown path.
|
|
||||||
*/
|
|
||||||
export async function mountOfficialPioRuntime(
|
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
manifest: BlogLive2DManifest,
|
|
||||||
): Promise<KtPioLive2DRuntimeHandle> {
|
|
||||||
await appendRuntimeScript(manifest.runtimeScript);
|
|
||||||
if (!window.KtPioLive2D) {
|
|
||||||
throw new Error('KtPioLive2D runtime was not registered.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.KtPioLive2D.mount({
|
|
||||||
canvas,
|
|
||||||
model3: manifest.model3,
|
|
||||||
wordpressParity: manifest.wordpressParity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends the official Cubism runtime wrapper only once per document.
|
|
||||||
* @param src Versioned runtime script URL generated from the self-hosted Pio package.
|
|
||||||
* @returns Promise that resolves after the script is available to register `window.KtPioLive2D`.
|
|
||||||
*/
|
|
||||||
async function appendRuntimeScript(src: string): Promise<void> {
|
|
||||||
if (runtimeScriptPromise) {
|
|
||||||
return runtimeScriptPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.getElementById(RUNTIME_SCRIPT_ID)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolveLoad: () => void = () => undefined;
|
|
||||||
let rejectLoad: (error: Error) => void = () => undefined;
|
|
||||||
runtimeScriptPromise = new Promise<void>((resolve, reject) => {
|
|
||||||
resolveLoad = resolve;
|
|
||||||
rejectLoad = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.id = RUNTIME_SCRIPT_ID;
|
|
||||||
script.src = src;
|
|
||||||
script.async = true;
|
|
||||||
script.onload = () => {
|
|
||||||
runtimeScriptPromise = null;
|
|
||||||
resolveLoad();
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
runtimeScriptPromise = null;
|
|
||||||
script.remove();
|
|
||||||
rejectLoad(new Error(`Live2D runtime failed: ${src}`));
|
|
||||||
};
|
|
||||||
document.body.appendChild(script);
|
|
||||||
|
|
||||||
return runtimeScriptPromise;
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
export interface BlogLive2DWordpressParity {
|
|
||||||
hitAreasCustom: Record<'body_x' | 'body_y' | 'head_x' | 'head_y', readonly [number, number]>;
|
|
||||||
layout: {
|
|
||||||
center_x: number;
|
|
||||||
center_y: number;
|
|
||||||
width: number;
|
|
||||||
};
|
|
||||||
motionGroups: Record<
|
|
||||||
'FlickHead' | 'Idle' | 'Sleepy' | 'TapBody',
|
|
||||||
{
|
|
||||||
legacyName: string;
|
|
||||||
sourceFiles: readonly string[];
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
parameterRig: {
|
|
||||||
breath: readonly {
|
|
||||||
cycle: number;
|
|
||||||
id: string;
|
|
||||||
offset: number;
|
|
||||||
peak: number;
|
|
||||||
weight: number;
|
|
||||||
}[];
|
|
||||||
look: readonly {
|
|
||||||
factorX: number;
|
|
||||||
factorXY: number;
|
|
||||||
factorY: number;
|
|
||||||
id: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, number>;
|
|
||||||
runtimeRig?: {
|
|
||||||
type: 'moc3-model-motion';
|
|
||||||
validation: string;
|
|
||||||
};
|
|
||||||
runtimeScript: string;
|
|
||||||
sourceTextures?: {
|
|
||||||
count: number;
|
|
||||||
manifest: string;
|
|
||||||
};
|
|
||||||
version: string;
|
|
||||||
wordpressParity?: BlogLive2DWordpressParity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KtPioLive2DRuntimeHandle {
|
|
||||||
/**
|
|
||||||
* Releases canvas bindings, animation frames, and WebGL/runtime resources owned by the mounted Pio model.
|
|
||||||
*/
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KtPioLive2DRuntime {
|
|
||||||
/**
|
|
||||||
* Mounts the self-hosted Pio model into the canvas controlled by `BlogLive2D`.
|
|
||||||
* @param options Canvas and model URL emitted by the validated manifest.
|
|
||||||
* @returns Runtime handle used by Vue teardown to release rendering resources.
|
|
||||||
*/
|
|
||||||
mount(options: {
|
|
||||||
canvas: HTMLCanvasElement;
|
|
||||||
model3: string;
|
|
||||||
wordpressParity?: BlogLive2DWordpressParity;
|
|
||||||
}): Promise<KtPioLive2DRuntimeHandle>;
|
|
||||||
}
|
|
||||||
356
src/components/blog/live2d/wordpressRuntimeBridge.ts
Normal file
356
src/components/blog/live2d/wordpressRuntimeBridge.ts
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
import { blogDomId } from '@/factories/blogDomFactory';
|
||||||
|
|
||||||
|
const WORDPRESS_RUNTIME_SCRIPT_ID = 'kt-blog-pio-wordpress-live2d-runtime';
|
||||||
|
const WORDPRESS_RUNTIME_CANVAS_CLASS = 'kt-blog__live2d-canvas';
|
||||||
|
const WORDPRESS_RUNTIME_HIT_AREA_BRIDGE_MARKER = '__ktBlogPioCustomHitAreaBridge';
|
||||||
|
let runtimeScriptPromise: Promise<void> | null = null;
|
||||||
|
let runtimeMountPromise: Promise<WordPressLive2DRuntimeHandle> | null = null;
|
||||||
|
let mountedRuntimeCanvas: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
|
export interface WordPressLive2DSettings {
|
||||||
|
AUDIO_ID: string;
|
||||||
|
BAN_BUTTON_CLASS: string;
|
||||||
|
BUTTON_ID: string;
|
||||||
|
CANVAS_ID: string;
|
||||||
|
DEBUG_LOG: boolean;
|
||||||
|
DEBUG_MOUSE_LOG: boolean;
|
||||||
|
HIT_AREA_BODY: string;
|
||||||
|
HIT_AREA_HEAD: string;
|
||||||
|
IS_BAN_BUTTON: boolean;
|
||||||
|
IS_BIND_BUTTON: boolean;
|
||||||
|
IS_PLAY_AUDIO: boolean;
|
||||||
|
IS_SCROLL_SCALE: boolean;
|
||||||
|
IS_START_TEXURE_CHANGE: boolean;
|
||||||
|
MODELS: string[][];
|
||||||
|
MOTION_GROUP_FLICK_HEAD: string;
|
||||||
|
MOTION_GROUP_IDLE: string;
|
||||||
|
MOTION_GROUP_PINCH_IN: string;
|
||||||
|
MOTION_GROUP_PINCH_OUT: string;
|
||||||
|
MOTION_GROUP_SHAKE: string;
|
||||||
|
MOTION_GROUP_TAP_BODY: string;
|
||||||
|
NORMAL_BUTTON_CLASS: string;
|
||||||
|
PRIORITY_FORCE: number;
|
||||||
|
PRIORITY_IDLE: number;
|
||||||
|
PRIORITY_NONE: number;
|
||||||
|
PRIORITY_NORMAL: number;
|
||||||
|
SCALE: number;
|
||||||
|
TEXURE_BUTTON_ID: string;
|
||||||
|
TEXURE_CHANGE_MODE: 'sequence' | 'random';
|
||||||
|
VIEW_LOGICAL_LEFT: number;
|
||||||
|
VIEW_LOGICAL_MAX_BOTTOM: number;
|
||||||
|
VIEW_LOGICAL_MAX_LEFT: number;
|
||||||
|
VIEW_LOGICAL_MAX_RIGHT: number;
|
||||||
|
VIEW_LOGICAL_MAX_TOP: number;
|
||||||
|
VIEW_LOGICAL_RIGHT: number;
|
||||||
|
VIEW_MAX_SCALE: number;
|
||||||
|
VIEW_MIN_SCALE: number;
|
||||||
|
canvasSize: {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordPressLive2DRuntimeHandle {
|
||||||
|
/**
|
||||||
|
* Releases Vue-owned runtime state. The WordPress runtime is kept as a page-level singleton for SPA routes.
|
||||||
|
*/
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
InitLive2D?: () => void;
|
||||||
|
LAppDefine?: WordPressLive2DSettings;
|
||||||
|
LAppLive2DManager?: WordPressLive2DManagerConstructor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordPressLive2DManagerConstructor {
|
||||||
|
prototype: WordPressLive2DManagerPrototype;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordPressLive2DManagerPrototype {
|
||||||
|
[WORDPRESS_RUNTIME_HIT_AREA_BRIDGE_MARKER]?: boolean;
|
||||||
|
tapEvent?: (this: WordPressLive2DManagerInstance, x: number, y: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordPressLive2DManagerInstance {
|
||||||
|
models?: Array<WordPressLive2DModel | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordPressLive2DModel {
|
||||||
|
modelSetting?: {
|
||||||
|
json?: {
|
||||||
|
hit_areas_custom?: WordPressLive2DCustomHitAreas;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
startRandomMotion?: (motionGroup: string, priority: number) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordPressLive2DCustomHitAreas {
|
||||||
|
body_x?: [number, number];
|
||||||
|
body_y?: [number, number];
|
||||||
|
head_x?: [number, number];
|
||||||
|
head_y?: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_WORDPRESS_LIVE2D_SCRIPT = '/live2d/wordpress-moc/live2d.min.js';
|
||||||
|
|
||||||
|
export const DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY = '/api/blog/live2d/pio/moc/index.json';
|
||||||
|
|
||||||
|
export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = {
|
||||||
|
AUDIO_ID: 'kt-blog-pio-audio',
|
||||||
|
BAN_BUTTON_CLASS: 'inactive',
|
||||||
|
BUTTON_ID: 'kt-blog-pio-change',
|
||||||
|
CANVAS_ID: blogDomId('live2dCanvas'),
|
||||||
|
DEBUG_LOG: false,
|
||||||
|
DEBUG_MOUSE_LOG: false,
|
||||||
|
HIT_AREA_BODY: 'body',
|
||||||
|
HIT_AREA_HEAD: 'head',
|
||||||
|
IS_BAN_BUTTON: true,
|
||||||
|
IS_BIND_BUTTON: false,
|
||||||
|
IS_PLAY_AUDIO: false,
|
||||||
|
IS_SCROLL_SCALE: false,
|
||||||
|
IS_START_TEXURE_CHANGE: false,
|
||||||
|
MODELS: [[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]],
|
||||||
|
MOTION_GROUP_FLICK_HEAD: 'flick_head',
|
||||||
|
MOTION_GROUP_IDLE: 'idle',
|
||||||
|
MOTION_GROUP_PINCH_IN: 'pinch_in',
|
||||||
|
MOTION_GROUP_PINCH_OUT: 'pinch_out',
|
||||||
|
MOTION_GROUP_SHAKE: 'shake',
|
||||||
|
MOTION_GROUP_TAP_BODY: 'tap_body',
|
||||||
|
NORMAL_BUTTON_CLASS: 'active',
|
||||||
|
PRIORITY_FORCE: 3,
|
||||||
|
PRIORITY_IDLE: 1,
|
||||||
|
PRIORITY_NONE: 0,
|
||||||
|
PRIORITY_NORMAL: 2,
|
||||||
|
SCALE: 1,
|
||||||
|
TEXURE_BUTTON_ID: '',
|
||||||
|
TEXURE_CHANGE_MODE: 'sequence',
|
||||||
|
VIEW_LOGICAL_LEFT: -1,
|
||||||
|
VIEW_LOGICAL_MAX_BOTTOM: -2,
|
||||||
|
VIEW_LOGICAL_MAX_LEFT: -2,
|
||||||
|
VIEW_LOGICAL_MAX_RIGHT: 2,
|
||||||
|
VIEW_LOGICAL_MAX_TOP: 2,
|
||||||
|
VIEW_LOGICAL_RIGHT: 1,
|
||||||
|
VIEW_MAX_SCALE: 2,
|
||||||
|
VIEW_MIN_SCALE: 0.8,
|
||||||
|
canvasSize: {
|
||||||
|
height: 320,
|
||||||
|
width: 220,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the WordPress-exported Cubism2 MOC runtime into the singleton blog canvas.
|
||||||
|
* @param settings WordPress runtime settings, including the MOC model entry and motion groups.
|
||||||
|
* @returns Handle used by Vue teardown; route unmounts intentionally keep the singleton runtime alive.
|
||||||
|
*/
|
||||||
|
export async function mountWordPressLive2DRuntime(
|
||||||
|
settings: WordPressLive2DSettings = DEFAULT_WORDPRESS_LIVE2D_SETTINGS,
|
||||||
|
): Promise<WordPressLive2DRuntimeHandle> {
|
||||||
|
const canvas = ensureWordPressRuntimeCanvas(settings);
|
||||||
|
if (mountedRuntimeCanvas?.isConnected && mountedRuntimeCanvas === canvas) {
|
||||||
|
return createWordPressRuntimeHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeMountPromise) {
|
||||||
|
return runtimeMountPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeMountPromise = initializeWordPressRuntime(canvas, settings).finally(() => {
|
||||||
|
runtimeMountPromise = null;
|
||||||
|
});
|
||||||
|
return runtimeMountPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or reuses the page-level Pio canvas so hash route switches do not destroy the WebGL owner.
|
||||||
|
* @param settings WordPress Cubism2 runtime settings that define canvas id and intrinsic WebGL size.
|
||||||
|
* @returns Connected canvas element passed to the WordPress runtime.
|
||||||
|
*/
|
||||||
|
function ensureWordPressRuntimeCanvas(settings: WordPressLive2DSettings): HTMLCanvasElement {
|
||||||
|
const canvasId = settings.CANVAS_ID;
|
||||||
|
const existingCanvas = document.getElementById(canvasId);
|
||||||
|
if (existingCanvas instanceof HTMLCanvasElement && existingCanvas.isConnected) {
|
||||||
|
existingCanvas.classList.add(WORDPRESS_RUNTIME_CANVAS_CLASS);
|
||||||
|
existingCanvas.width = settings.canvasSize.width;
|
||||||
|
existingCanvas.height = settings.canvasSize.height;
|
||||||
|
return existingCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.id = canvasId;
|
||||||
|
canvas.className = WORDPRESS_RUNTIME_CANVAS_CLASS;
|
||||||
|
canvas.width = settings.canvasSize.width;
|
||||||
|
canvas.height = settings.canvasSize.height;
|
||||||
|
document.body.appendChild(canvas);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the WordPress runtime script and starts it against the singleton canvas.
|
||||||
|
* @param canvas Persistent canvas whose id is passed through `window.LAppDefine.CANVAS_ID`.
|
||||||
|
* @param settings WordPress runtime settings for model entry, sizing, and interaction motions.
|
||||||
|
* @returns Runtime handle that preserves the page-level singleton on Vue route teardown.
|
||||||
|
*/
|
||||||
|
async function initializeWordPressRuntime(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
settings: WordPressLive2DSettings,
|
||||||
|
): Promise<WordPressLive2DRuntimeHandle> {
|
||||||
|
window.LAppDefine = createWordPressRuntimeSettings(settings, canvas.id);
|
||||||
|
await appendWordPressRuntimeScript(DEFAULT_WORDPRESS_LIVE2D_SCRIPT);
|
||||||
|
installWordPressCustomHitAreaBridge(settings);
|
||||||
|
if (!window.InitLive2D) {
|
||||||
|
throw new Error('WordPress Live2D runtime was not registered.');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.InitLive2D();
|
||||||
|
mountedRuntimeCanvas = canvas;
|
||||||
|
return createWordPressRuntimeHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the global `LAppDefine` object consumed by the legacy WordPress Cubism2 runtime.
|
||||||
|
* @param settings Vue-owned runtime settings and model entry URL.
|
||||||
|
* @param canvasId DOM id of the singleton canvas created by Blog Web.
|
||||||
|
* @returns A shallow copy safe to assign to `window.LAppDefine`.
|
||||||
|
*/
|
||||||
|
function createWordPressRuntimeSettings(
|
||||||
|
settings: WordPressLive2DSettings,
|
||||||
|
canvasId: string,
|
||||||
|
): WordPressLive2DSettings {
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
CANVAS_ID: canvasId,
|
||||||
|
MODELS: settings.MODELS.map((modelGroup) => [...modelGroup]),
|
||||||
|
canvasSize: { ...settings.canvasSize },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes the generic Cubism2 runtime honor WordPress Pio's `hit_areas_custom` contract.
|
||||||
|
* @param settings Runtime motion group and priority names used when a custom hit area matches.
|
||||||
|
*/
|
||||||
|
function installWordPressCustomHitAreaBridge(settings: WordPressLive2DSettings): void {
|
||||||
|
const managerPrototype = window.LAppLive2DManager?.prototype;
|
||||||
|
if (!managerPrototype || managerPrototype[WORDPRESS_RUNTIME_HIT_AREA_BRIDGE_MARKER]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTapEvent = managerPrototype.tapEvent;
|
||||||
|
managerPrototype.tapEvent = function tapWordPressCustomHitArea(x: number, y: number) {
|
||||||
|
const handled = startWordPressCustomHitAreaMotion(this, settings, x, y);
|
||||||
|
if (handled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return typeof originalTapEvent === 'function' ? originalTapEvent.call(this, x, y) : false;
|
||||||
|
};
|
||||||
|
managerPrototype[WORDPRESS_RUNTIME_HIT_AREA_BRIDGE_MARKER] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the WordPress Pio interaction motion matching the pointer position.
|
||||||
|
* @param manager Live2D manager instance created by the legacy Cubism2 runtime.
|
||||||
|
* @param settings Runtime motion group and priority names.
|
||||||
|
* @param x Pointer x coordinate in model-view space.
|
||||||
|
* @param y Pointer y coordinate in model-view space.
|
||||||
|
* @returns `true` when a custom hit area matched and a motion request was issued.
|
||||||
|
*/
|
||||||
|
function startWordPressCustomHitAreaMotion(
|
||||||
|
manager: WordPressLive2DManagerInstance,
|
||||||
|
settings: WordPressLive2DSettings,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): boolean {
|
||||||
|
const models = Array.isArray(manager.models) ? manager.models : [];
|
||||||
|
for (const model of models) {
|
||||||
|
const hitAreas = model?.modelSetting?.json?.hit_areas_custom;
|
||||||
|
if (!hitAreas || typeof model?.startRandomMotion !== 'function') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isPointInsideWordPressHitArea(hitAreas.head_x, hitAreas.head_y, x, y)) {
|
||||||
|
model.startRandomMotion(settings.MOTION_GROUP_FLICK_HEAD, settings.PRIORITY_NORMAL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isPointInsideWordPressHitArea(hitAreas.body_x, hitAreas.body_y, x, y)) {
|
||||||
|
model.startRandomMotion(settings.MOTION_GROUP_TAP_BODY, settings.PRIORITY_NORMAL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a WordPress custom hit rectangle whose coordinate arrays may be reversed.
|
||||||
|
* @param xRange Two x bounds from `hit_areas_custom`.
|
||||||
|
* @param yRange Two y bounds from `hit_areas_custom`.
|
||||||
|
* @param x Pointer x coordinate in model-view space.
|
||||||
|
* @param y Pointer y coordinate in model-view space.
|
||||||
|
* @returns `true` when the point falls inside the normalized rectangle.
|
||||||
|
*/
|
||||||
|
function isPointInsideWordPressHitArea(
|
||||||
|
xRange: [number, number] | undefined,
|
||||||
|
yRange: [number, number] | undefined,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): boolean {
|
||||||
|
if (!xRange || !yRange) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const minX = Math.min(...xRange);
|
||||||
|
const maxX = Math.max(...xRange);
|
||||||
|
const minY = Math.min(...yRange);
|
||||||
|
const maxY = Math.max(...yRange);
|
||||||
|
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns No-op Vue handle because the WordPress runtime cleans itself only on full page unload.
|
||||||
|
*/
|
||||||
|
function createWordPressRuntimeHandle(): WordPressLive2DRuntimeHandle {
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
// Keep the singleton runtime alive across hash-route component unmounts.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the WordPress universal Live2D runtime script only once per document.
|
||||||
|
* @param src Static runtime script copied from the WordPress Cubism2/Pio runtime package.
|
||||||
|
* @returns Promise that resolves after the script has registered `window.InitLive2D`.
|
||||||
|
*/
|
||||||
|
async function appendWordPressRuntimeScript(src: string): Promise<void> {
|
||||||
|
if (runtimeScriptPromise) {
|
||||||
|
return runtimeScriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById(WORDPRESS_RUNTIME_SCRIPT_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolveLoad: () => void = () => undefined;
|
||||||
|
let rejectLoad: (error: Error) => void = () => undefined;
|
||||||
|
runtimeScriptPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
resolveLoad = resolve;
|
||||||
|
rejectLoad = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.id = WORDPRESS_RUNTIME_SCRIPT_ID;
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => {
|
||||||
|
runtimeScriptPromise = null;
|
||||||
|
resolveLoad();
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
runtimeScriptPromise = null;
|
||||||
|
script.remove();
|
||||||
|
rejectLoad(new Error(`WordPress Live2D runtime failed: ${src}`));
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
|
||||||
|
return runtimeScriptPromise;
|
||||||
|
}
|
||||||
@ -29,16 +29,6 @@ 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',
|
||||||
@ -57,18 +47,6 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlogLive2DIdleAnimatorOptions {
|
|
||||||
enableIdleMotion?: boolean;
|
|
||||||
enablePointerParallax?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns CSS custom property block generated from the Blog animation factory.
|
* @returns CSS custom property block generated from the Blog animation factory.
|
||||||
*/
|
*/
|
||||||
@ -120,153 +98,6 @@ export function createBlogFrameScheduler(callback: () => void): BlogFrameSchedul
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
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;
|
|
||||||
let pointerTargetX = 0;
|
|
||||||
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 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(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
writeTransform(elapsedSeconds);
|
|
||||||
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);
|
|
||||||
if (enablePointerParallax) {
|
|
||||||
if (enableIdleMotion) {
|
|
||||||
window.removeEventListener('pointermove', onPointerMove);
|
|
||||||
window.removeEventListener('pointerleave', onPointerLeave);
|
|
||||||
} else {
|
|
||||||
canvas.removeEventListener('pointermove', onPointerMove);
|
|
||||||
canvas.removeEventListener('pointerleave', onPointerLeave);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
restoreTransform();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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`.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user