fix: 恢复Live2D真实动画链路

This commit is contained in:
sunlei 2026-07-15 20:46:46 +08:00
parent f769e7306c
commit 30bc26c031
10 changed files with 958 additions and 58 deletions

View File

@ -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>`

View File

@ -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)

View 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)
})
})

View 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])
})
})

View 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,
})
})
})

View 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);
},
};
}

View 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)
);
}

View File

@ -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,
};
}

View File

@ -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 {

View File

@ -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.