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 后不得重放卡片入场动画或产生缩放闪烁。
|
||||
- 左栏文章目录/站点概览切换必须保留 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/<slug>?adminPreview=1&articleId=<id>`。
|
||||
|
||||
@ -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)
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@ -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(
|
||||
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<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) => {
|
||||
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<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.
|
||||
* @param baseUrl Settings directory URL.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user