feat: 支持Tia看板娘模型切换
This commit is contained in:
parent
9f95504073
commit
2fc4d840e1
@ -9,9 +9,27 @@ import {
|
||||
DEFAULT_WORDPRESS_LIVE2D_MODEL_ENTRY,
|
||||
DEFAULT_WORDPRESS_LIVE2D_SCRIPT,
|
||||
DEFAULT_WORDPRESS_LIVE2D_SETTINGS,
|
||||
DEFAULT_WORDPRESS_LIVE2D_TIA_MODEL_ENTRY,
|
||||
mountWordPressLive2DRuntime,
|
||||
} 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', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
@ -48,7 +66,10 @@ describe('mountWordPressLive2DRuntime', () => {
|
||||
expect(document.querySelectorAll('canvas#live2d.live2d.kt-blog__live2d-canvas')).toHaveLength(1);
|
||||
expect(initLive2D).toHaveBeenCalledTimes(1);
|
||||
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 () => {
|
||||
@ -80,7 +101,10 @@ describe('mountWordPressLive2DRuntime', () => {
|
||||
await Promise.all([firstMount, secondMount]);
|
||||
|
||||
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 () => {
|
||||
@ -235,10 +259,18 @@ describe('BlogLive2D', () => {
|
||||
expect(document.querySelector('.gptInput #live2dChatText')).not.toBeNull();
|
||||
expect(document.querySelector('.gptInput #live2dSend')).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(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?.BUTTON_ID).toBe('kt-blog-pio-change');
|
||||
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(initLive2D).toHaveBeenCalledTimes(1);
|
||||
expect(canvas?.style.transform).toBe('');
|
||||
@ -280,6 +312,130 @@ describe('BlogLive2D', () => {
|
||||
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', () => {
|
||||
const runtimeSource = readFileSync(resolve(process.cwd(), 'public/live2d/wordpress-moc/live2d.min.js'), 'utf-8');
|
||||
|
||||
|
||||
@ -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_TIA_MODEL_ENTRY = '/api/blog/live2d/tia/moc/index.json';
|
||||
|
||||
export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = {
|
||||
AUDIO_ID: 'kt-blog-pio-audio',
|
||||
BAN_BUTTON_CLASS: 'inactive',
|
||||
@ -138,11 +140,11 @@ export const DEFAULT_WORDPRESS_LIVE2D_SETTINGS: WordPressLive2DSettings = {
|
||||
HIT_AREA_BODY: 'body',
|
||||
HIT_AREA_HEAD: 'head',
|
||||
IS_BAN_BUTTON: true,
|
||||
IS_BIND_BUTTON: false,
|
||||
IS_BIND_BUTTON: true,
|
||||
IS_PLAY_AUDIO: false,
|
||||
IS_SCROLL_SCALE: false,
|
||||
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_IDLE: 'idle',
|
||||
MOTION_GROUP_PINCH_IN: 'pinch_in',
|
||||
@ -223,9 +225,10 @@ function ensureWordPressRuntimeWidgetShell(settings: WordPressLive2DSettings): W
|
||||
tips.className = 'waifu-tips';
|
||||
const tool = createWordPressToolMenu();
|
||||
const chat = createWordPressChatPanel();
|
||||
const modelButton = createWordPressHiddenButton(settings.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) {
|
||||
document.body.appendChild(widget);
|
||||
}
|
||||
@ -235,6 +238,7 @@ function ensureWordPressRuntimeWidgetShell(settings: WordPressLive2DSettings): W
|
||||
chat: chat.container,
|
||||
closeButton: chat.closeButton,
|
||||
input: chat.input,
|
||||
modelButton,
|
||||
sendButton: chat.sendButton,
|
||||
textureButton,
|
||||
tips,
|
||||
@ -264,9 +268,11 @@ async function initializeWordPressRuntime(
|
||||
mountedRuntimeCanvas = shell.canvas;
|
||||
mountedPagePointerBridge?.destroy();
|
||||
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 = mountWordPressWidgetController(shell, { textureCount });
|
||||
mountedWidgetController = mountWordPressWidgetController(shell, { resolveTextureCount, textureCounts });
|
||||
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.
|
||||
* @param settings Runtime settings whose first model entry points at the active Pio `index.json`.
|
||||
* @returns Number of usable texture entries, or `undefined` when the model JSON cannot be inspected.
|
||||
* Creates a per-model texture count resolver so the toolbar can avoid destructive single-texture reloads.
|
||||
* @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.
|
||||
*/
|
||||
async function resolveWordPressModelTextureCount(settings: WordPressLive2DSettings): Promise<number | undefined> {
|
||||
const modelEntry = settings.MODELS[0]?.[0];
|
||||
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.
|
||||
*/
|
||||
async function resolveWordPressModelTextureCount(modelEntry: string): Promise<number | undefined> {
|
||||
try {
|
||||
const response = await window.fetch(modelEntry, { credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
|
||||
@ -9,6 +9,7 @@ export interface WordPressWidgetControllerElements {
|
||||
chat: HTMLElement;
|
||||
closeButton: HTMLButtonElement;
|
||||
input: HTMLInputElement;
|
||||
modelButton: HTMLButtonElement;
|
||||
sendButton: HTMLButtonElement;
|
||||
textureButton: HTMLButtonElement;
|
||||
tips: HTMLElement;
|
||||
@ -25,9 +26,15 @@ export interface WordPressWidgetControllerHandle {
|
||||
|
||||
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 {
|
||||
let hideTipsTimer = 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.
|
||||
@ -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.
|
||||
* @param action WordPress toolbar action encoded on the clicked `fui-*` icon span.
|
||||
@ -111,13 +133,30 @@ export function mountWordPressWidgetController(
|
||||
showMessage(WORDPRESS_WAIFU_MESSAGES.about);
|
||||
break;
|
||||
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);
|
||||
break;
|
||||
case 'photo':
|
||||
saveCanvas();
|
||||
break;
|
||||
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);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -4,10 +4,12 @@ export const WORDPRESS_WAIFU_MESSAGES = {
|
||||
chatOpen: '想说点什么吗?',
|
||||
close: '下次再见。',
|
||||
copy: '复制成功,记得带上出处哦。',
|
||||
model: '当前只启用 Pio 模型。',
|
||||
model: '正在切换看板娘。',
|
||||
modelLoading: '模型正在切换中,请稍后。',
|
||||
photo: '已尝试保存当前画面。',
|
||||
singleTexture: '当前只有一套服装。',
|
||||
texture: '正在切换 Pio 的服装。',
|
||||
texture: '正在切换服装。',
|
||||
textureLoading: '服装信息读取中。',
|
||||
touch: ['哇,你碰到我啦。', '今天也要好好经营小店。', '有新的灵感了吗?'],
|
||||
welcome: '欢迎来到 KwiTsukasa 的小站。',
|
||||
} as const;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user