feat: 完成 Live2D 服装中文预览
This commit is contained in:
parent
52c9caea14
commit
e0f5c209d1
@ -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/<slug>?adminPreview=1&articleId=<id>`。
|
||||
|
||||
@ -117,7 +117,8 @@ function installPreservedWebGLDrawingBuffer(): void {
|
||||
contextId: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => 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,
|
||||
)
|
||||
|
||||
@ -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<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('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<HTMLElement>('.waifu-tool .fui-eye')?.click();
|
||||
await flushPromises();
|
||||
const previewButton = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('.kt-blog__live2d-picker-option'),
|
||||
).find((button) => button.textContent?.includes('天蓝色比基尼'));
|
||||
previewButton?.click();
|
||||
await flushPromises();
|
||||
document.querySelector<HTMLButtonElement>('.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<HTMLElement>('.waifu-tool .fui-eye')?.click();
|
||||
await flushPromises();
|
||||
const secondTextureButton = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('.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();
|
||||
});
|
||||
|
||||
|
||||
30
src/__tests__/BlogLive2DCostumeLabels.spec.ts
Normal file
30
src/__tests__/BlogLive2DCostumeLabels.spec.ts
Normal file
@ -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('主题服装')
|
||||
})
|
||||
})
|
||||
113
src/__tests__/Live2DTexturePreview.spec.ts
Normal file
113
src/__tests__/Live2DTexturePreview.spec.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -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<Live2DModelSettings | null>,
|
||||
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) => (
|
||||
<button
|
||||
class={['kt-blog__live2d-picker-option', active && 'kt-blog__live2d-picker-option--active']}
|
||||
aria-pressed={pressed}
|
||||
class={['kt-blog__live2d-picker-option', status && `kt-blog__live2d-picker-option--${status}`]}
|
||||
disabled={props.pending}
|
||||
title={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span class="kt-blog__live2d-picker-option-label">{label}</span>
|
||||
{active ? <span class="kt-blog__live2d-picker-option-badge">当前</span> : null}
|
||||
{status === 'active' || status === 'current' ? (
|
||||
<span class="kt-blog__live2d-picker-option-badge">当前</span>
|
||||
) : null}
|
||||
{status === 'preview' ? <span class="kt-blog__live2d-picker-option-badge">预览中</span> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -72,7 +85,12 @@ export default defineComponent({
|
||||
const renderModelOptions = () => (
|
||||
<div class="kt-blog__live2d-picker-grid">
|
||||
{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),
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -89,13 +107,26 @@ export default defineComponent({
|
||||
return <div class="kt-blog__live2d-picker-empty">当前模型没有可切换服装。</div>;
|
||||
}
|
||||
return (
|
||||
<div class="kt-blog__live2d-picker-grid">
|
||||
{props.settings.textures.map((texture, index) =>
|
||||
renderOption(resolveTextureLabel(texture, index), index === props.activeTextureIndex, () =>
|
||||
emit('select-texture', index),
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div aria-live="polite" class="kt-blog__live2d-picker-preview-status">
|
||||
{resolveTexturePreviewStatus(
|
||||
props.settings,
|
||||
props.activeTextureIndex,
|
||||
props.previewTextureIndex,
|
||||
props.pending,
|
||||
)}
|
||||
</div>
|
||||
<div aria-busy={props.pending} class="kt-blog__live2d-picker-grid">
|
||||
{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),
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -106,24 +137,68 @@ export default defineComponent({
|
||||
size="md"
|
||||
title={props.type === 'model' ? '选择看板娘' : '选择服装'}
|
||||
onClose={() => emit('close')}
|
||||
>
|
||||
{props.errorMessage ? <div class="kt-blog__live2d-picker-error">{props.errorMessage}</div> : null}
|
||||
{props.type === 'model' ? renderModelOptions() : renderTextureOptions()}
|
||||
</BlogModal>
|
||||
v-slots={{
|
||||
default: () => (
|
||||
<>
|
||||
{props.errorMessage ? (
|
||||
<div aria-live="assertive" class="kt-blog__live2d-picker-error">
|
||||
{props.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{props.type === 'model' ? renderModelOptions() : renderTextureOptions()}
|
||||
</>
|
||||
),
|
||||
footer:
|
||||
props.type === 'texture'
|
||||
? () => (
|
||||
<>
|
||||
<button
|
||||
class="kt-blog__button kt-blog__button--neutral kt-blog__button--small"
|
||||
disabled={props.pending}
|
||||
type="button"
|
||||
onClick={() => emit('close')}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="kt-blog__button kt-blog__button--primary kt-blog__button--small"
|
||||
disabled={props.pending}
|
||||
type="button"
|
||||
onClick={() => emit('confirm-texture')}
|
||||
>
|
||||
使用此服装
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: 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 '点击服装后,可在左下角看板娘上即时预览。';
|
||||
}
|
||||
|
||||
284
src/components/blog/live2d/live2dCostumeLabels.ts
Normal file
284
src/components/blog/live2d/live2dCostumeLabels.ts
Normal file
@ -0,0 +1,284 @@
|
||||
const COSTUME_LABELS: Readonly<Record<string, string>> = {
|
||||
'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<Record<string, string>> = {
|
||||
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<Record<string, string>> = {
|
||||
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<Record<string, string>> = {
|
||||
pajamas: '睡衣款',
|
||||
real: '特别版',
|
||||
special: '特别版',
|
||||
}
|
||||
|
||||
const FALLBACK_WORD_TRANSLATIONS: Readonly<Record<string, string>> = {
|
||||
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()
|
||||
}
|
||||
@ -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<Live2DResolvedState>;
|
||||
/** Renders a texture temporarily without mutating committed state or persistent storage. */
|
||||
previewTexture(textureIndex: number): Promise<Live2DResolvedState>;
|
||||
/** Loads and persists a model selection. */
|
||||
switchModel(modelKey: string): Promise<Live2DResolvedState>;
|
||||
/** Renders and persists a texture selection for the active model. */
|
||||
switchTexture(textureIndex: number): Promise<Live2DResolvedState>;
|
||||
}
|
||||
|
||||
|
||||
@ -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<Live2DResolvedState> => {
|
||||
const createState = async (entry: Live2DModelEntry, requestedTexture?: number): Promise<Live2DResolvedState> => {
|
||||
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.
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user