feat: 支持Tia看板娘模型切换

This commit is contained in:
sunlei 2026-07-07 01:14:57 +08:00
parent 9f95504073
commit 2fc4d840e1
4 changed files with 254 additions and 20 deletions

View File

@ -9,9 +9,27 @@ import {
DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY, DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY,
DEFAULT_WORDPRESS_LIVE2D_SCRIPT, DEFAULT_WORDPRESS_LIVE2D_SCRIPT,
DEFAULT_WORDPRESS_LIVE2D_SETTINGS, DEFAULT_WORDPRESS_LIVE2D_SETTINGS,
DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY,
mountWordPressLive2DRuntime, mountWordPressLive2DRuntime,
} from '@/components/blog/live2d/wordpressRuntimeBridge'; } from '@/components/blog/live2d/wordpressRuntimeBridge';
interface DeferredValue<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
/**
* Creates a promise whose resolver stays in the test so toolbar loading guards can be checked before metadata arrives.
* @returns Promise and resolver pair controlled by the active unit test.
*/
function createDeferredValue<T>(): DeferredValue<T> {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
describe('mountWordPressLive2DRuntime', () => { describe('mountWordPressLive2DRuntime', () => {
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
@ -48,7 +66,10 @@ describe('mountWordPressLive2DRuntime', () => {
expect(document.querySelectorAll('canvas#live2d.live2d.kt-blog__live2d-canvas')).toHaveLength(1); expect(document.querySelectorAll('canvas#live2d.live2d.kt-blog__live2d-canvas')).toHaveLength(1);
expect(initLive2D).toHaveBeenCalledTimes(1); expect(initLive2D).toHaveBeenCalledTimes(1);
expect(window.LAppDefine).toMatchObject(DEFAULT_WORDPRESS_LIVE2D_SETTINGS); expect(window.LAppDefine).toMatchObject(DEFAULT_WORDPRESS_LIVE2D_SETTINGS);
expect(window.LAppDefine?.MODELS).toEqual([[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]]); expect(window.LAppDefine?.MODELS).toEqual([
[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY],
[DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY],
]);
}); });
it('coalesces concurrent WordPress runtime script loads before calling InitLive2D', async () => { it('coalesces concurrent WordPress runtime script loads before calling InitLive2D', async () => {
@ -80,7 +101,10 @@ describe('mountWordPressLive2DRuntime', () => {
await Promise.all([firstMount, secondMount]); await Promise.all([firstMount, secondMount]);
expect(initLive2D).toHaveBeenCalledTimes(1); expect(initLive2D).toHaveBeenCalledTimes(1);
expect(window.LAppDefine?.MODELS).toEqual([[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]]); expect(window.LAppDefine?.MODELS).toEqual([
[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY],
[DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY],
]);
}); });
it('maps WordPress Pio custom hit areas to source motion groups', async () => { it('maps WordPress Pio custom hit areas to source motion groups', async () => {
@ -235,10 +259,18 @@ describe('BlogLive2D', () => {
expect(document.querySelector('.gptInput #live2dChatText')).not.toBeNull(); expect(document.querySelector('.gptInput #live2dChatText')).not.toBeNull();
expect(document.querySelector('.gptInput #live2dSend')).not.toBeNull(); expect(document.querySelector('.gptInput #live2dSend')).not.toBeNull();
expect(document.querySelector('.gptInput #live2dSendClose')).not.toBeNull(); expect(document.querySelector('.gptInput #live2dSendClose')).not.toBeNull();
expect(document.getElementById('kt-blog-pio-change')).not.toBeNull();
expect(document.getElementById('live2d-texture-button')).not.toBeNull(); expect(document.getElementById('live2d-texture-button')).not.toBeNull();
expect(window.LAppDefine?.canvasSize).toEqual({ width: 280, height: 250 }); expect(window.LAppDefine?.canvasSize).toEqual({ width: 280, height: 250 });
expect(window.LAppDefine?.IS_BIND_BUTTON).toBe(true);
expect(window.LAppDefine?.IS_START_TEXURE_CHANGE).toBe(true); expect(window.LAppDefine?.IS_START_TEXURE_CHANGE).toBe(true);
expect(window.LAppDefine?.BUTTON_ID).toBe('kt-blog-pio-change');
expect(window.LAppDefine?.TEXURE_BUTTON_ID).toBe('live2d-texture-button'); expect(window.LAppDefine?.TEXURE_BUTTON_ID).toBe('live2d-texture-button');
expect(window.LAppDefine?.MODELS).toEqual([
[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY],
[DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY],
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY, { credentials: 'same-origin' }); expect(fetchMock).toHaveBeenCalledWith(DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY, { credentials: 'same-origin' });
expect(initLive2D).toHaveBeenCalledTimes(1); expect(initLive2D).toHaveBeenCalledTimes(1);
expect(canvas?.style.transform).toBe(''); expect(canvas?.style.transform).toBe('');
@ -280,6 +312,130 @@ describe('BlogLive2D', () => {
expect(document.querySelector('.waifu-tips')?.textContent).toContain('只有一套'); expect(document.querySelector('.waifu-tips')?.textContent).toContain('只有一套');
}); });
it('switches to the Tia model through the hidden WordPress model button and lazy-loads its texture count', async () => {
vi.stubGlobal('innerWidth', 1280);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
json: () => Promise.resolve({ textures: ['textures/default-costume.png', 'textures/witch-costume.png'] }),
ok: true,
})
.mockResolvedValueOnce({
json: () => Promise.resolve({ textures: ['textures/default-costume.png'] }),
ok: true,
});
vi.stubGlobal('fetch', fetchMock);
const originalAppendChild = document.body.appendChild.bind(document.body);
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
const element = originalAppendChild(node);
if (element instanceof HTMLScriptElement) {
window.InitLive2D = vi.fn();
window.LAppLive2DManager = function LAppLive2DManager() {};
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
element.onload?.(new Event('load'));
}
return element;
});
mount(BlogLive2D);
await flushPromises();
const hiddenModelButton = document.getElementById('kt-blog-pio-change') as HTMLButtonElement;
const hiddenTextureButton = document.getElementById('live2d-texture-button') as HTMLButtonElement;
const hiddenModelClick = vi.fn();
const hiddenTextureClick = vi.fn();
hiddenModelButton.addEventListener('click', hiddenModelClick);
hiddenTextureButton.addEventListener('click', hiddenTextureClick);
document.querySelector<HTMLElement>('.waifu-tool .fui-user')?.click();
await flushPromises();
document.querySelector<HTMLElement>('.waifu-tool .fui-eye')?.click();
expect(hiddenModelClick).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY, { credentials: 'same-origin' });
expect(hiddenTextureClick).not.toHaveBeenCalled();
expect(document.querySelector('.waifu-tips')?.textContent).toContain('只有一套');
});
it('keeps texture switching disabled while the next model metadata is still loading', async () => {
vi.stubGlobal('innerWidth', 1280);
const tiaModelResponse = createDeferredValue<{
json: () => Promise<{ textures: string[] }>;
ok: boolean;
}>();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
json: () => Promise.resolve({ textures: ['textures/default-costume.png', 'textures/witch-costume.png'] }),
ok: true,
})
.mockReturnValueOnce(tiaModelResponse.promise);
vi.stubGlobal('fetch', fetchMock);
const originalAppendChild = document.body.appendChild.bind(document.body);
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
const element = originalAppendChild(node);
if (element instanceof HTMLScriptElement) {
window.InitLive2D = vi.fn();
window.LAppLive2DManager = function LAppLive2DManager() {};
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
element.onload?.(new Event('load'));
}
return element;
});
mount(BlogLive2D);
await flushPromises();
const hiddenTextureButton = document.getElementById('live2d-texture-button') as HTMLButtonElement;
const hiddenTextureClick = vi.fn();
hiddenTextureButton.addEventListener('click', hiddenTextureClick);
document.querySelector<HTMLElement>('.waifu-tool .fui-user')?.click();
document.querySelector<HTMLElement>('.waifu-tool .fui-eye')?.click();
expect(hiddenTextureClick).not.toHaveBeenCalled();
expect(document.querySelector('.waifu-tips')?.textContent).toContain('服装信息读取中');
tiaModelResponse.resolve({
json: () => Promise.resolve({ textures: ['textures/default-costume.png'] }),
ok: true,
});
await flushPromises();
});
it('does not trigger texture switching while the legacy model button is loading a model', async () => {
vi.stubGlobal('innerWidth', 1280);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: () => Promise.resolve({ textures: ['textures/default-costume.png', 'textures/witch-costume.png'] }),
ok: true,
}),
);
const originalAppendChild = document.body.appendChild.bind(document.body);
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
const element = originalAppendChild(node);
if (element instanceof HTMLScriptElement) {
window.InitLive2D = vi.fn();
window.LAppLive2DManager = function LAppLive2DManager() {};
window.LAppLive2DManager.prototype.tapEvent = vi.fn();
element.onload?.(new Event('load'));
}
return element;
});
mount(BlogLive2D);
await flushPromises();
const hiddenModelButton = document.getElementById('kt-blog-pio-change') as HTMLButtonElement;
const hiddenTextureButton = document.getElementById('live2d-texture-button') as HTMLButtonElement;
const hiddenTextureClick = vi.fn();
hiddenModelButton.disabled = true;
hiddenTextureButton.addEventListener('click', hiddenTextureClick);
document.querySelector<HTMLElement>('.waifu-tool .fui-eye')?.click();
expect(hiddenTextureClick).not.toHaveBeenCalled();
expect(document.querySelector('.waifu-tips')?.textContent).toContain('模型正在切换中');
});
it('starts manual costume switching from the first variant and loops back to the default texture', () => { it('starts manual costume switching from the first variant and loops back to the default texture', () => {
const runtimeSource = readFileSync(resolve(process.cwd(), 'public/live2d/wordpress-moc/live2d.min.js'), 'utf-8'); const runtimeSource = readFileSync(resolve(process.cwd(), 'public/live2d/wordpress-moc/live2d.min.js'), 'utf-8');

View File

@ -128,6 +128,8 @@ export const DEFAULT_WORDPRESS_LIVE2D_SCRIPT = '/live2d/wordpress-moc/live2d.min
export const DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY = '/api/blog/live2d/pio/moc/index.json'; export const DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY = '/api/blog/live2d/pio/moc/index.json';
export const DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY = '/api/blog/live2d/tia/moc/index.json';
export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = { export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = {
AUDIO_ID: 'kt-blog-pio-audio', AUDIO_ID: 'kt-blog-pio-audio',
BAN_BUTTON_CLASS: 'inactive', BAN_BUTTON_CLASS: 'inactive',
@ -138,11 +140,11 @@ export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = {
HIT_AREA_BODY: 'body', HIT_AREA_BODY: 'body',
HIT_AREA_HEAD: 'head', HIT_AREA_HEAD: 'head',
IS_BAN_BUTTON: true, IS_BAN_BUTTON: true,
IS_BIND_BUTTON: false, IS_BIND_BUTTON: true,
IS_PLAY_AUDIO: false, IS_PLAY_AUDIO: false,
IS_SCROLL_SCALE: false, IS_SCROLL_SCALE: false,
IS_START_TEXURE_CHANGE: true, IS_START_TEXURE_CHANGE: true,
MODELS: [[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY]], MODELS: [[DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY], [DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY]],
MOTION_GROUP_FLICK_HEAD: 'flick_head', MOTION_GROUP_FLICK_HEAD: 'flick_head',
MOTION_GROUP_IDLE: 'idle', MOTION_GROUP_IDLE: 'idle',
MOTION_GROUP_PINCH_IN: 'pinch_in', MOTION_GROUP_PINCH_IN: 'pinch_in',
@ -223,9 +225,10 @@ function ensureWordPressRuntimeWidgetShell(settings: WordPressLive2DSettings): W
tips.className = 'waifu-tips'; tips.className = 'waifu-tips';
const tool = createWordPressToolMenu(); const tool = createWordPressToolMenu();
const chat = createWordPressChatPanel(); const chat = createWordPressChatPanel();
const modelButton = createWordPressHiddenButton(settings.BUTTON_ID);
const textureButton = createWordPressHiddenButton(settings.TEXURE_BUTTON_ID); const textureButton = createWordPressHiddenButton(settings.TEXURE_BUTTON_ID);
widget.replaceChildren(tips, canvas, tool, chat.container, textureButton); widget.replaceChildren(tips, canvas, tool, chat.container, modelButton, textureButton);
if (!existingWidget) { if (!existingWidget) {
document.body.appendChild(widget); document.body.appendChild(widget);
} }
@ -235,6 +238,7 @@ function ensureWordPressRuntimeWidgetShell(settings: WordPressLive2DSettings): W
chat: chat.container, chat: chat.container,
closeButton: chat.closeButton, closeButton: chat.closeButton,
input: chat.input, input: chat.input,
modelButton,
sendButton: chat.sendButton, sendButton: chat.sendButton,
textureButton, textureButton,
tips, tips,
@ -264,9 +268,11 @@ async function initializeWordPressRuntime(
mountedRuntimeCanvas = shell.canvas; mountedRuntimeCanvas = shell.canvas;
mountedPagePointerBridge?.destroy(); mountedPagePointerBridge?.destroy();
mountedPagePointerBridge = mountWordPressPagePointerBridge(shell.canvas); mountedPagePointerBridge = mountWordPressPagePointerBridge(shell.canvas);
const textureCount = await resolveWordPressModelTextureCount(settings); const textureCounts = settings.MODELS.map(() => undefined as number | undefined);
const resolveTextureCount = createWordPressModelTextureCountResolver(settings, textureCounts);
await resolveTextureCount(0);
mountedWidgetController?.destroy(); mountedWidgetController?.destroy();
mountedWidgetController = mountWordPressWidgetController(shell, { textureCount }); mountedWidgetController = mountWordPressWidgetController(shell, { resolveTextureCount, textureCounts });
return createWordPressRuntimeHandle(); return createWordPressRuntimeHandle();
} }
@ -330,16 +336,47 @@ function resolveWordPressViewY(canvas: HTMLCanvasElement, deviceY: number): numb
} }
/** /**
* Reads the active WordPress MOC model JSON so the toolbar can avoid destructive single-texture reloads. * Creates a per-model texture count resolver so the toolbar can avoid destructive single-texture reloads.
* @param settings Runtime settings whose first model entry points at the active Pio `index.json`. * @param settings Runtime settings whose `MODELS` entries point at each character's `index.json`.
* @param textureCounts Mutable count cache shared with the toolbar controller.
* @returns Function that resolves and caches the texture count for a specific model index.
*/
function createWordPressModelTextureCountResolver(
settings: WordPressLive2DSettings,
textureCounts: Array<number | undefined>,
): (modelIndex: number) => Promise<number | undefined> {
const pendingCounts: Array<Promise<number | undefined> | undefined> = [];
return async function resolveWordPressTextureCountForModel(modelIndex: number): Promise<number | undefined> {
if (typeof textureCounts[modelIndex] === 'number') {
return textureCounts[modelIndex];
}
if (pendingCounts[modelIndex]) {
return pendingCounts[modelIndex];
}
const modelEntry = settings.MODELS[modelIndex]?.[0];
if (!modelEntry || typeof window.fetch !== 'function') {
return undefined;
}
pendingCounts[modelIndex] = resolveWordPressModelTextureCount(modelEntry)
.then((count) => {
textureCounts[modelIndex] = count;
return count;
})
.finally(() => {
pendingCounts[modelIndex] = undefined;
});
return pendingCounts[modelIndex];
};
}
/**
* Reads one WordPress MOC model JSON and counts its usable texture entries.
* @param modelEntry API URL for a character's legacy MOC `index.json`.
* @returns Number of usable texture entries, or `undefined` when the model JSON cannot be inspected. * @returns Number of usable texture entries, or `undefined` when the model JSON cannot be inspected.
*/ */
async function resolveWordPressModelTextureCount(settings: WordPressLive2DSettings): Promise<number | undefined> { async function resolveWordPressModelTextureCount(modelEntry: string): Promise<number | undefined> {
const modelEntry = settings.MODELS[0]?.[0];
if (!modelEntry || typeof window.fetch !== 'function') {
return undefined;
}
try { try {
const response = await window.fetch(modelEntry, { credentials: 'same-origin' }); const response = await window.fetch(modelEntry, { credentials: 'same-origin' });
if (!response.ok) { if (!response.ok) {

View File

@ -9,6 +9,7 @@ export interface WordPressWidgetControllerElements {
chat: HTMLElement; chat: HTMLElement;
closeButton: HTMLButtonElement; closeButton: HTMLButtonElement;
input: HTMLInputElement; input: HTMLInputElement;
modelButton: HTMLButtonElement;
sendButton: HTMLButtonElement; sendButton: HTMLButtonElement;
textureButton: HTMLButtonElement; textureButton: HTMLButtonElement;
tips: HTMLElement; tips: HTMLElement;
@ -25,9 +26,15 @@ export interface WordPressWidgetControllerHandle {
export interface WordPressWidgetControllerOptions { export interface WordPressWidgetControllerOptions {
/** /**
* Number of texture entries declared by the active WordPress model JSON; single-texture models must not reload. * Mutable texture counts by WordPress model index; unknown values keep destructive texture switching disabled.
*/ */
textureCount?: number; textureCounts?: Array<number | undefined>;
/**
* Lazily inspects the WordPress model JSON for the selected model index.
* @param modelIndex Index inside `window.LAppDefine.MODELS` that is currently selected by the legacy runtime.
* @returns Texture count for that model, or `undefined` when the metadata cannot be read.
*/
resolveTextureCount?: (modelIndex: number) => Promise<number | undefined>;
} }
/** /**
@ -42,6 +49,8 @@ export function mountWordPressWidgetController(
): WordPressWidgetControllerHandle { ): WordPressWidgetControllerHandle {
let hideTipsTimer = 0; let hideTipsTimer = 0;
let closeTimer = 0; let closeTimer = 0;
let activeModelIndex = 0;
const modelCount = Math.max(options.textureCounts?.length ?? 1, 1);
/** /**
* Shows the WordPress-style tips bubble and schedules it to fade like the original plugin. * Shows the WordPress-style tips bubble and schedules it to fade like the original plugin.
@ -87,6 +96,19 @@ export function mountWordPressWidgetController(
} }
}; };
/**
* Reads the currently selected model's texture count without starting a destructive runtime action.
* @returns Texture count cached for the active model, or `undefined` while the metadata is still unknown.
*/
const getActiveTextureCount = () => options.textureCounts?.[activeModelIndex];
/**
* Starts a background metadata read for the active model when a previous model switch made it necessary.
*/
const warmActiveTextureCount = () => {
void options.resolveTextureCount?.(activeModelIndex);
};
/** /**
* Dispatches one toolbar action using the same feature set exposed by the WordPress plugin shell. * Dispatches one toolbar action using the same feature set exposed by the WordPress plugin shell.
* @param action WordPress toolbar action encoded on the clicked `fui-*` icon span. * @param action WordPress toolbar action encoded on the clicked `fui-*` icon span.
@ -111,13 +133,30 @@ export function mountWordPressWidgetController(
showMessage(WORDPRESS_WAIFU_MESSAGES.about); showMessage(WORDPRESS_WAIFU_MESSAGES.about);
break; break;
case 'model': case 'model':
if (elements.modelButton.disabled) {
showMessage(WORDPRESS_WAIFU_MESSAGES.modelLoading);
break;
}
elements.modelButton.click();
activeModelIndex = (activeModelIndex + 1) % modelCount;
warmActiveTextureCount();
showMessage(WORDPRESS_WAIFU_MESSAGES.model); showMessage(WORDPRESS_WAIFU_MESSAGES.model);
break; break;
case 'photo': case 'photo':
saveCanvas(); saveCanvas();
break; break;
case 'texture': case 'texture':
if (typeof options.textureCount === 'number' && options.textureCount < 2) { if (elements.modelButton.disabled) {
showMessage(WORDPRESS_WAIFU_MESSAGES.modelLoading);
break;
}
const textureCount = getActiveTextureCount();
if (typeof textureCount !== 'number') {
warmActiveTextureCount();
showMessage(WORDPRESS_WAIFU_MESSAGES.textureLoading);
break;
}
if (textureCount < 2) {
showMessage(WORDPRESS_WAIFU_MESSAGES.singleTexture); showMessage(WORDPRESS_WAIFU_MESSAGES.singleTexture);
break; break;
} }

View File

@ -4,10 +4,12 @@ export const WORDPRESS_WAIFU_MESSAGES = {
chatOpen: '想说点什么吗?', chatOpen: '想说点什么吗?',
close: '下次再见。', close: '下次再见。',
copy: '复制成功,记得带上出处哦。', copy: '复制成功,记得带上出处哦。',
model: '当前只启用 Pio 模型。', model: '正在切换看板娘。',
modelLoading: '模型正在切换中,请稍后。',
photo: '已尝试保存当前画面。', photo: '已尝试保存当前画面。',
singleTexture: '当前只有一套服装。', singleTexture: '当前只有一套服装。',
texture: '正在切换 Pio 的服装。', texture: '正在切换服装。',
textureLoading: '服装信息读取中。',
touch: ['哇,你碰到我啦。', '今天也要好好经营小店。', '有新的灵感了吗?'], touch: ['哇,你碰到我啦。', '今天也要好好经营小店。', '有新的灵感了吗?'],
welcome: '欢迎来到 KwiTsukasa 的小站。', welcome: '欢迎来到 KwiTsukasa 的小站。',
} as const; } as const;