From 2fc4d840e10148c520f067eecb554ed3d54714df Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 7 Jul 2026 01:14:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81Tia=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E5=A8=98=E6=A8=A1=E5=9E=8B=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/BlogLive2D.spec.ts | 160 +++++++++++++++++- .../blog/live2d/wordpressRuntimeBridge.ts | 63 +++++-- .../blog/live2d/wordpressWidgetController.ts | 45 ++++- .../blog/live2d/wordpressWidgetMessages.ts | 6 +- 4 files changed, 254 insertions(+), 20 deletions(-) diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts index ab2522a..ee0f4d1 100644 --- a/src/__tests__/BlogLive2D.spec.ts +++ b/src/__tests__/BlogLive2D.spec.ts @@ -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 { + promise: Promise; + 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(): DeferredValue { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((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('.waifu-tool .fui-user')?.click(); + await flushPromises(); + document.querySelector('.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('.waifu-tool .fui-user')?.click(); + document.querySelector('.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('.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'); diff --git a/src/components/blog/live2d/wordpressRuntimeBridge.ts b/src/components/blog/live2d/wordpressRuntimeBridge.ts index 99197a5..a48354e 100644 --- a/src/components/blog/live2d/wordpressRuntimeBridge.ts +++ b/src/components/blog/live2d/wordpressRuntimeBridge.ts @@ -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`. + * 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. + */ +function createWordPressModelTextureCountResolver( + settings: WordPressLive2DSettings, + textureCounts: Array, +): (modelIndex: number) => Promise { + const pendingCounts: Array | undefined> = []; + return async function resolveWordPressTextureCountForModel(modelIndex: number): Promise { + 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(settings: WordPressLive2DSettings): Promise { - const modelEntry = settings.MODELS[0]?.[0]; - if (!modelEntry || typeof window.fetch !== 'function') { - return undefined; - } - +async function resolveWordPressModelTextureCount(modelEntry: string): Promise { try { const response = await window.fetch(modelEntry, { credentials: 'same-origin' }); if (!response.ok) { diff --git a/src/components/blog/live2d/wordpressWidgetController.ts b/src/components/blog/live2d/wordpressWidgetController.ts index 6329136..b47f4ca 100644 --- a/src/components/blog/live2d/wordpressWidgetController.ts +++ b/src/components/blog/live2d/wordpressWidgetController.ts @@ -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; + /** + * 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; } /** @@ -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; } diff --git a/src/components/blog/live2d/wordpressWidgetMessages.ts b/src/components/blog/live2d/wordpressWidgetMessages.ts index 78f854a..6e91365 100644 --- a/src/components/blog/live2d/wordpressWidgetMessages.ts +++ b/src/components/blog/live2d/wordpressWidgetMessages.ts @@ -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;