fix: 恢复Live2D真实动画链路
This commit is contained in:
parent
f769e7306c
commit
30bc26c031
@ -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 站点同款 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。
|
- 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 主题色和暗色可读性。
|
- 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>`。
|
||||||
|
|||||||
@ -218,6 +218,26 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toBeGreaterThan(5000)
|
.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()
|
const widgetBox = await widget.boundingBox()
|
||||||
expect(widgetBox).not.toBeNull()
|
expect(widgetBox).not.toBeNull()
|
||||||
expect(Math.round(widgetBox?.x ?? -1)).toBe(0)
|
expect(Math.round(widgetBox?.x ?? -1)).toBe(0)
|
||||||
|
|||||||
34
src/__tests__/live2d/Cubism2EyeBlink.spec.ts
Normal file
34
src/__tests__/live2d/Cubism2EyeBlink.spec.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
204
src/__tests__/live2d/Cubism2ModelAnimator.spec.ts
Normal file
204
src/__tests__/live2d/Cubism2ModelAnimator.spec.ts
Normal file
@ -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> = {},
|
||||||
|
): 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])
|
||||||
|
})
|
||||||
|
})
|
||||||
36
src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts
Normal file
36
src/__tests__/live2d/Cubism2PointerCoordinates.spec.ts
Normal file
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
106
src/components/blog/live2d/runtime/cubism2EyeBlink.ts
Normal file
106
src/components/blog/live2d/runtime/cubism2EyeBlink.ts
Normal file
@ -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<Live2DCoreModel, 'setParamFloat'>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
381
src/components/blog/live2d/runtime/cubism2ModelAnimator.ts
Normal file
381
src/components/blog/live2d/runtime/cubism2ModelAnimator.ts
Normal file
@ -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<ArrayBuffer>;
|
||||||
|
now?: () => number;
|
||||||
|
random?: () => number;
|
||||||
|
settings: Live2DModelSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cubism2ModelAnimator {
|
||||||
|
preloadMotionGroup(groupName: string): Promise<void>;
|
||||||
|
setPointerTarget(x: number, y: number): void;
|
||||||
|
startMotionForPoint(x: number, y: number): Promise<boolean>;
|
||||||
|
startRandomMotion(groupName: string, priority?: number): Promise<boolean>;
|
||||||
|
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<string, Promise<Live2DCoreMotion>>();
|
||||||
|
const loadedMotions = new Map<string, Live2DCoreMotion>();
|
||||||
|
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<Live2DCoreMotion> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
export interface Cubism2ViewPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cubism2PointerCanvas = Pick<HTMLCanvasElement, 'height' | 'width' | 'getBoundingClientRect'>;
|
||||||
|
type Cubism2ClientPoint = Pick<MouseEvent, 'clientX' | 'clientY'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,12 @@
|
|||||||
export interface Live2DCoreMotion {
|
export interface Live2DCoreMotion {
|
||||||
setFadeIn?: (milliseconds: number) => void;
|
setFadeIn: (milliseconds: number) => void;
|
||||||
setFadeOut?: (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 {
|
export interface Live2DModelEntry {
|
||||||
@ -70,15 +76,17 @@ export interface Live2DCoreMotionConstructor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Live2DCoreModel {
|
export interface Live2DCoreModel {
|
||||||
addToParamFloat?: (id: string, value: number, weight?: number) => void;
|
addToParamFloat(id: string, value: number, weight?: number): void;
|
||||||
draw(): void;
|
draw(): void;
|
||||||
getCanvasHeight(): number;
|
getCanvasHeight(): number;
|
||||||
getCanvasWidth(): number;
|
getCanvasWidth(): number;
|
||||||
|
getModelContext(): Live2DCoreModelContext;
|
||||||
|
getParamIndex(id: string): number;
|
||||||
isPremultipliedAlpha?: () => boolean;
|
isPremultipliedAlpha?: () => boolean;
|
||||||
loadParam?: () => void;
|
loadParam(): void;
|
||||||
saveParam?: () => void;
|
saveParam(): void;
|
||||||
setMatrix?: (matrix: Float32Array) => 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;
|
setTexture(index: number, texture: WebGLTexture): void;
|
||||||
update(): void;
|
update(): void;
|
||||||
}
|
}
|
||||||
@ -92,8 +100,15 @@ export interface Live2DCoreModelConstructor {
|
|||||||
loadModel(buffer: ArrayBuffer): Live2DCoreModel;
|
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 {
|
export interface MotionQueueManagerConstructor {
|
||||||
new (): unknown;
|
new (): Live2DMotionQueueManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { loadCubism2Core } from './cubism2CoreLoader';
|
import { loadCubism2Core } from './cubism2CoreLoader';
|
||||||
|
import {
|
||||||
|
createCubism2ModelAnimator,
|
||||||
|
type Cubism2ModelAnimator,
|
||||||
|
} from './cubism2ModelAnimator';
|
||||||
import { configureCubism2ModelProjection } from './cubism2ModelProjection';
|
import { configureCubism2ModelProjection } from './cubism2ModelProjection';
|
||||||
|
import { toCubism2ViewPoint } from './cubism2PointerCoordinates';
|
||||||
import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes';
|
import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes';
|
||||||
import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease';
|
import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease';
|
||||||
|
|
||||||
@ -11,17 +16,10 @@ import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/com
|
|||||||
export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRendererAdapter {
|
export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRendererAdapter {
|
||||||
let gl: WebGLRenderingContext | null = null;
|
let gl: WebGLRenderingContext | null = null;
|
||||||
let model: Live2DCoreModel | null = null;
|
let model: Live2DCoreModel | null = null;
|
||||||
|
let animator: Cubism2ModelAnimator | null = null;
|
||||||
let activeTexture: WebGLTexture | null = null;
|
let activeTexture: WebGLTexture | null = null;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let startedAt = Date.now();
|
let touchDragging = false;
|
||||||
const pointer = {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
};
|
|
||||||
const pointerTarget = {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads and displays one model state.
|
* 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 modelBuffer = await fetchArrayBuffer(resolveAssetUrl(state.settings.baseUrl, state.settings.model));
|
||||||
const nextModel = window.Live2DModelWebGL!.loadModel(modelBuffer);
|
const nextModel = window.Live2DModelWebGL!.loadModel(modelBuffer);
|
||||||
configureCubism2ModelProjection(nextModel, canvas, state.settings.layout);
|
configureCubism2ModelProjection(nextModel, canvas, state.settings.layout);
|
||||||
const nextTexture = await loadTexture(
|
nextModel.saveParam();
|
||||||
|
const nextAnimator = createCubism2ModelAnimator({
|
||||||
|
Live2DMotion: window.Live2DMotion!,
|
||||||
|
MotionQueueManager: window.MotionQueueManager!,
|
||||||
|
loadMotionBytes: fetchArrayBuffer,
|
||||||
|
settings: state.settings,
|
||||||
|
});
|
||||||
|
let nextTexture: WebGLTexture;
|
||||||
|
try {
|
||||||
|
nextTexture = await loadTexture(
|
||||||
context,
|
context,
|
||||||
nextModel,
|
nextModel,
|
||||||
resolveAssetUrl(state.settings.baseUrl, state.settings.textures[state.textureIndex] || state.settings.textures[0] || ''),
|
resolveAssetUrl(state.settings.baseUrl, state.settings.textures[state.textureIndex] || state.settings.textures[0] || ''),
|
||||||
);
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
nextAnimator.stop();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
releaseActiveTexture();
|
releaseActiveTexture();
|
||||||
|
animator?.stop();
|
||||||
model = nextModel;
|
model = nextModel;
|
||||||
|
animator = nextAnimator;
|
||||||
activeTexture = nextTexture;
|
activeTexture = nextTexture;
|
||||||
startedAt = Date.now();
|
void nextAnimator.preloadMotionGroup('idle').catch((error: unknown) => {
|
||||||
|
console.warn('[KT Blog] Live2D idle motion preload failed.', error);
|
||||||
|
});
|
||||||
startLoop();
|
startLoop();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,26 +87,98 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend
|
|||||||
if (frame) {
|
if (frame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/** Draws one source-ordered Cubism2 animation frame. */
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
frame = window.requestAnimationFrame(tick);
|
frame = window.requestAnimationFrame(tick);
|
||||||
if (!gl || !model) {
|
if (!gl || !model || !animator) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
animateModel(model, startedAt, pointer, pointerTarget);
|
animator.update(model);
|
||||||
model.update();
|
|
||||||
model.draw();
|
model.draw();
|
||||||
};
|
};
|
||||||
frame = window.requestAnimationFrame(tick);
|
frame = window.requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the mouse against the whole page, matching the WordPress widget's global look-at behavior.
|
* Tracks the mouse in the source canvas-local coordinate space.
|
||||||
* @param event Page-level mouse move event.
|
* @param event Canvas mouse move event.
|
||||||
|
*/
|
||||||
|
const handleModelTurnHead = (event: Pick<MouseEvent, 'clientX' | 'clientY'>) => {
|
||||||
|
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) => {
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
pointerTarget.x = (event.clientX / Math.max(window.innerWidth, 1)) * 2 - 1;
|
event.preventDefault();
|
||||||
pointerTarget.y = -((event.clientY / Math.max(window.innerHeight, 1)) * 2 - 1);
|
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;
|
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 {
|
return {
|
||||||
destroy() {
|
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) {
|
if (frame) {
|
||||||
window.cancelAnimationFrame(frame);
|
window.cancelAnimationFrame(frame);
|
||||||
frame = 0;
|
frame = 0;
|
||||||
}
|
}
|
||||||
releaseActiveTexture();
|
releaseActiveTexture();
|
||||||
|
animator?.stop();
|
||||||
|
animator = null;
|
||||||
model = null;
|
model = null;
|
||||||
gl = null;
|
gl = null;
|
||||||
},
|
},
|
||||||
@ -190,32 +291,6 @@ function loadImage(url: string): Promise<HTMLImageElement> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* Resolves a relative model asset URL against its model settings location.
|
||||||
* @param baseUrl Settings directory URL.
|
* @param baseUrl Settings directory URL.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user