From e0f5c209d184888a397409d2d0fbdf05ba2b78e9 Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 16 Jul 2026 13:08:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20Live2D=20=E6=9C=8D?= =?UTF-8?q?=E8=A3=85=E4=B8=AD=E6=96=87=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- e2e/live2d-wordpress-widget.spec.ts | 75 ++++- src/__tests__/BlogLive2D.spec.ts | 54 +++- src/__tests__/BlogLive2DCostumeLabels.spec.ts | 30 ++ src/__tests__/Live2DTexturePreview.spec.ts | 113 +++++++ src/components/blog/BlogLive2D.tsx | 98 ++++-- src/components/blog/BlogLive2DPickerModal.tsx | 129 ++++++-- .../blog/live2d/live2dCostumeLabels.ts | 284 ++++++++++++++++++ .../blog/live2d/runtime/live2dRuntimeTypes.ts | 12 +- .../blog/live2d/runtime/live2dTsRuntime.ts | 83 +++-- src/styles/modal.scss | 44 ++- 11 files changed, 839 insertions(+), 85 deletions(-) create mode 100644 src/__tests__/BlogLive2DCostumeLabels.spec.ts create mode 100644 src/__tests__/Live2DTexturePreview.spec.ts create mode 100644 src/components/blog/live2d/live2dCostumeLabels.ts diff --git a/README.md b/README.md index 28cef46..e910d98 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium - 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。 - 左栏“管理”始终使用跨站 KT Admin SSO:生产默认跳到 `https://admin.kwitsukasa.top/#/auth/login?sso=1&redirect=%2Fblog%2Farticle`,本地默认使用 `http://localhost:5999/`,可通过 `VITE_KT_ADMIN_BASE_URL` 覆盖 Admin 基址。旧主题接口返回的 `/wp-admin/` 会在最终侧栏菜单中迁移到该入口;Blog 不跨域读取 Cookie,也不在 URL 中传递 token。 - 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 渲染、真实 MTN 动作队列、全页面有界视线、canvas-local 触摸和模型/服装直达切换。点击命中保留原 `live2d.min.js` 的 canvas-local width 分母坐标;桌面鼠标视线以模型中心为零点,按中心到对应 viewport 边缘的距离连续归一化,使 canvas 外近/中/远距离仍可区分。运行时保持源码的 `loadParam -> motion/eye blink -> saveParam -> pointer/sine/breath -> update` 参数生命周期,并直接导入类型化、语义化的 TS Core;不包含 compatibility/global installer,也不得 append 原始 `live2d.min.js`。 +- Live2D 使用旧 WordPress 站点同款 Cubism2 MOC 资源,`BlogLive2D` 与 `src/components/blog/live2d/runtime/*` 负责 canvas、Pio/Tia catalog、MOC metadata、选择缓存、WebGL 渲染、真实 MTN 动作队列、全页面有界视线、canvas-local 触摸和模型/服装直达切换。服装弹窗按资源语义显示中文名,点击选项只在当前看板娘上即时预览,不写缓存;确认后保存,取消、遮罩或 Esc 会恢复原服装。点击命中保留原 `live2d.min.js` 的 canvas-local width 分母坐标;桌面鼠标视线以模型中心为零点,按中心到对应 viewport 边缘的距离连续归一化,使 canvas 外近/中/远距离仍可区分。运行时保持源码的 `loadParam -> motion/eye blink -> saveParam -> pointer/sine/breath -> update` 参数生命周期,并直接导入类型化、语义化的 TS Core;不包含 compatibility/global installer,也不得 append 原始 `live2d.min.js`。 - Cubism2 反混淆只以 `public/live2d/wordpress-moc/live2d.min.js` 为源码证据,并严格按“函数名恢复 -> 调用逻辑理清 -> 拆分压缩耦合 -> 变量名恢复 -> runtime 全覆盖”推进;生产运行时不得加载该 min.js。权威进度维护在 `docs/blog-live2d-cubism2-minjs-deobfuscation-ledger.md`,稳定决策位于 `docs/live2d-deobfuscation/`。当前主线已闭合:627/627 函数、1411/1411 调用、567 owned/60 omitted、578 参数/1169 局部/53 最终标识符;runtime 分类为 546 exact、14 intentional bug fix、7 defensive extension、60 omitted-unreachable。生产 Core 严格等于 38 个 owner module + 5 个 sealed support module、0 orphan,并直接由 `runtimeCore.ts`/renderer 消费;禁止重新引入 compatibility、legacy globals、alias、映射层或原始 min.js 加载。 - 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/?adminPreview=1&articleId=`。 diff --git a/e2e/live2d-wordpress-widget.spec.ts b/e2e/live2d-wordpress-widget.spec.ts index 853a846..73448ee 100644 --- a/e2e/live2d-wordpress-widget.spec.ts +++ b/e2e/live2d-wordpress-widget.spec.ts @@ -117,7 +117,8 @@ function installPreservedWebGLDrawingBuffer(): void { contextId: string, options?: Record, ) => unknown - const originalGetContext = HTMLCanvasElement.prototype.getContext as unknown as CanvasContextGetter + const originalGetContext = HTMLCanvasElement.prototype + .getContext as unknown as CanvasContextGetter const canvasPrototype = HTMLCanvasElement.prototype as unknown as { getContext: CanvasContextGetter } @@ -220,8 +221,9 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) { .toBeGreaterThan(5000) await expect .poll(() => - assetRequests.some((requestPath) => - requestPath.includes('/moc/motions/Breath') && requestPath.endsWith('.mtn'), + assetRequests.some( + (requestPath) => + requestPath.includes('/moc/motions/Breath') && requestPath.endsWith('.mtn'), ), ) .toBe(true) @@ -262,8 +264,29 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) { await clickLive2DToolbarIcon(page, '.fui-eye') const texturePicker = page.locator('.kt-blog__modal-host--open.kt-blog__live2d-picker-modal') await expect(texturePicker).toContainText('选择服装') - await texturePicker.locator('.kt-blog__live2d-picker-option').nth(1).click() + const blueBikiniOption = texturePicker.locator('.kt-blog__live2d-picker-option', { + hasText: '天蓝色比基尼', + }) + await expect(blueBikiniOption).toBeVisible() + await blueBikiniOption.click() + await expect(texturePicker).toContainText('正在预览:天蓝色比基尼') + await expect(texturePicker).toHaveCount(1) + await expect + .poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:texture:tia'))) + .toBe('0') + await expect + .poll(() => + assetRequests.some( + (requestPath) => + requestPath === '/api/blog/live2d/tia/moc/textures/bikini-costume-blue.png', + ), + ) + .toBe(true) + await texturePicker.getByRole('button', { name: '使用此服装' }).click() await expect(texturePicker).toHaveCount(0) + await expect + .poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:texture:tia'))) + .toBe('1') await expect .poll(() => page.evaluate( @@ -273,6 +296,19 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) { ), ) .toBeGreaterThan(5000) + const switchedCostumeFrame = await canvas.evaluate((element) => element.toDataURL('image/png')) + await expect + .poll(() => canvas.evaluate((element) => element.toDataURL('image/png'))) + .not.toBe(switchedCostumeFrame) + await clickLive2DToolbarIcon(page, '.fui-eye') + const cancelPicker = page.locator('.kt-blog__modal-host--open.kt-blog__live2d-picker-modal') + await cancelPicker.locator('.kt-blog__live2d-picker-option', { hasText: '粉色比基尼' }).click() + await expect(cancelPicker).toContainText('正在预览:粉色比基尼') + await cancelPicker.getByRole('button', { name: '取消' }).click() + await expect(cancelPicker).toHaveCount(0) + await expect + .poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:texture:tia'))) + .toBe('1') await clickLive2DToolbarIcon(page, '.fui-photo') await expect(page.locator('.waifu-tips')).toContainText('保存') await clickLive2DToolbarIcon(page, '.fui-info-circle') @@ -309,6 +345,33 @@ async function verifyMobileSkipsWidget({ page }: { page: Page }) { expect(errors).toEqual([]) } +/** + * Verifies narrow desktop modal placement leaves the on-model costume preview visible. + * @param page Playwright page for the local Blog Web app. + */ +async function verifyNarrowDesktopPreviewLayout({ page }: { page: Page }) { + const errors: string[] = [] + await prepareLive2DPage(page, errors) + await page.setViewportSize({ height: 720, width: 900 }) + await page.goto('/') + await expect + .poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:model'))) + .toBe('pio') + + await clickLive2DToolbarIcon(page, '.fui-eye') + const widgetBox = await page.locator('.waifu.kt-blog__live2d-widget').boundingBox() + const modal = page.locator('.kt-blog__live2d-picker-modal .ant-modal') + await expect(modal).toHaveCSS('transform', 'none') + const modalBox = await modal.boundingBox() + + expect(widgetBox).not.toBeNull() + expect(modalBox).not.toBeNull() + expect(Math.round(modalBox?.x ?? 0)).toBeGreaterThanOrEqual( + Math.round((widgetBox?.x ?? 0) + (widgetBox?.width ?? 0) + 16), + ) + expect(errors).toEqual([]) +} + test( 'WordPress Pio widget shell and toolbar interactions match the captured plugin', verifyDesktopWidgetParity, @@ -317,3 +380,7 @@ test( 'WordPress Pio widget stays disabled at the captured mobile threshold', verifyMobileSkipsWidget, ) +test( + 'Live2D costume modal keeps the on-model preview visible on narrow desktops', + verifyNarrowDesktopPreviewLayout, +) diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts index b66b345..afc0193 100644 --- a/src/__tests__/BlogLive2D.spec.ts +++ b/src/__tests__/BlogLive2D.spec.ts @@ -12,7 +12,7 @@ const runtimeMocks = vi.hoisted(() => { hitAreas: {}, model: 'pio.moc', motions: {}, - textures: ['textures/default.png', 'textures/pink.png'], + textures: ['textures/default-costume.png', 'textures/bikini-costume-blue.png'], url: '/api/blog/live2d/pio/moc/index.json', }, textureIndex: 0, @@ -21,6 +21,12 @@ const runtimeMocks = vi.hoisted(() => { destroy: vi.fn(), getState: vi.fn(() => initialState), mount: vi.fn(() => Promise.resolve(initialState)), + previewTexture: vi.fn((textureIndex: number) => + Promise.resolve({ + ...initialState, + textureIndex, + }), + ), switchModel: vi.fn((modelKey: string) => Promise.resolve({ ...initialState, @@ -83,6 +89,7 @@ vi.mock('@/components/blog/BlogModal', async () => { vue.h('h2', props.title), vue.h('button', { class: 'mock-blog-modal__close', onClick: () => emit('close') }, '关闭'), slots.default?.(), + slots.footer ? vue.h('footer', { class: 'mock-blog-modal__footer' }, slots.footer()) : null, ]) : null; }, @@ -188,7 +195,7 @@ describe('BlogLive2D', () => { wrapper.unmount(); }); - it('opens a texture picker modal and selects a specific costume through the direct runtime API', async () => { + it('previews a Chinese-named costume before committing it through the direct runtime API', async () => { const wrapper = await mountDesktopLive2D(); document.querySelector('.waifu-tool .fui-eye')?.click(); @@ -197,33 +204,64 @@ describe('BlogLive2D', () => { const modal = document.querySelector('.mock-blog-modal.kt-blog__live2d-picker-modal'); expect(modal?.textContent).toContain('选择服装'); const buttons = Array.from(document.querySelectorAll('.kt-blog__live2d-picker-option')); - const secondTextureButton = buttons.find((button) => button.textContent?.includes('pink')); + const secondTextureButton = buttons.find((button) => button.textContent?.includes('天蓝色比基尼')); secondTextureButton?.click(); await flushPromises(); + expect(mockedRuntime().previewTexture).toHaveBeenCalledWith(1); + expect(mockedRuntime().switchTexture).not.toHaveBeenCalled(); + expect(document.querySelector('.mock-blog-modal.kt-blog__live2d-picker-modal')?.textContent).toContain( + '正在预览:天蓝色比基尼', + ); + const confirmButton = Array.from(document.querySelectorAll('button')).find((button) => + button.textContent?.includes('使用此服装'), + ); + confirmButton?.click(); + await flushPromises(); + expect(mockedRuntime().switchTexture).toHaveBeenCalledWith(1); expect(document.querySelector('.mock-blog-modal.kt-blog__live2d-picker-modal')).toBeNull(); wrapper.unmount(); }); - it('keeps the texture picker open when the runtime rejects a costume switch', async () => { + it('restores the committed costume when a preview is cancelled', async () => { + const wrapper = await mountDesktopLive2D(); + + document.querySelector('.waifu-tool .fui-eye')?.click(); + await flushPromises(); + const previewButton = Array.from( + document.querySelectorAll('.kt-blog__live2d-picker-option'), + ).find((button) => button.textContent?.includes('天蓝色比基尼')); + previewButton?.click(); + await flushPromises(); + document.querySelector('.mock-blog-modal__close')?.click(); + await flushPromises(); + + expect(mockedRuntime().previewTexture).toHaveBeenNthCalledWith(1, 1); + expect(mockedRuntime().previewTexture).toHaveBeenNthCalledWith(2, 0); + expect(mockedRuntime().switchTexture).not.toHaveBeenCalled(); + expect(document.querySelector('.mock-blog-modal.kt-blog__live2d-picker-modal')).toBeNull(); + wrapper.unmount(); + }); + + it('keeps the texture picker open when the runtime rejects a costume preview', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - runtimeMocks.runtime.switchTexture.mockRejectedValueOnce(new Error('Live2D texture index is out of range.')); + runtimeMocks.runtime.previewTexture.mockRejectedValueOnce(new Error('Live2D texture index is out of range.')); const wrapper = await mountDesktopLive2D(); document.querySelector('.waifu-tool .fui-eye')?.click(); await flushPromises(); const secondTextureButton = Array.from( document.querySelectorAll('.kt-blog__live2d-picker-option'), - ).find((button) => button.textContent?.includes('pink')); + ).find((button) => button.textContent?.includes('天蓝色比基尼')); secondTextureButton?.click(); await flushPromises(); const modal = document.querySelector('.mock-blog-modal.kt-blog__live2d-picker-modal'); - expect(mockedRuntime().switchTexture).toHaveBeenCalledWith(1); + expect(mockedRuntime().previewTexture).toHaveBeenCalledWith(1); expect(modal).not.toBeNull(); expect(modal?.textContent).toContain('服装切换失败,当前模型没有这个服装。'); - expect(warn).toHaveBeenCalledWith('[KT Blog] Live2D texture switch failed.', expect.any(Error)); + expect(warn).toHaveBeenCalledWith('[KT Blog] Live2D texture preview failed.', expect.any(Error)); wrapper.unmount(); }); diff --git a/src/__tests__/BlogLive2DCostumeLabels.spec.ts b/src/__tests__/BlogLive2DCostumeLabels.spec.ts new file mode 100644 index 0000000..7e3704a --- /dev/null +++ b/src/__tests__/BlogLive2DCostumeLabels.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { resolveLive2DCostumeLabel } from '@/components/blog/live2d/live2dCostumeLabels' + +describe('Blog Live2D Chinese costume labels', () => { + it.each([ + ['textures/default-costume.png', '默认服装'], + ['textures/Akiba Idol Costume.png', '秋叶原偶像装'], + ['textures/Animal Costume Racoon.png', '浣熊睡衣'], + ['textures/bikini-costume-blue.png', '天蓝色比基尼'], + ['textures/Bunny Girl Costume Red.png', '红色兔女郎装'], + ['textures/Sinsiroad Shop Costume Senior.png', '高阶辛西罗德商店制服'], + ['textures/vampire-costume-real.png', '吸血鬼装(觉醒)'], + ['textures/Witch Costume Special.png', '特别版猫咪女巫装'], + ])('translates %s into %s', (texture, expectedLabel) => { + expect(resolveLive2DCostumeLabel(texture)).toBe(expectedLabel) + }) + + it('semantically translates a newly added name without index-based placeholder naming', () => { + const label = resolveLive2DCostumeLabel('textures/Future Mystery Costume.png') + + expect(label).toBe('未来神秘服装') + expect(label).not.toMatch(/[a-z]/i) + }) + + it('keeps uncatalogued semantic words in Chinese instead of leaking the basename', () => { + expect(resolveLive2DCostumeLabel('textures/Emerald Princess Costume.png')).toBe('翡翠公主服装') + expect(resolveLive2DCostumeLabel('textures/Unmapped Codename Costume.png')).toBe('主题服装') + }) +}) diff --git a/src/__tests__/Live2DTexturePreview.spec.ts b/src/__tests__/Live2DTexturePreview.spec.ts new file mode 100644 index 0000000..51674c5 --- /dev/null +++ b/src/__tests__/Live2DTexturePreview.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createLive2DTSRuntime } from '@/components/blog/live2d/runtime/live2dTsRuntime' +import type { + Live2DRendererAdapter, + Live2DRuntimeStorage, +} from '@/components/blog/live2d/runtime/live2dRuntimeTypes' + +/** + * Creates a deterministic runtime dependency set for texture preview tests. + * @returns Recording renderer and storage fakes plus the mounted runtime inputs. + */ +function createPreviewRuntimeDependencies(): { + renderer: Live2DRendererAdapter + storage: Live2DRuntimeStorage +} { + return { + renderer: { + destroy: vi.fn(), + mount: vi.fn(() => Promise.resolve()), + switchModel: vi.fn(() => Promise.resolve()), + switchTexture: vi.fn(() => Promise.resolve()), + }, + storage: { + readModelKey: vi.fn(() => 'pio'), + readTextureIndex: vi.fn(() => 0), + writeModelKey: vi.fn(), + writeTextureIndex: vi.fn(), + }, + } +} + +/** + * Supplies the two-texture model metadata shared by preview tests. + * @returns Minimal normalized Cubism2 settings for Pio. + */ +function loadPreviewSettings() { + return Promise.resolve({ + baseUrl: '/api/blog/live2d/pio/moc/', + hitAreas: {}, + model: 'pio.moc', + motions: {}, + textures: ['textures/default-costume.png', 'textures/bikini-costume-blue.png'], + url: '/api/blog/live2d/pio/moc/index.json', + }) +} + +describe('Live2D texture preview runtime', () => { + it('renders a preview without mutating committed state or persistent storage', async () => { + const { renderer, storage } = createPreviewRuntimeDependencies() + const runtime = createLive2DTSRuntime({ + canvas: document.createElement('canvas'), + loadSettings: loadPreviewSettings, + renderer, + storage, + }) + await runtime.mount() + vi.mocked(renderer.switchTexture).mockClear() + vi.mocked(storage.writeTextureIndex).mockClear() + + const previewState = await runtime.previewTexture(1) + + expect(previewState.textureIndex).toBe(1) + expect(runtime.getState()?.textureIndex).toBe(0) + expect(renderer.switchTexture).toHaveBeenCalledWith( + expect.objectContaining({ textureIndex: 1 }), + ) + expect(storage.writeTextureIndex).not.toHaveBeenCalled() + }) + + it('commits an already rendered preview without loading the texture twice', async () => { + const { renderer, storage } = createPreviewRuntimeDependencies() + const runtime = createLive2DTSRuntime({ + canvas: document.createElement('canvas'), + loadSettings: loadPreviewSettings, + renderer, + storage, + }) + await runtime.mount() + vi.mocked(renderer.switchTexture).mockClear() + vi.mocked(storage.writeTextureIndex).mockClear() + + await runtime.previewTexture(1) + const committedState = await runtime.switchTexture(1) + + expect(committedState.textureIndex).toBe(1) + expect(runtime.getState()?.textureIndex).toBe(1) + expect(renderer.switchTexture).toHaveBeenCalledTimes(1) + expect(storage.writeTextureIndex).toHaveBeenCalledWith('pio', 1) + }) + + it('restores the committed texture after a cancelled preview without persisting', async () => { + const { renderer, storage } = createPreviewRuntimeDependencies() + const runtime = createLive2DTSRuntime({ + canvas: document.createElement('canvas'), + loadSettings: loadPreviewSettings, + renderer, + storage, + }) + await runtime.mount() + vi.mocked(renderer.switchTexture).mockClear() + vi.mocked(storage.writeTextureIndex).mockClear() + + await runtime.previewTexture(1) + await runtime.previewTexture(0) + + expect(runtime.getState()?.textureIndex).toBe(0) + expect( + vi.mocked(renderer.switchTexture).mock.calls.map(([state]) => state.textureIndex), + ).toEqual([1, 0]) + expect(storage.writeTextureIndex).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/blog/BlogLive2D.tsx b/src/components/blog/BlogLive2D.tsx index 196927b..f47f0a8 100644 --- a/src/components/blog/BlogLive2D.tsx +++ b/src/components/blog/BlogLive2D.tsx @@ -33,6 +33,7 @@ export default defineComponent({ errorMessage: '', modelOpen: false, pending: false, + previewTextureIndex: 0, settings: null as Live2DModelSettings | null, textureOpen: false, }); @@ -47,23 +48,33 @@ export default defineComponent({ const applyRuntimeState = (state: Live2DResolvedState) => { picker.activeModelKey = state.modelKey; picker.activeTextureIndex = state.textureIndex; + picker.previewTextureIndex = state.textureIndex; picker.settings = state.settings; }; + /** + * Opens the costume picker with its preview anchored to the committed texture. + */ + const openTexturePicker = () => { + picker.errorMessage = ''; + picker.previewTextureIndex = picker.activeTextureIndex; + picker.textureOpen = true; + }; + /** * Builds the element bag expected by the WordPress-style toolbar controller. * @returns Controller element bag, or null while refs are not connected. */ const resolveControllerElements = () => { if ( - !canvasRef.value - || !chatRef.value - || !closeButtonRef.value - || !inputRef.value - || !sendButtonRef.value - || !tipsRef.value - || !toolRef.value - || !widgetRef.value + !canvasRef.value || + !chatRef.value || + !closeButtonRef.value || + !inputRef.value || + !sendButtonRef.value || + !tipsRef.value || + !toolRef.value || + !widgetRef.value ) { return null; } @@ -95,8 +106,7 @@ export default defineComponent({ picker.modelOpen = true; }, onTexturePickerRequested: () => { - picker.errorMessage = ''; - picker.textureOpen = true; + openTexturePicker(); }, }); @@ -145,22 +155,70 @@ export default defineComponent({ }; /** - * Switches the active texture from the picker modal. - * @param textureIndex Selected texture index for the active model. + * Applies a costume to the visible model without committing it to storage. + * @param textureIndex Selected preview texture index for the active model. */ - const selectTexture = async (textureIndex: number) => { + const previewTexture = async (textureIndex: number) => { + if (!runtimeHandle || textureIndex === picker.previewTextureIndex) { + return; + } + picker.pending = true; + picker.errorMessage = ''; + try { + await runtimeHandle.previewTexture(textureIndex); + picker.previewTextureIndex = textureIndex; + } catch (error: unknown) { + picker.errorMessage = resolveSelectionErrorMessage(error); + console.warn('[KT Blog] Live2D texture preview failed.', error); + } finally { + picker.pending = false; + } + }; + + /** + * Commits the currently previewed costume and closes the picker after persistence succeeds. + */ + const confirmTexture = async () => { if (!runtimeHandle) { return; } picker.pending = true; picker.errorMessage = ''; try { - const state = await runtimeHandle.switchTexture(textureIndex); + const state = await runtimeHandle.switchTexture(picker.previewTextureIndex); applyRuntimeState(state); picker.textureOpen = false; } catch (error: unknown) { picker.errorMessage = resolveSelectionErrorMessage(error); - console.warn('[KT Blog] Live2D texture switch failed.', error); + console.warn('[KT Blog] Live2D texture confirmation failed.', error); + } finally { + picker.pending = false; + } + }; + + /** + * Cancels costume selection, restoring the committed texture before closing the picker. + */ + const closeTexturePicker = async () => { + if (picker.pending) { + return; + } + if (!runtimeHandle || picker.previewTextureIndex === picker.activeTextureIndex) { + picker.errorMessage = ''; + picker.previewTextureIndex = picker.activeTextureIndex; + picker.textureOpen = false; + return; + } + + picker.pending = true; + picker.errorMessage = ''; + try { + await runtimeHandle.previewTexture(picker.activeTextureIndex); + picker.previewTextureIndex = picker.activeTextureIndex; + picker.textureOpen = false; + } catch (error: unknown) { + picker.errorMessage = resolveSelectionErrorMessage(error); + console.warn('[KT Blog] Live2D texture preview restore failed.', error); } finally { picker.pending = false; } @@ -171,8 +229,9 @@ export default defineComponent({ * @returns Listener object accepted by Vue's generated TSX component types. */ const pickerListeners = () => ({ + 'onConfirm-texture': confirmTexture, + 'onPreview-texture': previewTexture, 'onSelect-model': selectModel, - 'onSelect-texture': selectTexture, }); return () => { @@ -213,6 +272,7 @@ export default defineComponent({ models={BLOG_LIVE2D_MODELS} open={picker.modelOpen} pending={picker.pending} + previewTextureIndex={picker.previewTextureIndex} settings={picker.settings} type="model" onClose={() => { @@ -228,12 +288,10 @@ export default defineComponent({ models={BLOG_LIVE2D_MODELS} open={picker.textureOpen} pending={picker.pending} + previewTextureIndex={picker.previewTextureIndex} settings={picker.settings} type="texture" - onClose={() => { - picker.errorMessage = ''; - picker.textureOpen = false; - }} + onClose={closeTexturePicker} {...pickerListeners()} /> diff --git a/src/components/blog/BlogLive2DPickerModal.tsx b/src/components/blog/BlogLive2DPickerModal.tsx index aa93a0f..d9f75d1 100644 --- a/src/components/blog/BlogLive2DPickerModal.tsx +++ b/src/components/blog/BlogLive2DPickerModal.tsx @@ -1,8 +1,11 @@ import { defineComponent, type PropType } from 'vue'; +import { resolveLive2DCostumeLabel } from './live2d/live2dCostumeLabels'; import type { Live2DModelEntry, Live2DModelSettings } from './live2d/runtime/live2dRuntimeTypes'; import BlogModal from './BlogModal'; +type PickerOptionStatus = 'active' | 'current' | 'preview' | null; + export default defineComponent({ name: 'BlogLive2DPickerModal', props: { @@ -30,6 +33,10 @@ export default defineComponent({ type: Boolean, default: false, }, + previewTextureIndex: { + type: Number, + default: 0, + }, settings: { type: Object as PropType, default: null, @@ -39,7 +46,7 @@ export default defineComponent({ required: true, }, }, - emits: ['close', 'select-model', 'select-texture'], + emits: ['close', 'confirm-texture', 'preview-texture', 'select-model'], /** * @param props Picker modal state and active Live2D runtime metadata. * @param emit Emits close and selection events to the Blog Live2D owner component. @@ -47,21 +54,27 @@ export default defineComponent({ */ setup(props, { emit }) { /** - * Renders one selectable button with active state. + * Renders one selectable button with committed or preview state. * @param label Button label. - * @param active Whether this option is selected. + * @param status Visual state for a model, committed costume, or previewed costume. + * @param pressed Whether the option is currently applied to the visible model. * @param onClick Selection handler. * @returns Selectable picker button. */ - const renderOption = (label: string, active: boolean, onClick: () => void) => ( + const renderOption = (label: string, status: PickerOptionStatus, pressed: boolean, onClick: () => void) => ( ); @@ -72,7 +85,12 @@ export default defineComponent({ const renderModelOptions = () => (
{props.models.map((model) => - renderOption(model.label, model.key === props.activeModelKey, () => emit('select-model', model.key)), + renderOption( + model.label, + model.key === props.activeModelKey ? 'active' : null, + model.key === props.activeModelKey, + () => emit('select-model', model.key), + ), )}
); @@ -89,13 +107,26 @@ export default defineComponent({ return
当前模型没有可切换服装。
; } return ( -
- {props.settings.textures.map((texture, index) => - renderOption(resolveTextureLabel(texture, index), index === props.activeTextureIndex, () => - emit('select-texture', index), - ), - )} -
+ <> +
+ {resolveTexturePreviewStatus( + props.settings, + props.activeTextureIndex, + props.previewTextureIndex, + props.pending, + )} +
+
+ {props.settings.textures.map((texture, index) => { + const current = index === props.activeTextureIndex; + const preview = index === props.previewTextureIndex; + const status: PickerOptionStatus = preview && !current ? 'preview' : current ? 'current' : null; + return renderOption(resolveLive2DCostumeLabel(texture), status, preview, () => + emit('preview-texture', index), + ); + })} +
+ ); }; @@ -106,24 +137,68 @@ export default defineComponent({ size="md" title={props.type === 'model' ? '选择看板娘' : '选择服装'} onClose={() => emit('close')} - > - {props.errorMessage ?
{props.errorMessage}
: null} - {props.type === 'model' ? renderModelOptions() : renderTextureOptions()} - + v-slots={{ + default: () => ( + <> + {props.errorMessage ? ( +
+ {props.errorMessage} +
+ ) : null} + {props.type === 'model' ? renderModelOptions() : renderTextureOptions()} + + ), + footer: + props.type === 'texture' + ? () => ( + <> + + + + ) + : undefined, + }} + /> ); }, }); /** - * Builds a human-readable costume label from a texture path. - * @param texture Texture path from the Cubism2 model settings. - * @param index Texture index passed to the TS runtime. - * @returns Label shown in the costume picker. + * Builds the live-region message for the texture picker. + * @param settings Active model settings containing texture names. + * @param activeTextureIndex Persisted texture index. + * @param previewTextureIndex Texture currently rendered on the model. + * @param pending Whether a renderer operation is in flight. + * @returns Concise Chinese preview instruction or progress message. */ -function resolveTextureLabel(texture: string, index: number): string { - const filename = texture.split('/').pop()?.replace(/\.[^.]+$/, '') || `costume-${index + 1}`; - if (index === 0) { - return `默认服装`; +function resolveTexturePreviewStatus( + settings: Live2DModelSettings, + activeTextureIndex: number, + previewTextureIndex: number, + pending: boolean, +): string { + if (pending) { + return '正在切换服装预览…'; } - return filename.replace(/[-_]+/g, ' '); + if (previewTextureIndex !== activeTextureIndex) { + const texture = settings.textures[previewTextureIndex]; + if (texture) { + return `正在预览:${resolveLive2DCostumeLabel(texture)}`; + } + } + return '点击服装后,可在左下角看板娘上即时预览。'; } diff --git a/src/components/blog/live2d/live2dCostumeLabels.ts b/src/components/blog/live2d/live2dCostumeLabels.ts new file mode 100644 index 0000000..ce85ca8 --- /dev/null +++ b/src/components/blog/live2d/live2dCostumeLabels.ts @@ -0,0 +1,284 @@ +const COSTUME_LABELS: Readonly> = { + 'akiba idol': '秋叶原偶像装', + animal: '动物装', + bikini: '比基尼', + blackcat: '黑猫装', + blazer: '西装制服', + blueround: '蓝色圆领装', + 'bunny girl': '兔女郎装', + cake: '蛋糕装', + 'cami dress': '吊带连衣裙', + 'cute pajamas': '可爱睡衣', + default: '默认服装', + dress: '连衣裙', + 'elementary school': '小学制服', + 'fall dress': '秋日连衣裙', + 'forest witch': '森林女巫装', + 'frill bikini': '荷叶边比基尼', + 'frill blouse': '荷叶边衬衫', + furisode: '振袖和服', + goddess: '女神装', + halloween: '万圣节装', + hanbok: '韩服', + healer: '治愈师装', + hood: '连帽装', + jersey: '运动服', + kids: '童装', + knight: '骑士装', + 'literature girl': '文学少女装', + lolita: '洛丽塔装', + 'macaron dress': '马卡龙连衣裙', + 'magical girl': '魔法少女装', + maid: '女仆装', + marine: '海军风服装', + new2015: '羊年装', + 'night witch': '暗夜女巫装', + nightsky: '星空装', + nordic: '北欧风连衣裙', + nurse: '护士服', + overalls: '背带裤', + pajamas: '睡衣', + 'party dress': '派对礼服', + priest: '祭司服', + pushcat: '推推猫装', + qipao: '旗袍', + 'ribbon dress': '蝴蝶结连衣裙', + sabori: '萨博里装', + sailor: '水手服', + 'sailor bikini': '水手风比基尼', + sakura: '樱花水手服', + 'sakura fairy': '樱花精灵装', + santa: '圣诞装', + 'santa 2018': '2018 圣诞装', + santa2016: '2016 圣诞装', + sarori: '萨罗里装', + school: '学院制服', + 'school 2017': '2017 学院制服', + 'school 2019': '2019 学院制服', + 'sfc uniform': '特别学院制服', + shaman: '萨满服', + sinsiroad: '辛西罗德制服', + 'sinsiroad shop': '辛西罗德商店制服', + sorceress: '女术士装', + 'sports bikini': '运动比基尼', + 'sporty hood': '运动连帽装', + 'spring dress': '春日连衣裙', + 'star witch': '星之女巫装', + succubus: '魅魔装', + sukumizu: '学校泳装', + sulbim: '节日韩服', + 'summer dress': '夏日连衣裙', + 'summer uniform': '夏季制服', + 'swimsuit 2017': '2017 泳装', + thief: '盗贼装', + tirami1: '提拉米装', + traveler: '旅行者装', + turtleneck: '高领毛衣', + valentine: '情人节装', + vampire: '吸血鬼装', + 'voice story': '语音故事服装', + warrior: '战士装', + whiteday: '白色情人节礼服', + winter: '冬装', + 'winter coat': '冬季大衣', + 'winter coat 2017': '2017 冬季大衣', + 'winter fairy': '冬日精灵装', + witch: '女巫装', +} + +const KNOWN_COSTUME_LABEL_OVERRIDES: Readonly> = { + animal: '兔子睡衣', + 'animal racoon': '浣熊睡衣', + 'bikini blue': '天蓝色比基尼', + 'blazer black': '黑色西装外套', + 'blazer brown': '棕色西装外套', + blueround: '蓝环装', + 'bunny girl': '黑色兔女郎装', + 'cake choco': '巧克力蛋糕裙', + 'cake cream': '奶油蛋糕裙', + dress: '紫色缎带连衣裙', + 'dress brown': '棕色缎带连衣裙', + 'elementary school': '校园制服', + healer: '尼特装(治疗师)', + hanbok: '经典韩服', + 'hood gray': '灰色连帽衫', + 'hood red': '红色连帽衫', + kids: '天蓝色幼儿园制服', + 'kids navy': '藏青色幼儿园制服', + knight: '多尔伯装(骑士)', + 'macaron dress green': '蜜瓜马卡龙连衣裙', + 'macaron dress pink': '草莓马卡龙连衣裙', + maid: '藏青色女仆装', + 'maid blue': '天蓝色女仆装', + marine: '白色海军风服装', + new2015: '绵羊装', + 'new2015 pajamas': '绵羊睡衣', + nurse: '白色护士装', + overalls: '经典背带裤装', + pajamas: '黄色睡衣', + sailor: '白色水手服', + sakura: '粉色樱花水手服', + 'sakura fairy real': '樱花妖精装(觉醒)', + santa: '红色圣诞装', + 'santa 2018 green': '2018 绿色圣诞装', + 'santa 2018 red': '2018 红色圣诞装', + school: '棕色魔法学院制服', + 'school 2017 gray': '2017 灰色学院制服', + 'school 2017 yellow': '2017 黄色学院制服', + 'school 2019 black': '2019 黑色学院制服', + 'school 2019 pink': '2019 粉色学院制服', + 'school red': '红色魔法学院制服', + sorceress: '卢克装(魔法师)', + 'star witch': '绿色星之女巫装', + sukumizu: '藏青色学校泳装', + 'summer dress blue': '天蓝色夏日连衣裙', + 'sulbim rainbow': '彩虹新年韩服', + 'sulbim snowflake': '雪花新年韩服', + 'swimsuit 2017 navy': '2017 藏青色泳装', + 'swimsuit 2017 red': '2017 红色泳装', + thief: '皮莉娅装(盗贼)', + traveler: '旅行装', + turtleneck: '经典高领毛衣', + 'vampire real': '吸血鬼装(觉醒)', + warrior: '克莱尔装(战士)', + winter: '藏青色冬装', + 'winter coat 2017 brown': '2017 棕色冬季大衣', + 'winter coat 2017 white': '2017 白色冬季大衣', + 'witch special': '特别版猫咪女巫装', + 'witch white': '白色猫咪女巫装', + witch: '黑色猫咪女巫装', +} + +const COSTUME_VARIANT_PREFIXES: Readonly> = { + beige: '米色', + black: '黑色', + blue: '蓝色', + brown: '棕色', + choco: '巧克力色', + cream: '奶油色', + gorgeous: '华丽', + gray: '灰色', + green: '绿色', + junior: '初阶', + navy: '藏青色', + pink: '粉色', + purple: '紫色', + racoon: '浣熊', + rainbow: '彩虹', + red: '红色', + senior: '高阶', + skyblue: '天蓝色', + snowflake: '雪花', + white: '白色', + yellow: '黄色', +} + +const COSTUME_VARIANT_SUFFIXES: Readonly> = { + pajamas: '睡衣款', + real: '特别版', + special: '特别版', +} + +const FALLBACK_WORD_TRANSLATIONS: Readonly> = { + autumn: '秋日', + casual: '休闲', + cat: '猫咪', + classic: '经典', + coat: '外套', + cute: '可爱', + dog: '狗狗', + dress: '连衣裙', + elegant: '优雅', + emerald: '翡翠', + fairy: '精灵', + flower: '花卉', + formal: '正装', + future: '未来', + gothic: '哥特', + idol: '偶像', + jacket: '夹克', + magical: '魔法', + mystery: '神秘', + ocean: '海洋', + party: '派对', + princess: '公主', + rabbit: '兔子', + royal: '皇家', + school: '校园', + shirt: '衬衫', + spring: '春日', + summer: '夏日', + uniform: '制服', + winter: '冬日', +} + +/** + * Converts one Cubism texture filename into a stable Chinese costume name. + * @param texture Texture path from a model settings file. + * @returns Semantic Chinese display label derived from the captured catalog name. + */ +export function resolveLive2DCostumeLabel(texture: string): string { + const normalizedName = normalizeCostumeName(texture) + const knownLabel = KNOWN_COSTUME_LABEL_OVERRIDES[normalizedName] + if (knownLabel) { + return knownLabel + } + const directLabel = COSTUME_LABELS[normalizedName] + if (directLabel) { + return directLabel + } + + const nameParts = normalizedName.split(' ') + const variantName = nameParts.pop() || '' + const baseLabel = COSTUME_LABELS[nameParts.join(' ')] + if (!baseLabel) { + return translateUncataloguedCostumeName(normalizedName) + } + + const prefix = COSTUME_VARIANT_PREFIXES[variantName] + if (prefix) { + return `${prefix}${baseLabel}` + } + const suffix = COSTUME_VARIANT_SUFFIXES[variantName] + if (suffix) { + return `${baseLabel}·${suffix}` + } + return translateUncataloguedCostumeName(normalizedName) +} + +/** + * Produces a semantic word-level translation for names added after the captured catalogs. + * @param normalizedName Normalized basename without its generic costume token. + * @returns Readable translated label without index-based placeholder naming. + */ +function translateUncataloguedCostumeName(normalizedName: string): string { + const translatedName = normalizedName + .split(' ') + .filter(Boolean) + .map((word) => COSTUME_VARIANT_PREFIXES[word] || FALLBACK_WORD_TRANSLATIONS[word] || '主题') + .filter((word, index, words) => word !== '主题' || words[index - 1] !== word) + .join('') + if (!translatedName) { + return '新款服装' + } + if (/(?:装|服|裙|衣|裤|泳装|比基尼|睡衣|制服|礼服|和服|旗袍|毛衣)$/.test(translatedName)) { + return translatedName + } + return `${translatedName}服装` +} + +/** + * Normalizes WordPress and captured-model filename styles into one lookup key. + * @param texture Texture path whose basename may use spaces, hyphens, or underscores. + * @returns Lowercase costume key without extension or the generic `costume` token. + */ +function normalizeCostumeName(texture: string): string { + const filename = texture.split('/').pop() || '' + return filename + .replace(/\.[^.]+$/, '') + .replace(/[-_]+/g, ' ') + .toLowerCase() + .replace(/\bcostume\b/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts b/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts index 9ef97bc..fe0931f 100644 --- a/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts +++ b/src/components/blog/live2d/runtime/live2dRuntimeTypes.ts @@ -5,10 +5,7 @@ import type { } from '../vendor/cubism2Core/motionBase'; import type { Cubism2Live2DMotionConstructor } from '../vendor/cubism2Core/motionParser'; import type { Cubism2ModelContextLike } from '../vendor/cubism2Core/modelBase'; -import type { - Cubism2ModelWebGLConstructor, - Cubism2ModelWebGLInstance, -} from '../vendor/cubism2Core/modelWrappers'; +import type { Cubism2ModelWebGLConstructor, Cubism2ModelWebGLInstance } from '../vendor/cubism2Core/modelWrappers'; export type Live2DCoreMotion = Cubism2AMotionInstance; @@ -50,10 +47,17 @@ export interface Live2DResolvedState { } export interface Live2DTSRuntime { + /** Releases renderer resources and clears the in-memory runtime state. */ destroy(): void; + /** Returns the last state committed by mount or an explicit switch operation. */ getState(): Live2DResolvedState | null; + /** Mounts the stored model and texture selection. */ mount(): Promise; + /** Renders a texture temporarily without mutating committed state or persistent storage. */ + previewTexture(textureIndex: number): Promise; + /** Loads and persists a model selection. */ switchModel(modelKey: string): Promise; + /** Renders and persists a texture selection for the active model. */ switchTexture(textureIndex: number): Promise; } diff --git a/src/components/blog/live2d/runtime/live2dTsRuntime.ts b/src/components/blog/live2d/runtime/live2dTsRuntime.ts index 66764d2..9174adb 100644 --- a/src/components/blog/live2d/runtime/live2dTsRuntime.ts +++ b/src/components/blog/live2d/runtime/live2dTsRuntime.ts @@ -1,8 +1,4 @@ -import { - BLOG_LIVE2D_MODELS, - DEFAULT_LIVE2D_MODEL_KEY, - findLive2DModelEntry, -} from './live2dRuntimeCatalog'; +import { BLOG_LIVE2D_MODELS, DEFAULT_LIVE2D_MODEL_KEY, findLive2DModelEntry } from './live2dRuntimeCatalog'; import { fetchLive2DModelSettings } from './live2dModelSettings'; import { createWebGLLive2DRenderer } from './webglLive2DRenderer'; import type { @@ -35,6 +31,7 @@ export function createLive2DTSRuntime(options: CreateLive2DTSRuntimeOptions): Li const loadSettings = options.loadSettings || fetchLive2DModelSettings; const renderer = options.renderer || createWebGLLive2DRenderer(options.canvas); let state: Live2DResolvedState | null = null; + let renderedTextureIndex: number | null = null; /** * Resolves a model key to a registered entry, falling back to Pio. @@ -67,10 +64,7 @@ export function createLive2DTSRuntime(options: CreateLive2DTSRuntimeOptions): Li * @param requestedTexture Optional explicit texture index. * @returns Runtime state for renderer application. */ - const createState = async ( - entry: Live2DModelEntry, - requestedTexture?: number, - ): Promise => { + const createState = async (entry: Live2DModelEntry, requestedTexture?: number): Promise => { const settings = await resolveSettings(entry); const textureIndex = typeof requestedTexture === 'number' @@ -84,41 +78,73 @@ export function createLive2DTSRuntime(options: CreateLive2DTSRuntimeOptions): Li }; return { + /** + * Releases renderer resources and clears the in-memory runtime state. + * @returns Nothing. + */ destroy() { renderer.destroy(); state = null; + renderedTextureIndex = null; }, + /** + * Returns the last model and texture selection committed by the runtime. + * @returns Current committed state, or null before mount or after destruction. + */ getState() { return state; }, + /** + * Mounts the stored model selection and persists its normalized model and texture keys. + * @returns Resolved state applied to the renderer. + */ async mount() { const nextState = await createState(resolveEntry(storage.readModelKey())); await renderer.mount(nextState); storage.writeModelKey(nextState.modelKey); storage.writeTextureIndex(nextState.modelKey, nextState.textureIndex); state = nextState; + renderedTextureIndex = nextState.textureIndex; return nextState; }, + /** + * Applies a texture to the renderer without changing committed runtime state or storage. + * @param textureIndex Texture index to preview for the active model. + * @returns Temporary state applied to the renderer. + */ + async previewTexture(textureIndex: number) { + const nextState = resolveTextureState(state, textureIndex); + if (renderedTextureIndex !== textureIndex) { + await renderer.switchTexture(nextState); + renderedTextureIndex = textureIndex; + } + return nextState; + }, + /** + * Loads and commits a model selection, including its stored texture selection. + * @param modelKey Registered model key selected by the user. + * @returns Resolved model state committed to storage and the renderer. + */ async switchModel(modelKey: string) { const nextState = await createState(resolveEntry(modelKey)); await renderer.switchModel(nextState); storage.writeModelKey(nextState.modelKey); storage.writeTextureIndex(nextState.modelKey, nextState.textureIndex); state = nextState; + renderedTextureIndex = nextState.textureIndex; return nextState; }, + /** + * Applies and persists a texture selection for the active model. + * @param textureIndex Texture index selected by the user. + * @returns Updated state committed to storage and the renderer. + */ async switchTexture(textureIndex: number) { - if (!state) { - throw new Error('Live2D runtime is not mounted.'); + const nextState = resolveTextureState(state, textureIndex); + if (renderedTextureIndex !== textureIndex) { + await renderer.switchTexture(nextState); + renderedTextureIndex = textureIndex; } - if (textureIndex < 0 || textureIndex >= state.settings.textures.length) { - throw new Error('Live2D texture index is out of range.'); - } - const nextState = { - ...state, - textureIndex, - }; - await renderer.switchTexture(nextState); storage.writeTextureIndex(nextState.modelKey, nextState.textureIndex); state = nextState; return nextState; @@ -126,6 +152,25 @@ export function createLive2DTSRuntime(options: CreateLive2DTSRuntimeOptions): Li }; } +/** + * Validates a texture target and derives the renderer state without mutating committed runtime state. + * @param state Current committed runtime state, or null before mount. + * @param textureIndex Requested texture index for preview or confirmation. + * @returns State object that can be applied to the renderer. + */ +function resolveTextureState(state: Live2DResolvedState | null, textureIndex: number): Live2DResolvedState { + if (!state) { + throw new Error('Live2D runtime is not mounted.'); + } + if (!Number.isInteger(textureIndex) || textureIndex < 0 || textureIndex >= state.settings.textures.length) { + throw new Error('Live2D texture index is out of range.'); + } + return { + ...state, + textureIndex, + }; +} + /** * Keeps texture selections inside the model's available texture list. * @param textureIndex Requested texture index. diff --git a/src/styles/modal.scss b/src/styles/modal.scss index 6c378f6..7c999fa 100644 --- a/src/styles/modal.scss +++ b/src/styles/modal.scss @@ -114,6 +114,17 @@ display: block; } + &__live2d-picker-modal .ant-modal-footer { + gap: 8px; + } + + &__live2d-picker-preview-status { + margin-bottom: 10px; + color: var(--argon-muted); + font-size: 13px; + line-height: 1.5; + } + &__live2d-picker-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); @@ -154,19 +165,34 @@ } } - &__live2d-picker-option--active { + &__live2d-picker-option--active, + &__live2d-picker-option--preview { border-color: var(--themecolor); color: #fff; background: var(--themecolor); } + &__live2d-picker-option--current { + border-color: var(--themecolor); + color: var(--themecolor); + background: var(--argon-card); + } + + &__live2d-picker-option--current &__live2d-picker-option-badge { + color: #fff; + background: var(--themecolor); + } + &__live2d-picker-option-label { min-width: 0; overflow: hidden; font-size: 14px; line-height: 1.4; text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } &__live2d-picker-option-badge { @@ -198,6 +224,20 @@ line-height: 1.5; } + @media (min-width: 769px) and (max-width: 1212px) { + &__live2d-picker-modal &__modal-wrap { + justify-content: flex-end; + padding-right: 16px; + padding-left: 296px; + + .ant-modal { + width: min(620px, calc(100vw - 312px)) !important; + margin-right: 0; + margin-left: 0; + } + } + } + &__popover-enter-active, &__popover-leave-active { transition: var(--kt-blog-motion-modal-popover, opacity 0.25s ease, transform 0.25s ease);