Compare commits

..

1 Commits
dev ... main

Author SHA1 Message Date
e0f5c209d1 feat: 完成 Live2D 服装中文预览 2026-07-16 13:08:23 +08:00
11 changed files with 839 additions and 85 deletions

View File

@ -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>`

View File

@ -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,7 +221,8 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) {
.toBeGreaterThan(5000)
await expect
.poll(() =>
assetRequests.some((requestPath) =>
assetRequests.some(
(requestPath) =>
requestPath.includes('/moc/motions/Breath') && requestPath.endsWith('.mtn'),
),
)
@ -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,
)

View File

@ -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();
});

View 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('主题服装')
})
})

View 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()
})
})

View File

@ -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()}
/>
</>

View File

@ -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 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}
v-slots={{
default: () => (
<>
{props.errorMessage ? (
<div aria-live="assertive" class="kt-blog__live2d-picker-error">
{props.errorMessage}
</div>
) : null}
{props.type === 'model' ? renderModelOptions() : renderTextureOptions()}
</BlogModal>
</>
),
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 '点击服装后,可在左下角看板娘上即时预览。';
}

View 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()
}

View File

@ -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>;
}

View File

@ -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.');
}
if (textureIndex < 0 || textureIndex >= state.settings.textures.length) {
throw new Error('Live2D texture index is out of range.');
}
const nextState = {
...state,
textureIndex,
};
const nextState = resolveTextureState(state, textureIndex);
if (renderedTextureIndex !== textureIndex) {
await renderer.switchTexture(nextState);
renderedTextureIndex = textureIndex;
}
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.

View File

@ -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);