diff --git a/README.md b/README.md index 552a261..c7ea209 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium - 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。 - 左栏文章目录/站点概览切换必须保留 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。 -- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 资源,`BlogLive2D` 与 `src/components/blog/live2d/runtime/*` 负责 canvas、Pio/Tia catalog、MOC metadata、选择缓存、WebGL 渲染、页面级鼠标视差和模型/服装直达切换。当前 `vendor/cubism2Core/compatibility/*`、`legacyKernel.ts`、`sdkGlobalInstaller.ts` 和 `window.Live2D*` 仍是反混淆迁移路径,不是最终架构;目标是调用方直接导入类型化、语义化的 TS Core API。运行时不得 append 原始 `live2d.min.js`,也不得恢复旧 `InitLive2D`、隐藏按钮轮换、自研 `KtPioLive2D`、manifest runtime、canvas transform fallback、`live2d-widgets` 或随机 CDN 模型。 +- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 资源,`BlogLive2D` 与 `src/components/blog/live2d/runtime/*` 负责 canvas、Pio/Tia catalog、MOC metadata、选择缓存、WebGL 渲染、真实 MTN 动作队列、canvas-local 指针/触摸和模型/服装直达切换。运行时已恢复源码的 `loadParam -> motion/eye blink -> saveParam -> pointer/sine/breath -> update` 参数生命周期,避免瞬态角度逐帧累积;当前 `vendor/cubism2Core/compatibility/*`、`legacyKernel.ts`、`sdkGlobalInstaller.ts` 和 `window.Live2D*` 仍是反混淆迁移路径,不是最终架构。运行时不得 append 原始 `live2d.min.js`,最终目标仍是调用方直接导入类型化、语义化的 TS Core API,并删除迁移 wiring。 - Cubism2 反混淆只以 `public/live2d/wordpress-moc/live2d.min.js` 为源码,并严格按“函数名恢复 -> 调用逻辑理清 -> 拆分压缩耦合 -> 变量名恢复”推进。进度维护在 `docs/blog-live2d-cubism2-minjs-deobfuscation-ledger.md`;稳定函数/调用决策和已复审 `module-splits.json` 位于 `docs/live2d-deobfuscation/`。当前 627 个函数身份、1411 个调用和 627 个模块 owner 决策已闭合,实际源码迁移审计仍为 5 migrated / 562 pending / 60 omitted;迁移必须直接在生产实现中使用源码可证的语义名,禁止短名映射层和兼容 alias。现有 TS 名或测试通过都不能替代恢复证明,最终交付是可维护 TS Core。 - 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/?adminPreview=1&articleId=`。 diff --git a/e2e/live2d-wordpress-widget.spec.ts b/e2e/live2d-wordpress-widget.spec.ts index f30cfbd..853a846 100644 --- a/e2e/live2d-wordpress-widget.spec.ts +++ b/e2e/live2d-wordpress-widget.spec.ts @@ -218,6 +218,26 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) { ), ) .toBeGreaterThan(5000) + await expect + .poll(() => + assetRequests.some((requestPath) => + requestPath.includes('/moc/motions/Breath') && requestPath.endsWith('.mtn'), + ), + ) + .toBe(true) + const firstAnimatedFrame = await canvas.evaluate((element) => element.toDataURL('image/png')) + await expect + .poll(() => canvas.evaluate((element) => element.toDataURL('image/png'))) + .not.toBe(firstAnimatedFrame) + await canvas.click({ position: { x: 102, y: 163 } }) + await expect + .poll(() => + assetRequests.some((requestPath) => + /\/moc\/motions\/(?:Sukebei|Touch)[^/]*\.mtn$/.test(requestPath), + ), + ) + .toBe(true) + await page.mouse.move(1180, 120) const widgetBox = await widget.boundingBox() expect(widgetBox).not.toBeNull() expect(Math.round(widgetBox?.x ?? -1)).toBe(0) diff --git a/src/__tests__/live2d/Cubism2EyeBlink.spec.ts b/src/__tests__/live2d/Cubism2EyeBlink.spec.ts new file mode 100644 index 0000000..392ac33 --- /dev/null +++ b/src/__tests__/live2d/Cubism2EyeBlink.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createCubism2EyeBlink } from '../../components/blog/live2d/runtime/cubism2EyeBlink' + +describe('Cubism2 eye blink', () => { + it('replays the source close-hold-open state sequence', () => { + let currentTimeMillis = 1_000 + const setParamFloat = vi.fn() + const eyeBlink = createCubism2EyeBlink({ + now: () => currentTimeMillis, + random: () => 0, + }) + + eyeBlink.update({ setParamFloat }) + currentTimeMillis = 1_001 + eyeBlink.update({ setParamFloat }) + currentTimeMillis = 1_101 + eyeBlink.update({ setParamFloat }) + currentTimeMillis = 1_151 + eyeBlink.update({ setParamFloat }) + currentTimeMillis = 1_301 + eyeBlink.update({ setParamFloat }) + + expect(setParamFloat.mock.calls.filter(([id]) => id === 'PARAM_EYE_L_OPEN')) + .toEqual([ + ['PARAM_EYE_L_OPEN', 1], + ['PARAM_EYE_L_OPEN', 1], + ['PARAM_EYE_L_OPEN', 0], + ['PARAM_EYE_L_OPEN', 0], + ['PARAM_EYE_L_OPEN', 1], + ]) + expect(setParamFloat).toHaveBeenCalledTimes(10) + }) +}) diff --git a/src/__tests__/live2d/Cubism2ModelAnimator.spec.ts b/src/__tests__/live2d/Cubism2ModelAnimator.spec.ts new file mode 100644 index 0000000..615ee39 --- /dev/null +++ b/src/__tests__/live2d/Cubism2ModelAnimator.spec.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createCubism2ModelAnimator } from '../../components/blog/live2d/runtime/cubism2ModelAnimator' +import type { + Live2DCoreModel, + Live2DCoreMotion, + Live2DMotionQueueManager, + Live2DModelSettings, +} from '../../components/blog/live2d/runtime/live2dRuntimeTypes' + +/** + * Creates the smallest model-settings record needed by source-order animation tests. + * @param overrides Settings fields replaced for one focused scenario. + * @returns Cubism2 model settings with one idle and one body-tap motion. + */ +function createAnimationSettings( + overrides: Partial = {}, +): Live2DModelSettings { + return { + baseUrl: '/api/blog/live2d/pio/moc/', + hitAreas: { + bodyX: [-0.5, 0.5], + bodyY: [0.3, -0.9], + headX: [-0.35, 0.6], + headY: [0.19, -0.2], + }, + model: 'model.moc', + motions: { + idle: [{ file: 'motions/Breath1.mtn' }], + tap_body: [{ fadeIn: 250, fadeOut: 500, file: 'motions/Touch1.mtn' }], + }, + textures: ['textures/default-costume.png'], + url: '/api/blog/live2d/pio/moc/index.json', + ...overrides, + } +} + +/** + * Creates a model facade that records the semantic frame-update order. + * @param calls Mutable call log populated by every model method. + * @returns Model facade accepted by the Cubism2 animator. + */ +function createRecordingModel(calls: string[]): Live2DCoreModel { + return { + addToParamFloat(id) { + calls.push(`add:${id}`) + }, + draw() { + calls.push('draw') + }, + getCanvasHeight: () => 2, + getCanvasWidth: () => 2, + getModelContext: () => ({ + getParamFloat: () => 0, + getParamMax: () => 1, + getParamMin: () => -1, + }), + getParamIndex: () => 0, + loadParam() { + calls.push('loadParam') + }, + saveParam() { + calls.push('saveParam') + }, + setParamFloat(id) { + calls.push(`set:${id}`) + }, + setTexture() {}, + update() { + calls.push('update') + }, + } +} + +describe('Cubism2 model animator', () => { + it('loads a real idle MTN and preserves the source frame-update order', async () => { + const calls: string[] = [] + const motion: Live2DCoreMotion = { + setFadeIn: vi.fn(), + setFadeOut: vi.fn(), + } + const loadMotionBytes = vi.fn(async () => new ArrayBuffer(4)) + const motionConstructor = { + loadMotion: vi.fn(() => motion), + } + + class RecordingMotionQueueManager implements Live2DMotionQueueManager { + private active = false + + /** @returns True until the first motion starts. */ + isFinished(): boolean { + return !this.active + } + + /** @returns Stable test motion handle. */ + startMotion(): number { + calls.push('startMotion') + this.active = true + return 1 + } + + /** Stops all fake motions. */ + stopAllMotions(): void { + this.active = false + } + + /** @returns True while the fake idle motion is active. */ + updateParam(): boolean { + calls.push('motion') + return this.active + } + } + + const animator = createCubism2ModelAnimator({ + MotionQueueManager: RecordingMotionQueueManager, + Live2DMotion: motionConstructor, + loadMotionBytes, + now: () => 1_000, + random: () => 0, + settings: createAnimationSettings(), + }) + + await animator.preloadMotionGroup('idle') + animator.update(createRecordingModel(calls)) + + expect(loadMotionBytes).toHaveBeenCalledWith( + '/api/blog/live2d/pio/moc/motions/Breath1.mtn', + ) + expect(motionConstructor.loadMotion).toHaveBeenCalledOnce() + expect(motion.setFadeIn).toHaveBeenCalledWith(1_000) + expect(motion.setFadeOut).toHaveBeenCalledWith(1_000) + expect(calls).toEqual([ + 'startMotion', + 'loadParam', + 'motion', + 'saveParam', + 'add:PARAM_ANGLE_X', + 'add:PARAM_ANGLE_Y', + 'add:PARAM_ANGLE_Z', + 'add:PARAM_BODY_ANGLE_X', + 'add:PARAM_EYE_BALL_X', + 'add:PARAM_EYE_BALL_Y', + 'add:PARAM_ANGLE_X', + 'add:PARAM_ANGLE_Y', + 'add:PARAM_ANGLE_Z', + 'add:PARAM_BODY_ANGLE_X', + 'set:PARAM_BREATH', + 'update', + ]) + }) + + it('starts the source motion group for a body hit instead of leaving taps inert', async () => { + const startedMotions: Live2DCoreMotion[] = [] + const bodyMotion: Live2DCoreMotion = { + setFadeIn: vi.fn(), + setFadeOut: vi.fn(), + } + + class TapMotionQueueManager implements Live2DMotionQueueManager { + /** @returns True before the tap motion starts. */ + isFinished(): boolean { + return startedMotions.length === 0 + } + + /** + * Records the motion selected for the body hit. + * @param motion Motion loaded from the matching group. + * @returns Stable test motion handle. + */ + startMotion(motion: Live2DCoreMotion): number { + startedMotions.push(motion) + return 2 + } + + /** Clears recorded motions. */ + stopAllMotions(): void { + startedMotions.length = 0 + } + + /** @returns Whether a tap motion is active. */ + updateParam(): boolean { + return startedMotions.length > 0 + } + } + + const loadMotionBytes = vi.fn(async () => new ArrayBuffer(8)) + const animator = createCubism2ModelAnimator({ + MotionQueueManager: TapMotionQueueManager, + Live2DMotion: { loadMotion: () => bodyMotion }, + loadMotionBytes, + now: () => 2_000, + random: () => 0, + settings: createAnimationSettings(), + }) + + await expect(animator.startMotionForPoint(0, -0.5)).resolves.toBe(true) + expect(loadMotionBytes).toHaveBeenCalledWith( + '/api/blog/live2d/pio/moc/motions/Touch1.mtn', + ) + expect(bodyMotion.setFadeIn).toHaveBeenCalledWith(250) + expect(bodyMotion.setFadeOut).toHaveBeenCalledWith(500) + expect(startedMotions).toEqual([bodyMotion]) + }) +}) diff --git a/src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts b/src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts new file mode 100644 index 0000000..88b286a --- /dev/null +++ b/src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { toCubism2ViewPoint } from '../../components/blog/live2d/runtime/cubism2PointerCoordinates' + +describe('Cubism2 pointer coordinates', () => { + it('uses canvas-local coordinates and canvas width for both source axes', () => { + const canvas = { + getBoundingClientRect: () => ({ + bottom: 270, + height: 250, + left: 10, + right: 290, + top: 20, + width: 280, + x: 10, + y: 20, + toJSON: () => ({}), + }), + height: 500, + width: 560, + } + + expect(toCubism2ViewPoint(canvas, { clientX: 10, clientY: 20 })).toEqual({ + x: -1, + y: 500 / 560, + }) + expect(toCubism2ViewPoint(canvas, { clientX: 290, clientY: 270 })).toEqual({ + x: 1, + y: -500 / 560, + }) + expect(toCubism2ViewPoint(canvas, { clientX: 150, clientY: 145 })).toEqual({ + x: 0, + y: 0, + }) + }) +}) diff --git a/src/components/blog/live2d/runtime/cubism2EyeBlink.ts b/src/components/blog/live2d/runtime/cubism2EyeBlink.ts new file mode 100644 index 0000000..d376cbe --- /dev/null +++ b/src/components/blog/live2d/runtime/cubism2EyeBlink.ts @@ -0,0 +1,106 @@ +import type { Live2DCoreModel } from './live2dRuntimeTypes'; + +const DEFAULT_BLINK_INTERVAL_MILLIS = 4_000; +const DEFAULT_CLOSING_MILLIS = 100; +const DEFAULT_CLOSED_MILLIS = 50; +const DEFAULT_OPENING_MILLIS = 150; + +type EyeBlinkState = 'closed' | 'closing' | 'first' | 'interval' | 'opening'; +type EyeBlinkModel = Pick; + +export interface Cubism2EyeBlink { + setBlinkDurations(closingMillis: number, closedMillis: number, openingMillis: number): void; + setBlinkInterval(blinkIntervalMillis: number): void; + update(model: EyeBlinkModel): void; +} + +export interface CreateCubism2EyeBlinkOptions { + now?: () => number; + random?: () => number; +} + +/** + * Creates the semantic Cubism2 `L2DEyeBlink` state machine restored from the source runtime. + * @param options Injectable clock and random source used for deterministic verification. + * @returns Eye-blink controller that writes the two Cubism eye-open parameters. + */ +export function createCubism2EyeBlink( + options: CreateCubism2EyeBlinkOptions = {}, +): Cubism2EyeBlink { + const now = options.now ?? Date.now; + const random = options.random ?? Math.random; + let state: EyeBlinkState = 'first'; + let nextBlinkMillis = 0; + let stateStartMillis = 0; + let blinkIntervalMillis = DEFAULT_BLINK_INTERVAL_MILLIS; + let closingMillis = DEFAULT_CLOSING_MILLIS; + let closedMillis = DEFAULT_CLOSED_MILLIS; + let openingMillis = DEFAULT_OPENING_MILLIS; + + /** + * Calculates the source-compatible absolute timestamp for the next blink. + * @returns Next blink time in milliseconds. + */ + const calculateNextBlinkMillis = (): number => ( + now() + random() * (2 * blinkIntervalMillis - 1) + ); + + return { + setBlinkDurations(nextClosingMillis, nextClosedMillis, nextOpeningMillis) { + closingMillis = nextClosingMillis; + closedMillis = nextClosedMillis; + openingMillis = nextOpeningMillis; + }, + setBlinkInterval(nextBlinkIntervalMillis) { + blinkIntervalMillis = nextBlinkIntervalMillis; + }, + update(model) { + const currentTimeMillis = now(); + let eyeOpenValue = 1; + let phaseProgress = 0; + + switch (state) { + case 'closing': + phaseProgress = (currentTimeMillis - stateStartMillis) / closingMillis; + if (phaseProgress >= 1) { + phaseProgress = 1; + state = 'closed'; + stateStartMillis = currentTimeMillis; + } + eyeOpenValue = 1 - phaseProgress; + break; + case 'closed': + phaseProgress = (currentTimeMillis - stateStartMillis) / closedMillis; + if (phaseProgress >= 1) { + state = 'opening'; + stateStartMillis = currentTimeMillis; + } + eyeOpenValue = 0; + break; + case 'opening': + phaseProgress = (currentTimeMillis - stateStartMillis) / openingMillis; + if (phaseProgress >= 1) { + phaseProgress = 1; + state = 'interval'; + nextBlinkMillis = calculateNextBlinkMillis(); + } + eyeOpenValue = phaseProgress; + break; + case 'interval': + if (nextBlinkMillis < currentTimeMillis) { + state = 'closing'; + stateStartMillis = currentTimeMillis; + } + break; + case 'first': + default: + state = 'interval'; + nextBlinkMillis = calculateNextBlinkMillis(); + break; + } + + model.setParamFloat('PARAM_EYE_L_OPEN', eyeOpenValue); + model.setParamFloat('PARAM_EYE_R_OPEN', eyeOpenValue); + }, + }; +} diff --git a/src/components/blog/live2d/runtime/cubism2ModelAnimator.ts b/src/components/blog/live2d/runtime/cubism2ModelAnimator.ts new file mode 100644 index 0000000..f7c85f2 --- /dev/null +++ b/src/components/blog/live2d/runtime/cubism2ModelAnimator.ts @@ -0,0 +1,381 @@ +import { createCubism2EyeBlink } from './cubism2EyeBlink'; +import type { + Live2DCoreModel, + Live2DCoreMotion, + Live2DCoreMotionConstructor, + Live2DHitAreas, + Live2DModelSettings, + Live2DMotionQueueManager, + Live2DMotionSetting, + MotionQueueManagerConstructor, +} from './live2dRuntimeTypes'; + +const IDLE_MOTION_PRIORITY = 1; +const INTERACTION_MOTION_PRIORITY = 2; +const DEFAULT_MOTION_FADE_MILLIS = 1_000; + +export interface CreateCubism2ModelAnimatorOptions { + Live2DMotion: Live2DCoreMotionConstructor; + MotionQueueManager: MotionQueueManagerConstructor; + loadMotionBytes(url: string): Promise; + now?: () => number; + random?: () => number; + settings: Live2DModelSettings; +} + +export interface Cubism2ModelAnimator { + preloadMotionGroup(groupName: string): Promise; + setPointerTarget(x: number, y: number): void; + startMotionForPoint(x: number, y: number): Promise; + startRandomMotion(groupName: string, priority?: number): Promise; + stop(): void; + update(model: Live2DCoreModel): void; +} + +interface PriorityMotionManager { + cancelReservation(priority: number): void; + isFinished(): boolean; + reserveMotion(priority: number): boolean; + startMotion(motion: Live2DCoreMotion, priority: number): number; + stopAllMotions(): void; + updateParam(model: Live2DCoreModel): boolean; +} + +interface Cubism2TargetPoint { + currentX: number; + currentY: number; + lastUpdateMillis: number; + targetX: number; + targetY: number; + velocityX: number; + velocityY: number; +} + +/** + * Creates the source-ordered Cubism2 model animator used by the Blog renderer. + * @param options Restored SDK constructors, model settings, clock, random source, and MTN loader. + * @returns Animator that owns motion playback, eye blink, pointer smoothing, and parameter order. + */ +export function createCubism2ModelAnimator( + options: CreateCubism2ModelAnimatorOptions, +): Cubism2ModelAnimator { + const now = options.now ?? Date.now; + const random = options.random ?? Math.random; + const motionManager = createPriorityMotionManager(options.MotionQueueManager); + const loadingMotions = new Map>(); + const loadedMotions = new Map(); + const targetPoint = createTargetPoint(); + const startedAtMillis = now(); + let stopped = false; + const eyeBlink = createCubism2EyeBlink({ now, random }); + + /** + * Resolves one motion setting to its model-relative request URL. + * @param motion Motion entry read from the model settings. + * @returns URL accepted by the renderer's binary fetch helper. + */ + const resolveMotionUrl = (motion: Live2DMotionSetting): string => { + const baseUrl = options.settings.baseUrl.endsWith('/') + ? options.settings.baseUrl + : `${options.settings.baseUrl}/`; + return `${baseUrl}${motion.file.replace(/^\.\//, '')}`; + }; + + /** + * Loads and parses one MTN once, applying the ModelSettingJson fade defaults from min.js. + * @param setting Motion entry selected from a named group. + * @returns Parsed Cubism2 motion ready for the queue. + */ + const loadMotion = (setting: Live2DMotionSetting): Promise => { + const motionUrl = resolveMotionUrl(setting); + const loaded = loadedMotions.get(motionUrl); + if (loaded) { + return Promise.resolve(loaded); + } + const loading = loadingMotions.get(motionUrl); + if (loading) { + return loading; + } + const nextLoading = options.loadMotionBytes(motionUrl) + .then((motionBytes) => { + const motion = options.Live2DMotion.loadMotion(motionBytes); + motion.setFadeIn(setting.fadeIn ?? DEFAULT_MOTION_FADE_MILLIS); + motion.setFadeOut(setting.fadeOut ?? DEFAULT_MOTION_FADE_MILLIS); + if (!stopped) { + loadedMotions.set(motionUrl, motion); + } + loadingMotions.delete(motionUrl); + return motion; + }) + .catch((error: unknown) => { + loadingMotions.delete(motionUrl); + throw error; + }); + loadingMotions.set(motionUrl, nextLoading); + return nextLoading; + }; + + /** + * Starts a selected motion synchronously when it was preloaded, otherwise after its fetch completes. + * @param setting Motion entry selected from the model group. + * @param priority Source-compatible idle or interaction priority. + * @returns Promise resolving true after the motion starts, or false when stopped. + */ + const startMotion = ( + setting: Live2DMotionSetting, + priority: number, + ): Promise => { + const motionUrl = resolveMotionUrl(setting); + const loaded = loadedMotions.get(motionUrl); + if (loaded) { + motionManager.startMotion(loaded, priority); + return Promise.resolve(true); + } + return loadMotion(setting) + .then((motion) => { + if (stopped) { + motionManager.cancelReservation(priority); + return false; + } + motionManager.startMotion(motion, priority); + return true; + }) + .catch((error: unknown) => { + motionManager.cancelReservation(priority); + throw error; + }); + }; + + return { + async preloadMotionGroup(groupName) { + const group = options.settings.motions[groupName] ?? []; + await Promise.all(group.map((setting) => loadMotion(setting))); + }, + setPointerTarget(x, y) { + targetPoint.targetX = x; + targetPoint.targetY = y; + }, + startMotionForPoint(x, y) { + const groupName = resolveHitMotionGroup(options.settings.hitAreas, x, y); + if (!groupName) { + return Promise.resolve(false); + } + return this.startRandomMotion(groupName, INTERACTION_MOTION_PRIORITY); + }, + startRandomMotion(groupName, priority = INTERACTION_MOTION_PRIORITY) { + const group = options.settings.motions[groupName] ?? []; + if (stopped || group.length === 0 || !motionManager.reserveMotion(priority)) { + return Promise.resolve(false); + } + const motionIndex = Math.floor(random() * group.length); + const setting = group[motionIndex]; + if (!setting) { + motionManager.cancelReservation(priority); + return Promise.resolve(false); + } + return startMotion(setting, priority); + }, + stop() { + stopped = true; + motionManager.stopAllMotions(); + loadingMotions.clear(); + loadedMotions.clear(); + }, + update(model) { + if (motionManager.isFinished()) { + void this.startRandomMotion('idle', IDLE_MOTION_PRIORITY).catch((error: unknown) => { + console.warn('[KT Blog] Live2D idle motion failed.', error); + }); + } + + model.loadParam(); + const motionUpdated = motionManager.updateParam(model); + if (!motionUpdated) { + eyeBlink.update(model); + } + model.saveParam(); + + const currentTimeMillis = now(); + updateTargetPoint(targetPoint, currentTimeMillis); + const elapsedSeconds = (currentTimeMillis - startedAtMillis) / 1_000; + const phase = elapsedSeconds * 2 * Math.PI; + const dragX = targetPoint.currentX; + const dragY = targetPoint.currentY; + model.addToParamFloat('PARAM_ANGLE_X', dragX * 30, 1); + model.addToParamFloat('PARAM_ANGLE_Y', dragY * 30, 1); + model.addToParamFloat('PARAM_ANGLE_Z', dragX * dragY * -30, 1); + model.addToParamFloat('PARAM_BODY_ANGLE_X', dragX * 10, 1); + model.addToParamFloat('PARAM_EYE_BALL_X', dragX, 1); + model.addToParamFloat('PARAM_EYE_BALL_Y', dragY, 1); + model.addToParamFloat('PARAM_ANGLE_X', Number(15 * Math.sin(phase / 6.5345)), 0.5); + model.addToParamFloat('PARAM_ANGLE_Y', Number(8 * Math.sin(phase / 3.5345)), 0.5); + model.addToParamFloat('PARAM_ANGLE_Z', Number(10 * Math.sin(phase / 5.5345)), 0.5); + model.addToParamFloat('PARAM_BODY_ANGLE_X', Number(4 * Math.sin(phase / 15.5345)), 0.5); + model.setParamFloat('PARAM_BREATH', Number(0.5 + 0.5 * Math.sin(phase / 3.2345)), 1); + model.update(); + }, + }; +} + +/** + * Wraps the SDK2 queue with the priority reservation semantics used by L2DMotionManager. + * @param QueueManager Restored semantic queue constructor. + * @returns Priority-aware manager for idle and interaction motions. + */ +function createPriorityMotionManager( + QueueManager: MotionQueueManagerConstructor, +): PriorityMotionManager { + const queue: Live2DMotionQueueManager = new QueueManager(); + let currentPriority = 0; + let reservedPriority = 0; + + return { + cancelReservation(priority) { + if (reservedPriority === priority) { + reservedPriority = 0; + } + }, + isFinished() { + return queue.isFinished(); + }, + reserveMotion(priority) { + if (reservedPriority >= priority || currentPriority >= priority) { + return false; + } + reservedPriority = priority; + return true; + }, + startMotion(motion, priority) { + if (priority === reservedPriority) { + reservedPriority = 0; + } + currentPriority = priority; + return queue.startMotion(motion); + }, + stopAllMotions() { + queue.stopAllMotions(); + currentPriority = 0; + reservedPriority = 0; + }, + updateParam(model) { + const updated = queue.updateParam(model); + if (queue.isFinished()) { + currentPriority = 0; + } + return updated; + }, + }; +} + +/** + * Creates the zeroed L2DTargetPoint state used for page-level look-at smoothing. + * @returns Mutable source-compatible target point. + */ +function createTargetPoint(): Cubism2TargetPoint { + return { + currentX: 0, + currentY: 0, + lastUpdateMillis: 0, + targetX: 0, + targetY: 0, + velocityX: 0, + velocityY: 0, + }; +} + +/** + * Advances L2DTargetPoint with the original acceleration and braking equations. + * @param point Mutable target point state. + * @param currentTimeMillis Current Cubism2 user time in milliseconds. + */ +function updateTargetPoint(point: Cubism2TargetPoint, currentTimeMillis: number): void { + const epsilon = 0.01; + const frameRate = 30; + const timeToMaxSpeed = 0.15; + const maxVelocity = (40 / 7.5) / frameRate; + if (point.lastUpdateMillis === 0) { + point.lastUpdateMillis = currentTimeMillis; + return; + } + const deltaTimeWeight = (currentTimeMillis - point.lastUpdateMillis) * frameRate / 1_000; + point.lastUpdateMillis = currentTimeMillis; + const framesToMaxSpeed = timeToMaxSpeed * frameRate; + const maxAcceleration = (deltaTimeWeight * maxVelocity) / framesToMaxSpeed; + const deltaX = point.targetX - point.currentX; + const deltaY = point.targetY - point.currentY; + if (Math.abs(deltaX) <= epsilon && Math.abs(deltaY) <= epsilon) { + return; + } + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + const targetVelocityX = (maxVelocity * deltaX) / distance; + const targetVelocityY = (maxVelocity * deltaY) / distance; + let accelerationX = targetVelocityX - point.velocityX; + let accelerationY = targetVelocityY - point.velocityY; + const acceleration = Math.sqrt(accelerationX * accelerationX + accelerationY * accelerationY); + if (acceleration < -maxAcceleration || acceleration > maxAcceleration) { + accelerationX *= maxAcceleration / acceleration; + accelerationY *= maxAcceleration / acceleration; + } + point.velocityX += accelerationX; + point.velocityY += accelerationY; + + const brakingVelocity = 0.5 * ( + Math.sqrt(maxAcceleration * maxAcceleration + 16 * maxAcceleration * distance - 8 * maxAcceleration * distance) + - maxAcceleration + ); + const currentVelocity = Math.sqrt( + point.velocityX * point.velocityX + point.velocityY * point.velocityY, + ); + if (currentVelocity > brakingVelocity) { + point.velocityX *= brakingVelocity / currentVelocity; + point.velocityY *= brakingVelocity / currentVelocity; + } + point.currentX += point.velocityX; + point.currentY += point.velocityY; +} + +/** + * Resolves the custom WordPress hit ranges to the motion group used by the model package. + * @param hitAreas Normalized custom body/head ranges from index.json. + * @param x Model-space horizontal coordinate. + * @param y Model-space vertical coordinate. + * @returns `flick_head`, `tap_body`, or null when the point misses both ranges. + */ +function resolveHitMotionGroup( + hitAreas: Live2DHitAreas, + x: number, + y: number, +): 'flick_head' | 'tap_body' | null { + if (isPointInsideRanges(x, y, hitAreas.headX, hitAreas.headY)) { + return 'flick_head'; + } + if (isPointInsideRanges(x, y, hitAreas.bodyX, hitAreas.bodyY)) { + return 'tap_body'; + } + return null; +} + +/** + * Checks one point against possibly reversed WordPress range endpoints. + * @param x Horizontal point coordinate. + * @param y Vertical point coordinate. + * @param xRange Horizontal endpoints from index.json. + * @param yRange Vertical endpoints from index.json. + * @returns True when both coordinates are inside their inclusive ranges. + */ +function isPointInsideRanges( + x: number, + y: number, + xRange?: [number, number], + yRange?: [number, number], +): boolean { + if (!xRange || !yRange) { + return false; + } + return ( + Math.min(...xRange) <= x + && x <= Math.max(...xRange) + && Math.min(...yRange) <= y + && y <= Math.max(...yRange) + ); +} diff --git a/src/components/blog/live2d/runtime/cubism2PointerCoordinates.ts b/src/components/blog/live2d/runtime/cubism2PointerCoordinates.ts new file mode 100644 index 0000000..aecf958 --- /dev/null +++ b/src/components/blog/live2d/runtime/cubism2PointerCoordinates.ts @@ -0,0 +1,29 @@ +export interface Cubism2ViewPoint { + x: number; + y: number; +} + +type Cubism2PointerCanvas = Pick; +type Cubism2ClientPoint = Pick; + +/** + * Converts a browser pointer into the source runtime's canvas-local Cubism2 view coordinates. + * @param canvas Canvas whose drawing-buffer dimensions define `deviceToScreen`. + * @param point Browser client coordinates from a mouse or touch event. + * @returns Source view point where both axes use canvas width as the scale denominator. + */ +export function toCubism2ViewPoint( + canvas: Cubism2PointerCanvas, + point: Cubism2ClientPoint, +): Cubism2ViewPoint { + const bounds = canvas.getBoundingClientRect(); + const cssWidth = Math.max(bounds.width, 1); + const cssHeight = Math.max(bounds.height, 1); + const deviceX = (point.clientX - bounds.left) * canvas.width / cssWidth; + const deviceY = (point.clientY - bounds.top) * canvas.height / cssHeight; + const coordinateScale = Math.max(canvas.width, 1); + return { + x: (deviceX - canvas.width / 2) * 2 / coordinateScale, + y: (canvas.height / 2 - deviceY) * 2 / coordinateScale, + }; +} diff --git a/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts b/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts index d5cb077..5b7bc76 100644 --- a/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts +++ b/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts @@ -1,6 +1,12 @@ export interface Live2DCoreMotion { - setFadeIn?: (milliseconds: number) => void; - setFadeOut?: (milliseconds: number) => void; + setFadeIn: (milliseconds: number) => void; + setFadeOut: (milliseconds: number) => void; +} + +export interface Live2DCoreModelContext { + getParamFloat(paramIndex: number): number; + getParamMax(paramIndex: number): number; + getParamMin(paramIndex: number): number; } export interface Live2DModelEntry { @@ -70,15 +76,17 @@ export interface Live2DCoreMotionConstructor { } export interface Live2DCoreModel { - addToParamFloat?: (id: string, value: number, weight?: number) => void; + addToParamFloat(id: string, value: number, weight?: number): void; draw(): void; getCanvasHeight(): number; getCanvasWidth(): number; + getModelContext(): Live2DCoreModelContext; + getParamIndex(id: string): number; isPremultipliedAlpha?: () => boolean; - loadParam?: () => void; - saveParam?: () => void; + loadParam(): void; + saveParam(): void; setMatrix?: (matrix: Float32Array) => void; - setParamFloat?: (id: string, value: number, weight?: number) => void; + setParamFloat(id: string, value: number, weight?: number): void; setTexture(index: number, texture: WebGLTexture): void; update(): void; } @@ -92,8 +100,15 @@ export interface Live2DCoreModelConstructor { loadModel(buffer: ArrayBuffer): Live2DCoreModel; } +export interface Live2DMotionQueueManager { + isFinished(motionHandle?: number): boolean; + startMotion(motion: Live2DCoreMotion, priority?: number): number; + stopAllMotions(): void; + updateParam(model: Live2DCoreModel): boolean; +} + export interface MotionQueueManagerConstructor { - new (): unknown; + new (): Live2DMotionQueueManager; } declare global { diff --git a/src/components/blog/live2d/runtime/webglLive2DRenderer.ts b/src/components/blog/live2d/runtime/webglLive2DRenderer.ts index 1ed57af..977cc0f 100644 --- a/src/components/blog/live2d/runtime/webglLive2DRenderer.ts +++ b/src/components/blog/live2d/runtime/webglLive2DRenderer.ts @@ -1,5 +1,10 @@ import { loadCubism2Core } from './cubism2CoreLoader'; +import { + createCubism2ModelAnimator, + type Cubism2ModelAnimator, +} from './cubism2ModelAnimator'; import { configureCubism2ModelProjection } from './cubism2ModelProjection'; +import { toCubism2ViewPoint } from './cubism2PointerCoordinates'; import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes'; import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease'; @@ -11,17 +16,10 @@ import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/com export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRendererAdapter { let gl: WebGLRenderingContext | null = null; let model: Live2DCoreModel | null = null; + let animator: Cubism2ModelAnimator | null = null; let activeTexture: WebGLTexture | null = null; let frame = 0; - let startedAt = Date.now(); - const pointer = { - x: 0, - y: 0, - }; - const pointerTarget = { - x: 0, - y: 0, - }; + let touchDragging = false; /** * Loads and displays one model state. @@ -32,15 +30,32 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend const modelBuffer = await fetchArrayBuffer(resolveAssetUrl(state.settings.baseUrl, state.settings.model)); const nextModel = window.Live2DModelWebGL!.loadModel(modelBuffer); configureCubism2ModelProjection(nextModel, canvas, state.settings.layout); - const nextTexture = await loadTexture( - context, - nextModel, - resolveAssetUrl(state.settings.baseUrl, state.settings.textures[state.textureIndex] || state.settings.textures[0] || ''), - ); + nextModel.saveParam(); + const nextAnimator = createCubism2ModelAnimator({ + Live2DMotion: window.Live2DMotion!, + MotionQueueManager: window.MotionQueueManager!, + loadMotionBytes: fetchArrayBuffer, + settings: state.settings, + }); + let nextTexture: WebGLTexture; + try { + nextTexture = await loadTexture( + context, + nextModel, + resolveAssetUrl(state.settings.baseUrl, state.settings.textures[state.textureIndex] || state.settings.textures[0] || ''), + ); + } catch (error: unknown) { + nextAnimator.stop(); + throw error; + } releaseActiveTexture(); + animator?.stop(); model = nextModel; + animator = nextAnimator; activeTexture = nextTexture; - startedAt = Date.now(); + void nextAnimator.preloadMotionGroup('idle').catch((error: unknown) => { + console.warn('[KT Blog] Live2D idle motion preload failed.', error); + }); startLoop(); }; @@ -72,26 +87,98 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend if (frame) { return; } + /** Draws one source-ordered Cubism2 animation frame. */ const tick = () => { frame = window.requestAnimationFrame(tick); - if (!gl || !model) { + if (!gl || !model || !animator) { return; } gl.clear(gl.COLOR_BUFFER_BIT); - animateModel(model, startedAt, pointer, pointerTarget); - model.update(); + animator.update(model); model.draw(); }; frame = window.requestAnimationFrame(tick); }; /** - * Tracks the mouse against the whole page, matching the WordPress widget's global look-at behavior. - * @param event Page-level mouse move event. + * Tracks the mouse in the source canvas-local coordinate space. + * @param event Canvas mouse move event. + */ + const handleModelTurnHead = (event: Pick) => { + const point = toCubism2ViewPoint(canvas, event); + animator?.setPointerTarget(point.x, point.y); + void animator?.startMotionForPoint(point.x, point.y).catch((error: unknown) => { + console.warn('[KT Blog] Live2D interaction motion failed.', error); + }); + }; + + /** Restores the source look-front behavior after the pointer leaves or is released. */ + const handlePointerRelease = () => { + animator?.setPointerTarget(0, 0); + }; + + /** + * Handles the source left-button press behavior. + * @param event Canvas mouse-down event. + */ + const handleMouseDown = (event: MouseEvent) => { + if (event.button !== 0) { + return; + } + event.preventDefault(); + handleModelTurnHead(event); + }; + + /** + * Handles the source hover behavior, including hit-motion attempts. + * @param event Canvas mouse-move event. */ const handleMouseMove = (event: MouseEvent) => { - pointerTarget.x = (event.clientX / Math.max(window.innerWidth, 1)) * 2 - 1; - pointerTarget.y = -((event.clientY / Math.max(window.innerHeight, 1)) * 2 - 1); + event.preventDefault(); + handleModelTurnHead(event); + }; + + /** + * Starts source-compatible single-touch tracking and interaction. + * @param event Canvas touch-start event. + */ + const handleTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) { + return; + } + event.preventDefault(); + const touch = event.touches[0]; + if (touch) { + touchDragging = true; + handleModelTurnHead(touch); + } + }; + + /** + * Follows a source-compatible single touch without repeatedly starting hit motions. + * @param event Canvas touch-move event. + */ + const handleTouchMove = (event: TouchEvent) => { + if (!touchDragging) { + return; + } + const touch = event.touches[0]; + if (!touch) { + return; + } + event.preventDefault(); + const point = toCubism2ViewPoint(canvas, touch); + animator?.setPointerTarget(point.x, point.y); + }; + + /** + * Ends source-compatible touch tracking and restores the front-facing target. + * @param event Canvas touch-end event. + */ + const handleTouchEnd = (event: TouchEvent) => { + event.preventDefault(); + touchDragging = false; + handlePointerRelease(); }; /** @@ -105,16 +192,30 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend activeTexture = null; }; - window.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mouseup', handlePointerRelease); + canvas.addEventListener('mouseout', handlePointerRelease); + canvas.addEventListener('touchstart', handleTouchStart, { passive: false }); + canvas.addEventListener('touchmove', handleTouchMove, { passive: false }); + canvas.addEventListener('touchend', handleTouchEnd, { passive: false }); return { destroy() { - window.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mousedown', handleMouseDown); + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mouseup', handlePointerRelease); + canvas.removeEventListener('mouseout', handlePointerRelease); + canvas.removeEventListener('touchstart', handleTouchStart); + canvas.removeEventListener('touchmove', handleTouchMove); + canvas.removeEventListener('touchend', handleTouchEnd); if (frame) { window.cancelAnimationFrame(frame); frame = 0; } releaseActiveTexture(); + animator?.stop(); + animator = null; model = null; gl = null; }, @@ -190,32 +291,6 @@ function loadImage(url: string): Promise { }); } -/** - * Applies lightweight idle motion parameters before drawing. - * @param model Live2D core model. - * @param startedAt Runtime model start timestamp. - * @param pointer Current smoothed page-level look-at value. - * @param pointerTarget Latest target look-at value derived from global pointer movement. - */ -function animateModel( - model: Live2DCoreModel, - startedAt: number, - pointer: { x: number; y: number }, - pointerTarget: { x: number; y: number }, -): void { - const timeSec = (Date.now() - startedAt) / 1000; - const t = timeSec * 2 * Math.PI; - pointer.x += (pointerTarget.x - pointer.x) * 0.12; - pointer.y += (pointerTarget.y - pointer.y) * 0.12; - model.loadParam?.(); - model.addToParamFloat?.('PARAM_ANGLE_X', pointer.x * 25 + 15 * Math.sin(t / 6.5345), 0.5); - model.addToParamFloat?.('PARAM_ANGLE_Y', pointer.y * 16 + 8 * Math.sin(t / 3.5345), 0.5); - model.addToParamFloat?.('PARAM_ANGLE_Z', 10 * Math.sin(t / 5.5345), 0.5); - model.addToParamFloat?.('PARAM_BODY_ANGLE_X', 4 * Math.sin(t / 15.5345), 0.5); - model.setParamFloat?.('PARAM_BREATH', 0.5 + 0.5 * Math.sin(t / 3.2345), 1); - model.saveParam?.(); -} - /** * Resolves a relative model asset URL against its model settings location. * @param baseUrl Settings directory URL.