This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -73,10 +73,10 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.getByText('2D素材风格')).toBeTruthy();
expect(screen.getByRole('button', { name: '生成音效' })).toBeTruthy();
expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy();
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.getByText('消耗10光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
expect(screen.queryByText('参考图')).toBeNull();
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
@@ -142,7 +142,39 @@ test('match3d workspace supports custom 2d asset style prompt', () => {
);
});
test('match3d workspace can enable click sound generation from entry toggle', () => {
test('match3d workspace submits strict pixel-retro style prompt', () => {
const onCreateFromForm = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '复古水果铺' },
});
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStyleId: 'pixel-retro',
assetStyleLabel: '像素复古',
assetStylePrompt: expect.stringContaining('64x64'),
}),
);
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStylePrompt: expect.stringContaining('禁止抗锯齿'),
}),
);
});
test('match3d workspace keeps click sound generation disabled from entry form', () => {
const onCreateFromForm = vi.fn();
render(
@@ -157,13 +189,12 @@ test('match3d workspace can enable click sound generation from entry toggle', ()
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '海岛甜品' },
});
fireEvent.click(screen.getByRole('button', { name: '生成音效' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
themeText: '海岛甜品',
generateClickSound: true,
generateClickSound: false,
}),
);
});

View File

@@ -1,4 +1,4 @@
import { Loader2, Music2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
@@ -26,7 +26,6 @@ type Match3DFormState = {
difficultyOptionId: Match3DDifficultyOptionId;
assetStyleId: Match3DAssetStyleOptionId;
customAssetStylePrompt: string;
generateClickSound: boolean;
};
const EMPTY_FORM_STATE: Match3DFormState = {
@@ -34,7 +33,6 @@ const EMPTY_FORM_STATE: Match3DFormState = {
difficultyOptionId: 'standard',
assetStyleId: 'flat-icon',
customAssetStylePrompt: '',
generateClickSound: false,
};
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
@@ -68,7 +66,7 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
label: '像素复古',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'复古像素 2D 游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,不使用真实 3D 渲染。',
'真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。',
},
{
id: 'watercolor',
@@ -186,10 +184,6 @@ function resolveInitialFormState(
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
customAssetStylePrompt: assetStylePrompt,
generateClickSound:
initialFormPayload?.generateClickSound ??
config?.generateClickSound ??
false,
};
}
@@ -259,13 +253,12 @@ export function Match3DAgentWorkspace({
assetStyleId: formState.assetStyleId,
assetStyleLabel,
assetStylePrompt,
generateClickSound: formState.generateClickSound,
generateClickSound: false,
}),
[
assetStyleLabel,
assetStylePrompt,
formState.assetStyleId,
formState.generateClickSound,
selectedDifficultyOption,
themeText,
],
@@ -298,7 +291,7 @@ export function Match3DAgentWorkspace({
if (session) {
onExecuteAction({
action: 'match3d_compile_draft',
generateClickSound: formState.generateClickSound,
generateClickSound: false,
});
}
};
@@ -458,43 +451,6 @@ export function Match3DAgentWorkspace({
})}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() =>
setFormState((current) => ({
...current,
generateClickSound: !current.generateClickSound,
}))
}
className={`flex min-h-12 shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border px-3 py-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
formState.generateClickSound
? 'border-rose-200 bg-rose-50/80 text-rose-700 shadow-[0_8px_18px_rgba(244,63,94,0.10)]'
: 'border-[var(--platform-subpanel-border)] bg-white/58 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white/86'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={formState.generateClickSound}
aria-label="生成音效"
>
<span className="inline-flex min-w-0 items-center gap-2">
<Music2 className="h-4 w-4 shrink-0" />
<span className="truncate text-sm font-black"></span>
</span>
<span
className={`relative h-6 w-11 shrink-0 rounded-full transition ${
formState.generateClickSound
? 'bg-rose-400'
: 'bg-slate-200'
}`}
aria-hidden="true"
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition ${
formState.generateClickSound ? 'left-6' : 'left-1'
}`}
/>
</span>
</button>
</div>
</div>
@@ -524,7 +480,7 @@ export function Match3DAgentWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
10
</span>
</span>
</button>

View File

@@ -25,6 +25,9 @@ vi.mock('../ResolvedAssetImage', () => ({
vi.mock('../../services/assetReadUrlService', () => ({
isGeneratedLegacyPath: (value: string) =>
/^\/?generated-[^/?#]+\/.+/u.test(value.trim()),
resolveAssetReadUrl: vi.fn((value: string) =>
Promise.resolve(`https://signed.example.com/${value.replace(/^\/+/u, '')}`),
),
}));
vi.mock('../../services/match3d-works', () => ({
@@ -167,6 +170,7 @@ describe('Match3DResultView', () => {
expect(match3dWorksService.generateMatch3DWorkTags).toHaveBeenCalledWith({
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
});
expect(screen.getByText('果园')).toBeTruthy();
expect(screen.getByText('轻量休闲')).toBeTruthy();
@@ -500,6 +504,7 @@ describe('Match3DResultView', () => {
expect(screen.getByRole('dialog', { name: /水果核心物件/u })).toBeTruthy();
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByText('暂无音效')).toBeTruthy();
expect(screen.getByLabelText('生成点击音效10光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
});
@@ -633,7 +638,8 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('物品名称 4'), {
target: { value: '苹果' },
});
fireEvent.click(screen.getByRole('button', { name: '生成物品素材' }));
expect(screen.getByRole('button', { name: /生成物品素材 · 2光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
await waitFor(() => {
expect(match3dWorksService.generateMatch3DItemAssets).toHaveBeenCalledWith(
@@ -674,7 +680,7 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('物品名称 1'), {
target: { value: '草莓' },
});
fireEvent.click(screen.getByRole('button', { name: '生成物品素材' }));
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
await waitFor(() => {
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
@@ -716,14 +722,20 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(
screen.getByRole('button', { name: //u }).getAttribute('aria-pressed'),
screen.getByRole('button', { name: '轻松 8次 · 3种' }),
).toBeTruthy();
const difficultySlider = screen.getByRole('slider', { name: '难度' });
expect((difficultySlider as HTMLInputElement).value).toBe('1');
expect(
screen
.getByRole('button', { name: '标准 12次 · 9种' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByText('36 件')).toBeTruthy();
expect(screen.getAllByText('9 种').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.change(difficultySlider, { target: { value: '3' } });
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
@@ -799,7 +811,6 @@ describe('Match3DResultView', () => {
);
expect(screen.getByDisplayValue('物品1')).toBeTruthy();
expect(screen.getAllByText('素材已就绪').length).toBeGreaterThan(0);
expect(
[...document.querySelectorAll('img')].some((image) =>
image
@@ -845,7 +856,29 @@ describe('Match3DResultView', () => {
expect(
imageSources.some((source) => source.includes('views/view-05.png')),
).toBe(true);
expect(screen.getAllByText('5 视角').length).toBeGreaterThan(0);
});
test('物品详情五视角预览使用 1:1 五格布局', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [createReadyGeneratedItemAsset(1)],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
const preview = screen.getByLabelText('物品1五视角预览');
expect(preview.className).toContain('aspect-square');
expect(preview.className).toContain('grid-cols-[repeat(5,minmax(0,1fr))]');
expect(preview.querySelectorAll('img')).toHaveLength(5);
});
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
@@ -882,7 +915,11 @@ describe('Match3DResultView', () => {
);
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('素材已就绪').length).toBeGreaterThan(0);
expect(
[...document.querySelectorAll('img')].some((image) =>
image.getAttribute('src')?.includes('items/strawberry/image.png'),
),
).toBe(true);
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
});
@@ -997,6 +1034,11 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
@@ -1015,6 +1057,11 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/background/new/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
},
@@ -1026,6 +1073,11 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/background/new/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
},
@@ -1057,7 +1109,8 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
target: { value: '新背景提示词' },
});
fireEvent.click(screen.getByRole('button', { name: '重新生成' }));
expect(screen.getByRole('button', { name: /重新生成 · 2光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
await waitFor(() => {
expect(
@@ -1074,6 +1127,8 @@ describe('Match3DResultView', () => {
prompt: '新背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
@@ -1128,7 +1183,11 @@ describe('Match3DResultView', () => {
);
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('素材已就绪').length).toBeGreaterThan(0);
expect(
[...document.querySelectorAll('img')].some((image) =>
image.getAttribute('src')?.includes('views/view-01.png'),
),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -1240,11 +1299,9 @@ describe('Match3DResultView', () => {
],
}),
);
expect(
document.querySelector(
'audio[src="/generated-match3d-assets/audio/click.wav"]',
),
).toBeTruthy();
expect(screen.getByLabelText('草莓点击音效').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/click.wav',
);
});
});
@@ -1312,6 +1369,7 @@ describe('Match3DResultView', () => {
'轻快, 休闲',
);
expect(screen.queryByLabelText('抓大鹅背景音乐提示词')).toBeNull();
expect(screen.getByRole('button', { name: /生成音乐 · 5光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成音乐/u }));
@@ -1351,11 +1409,9 @@ describe('Match3DResultView', () => {
],
}),
);
expect(
document.querySelector(
'audio[src="/generated-match3d-assets/audio/music.wav"]',
),
).toBeTruthy();
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/music.wav',
);
});
});
});

View File

@@ -1,6 +1,5 @@
import {
ArrowLeft,
Box,
CheckCircle2,
Eye,
ImageIcon,
@@ -10,6 +9,7 @@
Play,
Plus,
Send,
Trash2,
Wand2,
X,
} from 'lucide-react';
@@ -29,6 +29,8 @@ import type {
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
createBackgroundMusicTask,
createSoundEffectTask,
@@ -40,8 +42,8 @@ import {
generateMatch3DBackgroundImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
publishMatch3DWork,
generateMatch3DWorkTags,
publishMatch3DWork,
updateMatch3DGeneratedItemAssets,
updateMatch3DWork,
} from '../../services/match3d-works';
@@ -50,8 +52,14 @@ import {
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import { useAuthUi } from '../auth/AuthUiContext';
import {
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_SPINNER_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from '../match3d-runtime/match3dRuntimeUiStyles';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type Match3DResultViewProps = {
@@ -135,7 +143,11 @@ const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND = 'match3d_background_music';
const MATCH3D_CLICK_SOUND_ASSET_KIND = 'match3d_click_sound';
const MATCH3D_AUDIO_POINTS_COST = 10;
const MATCH3D_BACKGROUND_MUSIC_POINTS_COST = 5;
const MATCH3D_CLICK_SOUND_POINTS_COST = 10;
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
{ id: 'work', label: '作品信息' },
@@ -180,13 +192,11 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
type Match3DDifficultyOptionId =
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
type Match3DDifficultyOption = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number];
const MATCH3D_FALLBACK_BACKGROUND_PREVIEW_SRC =
const MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC =
'/match3d-background-references/pot-fused-reference.png';
const MATCH3D_DIFFICULTY_CARD_CLASS =
'min-h-[5.25rem] rounded-[1rem] border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200';
const MATCH3D_MATERIAL_TAB_BUTTON_CLASS =
'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition';
@@ -279,7 +289,7 @@ function resolveMatch3DBackgroundPreviewSource(
'',
)
.find(Boolean) ||
MATCH3D_FALLBACK_BACKGROUND_PREVIEW_SRC
''
);
}
@@ -300,9 +310,24 @@ function resolveMatch3DBackgroundPrompt(
);
}
function resolveMatch3DContainerPreviewSource(
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || ''
);
}
function buildFallbackMatch3DBackgroundPrompt(themeText: string) {
const theme = themeText.trim() || '抓大鹅';
return `${theme}题材抓大鹅游戏竖屏背景图,绿色纵向渐变背景与居中浅锅、圆盘状竞技区域自然融合,中央区域留出清晰可玩空间,无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
return `${theme}题材抓大鹅游戏竖屏背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
}
function normalizeTags(value: string) {
@@ -348,19 +373,6 @@ function buildFallbackMatch3DClickSoundPrompt(
return `${normalizedTheme}题材抓大鹅中“${normalizedName}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。`;
}
function buildFallbackMatch3DBackgroundMusicPrompt(
editState: Match3DResultEditState,
) {
return [
editState.gameName.trim(),
editState.themeText.trim(),
editState.summary.trim(),
'轻快、适合抓大鹅消除游戏循环播放的背景音乐',
]
.filter(Boolean)
.join('');
}
function normalizeMatch3DTag(value: string) {
return value
.trim()
@@ -380,18 +392,6 @@ function normalizeMatch3DTagListText(value: string) {
];
}
function parseMatch3DItemNameInput(value: string) {
return [
...new Set(
value
.split(/[\n,;]/u)
.map((item) => item.trim().replace(/^[-*\d.)\s]+/u, ''))
.filter(Boolean)
.map((item) => item.slice(0, 12)),
),
];
}
function normalizeMatch3DItemName(value: string) {
return value
.trim()
@@ -403,6 +403,16 @@ function normalizeMatch3DItemNameList(values: readonly string[]) {
return [...new Set(values.map(normalizeMatch3DItemName).filter(Boolean))];
}
function calculateMatch3DItemAssetsPointsCost(itemCount: number) {
if (itemCount <= 0) {
return 0;
}
return (
Math.ceil(itemCount / MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE) *
MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH
);
}
function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
}
@@ -462,6 +472,8 @@ function hasPersistableMatch3DGeneratedItemAsset(
asset.subscriptionKey?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
asset.backgroundAsset?.prompt?.trim() ||
asset.backgroundMusic ||
asset.clickSound,
@@ -499,6 +511,9 @@ function getMatch3DGeneratedItemAssetPersistenceSignature(
asset.backgroundAsset?.prompt?.trim() ?? '',
asset.backgroundAsset?.imageSrc?.trim() ?? '',
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
asset.backgroundAsset?.containerPrompt?.trim() ?? '',
asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.status?.trim() ?? '',
asset.backgroundAsset?.error?.trim() ?? '',
asset.clickSound?.audioSrc?.trim() ??
@@ -817,28 +832,6 @@ function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
return 'unknown';
}
function getMatch3DAssetStatusLabel(status: Match3DAssetTaskStatus) {
if (status === 'idle') return '未生成';
if (status === 'submitting') return '提交中';
if (status === 'waiting') return '排队中';
if (status === 'generating') return '生成中';
if (status === 'image_ready') return '素材已就绪';
if (status === 'done') return '已完成';
if (status === 'failed') return '失败';
return '待确认';
}
function getMatch3DAssetStatusPillClass(status: Match3DAssetTaskStatus) {
if (status === 'done') return 'platform-pill--success';
if (status === 'failed') return 'platform-pill--rose';
if (status === 'image_ready') return 'platform-pill--cool';
if (status === 'generating' || status === 'submitting') {
return 'platform-pill--warm';
}
if (status === 'waiting') return 'platform-pill--cool';
return 'platform-pill--neutral';
}
function Match3DAudioProgress({
label,
progress,
@@ -863,6 +856,35 @@ function Match3DAudioProgress({
);
}
function Match3DResolvedAudio({
ariaLabel,
src,
}: {
ariaLabel?: string;
src: string;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
expireSeconds: 300,
});
if (!resolvedUrl) {
return (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
);
}
return (
<audio
className="mt-3 w-full"
controls
src={resolvedUrl}
aria-label={ariaLabel}
/>
);
}
function getMatch3DBatchGenerationStatusLabel(
phase: Match3DBatchItemGenerationState['phase'],
) {
@@ -1653,48 +1675,120 @@ function Match3DConfigTab({
onChange: (nextState: Match3DResultEditState) => void;
}) {
const selectedOption = getMatch3DDifficultyOptionFromEditState(editState);
const selectedOptionIndex = MATCH3D_DIFFICULTY_OPTIONS.findIndex(
(option) => option.id === selectedOption.id,
);
const selectedSliderIndex = Math.max(0, selectedOptionIndex);
const runtimeTypeCount = selectedOption.itemTypeCount;
const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets);
const trackProgress =
selectedSliderIndex / Math.max(1, MATCH3D_DIFFICULTY_OPTIONS.length - 1);
const applyDifficultyOption = (option: Match3DDifficultyOption) => {
onChange({
...editState,
clearCountText: String(option.clearCount),
difficultyText: String(option.difficulty),
});
};
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
const nextIndex = Number.parseInt(event.target.value, 10);
const nextOption = MATCH3D_DIFFICULTY_OPTIONS[nextIndex];
if (nextOption) {
applyDifficultyOption(nextOption);
}
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = selectedOption.id === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() =>
onChange({
...editState,
clearCountText: String(option.clearCount),
difficultyText: String(option.difficulty),
})
}
className={`${MATCH3D_DIFFICULTY_CARD_CLASS} ${
selected
? 'border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_10px_24px_rgba(244,63,94,0.18)]'
: 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
<div className="text-base font-black">{option.label}</div>
<div className="relative px-1 pb-1 pt-2">
<div className="relative mx-[1.35rem] h-10">
<div className="absolute left-0 right-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-white/75 shadow-[inset_0_0_0_1px_rgba(244,114,182,0.16)]" />
<div
className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,#ff8aac_0%,#ff5f7e_54%,#ff9b88_100%)] transition-[width] duration-200"
style={{ width: `${trackProgress * 100}%` }}
/>
{MATCH3D_DIFFICULTY_OPTIONS.map((option, index) => {
const selected = selectedOption.id === option.id;
return (
<div
className={`mt-2 grid grid-cols-2 gap-1 text-[11px] font-bold ${
key={option.id}
aria-hidden="true"
className={`absolute top-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'text-white/88'
: 'text-[var(--platform-text-base)]'
}`}
? 'border-[#ff5f7e] bg-white shadow-[0_8px_18px_rgba(244,63,94,0.2)]'
: 'border-rose-100 bg-white/90 hover:border-rose-200'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
style={{
left: `${(index / (MATCH3D_DIFFICULTY_OPTIONS.length - 1)) * 100}%`,
}}
>
<span>{option.clearCount} </span>
<span>{option.itemTypeCount} </span>
<span
className={`h-3.5 w-3.5 rounded-full ${
selected
? 'bg-[var(--platform-accent)]'
: 'bg-rose-100'
}`}
/>
</div>
</button>
);
})}
);
})}
<input
type="range"
min={0}
max={MATCH3D_DIFFICULTY_OPTIONS.length - 1}
step={1}
value={selectedSliderIndex}
disabled={isBusy}
onChange={handleSliderChange}
className="absolute inset-x-0 top-1/2 z-10 h-10 -translate-y-1/2 cursor-pointer opacity-0 disabled:cursor-not-allowed"
aria-label="难度"
aria-valuetext={selectedOption.label}
/>
</div>
<div className="mt-3 grid grid-cols-4 gap-1">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = selectedOption.id === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() => applyDifficultyOption(option)}
className={`rounded-[0.9rem] px-1.5 py-2 text-center transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'bg-[#fff1f5] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(244,63,94,0.18)]'
: 'text-[var(--platform-text-base)] hover:bg-white/58'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
<div className="text-sm font-black">{option.label}</div>
<div className="mt-1 text-[10px] font-bold leading-4 text-[var(--platform-text-soft)]">
{option.clearCount} · {option.itemTypeCount}
</div>
</button>
);
})}
</div>
</div>
<div className="mt-3 rounded-[1rem] border border-rose-100/80 bg-white/62 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{selectedOption.label}
</div>
<div className="mt-1 text-xs font-bold text-[var(--platform-text-base)]">
{selectedOption.clearCount} · {selectedOption.itemTypeCount}{' '}
</div>
</div>
<div className="rounded-full bg-[var(--platform-accent)] px-3 py-1 text-xs font-black text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]">
{selectedOption.difficulty}
</div>
</div>
</div>
<div className="sr-only" aria-live="polite">
{selectedOption.label}
</div>
</section>
@@ -1735,67 +1829,51 @@ function Match3DItemAssetListCard({
onClick: () => void;
onDelete: () => void;
}) {
const pillClass = getMatch3DAssetStatusPillClass(asset.status);
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
const previewSource = previewSources[0] ?? asset.referenceImageSrc.trim();
return (
<div
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
active ? 'border-emerald-300/55 bg-emerald-500/10' : 'platform-subpanel'
className={`group min-w-0 rounded-[1.15rem] border p-2 text-left transition-colors ${
active
? 'border-rose-300/70 bg-rose-50/80'
: 'border-[var(--platform-subpanel-border)] bg-white/76 hover:border-rose-200 hover:bg-white'
}`}
>
<div className="flex items-start gap-3">
<div className="grid min-h-full grid-rows-[minmax(0,1fr)_auto] gap-2">
<button
type="button"
onClick={onClick}
className="flex min-w-0 flex-1 items-start gap-3 text-left"
className="grid min-h-0 gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200"
aria-label={`打开${asset.name}物品素材`}
>
<div className="platform-subpanel grid h-[4.75rem] w-[4.75rem] shrink-0 place-items-center overflow-hidden rounded-[1rem]">
{asset.referenceImageSrc ? (
<div className="grid aspect-square min-h-0 place-items-center overflow-hidden rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/82">
{previewSource ? (
<ResolvedAssetImage
src={asset.referenceImageSrc}
src={previewSource}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
className="h-full w-full object-contain p-1"
/>
) : (
<Box className="h-7 w-7 text-[var(--platform-text-soft)]" />
<ImageIcon className="h-7 w-7 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-[var(--platform-text-strong)]">
{asset.name}
</div>
<span
className={`platform-pill ${pillClass} shrink-0 px-2.5 py-1 text-[10px]`}
>
{getMatch3DAssetStatusLabel(asset.status)}
</span>
</div>
<div className="mt-1.5 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
{asset.usage}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{previewSources.length}
</span>
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
2D素材
</span>
</div>
</div>
</button>
<button
type="button"
onClick={onDelete}
className="platform-icon-button h-8 w-8 shrink-0"
aria-label="删除物品素材"
title="删除"
>
<X className="h-4 w-4" />
<span className="truncate text-[13px] font-bold leading-5 text-[var(--platform-text-strong)]">
{asset.name}
</span>
</button>
<div className="flex min-w-0 justify-end">
<button
type="button"
onClick={onDelete}
className="platform-icon-button h-8 w-8 shrink-0 text-rose-500"
aria-label="删除物品素材"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
@@ -1821,11 +1899,14 @@ function Match3DItemAssetDetail({
return (
<section className="platform-subpanel min-h-0 rounded-[1.5rem] p-4 sm:p-5">
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(18rem,0.95fr)_minmax(14rem,0.62fr)]">
<div className="grid aspect-square min-h-[18rem] grid-cols-2 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
<div
className="grid aspect-square min-h-[18rem] grid-cols-[repeat(5,minmax(0,1fr))] grid-rows-1 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3"
aria-label={`${asset.name}五视角预览`}
>
{previewSources.map((source, index) => (
<div
key={`${source}-${index}`}
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
className="grid aspect-square h-auto min-h-0 w-full self-center place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
>
<ResolvedAssetImage
src={source}
@@ -1835,6 +1916,14 @@ function Match3DItemAssetDetail({
/>
</div>
))}
{previewSources.length <= 0 ? (
<div
className="col-span-5 grid min-h-0 place-items-center text-[var(--platform-text-soft)]"
aria-hidden="true"
>
<ImageIcon className="h-10 w-10" />
</div>
) : null}
</div>
<div className="min-h-0 space-y-3">
@@ -1862,8 +1951,8 @@ function Match3DItemAssetDetail({
disabled={busy || soundBusy}
onClick={() => onGenerateClickSound(asset)}
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label={`生成点击音效,${MATCH3D_AUDIO_POINTS_COST}光点`}
title={`生成点击音效 · ${MATCH3D_AUDIO_POINTS_COST}光点`}
aria-label={`生成点击音效,${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
>
{soundBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1892,10 +1981,9 @@ function Match3DItemAssetDetail({
/>
) : null}
{asset.clickSound?.audioSrc ? (
<audio
className="mt-3 w-full"
controls
<Match3DResolvedAudio
src={asset.clickSound.audioSrc}
ariaLabel={`${asset.name}点击音效`}
/>
) : (
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-soft)]">
@@ -1948,7 +2036,10 @@ function Match3DAssetsTab({
</button>
</div>
<Match3DBatchGenerationProgress generationState={batchGenerationState} />
<section className="space-y-3" aria-label="抓大鹅 2D 素材列表">
<section
className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4"
aria-label="抓大鹅 2D 素材列表"
>
{assets.map((asset) => (
<Match3DItemAssetListCard
key={asset.id}
@@ -2002,6 +2093,7 @@ function Match3DBatchAddItemsPanel({
}) {
const parsedNames = normalizeMatch3DItemNameList(values);
const isGenerating = generationState.phase === 'generating';
const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length);
return (
<Match3DModalShell title="批量新增物品" onClose={onClose}>
@@ -2061,7 +2153,7 @@ function Match3DBatchAddItemsPanel({
) : (
<Plus className="h-4 w-4" />
)}
· {pointsCost}
</button>
</div>
</Match3DModalShell>
@@ -2191,7 +2283,10 @@ function Match3DMusicTab({
/>
) : null}
{currentMusic?.audioSrc ? (
<audio className="mt-3 w-full" controls src={currentMusic.audioSrc} />
<Match3DResolvedAudio
src={currentMusic.audioSrc}
ariaLabel="抓大鹅背景音乐"
/>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
@@ -2236,7 +2331,7 @@ function Match3DMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_AUDIO_POINTS_COST}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}
</button>
</section>
@@ -2279,6 +2374,7 @@ function Match3DAssetConfigTabs({
function Match3DUIAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
busy,
isGenerating,
@@ -2286,6 +2382,7 @@ function Match3DUIAssetsTab({
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
busy: boolean;
isGenerating: boolean;
@@ -2353,7 +2450,7 @@ function Match3DUIAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
</div>
@@ -2369,6 +2466,7 @@ function Match3DUIAssetsTab({
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
@@ -2378,9 +2476,11 @@ function Match3DUIAssetsTab({
function Match3DUIRuntimePreviewPanel({
backgroundPreviewSrc,
containerPreviewSrc,
onClose,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
onClose: () => void;
}) {
return (
@@ -2395,14 +2495,14 @@ function Match3DUIRuntimePreviewPanel({
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<header className="relative z-10 flex items-center justify-between gap-2">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 backdrop-blur">
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<ArrowLeft size={20} />
</span>
<span className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
1:30
</span>
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 backdrop-blur">
<span className="h-4 w-4 rounded-full border-2 border-white/84 border-l-transparent" />
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
</span>
</header>
@@ -2412,16 +2512,25 @@ function Match3DUIRuntimePreviewPanel({
style={{ width: 'min(92%, 58dvh, 100%)' }}
aria-hidden="true"
>
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{containerPreviewSrc ? (
<ResolvedAssetImage
src={containerPreviewSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] h-[108%] w-[108%] object-contain"
/>
) : (
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
)}
</div>
</section>
<section className="relative z-10 mt-3 rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<section className={`relative z-10 ${MATCH3D_RUNTIME_GLASS_TRAY_CLASS}`}>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: 7 }).map((_, index) => (
<span
key={index}
className="h-14 rounded-xl bg-white/10 sm:h-16"
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
/>
))}
</div>
@@ -2437,6 +2546,7 @@ function Match3DAssetConfigTab({
activeAssetId,
assetDrafts,
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
backgroundGenerationError,
batchGenerationState,
@@ -2459,6 +2569,7 @@ function Match3DAssetConfigTab({
activeAssetId: string | null;
assetDrafts: Match3DItemAssetDraft[];
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
backgroundGenerationError: string | null;
batchGenerationState: Match3DBatchItemGenerationState;
@@ -2507,6 +2618,7 @@ function Match3DAssetConfigTab({
{activeAssetConfigTab === 'ui' ? (
<Match3DUIAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
busy={busy}
isGenerating={isGeneratingBackground}
@@ -2578,7 +2690,7 @@ export function Match3DResultView({
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const generatedItemAssets = useMemo(
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
[draft?.generatedItemAssets, profile],
[draft, profile],
);
const blockers = useMemo(
() => buildPublishBlockers(editState, generatedItemAssets),
@@ -2606,6 +2718,12 @@ export function Match3DResultView({
() => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
[draft, generatedItemAssets, profile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(generatedItemAssets) ||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[generatedItemAssets],
);
const coverSourceAssets = useMemo(
() => resolveMatch3DCoverSourceAssets(assetDrafts, backgroundPreviewSrc),
[assetDrafts, backgroundPreviewSrc],
@@ -2621,6 +2739,8 @@ export function Match3DResultView({
setCoverPanelError(null);
setBackgroundGenerationError(null);
setIsGeneratingBackground(false);
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
@@ -2628,6 +2748,8 @@ export function Match3DResultView({
setActiveAssetId(null);
setSoundBusyAssetId(null);
setSoundGenerationProgress(null);
// 中文注释:素材草稿只跟随持久化素材字段和作品切换重建,避免无关 profile 字段刷新关闭当前面板。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
draft?.generatedItemAssets,
profile.generatedItemAssets,
@@ -2690,7 +2812,7 @@ export function Match3DResultView({
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, generatedItemAssets, onSaved, profile]);
}, [editState, generatedItemAssets, isGeneratingBackground, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
@@ -2823,7 +2945,11 @@ export function Match3DResultView({
setIsGeneratingTags(true);
try {
const response = await generateMatch3DWorkTags({ gameName, themeText });
const response = await generateMatch3DWorkTags({
gameName,
themeText,
summary: editState.summary.trim(),
});
const nextTags = normalizeTags(response.tags.join(''));
if (nextTags.length <= 0) {
throw new Error('未生成有效标签。');
@@ -3223,6 +3349,7 @@ export function Match3DResultView({
activeAssetId={activeAssetId}
assetDrafts={assetDrafts}
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
backgroundGenerationError={backgroundGenerationError}
batchGenerationState={batchGenerationState}

View File

@@ -358,6 +358,123 @@ test('运行态会先换签 generated 图片素材再渲染局内物品', async
);
});
test('运行态按 generated itemId 编号映射到后端 match3d-type 类型', async () => {
const baseRun = startLocalMatch3DRun(3);
const baseTypeIds = [...new Set(baseRun.items.map((item) => item.itemTypeId))];
const run: Match3DRunSnapshot = {
...baseRun,
items: baseRun.items.map((item) =>
item.itemTypeId === baseTypeIds[0]
? {...item, itemTypeId: 'match3d-type-01'}
: item.itemTypeId === baseTypeIds[1]
? {...item, itemTypeId: 'match3d-type-02'}
: item,
),
};
const typeOneItem = run.items.find(
(item) => item.itemTypeId === 'match3d-type-01',
);
expect(typeOneItem).toBeTruthy();
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '樱桃',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/cherry-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/apple-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
];
renderRuntime(run, generatedItemAssets);
const token = screen.getByTestId(
`match3d-item-${typeOneItem!.itemInstanceId}`,
);
await waitFor(() => {
expect(token.querySelector('img')?.getAttribute('src')).toContain(
'/match3d/cherry-view-01.png',
);
});
});
test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundAsset: {
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey: null,
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/task/container.png',
status: 'image_ready',
error: null,
},
},
];
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/match3d-container.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
);
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
});
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
const smallRun = startLocalMatch3DRun(12);
const hardRun = startLocalMatch3DRun(20);

View File

@@ -47,6 +47,12 @@ import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
import {
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from './match3dRuntimeUiStyles';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
@@ -158,6 +164,11 @@ function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
.sort(compareMatch3DGeneratedTypeId);
}
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
const parsed = Number.parseInt(value?.match(/(\d+)$/u)?.[1] ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
}
function buildMatch3DImageSourcesByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
@@ -166,14 +177,28 @@ function buildMatch3DImageSourcesByType(
return new Map<string, string[]>();
}
const typeIds = resolveMatch3DGeneratedTypeIds(run);
const readyAssets = generatedItemAssets
.map((asset) => getMatch3DGeneratedImageViewSources(asset))
.filter((sources) => sources.length > 0);
const readyAssets = generatedItemAssets.flatMap((asset, fallbackIndex) => {
const sources = getMatch3DGeneratedImageViewSources(asset);
return sources.length > 0
? [
{
fallbackIndex,
itemIndex: resolveMatch3DGeneratedItemIndex(asset.itemId),
sources,
},
]
: [];
});
return new Map(
typeIds.flatMap((typeId, index) => {
const sources = readyAssets[index];
return sources ? [[typeId, sources] as const] : [];
const directIndex = resolveMatch3DGeneratedItemIndex(typeId);
const asset =
readyAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ??
readyAssets.find((entry) => entry.fallbackIndex === index);
return asset ? [[typeId, asset.sources] as const] : [];
}),
);
}
@@ -543,6 +568,15 @@ export function Match3DRuntimeShell({
)
.find(Boolean) ||
'';
const containerAssetSrc =
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || '';
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, generatedItemAssets),
[generatedItemAssets, run],
@@ -566,6 +600,7 @@ export function Match3DRuntimeShell({
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -584,7 +619,7 @@ export function Match3DRuntimeShell({
);
}, [generatedItemAssets, run]);
useEffect(() => {
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (audio) {
@@ -596,6 +631,10 @@ export function Match3DRuntimeShell({
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, run]);
useEffect(() => {
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
useEffect(() => {
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
@@ -668,6 +707,35 @@ export function Match3DRuntimeShell({
};
}, [backgroundAssetSrc]);
useEffect(() => {
if (!containerAssetSrc) {
setResolvedContainerImageSrc('');
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [containerAssetSrc]);
useEffect(() => {
const rawSources = [
...new Set(
@@ -730,6 +798,7 @@ export function Match3DRuntimeShell({
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
tryPlayBackgroundMusic();
playClickSound(item);
setPendingClick({
clientEventId,
@@ -823,19 +892,19 @@ export function Match3DRuntimeShell({
<header className="flex items-center justify-between gap-2">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onBack}
aria-label="返回"
>
<ArrowLeft size={20} />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<div className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
<Clock3 size={16} />
<span>{formatTimer(timeLeftMs)}</span>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onRestart}
aria-label="重新开始"
>
@@ -853,7 +922,17 @@ export function Match3DRuntimeShell({
onPointerDown={handleBoardPointerDown}
data-testid="match3d-board"
>
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{resolvedContainerImageSrc ? (
<img
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] z-0 h-[108%] w-[108%] object-contain"
data-testid="match3d-container-image"
/>
) : (
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
)}
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
@@ -876,7 +955,7 @@ export function Match3DRuntimeShell({
</div>
</section>
<section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<section className={MATCH3D_RUNTIME_GLASS_TRAY_CLASS}>
<div
className="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
@@ -892,7 +971,7 @@ export function Match3DRuntimeShell({
return (
<div
key={slot.slotIndex}
className="relative z-0 h-14 min-w-0 rounded-xl bg-white/10 p-1 sm:h-16"
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
data-testid="match3d-tray-slot"
>
<Match3DTrayToken

View File

@@ -0,0 +1,15 @@
// 中文注释:运行态 HUD 使用题材无关的半透明玻璃样式,避免和 AI 生成背景、容器素材绑定。
export const MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS =
'flex h-10 w-10 items-center justify-center rounded-full border border-white/65 bg-white/72 text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md transition hover:bg-white/86 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/82';
export const MATCH3D_RUNTIME_GLASS_TIMER_CLASS =
'flex min-w-[4.25rem] items-center justify-center gap-1.5 rounded-full border border-white/65 bg-white/72 px-3 py-2 text-sm font-black text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md';
export const MATCH3D_RUNTIME_GLASS_SPINNER_CLASS =
'h-4 w-4 rounded-full border-2 border-slate-700/76 border-l-transparent';
export const MATCH3D_RUNTIME_GLASS_TRAY_CLASS =
'mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/56 bg-white/34 p-2 shadow-[0_14px_32px_rgba(15,23,42,0.16)] backdrop-blur-md';
export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';

View File

@@ -162,7 +162,10 @@ import {
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import { preloadMatch3DGeneratedModelAssets } from '../../services/match3dGeneratedModelCache';
import {
hasMatch3DGeneratedImageAsset,
preloadMatch3DGeneratedRuntimeAssets,
} from '../../services/match3dGeneratedModelCache';
import {
buildBabyObjectMatchGenerationAnchorEntries,
buildBigFishGenerationAnchorEntries,
@@ -359,6 +362,18 @@ type PendingDraftShelfMap = Partial<
Record<string, PendingDraftShelfState>
>
>;
type Match3DBackgroundCompileTask = {
session: Match3DAgentSessionSnapshot;
payload: CreateMatch3DSessionRequest;
generationState: MiniGameDraftGenerationState;
error: string | null;
};
type PuzzleBackgroundCompileTask = {
session: PuzzleAgentSessionSnapshot;
payload: CreatePuzzleAgentSessionRequest;
generationState: MiniGameDraftGenerationState;
error: string | null;
};
type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
@@ -667,14 +682,10 @@ function buildMatch3DProfileFromSession(
};
}
function hasMatch3DGeneratedModelAsset(
function hasMatch3DRuntimeAsset(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
return Boolean(
assets?.some(
(asset) => asset.modelSrc?.trim() || asset.modelObjectKey?.trim(),
),
);
return hasMatch3DGeneratedImageAsset(assets);
}
function resolveMatch3DRuntimeGeneratedItemAssets(
@@ -690,7 +701,7 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
: [];
if (runProfileId && profile?.profileId === runProfileId) {
if (hasMatch3DGeneratedModelAsset(profileAssets)) {
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
}
@@ -699,10 +710,12 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return hasMatch3DGeneratedModelAsset(publicDetailAssets)
return hasMatch3DRuntimeAsset(publicDetailAssets)
? publicDetailAssets
: profileAssets;
}
return profileAssets;
}
if (
@@ -714,9 +727,10 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
return publicDetailAssets;
}
return hasMatch3DGeneratedModelAsset(profileAssets)
? profileAssets
: publicDetailAssets;
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
}
return publicDetailAssets.length > 0 ? publicDetailAssets : profileAssets;
}
function resolveActiveMatch3DRuntimeProfile(
@@ -777,23 +791,23 @@ function resolveMatch3DGenerationStateFromAssets(
const assetList = assets ?? [];
const imageReadyCount = assetList.filter(
(asset) => asset.imageObjectKey?.trim() || asset.imageSrc?.trim(),
).length;
const modelReadyCount = assetList.filter(
(asset) =>
asset.status === 'model_ready' &&
(asset.modelObjectKey?.trim() || asset.modelSrc?.trim()),
asset.imageViews?.some(
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
) ||
asset.imageObjectKey?.trim() ||
asset.imageSrc?.trim(),
).length;
const totalAssetCount = Math.max(3, assetList.length);
const totalAssetCount = Math.max(5, assetList.length);
const failedAsset = assetList.find((asset) => asset.error?.trim());
return {
...current,
phase:
imageReadyCount > 0 || modelReadyCount > 0
imageReadyCount > 0
? 'match3d-generate-views'
: current.phase,
completedAssetCount: modelReadyCount,
completedAssetCount: imageReadyCount,
totalAssetCount,
error: failedAsset?.error?.trim() || current.error,
};
@@ -2071,6 +2085,8 @@ export function PlatformEntryFlowShellImpl({
useState<CreateMatch3DSessionRequest | null>(null);
const [match3dGenerationState, setMatch3DGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [match3dBackgroundCompileTasks, setMatch3DBackgroundCompileTasks] =
useState<Record<string, Match3DBackgroundCompileTask>>({});
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
const [squareHoleWorks, setSquareHoleWorks] = useState<
SquareHoleWorkSummary[]
@@ -2155,6 +2171,8 @@ export function PlatformEntryFlowShellImpl({
);
const [puzzleGenerationState, setPuzzleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleBackgroundCompileTasks, setPuzzleBackgroundCompileTasks] =
useState<Record<string, PuzzleBackgroundCompileTask>>({});
const [miniGameGenerationProgressNowMs, setMiniGameGenerationProgressNowMs] =
useState(() => Date.now());
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
@@ -2287,6 +2305,8 @@ export function PlatformEntryFlowShellImpl({
);
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
const selectionStageRef = useRef(selectionStage);
const activeMatch3DGenerationSessionIdRef = useRef<string | null>(null);
const activePuzzleGenerationSessionIdRef = useRef<string | null>(null);
const [draftGenerationNotices, setDraftGenerationNotices] =
useState<DraftGenerationNoticeMap>({});
const [pendingDraftShelfItems, setPendingDraftShelfItems] =
@@ -2437,6 +2457,24 @@ export function PlatformEntryFlowShellImpl({
},
[updatePendingDraftShelfItem],
);
const getMatch3DBackgroundCompileTask = useCallback(
(sessionId: string | null | undefined) => {
const normalizedSessionId = normalizeDraftNoticeId(sessionId);
return normalizedSessionId
? (match3dBackgroundCompileTasks[normalizedSessionId] ?? null)
: null;
},
[match3dBackgroundCompileTasks],
);
const getPuzzleBackgroundCompileTask = useCallback(
(sessionId: string | null | undefined) => {
const normalizedSessionId = normalizeDraftNoticeId(sessionId);
return normalizedSessionId
? (puzzleBackgroundCompileTasks[normalizedSessionId] ?? null)
: null;
},
[puzzleBackgroundCompileTasks],
);
useEffect(() => {
let cancelled = false;
@@ -2501,6 +2539,18 @@ export function PlatformEntryFlowShellImpl({
selectionStageRef.current = 'platform';
setSelectionStage('platform');
}, [enterCreateTab, setSelectionStage]);
const isViewingMatch3DGeneration = useCallback((sessionId: string) => {
return (
selectionStageRef.current === 'match3d-generating' &&
activeMatch3DGenerationSessionIdRef.current === sessionId
);
}, []);
const isViewingPuzzleGeneration = useCallback((sessionId: string) => {
return (
selectionStageRef.current === 'puzzle-generating' &&
activePuzzleGenerationSessionIdRef.current === sessionId
);
}, []);
const resolveBigFishErrorMessage = useCallback(
(error: unknown, fallback: string) =>
@@ -3515,9 +3565,9 @@ export function PlatformEntryFlowShellImpl({
...current,
phase: 'ready',
completedAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 3,
response.session.draft?.generatedItemAssets?.length ?? 5,
totalAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 3,
response.session.draft?.generatedItemAssets?.length ?? 5,
}
: current,
);
@@ -3552,7 +3602,7 @@ export function PlatformEntryFlowShellImpl({
);
if (openResult && runtimeProfile) {
try {
await preloadMatch3DGeneratedModelAssets(
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
);
@@ -4151,6 +4201,39 @@ export function PlatformEntryFlowShellImpl({
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
const activeMatch3DBackgroundCompileTask =
getMatch3DBackgroundCompileTask(match3dSession?.sessionId);
const match3dGenerationViewState =
activeMatch3DBackgroundCompileTask?.generationState ??
match3dGenerationState;
const match3dGenerationViewSession =
activeMatch3DBackgroundCompileTask?.session ?? match3dSession;
const match3dGenerationViewPayload =
activeMatch3DBackgroundCompileTask?.payload ?? match3dFormDraftPayload;
const match3dGenerationViewError =
activeMatch3DBackgroundCompileTask?.error ?? match3dError;
const isMatch3DGenerationViewBusy =
isMatch3DBusy ||
isMiniGameDraftGenerating(
activeMatch3DBackgroundCompileTask?.generationState ?? null,
);
const activePuzzleBackgroundCompileTask = getPuzzleBackgroundCompileTask(
puzzleSession?.sessionId,
);
const puzzleGenerationViewState =
activePuzzleBackgroundCompileTask?.generationState ?? puzzleGenerationState;
const puzzleGenerationViewSession =
activePuzzleBackgroundCompileTask?.session ?? puzzleSession;
const puzzleGenerationViewPayload =
activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload;
const puzzleGenerationViewError =
activePuzzleBackgroundCompileTask?.error ?? puzzleError;
const isPuzzleGenerationViewBusy =
isPuzzleBusy ||
isMiniGameDraftGenerating(
activePuzzleBackgroundCompileTask?.generationState ?? null,
);
const match3DGeneratingSessionId =
selectionStage === 'match3d-generating' ? match3dSession?.sessionId : null;
@@ -4302,60 +4385,366 @@ export function PlatformEntryFlowShellImpl({
const createPuzzleDraftFromForm = useCallback(
async (payload: CreatePuzzleAgentSessionRequest) => {
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(null);
const nextSession =
puzzleFlow.session && !isEmptyPuzzleFormOnlyDraft(puzzleFlow.session)
? puzzleFlow.session
: await puzzleFlow.openWorkspace(payload);
if (!nextSession) {
setPuzzleCreationError(null);
setPuzzleError(null);
let nextSession: PuzzleAgentSessionSnapshot;
try {
const response = await createPuzzleAgentSession(payload);
nextSession = response.session;
} catch (error) {
const errorMessage = resolvePuzzleErrorMessage(
error,
'开启拼图创作工作台失败。',
);
setPuzzleCreationError(errorMessage);
setPuzzleError(errorMessage);
return;
}
markPendingDraftGenerating('puzzle', nextSession.sessionId);
await puzzleFlow.executeAction(
buildPuzzleCompileActionFromFormPayload(payload),
nextSession,
);
const generationState = createMiniGameDraftGenerationState('puzzle');
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState,
error: null,
},
}));
puzzleFlow.setSession(nextSession);
setPuzzleGenerationState(generationState);
markDraftGenerating('puzzle', [
nextSession.sessionId,
buildPuzzleResultWorkId(nextSession.sessionId),
nextSession.publishedProfileId,
buildPuzzleResultProfileId(nextSession.sessionId),
]);
markPendingDraftGenerating('puzzle', nextSession.sessionId);
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = nextSession.sessionId;
setSelectionStage('puzzle-generating');
try {
const actionPayload = buildPuzzleCompileActionFromFormPayload(payload);
const response = await executePuzzleAgentAction(
nextSession.sessionId,
actionPayload,
);
setPuzzleOperation(response.operation);
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
const readyGenerationState = {
...generationState,
phase: 'ready' as const,
completedAssetCount: 1,
totalAssetCount: 1,
};
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: response.session,
payload,
generationState: readyGenerationState,
error: null,
},
}));
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
puzzleFlow.setSession(response.session);
setPuzzleGenerationState(readyGenerationState);
}
const profileId =
response.session.publishedProfileId ??
buildPuzzleResultProfileId(response.session.sessionId);
markPendingDraftReady('puzzle', response.session.sessionId, openResult);
markDraftReady(
'puzzle',
[
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
profileId,
],
openResult,
);
void refreshPuzzleShelf();
if (openResult && response.session.draft) {
const draft = response.session.draft;
const draftProfileId =
response.session.publishedProfileId ??
buildPuzzleResultProfileId(response.session.sessionId);
if (!draft.coverImageSrc || !draftProfileId) {
setPuzzleError(
!draft.coverImageSrc
? '请先选择一张正式拼图图片。'
: '这份拼图草稿缺少会话信息,请重新开始创作。',
);
setSelectionStage('puzzle-result');
return;
}
try {
const { item } = await updatePuzzleWork(draftProfileId, {
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
levels: draft.levels ?? [],
});
const run = startLocalPuzzleRun(item);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
setSelectionStage('puzzle-runtime');
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'),
);
setSelectionStage('puzzle-result');
}
}
} catch (error) {
const errorMessage = resolvePuzzleErrorMessage(
error,
'执行拼图操作失败。',
);
const failedGenerationState = {
...generationState,
phase: 'failed' as const,
error: errorMessage,
};
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
setPuzzleError(errorMessage);
setPuzzleGenerationState(failedGenerationState);
}
}
},
[markPendingDraftGenerating, puzzleFlow],
[
markDraftGenerating,
markDraftReady,
markPendingDraftGenerating,
markPendingDraftReady,
isViewingPuzzleGeneration,
puzzleFlow,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setPuzzleError,
setSelectionStage,
],
);
const createMatch3DDraftFromForm = useCallback(
async (payload: CreateMatch3DSessionRequest) => {
setMatch3DFormDraftPayload(payload);
setMatch3DGenerationState(null);
setMatch3DSession(null);
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
setMatch3DRun(null);
setMatch3DError(null);
setStreamingMatch3DReplyText('');
setIsStreamingMatch3DReply(false);
const nextSession = await match3dFlow.openWorkspace(payload);
if (!nextSession) {
let nextSession: Match3DAgentSessionSnapshot;
try {
const response = await match3dCreationClient.createSession(payload);
nextSession = response.session;
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '开启抓大鹅共创工作台失败。'),
);
return;
}
markDraftGenerating('match3d', [nextSession.sessionId]);
markPendingDraftGenerating('match3d', nextSession.sessionId);
await match3dFlow.executeAction(
{
action: 'match3d_compile_draft',
generateClickSound: payload.generateClickSound,
const generationState = createMiniGameDraftGenerationState('match3d');
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState,
error: null,
},
nextSession,
);
}));
setMatch3DSession(nextSession);
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
setMatch3DRun(null);
setMatch3DGenerationState(generationState);
markDraftGenerating('match3d', [
nextSession.draft?.profileId,
nextSession.publishedProfileId,
nextSession.sessionId,
]);
markPendingDraftGenerating('match3d', nextSession.sessionId);
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = nextSession.sessionId;
setSelectionStage('match3d-generating');
try {
const response = await match3dCreationClient.executeAction(
nextSession.sessionId,
{
action: 'match3d_compile_draft',
generateClickSound: payload.generateClickSound,
},
);
const openResult = isViewingMatch3DGeneration(nextSession.sessionId);
const readyGenerationState = {
...generationState,
phase: 'ready' as const,
completedAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 5,
totalAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 5,
};
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: response.session,
payload,
generationState: readyGenerationState,
error: null,
},
}));
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DSession(response.session);
setMatch3DGenerationState(readyGenerationState);
}
const profileId = response.session.draft?.profileId;
if (!profileId) {
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
}
return;
}
let runtimeProfile: Match3DWorkProfile | null = null;
try {
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
...item,
generatedItemAssets:
response.session.draft?.generatedItemAssets ??
item.generatedItemAssets,
};
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(runtimeProfile);
}
await refreshMatch3DShelf().catch(() => undefined);
} catch {
runtimeProfile = buildMatch3DProfileFromSession(response.session);
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(runtimeProfile);
}
}
markPendingDraftReady(
'match3d',
response.session.sessionId,
openResult,
);
markDraftReady(
'match3d',
[profileId, response.session.sessionId],
openResult,
);
if (openResult && runtimeProfile) {
try {
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
);
const { run } = await match3dRuntimeAdapter.startRun(
runtimeProfile.profileId,
);
setMatch3DRuntimeProfile(runtimeProfile);
setMatch3DRun(run);
setMatch3DProfile(runtimeProfile);
setMatch3DRuntimeReturnStage('match3d-result');
setSelectionStage('match3d-runtime');
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '启动抓大鹅玩法失败。'),
);
setSelectionStage('match3d-result');
}
}
} catch (error) {
const errorMessage = resolveMatch3DErrorMessage(
error,
'执行抓大鹅操作失败。',
);
const failedGenerationState = {
...generationState,
phase: 'failed' as const,
error: errorMessage,
};
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DError(errorMessage);
setMatch3DGenerationState(failedGenerationState);
}
try {
const { session: latestSession } =
await match3dCreationClient.getSession(nextSession.sessionId);
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: latestSession,
payload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DSession(latestSession);
const profileId =
latestSession.draft?.profileId ?? latestSession.publishedProfileId;
if (profileId) {
const { item } = await getMatch3DWorkDetail(profileId);
setMatch3DProfile(item);
}
}
await refreshMatch3DShelf().catch(() => undefined);
} catch {
await refreshMatch3DShelf().catch(() => undefined);
}
}
},
[
match3dFlow,
match3dRuntimeAdapter,
isViewingMatch3DGeneration,
markDraftGenerating,
markDraftReady,
markPendingDraftGenerating,
markPendingDraftReady,
refreshMatch3DShelf,
resolveMatch3DErrorMessage,
setIsStreamingMatch3DReply,
setMatch3DError,
setMatch3DProfile,
setMatch3DRun,
setMatch3DSession,
setSelectionStage,
setStreamingMatch3DReplyText,
],
);
@@ -4552,6 +4941,8 @@ export function PlatformEntryFlowShellImpl({
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
setMatch3DFormDraftPayload(null);
setMatch3DBackgroundCompileTasks({});
activeMatch3DGenerationSessionIdRef.current = null;
setActiveCreationFormType('puzzle');
setMatch3DWorks([]);
setMatch3DGalleryEntries([]);
@@ -4585,6 +4976,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleGenerationState(null);
setPuzzleBackgroundCompileTasks({});
activePuzzleGenerationSessionIdRef.current = null;
setIsPuzzleNextLevelGenerating(false);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
@@ -5361,7 +5754,7 @@ export function PlatformEntryFlowShellImpl({
const executePuzzleBackgroundAction = useCallback(
async (payload: PuzzleAgentActionRequest) => {
const targetSession = puzzleFlow.session;
const targetSession = puzzleSession;
if (!targetSession) {
return;
}
@@ -5383,7 +5776,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
}
},
[puzzleFlow, resolvePuzzleErrorMessage, setPuzzleError],
[puzzleFlow, puzzleSession, resolvePuzzleErrorMessage, setPuzzleError],
);
const retryPuzzleDraftGeneration = useCallback(() => {
@@ -5427,7 +5820,7 @@ export function PlatformEntryFlowShellImpl({
(payload: PuzzleAgentActionRequest) => {
if (
payload.action === 'compile_puzzle_draft' &&
isEmptyPuzzleFormOnlyDraft(puzzleFlow.session)
isEmptyPuzzleFormOnlyDraft(puzzleSession)
) {
const formPayload = buildPuzzleFormPayloadFromAction(payload);
if (formPayload) {
@@ -5447,7 +5840,7 @@ export function PlatformEntryFlowShellImpl({
createPuzzleDraftFromForm,
executePuzzleAction,
executePuzzleBackgroundAction,
puzzleFlow.session,
puzzleSession,
],
);
@@ -5815,7 +6208,7 @@ export function PlatformEntryFlowShellImpl({
try {
let runtimeProfile = profile;
if (!hasMatch3DGeneratedModelAsset(profile.generatedItemAssets)) {
if (!hasMatch3DRuntimeAsset(profile.generatedItemAssets)) {
try {
const { item } = await getMatch3DWorkDetail(profile.profileId);
runtimeProfile = item;
@@ -5823,7 +6216,7 @@ export function PlatformEntryFlowShellImpl({
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
}
}
await preloadMatch3DGeneratedModelAssets(
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
);
@@ -7628,10 +8021,31 @@ export function PlatformEntryFlowShellImpl({
if (
item.sourceSessionId === puzzleSession?.sessionId &&
isMiniGameDraftGenerating(puzzleGenerationState)
isMiniGameDraftGenerating(puzzleGenerationViewState)
) {
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('puzzle-generating');
return;
}
const backgroundTask = getPuzzleBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
) {
puzzleFlow.setSession(backgroundTask.session);
setPuzzleFormDraftPayload(backgroundTask.payload);
setPuzzleGenerationState(backgroundTask.generationState);
if (backgroundTask.error) {
setPuzzleError(backgroundTask.error);
}
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('puzzle-generating');
return;
}
@@ -7655,10 +8069,11 @@ export function PlatformEntryFlowShellImpl({
},
[
enterCreateTab,
getPuzzleBackgroundCompileTask,
markDraftNoticeSeen,
openPuzzleDetail,
puzzleFlow,
puzzleGenerationState,
puzzleGenerationViewState,
puzzleSession?.sessionId,
refreshPuzzleShelf,
setPuzzleError,
@@ -7695,10 +8110,31 @@ export function PlatformEntryFlowShellImpl({
if (
item.sourceSessionId === match3dSession?.sessionId &&
isMiniGameDraftGenerating(match3dGenerationState)
isMiniGameDraftGenerating(match3dGenerationViewState)
) {
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('match3d-generating');
return;
}
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
) {
setMatch3DSession(backgroundTask.session);
setMatch3DFormDraftPayload(backgroundTask.payload);
setMatch3DGenerationState(backgroundTask.generationState);
if (backgroundTask.error) {
setMatch3DError(backgroundTask.error);
}
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('match3d-generating');
return;
}
@@ -7726,9 +8162,10 @@ export function PlatformEntryFlowShellImpl({
},
[
enterCreateTab,
getMatch3DBackgroundCompileTask,
markDraftNoticeSeen,
match3dFlow,
match3dGenerationState,
match3dGenerationViewState,
match3dSession?.sessionId,
openPublicWorkDetail,
refreshMatch3DShelf,
@@ -9630,18 +10067,7 @@ export function PlatformEntryFlowShellImpl({
>
{getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => {
const selected = item.id === activeCreationFormType;
const disabled =
item.locked ||
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply ||
isBabyObjectMatchBusy;
const disabled = item.locked;
return (
<button
@@ -9708,7 +10134,7 @@ export function PlatformEntryFlowShellImpl({
>
<Match3DAgentWorkspace
session={match3dSession}
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
isBusy={isStreamingMatch3DReply}
error={match3dError}
onBack={leaveMatch3DFlow}
onExecuteAction={(payload) => {
@@ -9765,7 +10191,7 @@ export function PlatformEntryFlowShellImpl({
>
<PuzzleAgentWorkspace
session={puzzleSession}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
isBusy={isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}
onSubmitMessage={(payload) => {
@@ -10287,7 +10713,7 @@ export function PlatformEntryFlowShellImpl({
>
<Match3DAgentWorkspace
session={match3dSession}
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
isBusy={isStreamingMatch3DReply}
error={match3dError}
onBack={leaveMatch3DFlow}
onExecuteAction={(payload) => {
@@ -10311,19 +10737,19 @@ export function PlatformEntryFlowShellImpl({
>
<CustomWorldGenerationView
settingText={
match3dSession?.lastAssistantReply ??
match3dGenerationViewSession?.lastAssistantReply ??
'正在生成本局抓大鹅物品素材。'
}
anchorEntries={buildMatch3DGenerationAnchorEntries(
match3dSession,
match3dFormDraftPayload,
match3dGenerationViewSession,
match3dGenerationViewPayload,
)}
progress={buildMiniGameDraftGenerationProgress(
match3dGenerationState,
match3dGenerationViewState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isMatch3DBusy}
error={match3dError}
isGenerating={isMatch3DGenerationViewBusy}
error={match3dGenerationViewError}
onBack={returnToCreationCenterFromGeneration}
onEditSetting={() => {
setSelectionStage('match3d-agent-workspace');
@@ -10364,7 +10790,7 @@ export function PlatformEntryFlowShellImpl({
isBusy={isMatch3DBusy}
error={match3dError}
onBack={() => {
setSelectionStage('match3d-agent-workspace');
returnToCreationCenterFromGeneration();
}}
onSaved={(profile) => {
setMatch3DProfile(profile);
@@ -10388,7 +10814,6 @@ export function PlatformEntryFlowShellImpl({
}}
onStartTestRun={(profile, options) => {
setMatch3DProfile(profile);
setMatch3DRuntimeProfile(profile);
void startMatch3DRunFromProfile(
profile,
'match3d-result',
@@ -10895,7 +11320,7 @@ export function PlatformEntryFlowShellImpl({
>
<PuzzleAgentWorkspace
session={puzzleSession}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
isBusy={isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}
onSubmitMessage={(payload) => {
@@ -10950,18 +11375,19 @@ export function PlatformEntryFlowShellImpl({
>
<CustomWorldGenerationView
settingText={
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
puzzleGenerationViewSession?.lastAssistantReply ??
'正在整理当前拼图草稿。'
}
anchorEntries={buildPuzzleGenerationAnchorEntries(
puzzleSession,
puzzleFormDraftPayload,
puzzleGenerationViewSession,
puzzleGenerationViewPayload,
)}
progress={buildMiniGameDraftGenerationProgress(
puzzleGenerationState,
puzzleGenerationViewState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isPuzzleBusy}
error={puzzleError}
isGenerating={isPuzzleGenerationViewBusy}
error={puzzleGenerationViewError}
onBack={returnToCreationCenterFromGeneration}
onEditSetting={() => {
setSelectionStage('puzzle-agent-workspace');
@@ -11537,18 +11963,7 @@ export function PlatformEntryFlowShellImpl({
{creationEntryConfig ? (
<PlatformEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply ||
isBabyObjectMatchBusy
}
isBusy={sessionController.isCreatingAgentSession}
error={
creationEntryConfigError ??
bigFishError ??
@@ -11564,18 +11979,7 @@ export function PlatformEntryFlowShellImpl({
entryConfig={creationEntryConfig}
creationTypes={creationEntryTypes}
onClose={() => {
if (
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply ||
isBabyObjectMatchBusy
) {
if (sessionController.isCreatingAgentSession) {
return;
}
setShowCreationTypeModal(false);

View File

@@ -20,11 +20,22 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
'data-testid'?: string;
}) => (
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : null
),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
@@ -37,6 +48,16 @@ vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src
? `https://signed.example.com/${src.replace(/^\/+/u, '')}`
: '',
isResolving: false,
shouldResolve: Boolean(src?.trim().startsWith('/generated-')),
}),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -157,6 +178,8 @@ describe('PuzzleResultView', () => {
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '素材配置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
@@ -171,6 +194,33 @@ describe('PuzzleResultView', () => {
);
});
test('result action bar restores draft trial entry', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
workTitle: '暖灯猫街作品',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
],
}),
);
});
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -645,7 +695,7 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background.png',
@@ -657,6 +707,11 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
const preview = screen.getByRole('dialog', { name: 'UI预览' });
expect(
within(preview)
.getByTestId('puzzle-ui-runtime-preview-background')
.getAttribute('src'),
).toBe('/generated-puzzle-assets/session/ui/background.png');
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
});
@@ -671,11 +726,12 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新拼图UI背景提示词' },
});
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
expect(screen.getByRole('button', { name: /生成UI背景 · 2光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_ui_background',
@@ -696,6 +752,46 @@ describe('PuzzleResultView', () => {
]);
});
test('素材配置背景音乐试听使用签名地址', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/music.mp3',
prompt: '',
title: '雨夜轻响',
updatedAt: '2026-05-12T10:00:00.000Z',
},
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByRole('button', { name: /重新生成音乐 · 5光点/u })).toBeTruthy();
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/music.mp3',
);
});
test('auto saves UI background prompt edits through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -711,7 +807,7 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新的自动保存UI背景提示词' },
});

View File

@@ -18,8 +18,8 @@ import {
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
@@ -33,6 +33,7 @@ import {
} from '../../services/creation-audio';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
@@ -61,7 +62,8 @@ type PuzzleResultViewProps = {
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'ui' | 'music';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui' | 'music';
type DraftEditState = {
workTitle: string;
@@ -74,12 +76,27 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_MUSIC_POINT_COST = 5;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
{ id: 'levels', label: '拼图关卡' },
{ id: 'work', label: '作品信息' },
{ id: 'assets', label: '素材配置' },
];
const PUZZLE_ASSET_CONFIG_TABS: Array<{
id: PuzzleAssetConfigTabId;
label: string;
}> = [
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '背景音乐' },
];
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
estimateSeconds: number;
@@ -419,13 +436,8 @@ function PuzzleResultTabs({
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-4 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
{ id: 'ui' as const, label: 'UI' },
{ id: 'music' as const, label: '音乐' },
].map((tab) => (
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{PUZZLE_RESULT_TABS.map((tab) => (
<button
key={tab.id}
type="button"
@@ -444,6 +456,34 @@ function PuzzleResultTabs({
);
}
function PuzzleAssetConfigTabs({
activeTab,
onChange,
}: {
activeTab: PuzzleAssetConfigTabId;
onChange: (tab: PuzzleAssetConfigTabId) => void;
}) {
return (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
{PUZZLE_ASSET_CONFIG_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition ${
activeTab === tab.id
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)] hover:bg-white/60'
}`}
aria-pressed={activeTab === tab.id}
>
{tab.label}
</button>
))}
</div>
);
}
function PuzzleThemeTagEditor({
editState,
isBusy,
@@ -1467,7 +1507,7 @@ function PuzzleUiAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
@@ -1543,6 +1583,7 @@ function PuzzleUiRuntimePreviewPanel({
src={backgroundPreviewSrc}
refreshKey={`${imageRefreshKey}:ui-runtime-preview`}
alt=""
data-testid="puzzle-ui-runtime-preview-background"
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
@@ -1632,6 +1673,10 @@ function PuzzleMusicTab({
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const { resolvedUrl: resolvedMusicSrc } = useResolvedAssetReadUrl(
currentMusic?.audioSrc,
{ expireSeconds: 300 },
);
const canGenerate = title.trim().length > 0;
const writeMusic = (music: CreationAudioAsset) => {
@@ -1708,12 +1753,17 @@ function PuzzleMusicTab({
</span>
) : null}
</div>
{currentMusic?.audioSrc ? (
{currentMusic?.audioSrc && resolvedMusicSrc ? (
<audio
className="mt-3 w-full"
controls
src={currentMusic.audioSrc}
src={resolvedMusicSrc}
aria-label="拼图背景音乐"
/>
) : currentMusic?.audioSrc ? (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
@@ -1758,7 +1808,7 @@ function PuzzleMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
</button>
</section>
@@ -1771,22 +1821,75 @@ function PuzzleMusicTab({
);
}
function PuzzleAssetConfigTab({
activeAssetConfigTab,
editState,
imageRefreshKey,
isBusy,
profileId,
sessionId,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
}: {
activeAssetConfigTab: PuzzleAssetConfigTabId;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
profileId: string | null;
sessionId: string;
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
onChange: (nextState: DraftEditState) => void;
onGenerateUiBackground: (prompt: string) => void;
}) {
return (
<div className="min-h-0">
<PuzzleAssetConfigTabs
activeTab={activeAssetConfigTab}
onChange={onAssetConfigTabChange}
/>
{activeAssetConfigTab === 'ui' ? (
<PuzzleUiAssetsTab
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onChange={onChange}
onGenerate={onGenerateUiBackground}
/>
) : null}
{activeAssetConfigTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId}
sessionId={sessionId}
isBusy={isBusy}
onChange={onChange}
/>
) : null}
</div>
);
}
function PuzzleResultActionBar({
actionError,
editState,
imageRefreshKey,
isBusy,
canStartTestRun,
publishReady,
publishBlockers,
onPublish,
onStartTestRun,
}: {
actionError: string | null;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
canStartTestRun: boolean;
publishReady: boolean;
publishBlockers: string[];
onPublish: () => void;
onStartTestRun?: () => void;
}) {
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
@@ -1798,6 +1901,19 @@ function PuzzleResultActionBar({
return (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
{onStartTestRun ? (
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy || !canStartTestRun}
className={`platform-button platform-button--ghost ${isBusy || !canStartTestRun ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
onClick={() => {
@@ -1844,6 +1960,8 @@ export function PuzzleResultView({
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<PuzzleAssetConfigTabId>('ui');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
@@ -2093,6 +2211,7 @@ export function PuzzleResultView({
generationStatus: level.generationStatus,
levels: [level],
});
const canStartTestRun = Boolean(onStartTestRun && primaryImageSrc);
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
@@ -2174,13 +2293,17 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'ui' ? (
<PuzzleUiAssetsTab
{activeTab === 'assets' ? (
<PuzzleAssetConfigTab
activeAssetConfigTab={activeAssetConfigTab}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
profileId={profileId ?? null}
sessionId={session.sessionId}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerate={(prompt) => {
onGenerateUiBackground={(prompt) => {
const firstLevel = editState.levels[0] ?? null;
if (!firstLevel) {
return;
@@ -2207,15 +2330,6 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId ?? null}
sessionId={session.sessionId}
isBusy={isBusy}
onChange={setEditState}
/>
) : null}
</div>
{error ? (
@@ -2234,8 +2348,14 @@ export function PuzzleResultView({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
canStartTestRun={canStartTestRun}
publishReady={publishState.publishReady}
publishBlockers={publishState.blockers}
onStartTestRun={
onStartTestRun
? () => onStartTestRun(syncedDraft)
: undefined
}
onPublish={() => {
if (!publishState.publishReady) {
return;

View File

@@ -22,7 +22,15 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
const mocapMock = vi.hoisted(() => ({
@@ -623,6 +631,36 @@ test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
expect(screen.queryByText('等待下一关候选')).toBeNull();
});
test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
const runWithUiBackground: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
remainingMs: 300_000,
timeLimitMs: 300_000,
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithUiBackground}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const backgroundImage = container.querySelector(
'img[src="/generated-puzzle-assets/session/ui/background.png"]',
);
expect(backgroundImage).toBeTruthy();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();

View File

@@ -11,7 +11,7 @@ import {
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
@@ -472,6 +472,17 @@ export function PuzzleRuntimeShell({
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
currentLevel?.uiBackgroundImageSrc ?? null,
);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
const primaryMocapHandState = primaryMocapHand?.state;
@@ -498,16 +509,8 @@ export function PuzzleRuntimeShell({
}, [currentLevel]);
useEffect(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
const commitSelectedPieceId = (pieceId: string | null) => {
selectedPieceIdRef.current = pieceId;
@@ -949,6 +952,7 @@ export function PuzzleRuntimeShell({
if (isInteractionLocked) {
return;
}
tryPlayBackgroundMusic();
if (!selectedPieceIdBeforeInput) {
commitSelectedPieceId(pieceId);
@@ -1257,6 +1261,7 @@ export function PuzzleRuntimeShell({
if (isInteractionLocked) {
return;
}
tryPlayBackgroundMusic();
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);

View File

@@ -63,6 +63,7 @@ import {
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import * as match3dGeneratedModelCache from '../../services/match3dGeneratedModelCache';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
@@ -436,7 +437,20 @@ vi.mock('../../services/match3d-works', () => ({
}));
vi.mock('../../services/match3dGeneratedModelCache', () => ({
preloadMatch3DGeneratedModelAssets: vi.fn(() => Promise.resolve()),
hasMatch3DGeneratedImageAsset: vi.fn(
(assets: Match3DWorkSummary['generatedItemAssets']) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
),
preloadMatch3DGeneratedRuntimeAssets: vi.fn(() => Promise.resolve()),
}));
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
@@ -719,9 +733,11 @@ vi.mock('../match3d-result/Match3DResultView', () => ({
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
isBusy,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
@@ -736,8 +752,12 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<div data-testid="match3d-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
onCreateFromForm?.({
seedText: '赛博水果摊题材消除9次难度6',
@@ -773,6 +793,54 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
).length
}
</div>
<div data-testid="match3d-runtime-generated-asset-count">
{
generatedItemAssets.filter(
(asset) =>
asset.modelSrc?.trim() ||
asset.modelObjectKey?.trim() ||
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
) ||
asset.backgroundMusic?.audioSrc?.trim() ||
asset.clickSound?.audioSrc?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-generated-item-image-count">
{
generatedItemAssets.filter(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
).length
}
</div>
<div data-testid="match3d-runtime-background-music-count">
{
generatedItemAssets.filter((asset) =>
asset.backgroundMusic?.audioSrc?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-container-ui-count">
{
generatedItemAssets.filter(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<button type="button" onClick={onBack}>
</button>
@@ -1618,6 +1686,23 @@ function TestWrapper({
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(
match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset,
).mockImplementation((assets) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
);
vi.mocked(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).mockResolvedValue(undefined);
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
match3dServerRuntimeAdapterMock,
);
@@ -2676,6 +2761,228 @@ test('running match3d form generation can return to draft tab and reopen progres
});
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-session',
draft: null,
stage: 'collecting_config',
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
const puzzleReadySession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-parallel-1',
seedText: '暖灯猫街',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: buildPuzzleAnchorPack(),
draft: {
workTitle: '并行拼图',
workDescription: '抓大鹅后台生成时创建的新拼图。',
levelName: '并行拼图',
summary: '抓大鹅后台生成时创建的新拼图。',
themeTags: ['并行创作'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: buildPuzzleAnchorPack(),
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
levels: [
{
levelId: 'puzzle-level-parallel-1',
levelName: '并行拼图',
pictureDescription: '一只猫在雨夜灯牌下回头。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
},
],
},
messages: [],
lastAssistantReply: '拼图草稿已经生成。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-13T10:00:00.000Z',
};
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-parallel-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: puzzleReadySession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
const generatePuzzleButton = await screen.findByRole('button', {
name: '生成草稿',
});
expect((generatePuzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generatePuzzleButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
});
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'compile_puzzle_draft',
}),
);
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCompile({ session: buildMockMatch3DAgentSession() });
});
});
test('running match3d form generation keeps same template generation available', async () => {
const user = userEvent.setup();
const firstSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
draft: null,
stage: 'collecting_config',
});
const secondSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
draft: null,
stage: 'collecting_config',
});
let resolveFirstCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
let resolveSecondCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession)
.mockResolvedValueOnce({ session: firstSession })
.mockResolvedValueOnce({ session: secondSession });
vi.mocked(match3dCreationClient.executeAction)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstCompile = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondCompile = resolve;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' });
expect((match3dTab as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dTab);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成抓大鹅草稿',
});
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByTestId('match3d-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(2);
});
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
1,
'match3d-parallel-session-1',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
2,
'match3d-parallel-session-2',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('抓大鹅草稿').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('生成中').length).toBeGreaterThanOrEqual(2);
});
await act(async () => {
resolveFirstCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
}),
});
resolveSecondCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
}),
});
});
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
@@ -2761,6 +3068,166 @@ test('match3d result trial passes generated models into first runtime mount', as
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
test('match3d result trial passes generated 2D image views into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${String(viewIndex).padStart(2, '0')}`,
viewIndex,
imageSrc:
`/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-${String(viewIndex).padStart(2, '0')}.png`,
imageObjectKey: null,
})),
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
];
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-draft-2d-1',
profileId: 'match3d-profile-draft-2d-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-13T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-draft-2d-1',
draft: {
profileId: 'match3d-profile-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
}),
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDraftWork,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dDraftWork.profileId),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-2d-1',
{},
);
});
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '0');
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
});
test('match3d result back returns to platform creation page', async () => {
const user = userEvent.setup();
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-back-1',
profileId: 'match3d-profile-back-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-12T12:10:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-back-1',
draft: {
profileId: 'match3d-profile-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
test('match3d draft generation auto starts trial and runtime back opens draft result', async () => {
@@ -3915,7 +4382,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
});
});
test('home recommendation Match3D runtime refetches detail when stale card only has image assets', async () => {
test('home recommendation Match3D runtime keeps image, music and UI assets without requiring models', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-image-only',
profileId: 'match3d-profile-card-image-only',
@@ -3949,6 +4416,108 @@ test('home recommendation Match3D runtime refetches detail when stale card only
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc:
'/generated-match3d-assets/session/profile/audio/background.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-12T10:00:00.000Z',
},
backgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-image-only',
);
expect(
screen.getByTestId('match3d-runtime-background-music-count'),
).toHaveProperty('textContent', '1');
expect(screen.getByTestId('match3d-runtime-container-ui-count')).toHaveProperty(
'textContent',
'1',
);
});
test('home recommendation Match3D runtime reloads detail when card only has UI assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-ui-only',
profileId: 'match3d-profile-card-ui-only',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-ui-only',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '消除水果素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
},
],
};
@@ -3956,17 +4525,29 @@ test('home recommendation Match3D runtime refetches detail when stale card only
...match3dCard,
generatedItemAssets: [
{
...match3dCard.generatedItemAssets![0]!,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-01.png',
imageObjectKey: null,
},
],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
@@ -3981,14 +4562,12 @@ test('home recommendation Match3D runtime refetches detail when stale card only
await waitFor(() => {
expect(getMatch3DWorkDetail).toHaveBeenCalledWith(
'match3d-profile-card-image-only',
'match3d-profile-card-ui-only',
);
});
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
});
expect(
await screen.findByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
});
test('home recommendation surfaces start failure instead of staying in loading state', async () => {

View File

@@ -586,4 +586,46 @@ describe('apiClient', () => {
},
});
});
it('uses api error details.reason when details.message is absent', async () => {
setStoredAccessToken('details-reason-token', { emit: false });
fetchMock.mockResolvedValueOnce(
createResponseMock({
status: 503,
body: JSON.stringify({
ok: false,
data: null,
error: {
code: 'SERVICE_UNAVAILABLE',
message: '服务暂不可用',
details: {
provider: 'vector-engine',
reason: 'VECTOR_ENGINE_API_KEY 未配置',
},
},
meta: {},
}),
headers: {
'Content-Type': 'application/json',
},
}),
);
await expect(
requestJson(
'/api/creation/match3d/sessions/test/actions',
{
method: 'POST',
},
'执行抓大鹅共创操作失败',
),
).rejects.toMatchObject({
message: 'VECTOR_ENGINE_API_KEY 未配置',
status: 503,
code: 'SERVICE_UNAVAILABLE',
details: {
provider: 'vector-engine',
},
});
});
});

View File

@@ -0,0 +1,50 @@
import { beforeEach, expect, test, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
vi.mock('../apiClient', () => ({
fetchWithApiAuth: vi.fn(),
requestJson: requestJsonMock,
}));
import { createCreationAgentClient } from './creationAgentClientFactory';
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ session: { sessionId: 'session-1' } });
});
test('creation agent action requests are not auto-retried by default', async () => {
const client = createCreationAgentClient<
Record<string, never>,
{ session: { sessionId: string } },
{ session: { sessionId: string } },
{ sessionId: string },
{ text: string },
{ session: { sessionId: string } },
{ action: string },
{ session: { sessionId: string } }
>({
apiBase: '/api/runtime/puzzle/agent/sessions',
messages: {
createSession: '创建失败',
getSession: '读取失败',
sendMessage: '发送失败',
streamIncomplete: '流式结果不完整',
executeAction: '执行失败',
},
});
await client.executeAction('session-1', { action: 'compile_puzzle_draft' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/puzzle/agent/sessions/session-1/actions',
expect.objectContaining({ method: 'POST' }),
'执行失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 0 }),
}),
);
});

View File

@@ -22,6 +22,7 @@ type CreationAgentClientOptions = {
executeActionTimeoutMs?: number;
readRetry?: ApiRetryOptions;
writeRetry?: ApiRetryOptions;
executeActionRetry?: ApiRetryOptions;
};
const DEFAULT_CREATION_AGENT_READ_RETRY: ApiRetryOptions = {
@@ -37,6 +38,10 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
retryUnsafeMethods: true,
};
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
maxRetries: 0,
};
function buildJsonPostInit(payload: unknown): RequestInit {
return {
method: 'POST',
@@ -88,6 +93,7 @@ export function createCreationAgentClient<
executeActionTimeoutMs,
readRetry = DEFAULT_CREATION_AGENT_READ_RETRY,
writeRetry = DEFAULT_CREATION_AGENT_WRITE_RETRY,
executeActionRetry = DEFAULT_CREATION_AGENT_ACTION_RETRY,
}: CreationAgentClientOptions) {
const createSession = (
payload: TCreateSessionPayload,
@@ -153,7 +159,7 @@ export function createCreationAgentClient<
buildJsonPostInit(payload),
messages.executeAction,
{
retry: writeRetry,
retry: executeActionRetry,
timeoutMs: executeActionTimeoutMs,
},
);

View File

@@ -4,7 +4,10 @@ import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
import {
clearMatch3DGeneratedModelBytesCache,
getMatch3DGeneratedImageViewSources,
getMatch3DGeneratedImageAssetSources,
getMatch3DGeneratedModelAssetSources,
hasMatch3DGeneratedImageAsset,
preloadMatch3DGeneratedImageAssets,
preloadMatch3DGeneratedModelAssets,
readMatch3DGeneratedModelBytes,
} from './match3dGeneratedModelCache';
@@ -145,4 +148,119 @@ describe('match3dGeneratedModelCache', () => {
false,
);
});
test('运行态图片素材判断只认物品图片,不把背景或音频当物品素材', () => {
expect(
hasMatch3DGeneratedImageAsset([
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc:
'/generated-match3d-assets/session/profile/audio/background.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-13T10:00:00.000Z',
},
backgroundAsset: {
prompt: '果园背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
containerPrompt: '果园浅盘',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
},
]),
).toBe(false);
expect(
hasMatch3DGeneratedImageAsset([
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc:
'/generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
imageObjectKey: null,
},
],
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
]),
).toBe(true);
});
test('运行态预加载使用 2D 图片源而不是旧模型源', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/view-01.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
const assets = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
},
],
modelSrc:
'/generated-match3d-assets/session/profile/items/item-1/model/model.glb',
modelObjectKey: null,
status: 'image_ready',
},
];
expect(getMatch3DGeneratedImageAssetSources(assets)).toEqual([
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
]);
await preloadMatch3DGeneratedImageAssets(assets, { expireSeconds: 300 });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'/api/assets/read-url',
);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'views%2Fview-01.png',
);
});
});

View File

@@ -1,5 +1,5 @@
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
import { readAssetBytes } from './assetReadUrlService';
import { readAssetBytes, resolveAssetReadUrl } from './assetReadUrlService';
type CachedMatch3DModelBytes = {
accessedAt: number;
@@ -117,6 +117,14 @@ export function getMatch3DGeneratedImageAssetSources(
];
}
export function hasMatch3DGeneratedImageAsset(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
return Boolean(
assets?.some((asset) => getMatch3DGeneratedImageViewSources(asset).length > 0),
);
}
export function getMatch3DGeneratedModelAssetSources(
assets: readonly Match3DGeneratedItemAsset[] = [],
) {
@@ -198,6 +206,28 @@ export function preloadMatch3DGeneratedModelAssets(
);
}
export async function preloadMatch3DGeneratedImageAssets(
assets: readonly Match3DGeneratedItemAsset[] = [],
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
const sources = getMatch3DGeneratedImageAssetSources(assets);
await Promise.allSettled(
sources.map((source) =>
resolveAssetReadUrl(source, {
expireSeconds: options.expireSeconds,
}),
),
);
}
export async function preloadMatch3DGeneratedRuntimeAssets(
assets: readonly Match3DGeneratedItemAsset[] = [],
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
// 中文注释:新抓大鹅运行态以 2D 图片为主3D 模型只作为历史草稿预览兼容。
await preloadMatch3DGeneratedImageAssets(assets, options);
}
export function clearMatch3DGeneratedModelBytesCache() {
match3dModelBytesCache.clear();
}

View File

@@ -20,23 +20,26 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
const progress = buildMiniGameDraftGenerationProgress(state, 2500);
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成首关画面',
'生成背景音乐',
'生成UI背景',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'理解画面描述,生成首关名称与可编辑草稿。',
'读取画面描述,建立可编辑草稿与首关结构。',
);
expect(progress?.estimatedRemainingMs).toBe(59_500);
expect(progress?.estimatedRemainingMs).toBe(178_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
test('puzzle draft generation advances steps across the 60 second estimate', () => {
test('puzzle draft generation advances steps across the current asset pipeline', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -46,18 +49,23 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 16_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 56_000);
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const musicProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 146_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 176_000);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(45_000);
expect(imageProgress?.steps[0]?.status).toBe('completed');
expect(imageProgress?.steps[1]?.status).toBe('active');
expect(imageProgress?.steps[1]?.completed).toBeGreaterThan(0);
expect(imageProgress?.estimatedRemainingMs).toBe(155_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(musicProgress?.phaseId).toBe('puzzle-background-music');
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[1]?.status).toBe('completed');
expect(writeBackProgress?.steps[2]?.status).toBe('active');
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
@@ -70,12 +78,12 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 80_000);
const progress = buildMiniGameDraftGenerationProgress(state, 200_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[2]?.completed).toBe(1);
expect(progress?.steps[5]?.completed).toBe(1);
});
test('puzzle ready copy points to result page work info completion', () => {
@@ -158,20 +166,24 @@ describe('miniGameDraftGenerationProgress', () => {
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 17_000,
state.startedAtMs + 30_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'match3d-work-title',
'match3d-item-names',
'match3d-background-prompt',
'match3d-material-sheet',
'match3d-slice-images',
'match3d-upload-images',
'match3d-generate-views',
'match3d-background-music',
'match3d-background-image',
'match3d-write-draft',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('生成素材图');
expect(progress?.estimatedRemainingMs).toBe(583_000);
expect(progress?.phaseLabel).toBe('分批生成素材图');
expect(progress?.estimatedRemainingMs).toBe(570_000);
});
test('match3d draft generation starts from title generation', () => {
@@ -183,8 +195,10 @@ describe('miniGameDraftGenerationProgress', () => {
);
expect(progress?.phaseId).toBe('match3d-work-title');
expect(progress?.phaseLabel).toBe('生成游戏名称');
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
expect(progress?.phaseLabel).toBe('建立草稿存档');
expect(progress?.steps[0]?.detail).toBe(
'创建可恢复作品草稿,锁定本次题材和难度。',
);
});
test('match3d draft generation keeps backend observed asset phase', () => {
@@ -201,9 +215,33 @@ describe('miniGameDraftGenerationProgress', () => {
);
expect(progress?.phaseId).toBe('match3d-generate-views');
expect(progress?.steps.at(-1)?.detail).toContain('点击音效');
expect(progress?.steps.at(-1)?.completed).toBe(1);
expect(progress?.steps.at(-1)?.total).toBe(3);
expect(progress?.steps[6]?.detail).toContain('音效提示词');
expect(progress?.steps[6]?.completed).toBe(1);
expect(progress?.steps[6]?.total).toBe(3);
});
test('match3d draft generation reaches music, background image and writeback phases', () => {
const state = createMiniGameDraftGenerationState('match3d');
const musicProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 400_000,
);
const backgroundProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 500_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 550_000,
);
expect(musicProgress?.phaseId).toBe('match3d-background-music');
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
});
test('match3d generation anchors show theme and difficulty item count', () => {
@@ -223,7 +261,7 @@ describe('miniGameDraftGenerationProgress', () => {
{
id: 'match3d-items',
label: '物品数量',
value: '21 件',
value: '25 件',
},
]);
});

View File

@@ -28,6 +28,7 @@ export type MiniGameDraftGenerationKind =
export type MiniGameDraftGenerationPhase =
| 'idle'
| 'compile'
| 'puzzle-level-name'
| 'big-fish-draft'
| 'big-fish-levels'
| 'big-fish-runtime'
@@ -37,15 +38,21 @@ export type MiniGameDraftGenerationPhase =
| 'square-hole-ready'
| 'match3d-work-title'
| 'match3d-item-names'
| 'match3d-background-prompt'
| 'match3d-material-sheet'
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-generate-views'
| 'match3d-background-music'
| 'match3d-background-image'
| 'match3d-write-draft'
| 'match3d-ready'
| 'baby-object-draft'
| 'baby-object-images'
| 'baby-object-ready'
| 'puzzle-images'
| 'puzzle-background-music'
| 'puzzle-ui-background'
| 'puzzle-select-image'
| 'ready'
| 'failed';
@@ -76,35 +83,61 @@ const PUZZLE_STEPS = [
{
id: 'compile',
label: '编译首关草稿',
detail: '理解画面描述,生成首关名称与可编辑草稿。',
weight: 20,
detail: '读取画面描述,建立可编辑草稿与首关结构。',
weight: 10,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据画面描述和图像语义整理首关题目。',
weight: 8,
},
{
id: 'puzzle-images',
label: '生成首关画面',
detail: '调用图片模型生成适合切块的正方形首图。',
weight: 70,
weight: 42,
},
{
id: 'puzzle-background-music',
label: '生成背景音乐',
detail: '用作品题目生成纯音乐并转存音频资产。',
weight: 18,
},
{
id: 'puzzle-ui-background',
label: '生成UI背景',
detail: '生成不含槽位和控件的 9:16 纯背景。',
weight: 14,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '确认首图并同步关卡数据,准备进入结果页。',
weight: 10,
detail: '写入首图、音乐、UI背景和首关数据。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_ESTIMATED_WAIT_MS = 60_000;
const PUZZLE_ESTIMATED_WAIT_MS = 180_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const PUZZLE_PHASE_TIMELINE: Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
'compile' | 'puzzle-images' | 'puzzle-select-image'
| 'compile'
| 'puzzle-level-name'
| 'puzzle-images'
| 'puzzle-background-music'
| 'puzzle-ui-background'
| 'puzzle-select-image'
>;
durationMs: number;
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-images', durationMs: 42_000 },
{ phase: 'puzzle-select-image', durationMs: 6_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 70_000 },
{ phase: 'puzzle-background-music', durationMs: 48_000 },
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];
const BIG_FISH_STEPS = [
@@ -152,39 +185,63 @@ const SQUARE_HOLE_STEPS = [
const MATCH3D_STEPS = [
{
id: 'match3d-work-title',
label: '生成游戏名称',
detail: '根据题材设定生成作品名称与标签。',
label: '建立草稿存档',
detail: '创建可恢复作品草稿,锁定本次题材和难度。',
weight: 8,
},
{
id: 'match3d-item-names',
label: '生成物品名称',
detail: '根据难度生成本局物品名称。',
weight: 8,
label: '生成作品计划',
detail: '生成游戏名称、物品名称、音乐名称与标签。',
weight: 10,
},
{
id: 'match3d-background-prompt',
label: '生成背景提示词',
detail: '整理纯背景图与容器 UI 图提示词。',
weight: 6,
},
{
id: 'match3d-material-sheet',
label: '生成素材图',
label: '分批生成素材图',
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
weight: 18,
weight: 22,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成每个物品的五个视角。',
weight: 8,
weight: 10,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '写入独立 2D 视角素材。',
weight: 8,
detail: '上传每个物品的 2D 视角素材。',
weight: 12,
},
{
id: 'match3d-generate-views',
label: '整理素材',
detail: '校验多视角素材并按需并行生成点击音效。',
weight: 50,
label: '校验素材结构',
detail: '确认物品顺序、五视角图片和音效提示词。',
weight: 6,
},
{
id: 'match3d-background-music',
label: '生成背景音乐',
detail: '用音乐名称生成纯音乐并转存音频资产。',
weight: 14,
},
{
id: 'match3d-background-image',
label: '生成UI背景',
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
weight: 16,
},
{
id: 'match3d-write-draft',
label: '写入草稿页',
detail: '保存素材、音乐、背景、容器和作品草稿。',
weight: 2,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -193,10 +250,14 @@ const MATCH3D_PHASE_ORDER: Partial<
> = {
'match3d-work-title': 0,
'match3d-item-names': 1,
'match3d-material-sheet': 2,
'match3d-slice-images': 3,
'match3d-upload-images': 4,
'match3d-generate-views': 5,
'match3d-background-prompt': 2,
'match3d-material-sheet': 3,
'match3d-slice-images': 4,
'match3d-upload-images': 5,
'match3d-generate-views': 6,
'match3d-background-music': 7,
'match3d-background-image': 8,
'match3d-write-draft': 9,
};
const BABY_OBJECT_MATCH_STEPS = [
@@ -331,17 +392,25 @@ function resolveMatch3DPhaseByElapsedMs(
currentPhase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
const elapsedPhase =
elapsedMs >= 92_000
? 'match3d-generate-views'
: elapsedMs >= 72_000
? 'match3d-upload-images'
: elapsedMs >= 58_000
? 'match3d-slice-images'
: elapsedMs >= 16_000
? 'match3d-material-sheet'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
elapsedMs >= 540_000
? 'match3d-write-draft'
: elapsedMs >= 460_000
? 'match3d-background-image'
: elapsedMs >= 370_000
? 'match3d-background-music'
: elapsedMs >= 340_000
? 'match3d-generate-views'
: elapsedMs >= 260_000
? 'match3d-upload-images'
: elapsedMs >= 210_000
? 'match3d-slice-images'
: elapsedMs >= 28_000
? 'match3d-material-sheet'
: elapsedMs >= 12_000
? 'match3d-background-prompt'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
@@ -645,18 +714,19 @@ function resolveMatch3DGeneratedItemCount(
clearCount: number | null | undefined,
difficulty: number | null | undefined,
) {
if (clearCount === 8) return 3;
if (clearCount === 12) return 9;
if (clearCount === 16) return 15;
if (clearCount === 20 || clearCount === 21) return 21;
const roundToSheet = (count: number) => Math.ceil(count / 5) * 5;
if (clearCount === 8) return roundToSheet(3);
if (clearCount === 12) return roundToSheet(9);
if (clearCount === 16) return roundToSheet(15);
if (clearCount === 20 || clearCount === 21) return roundToSheet(21);
const normalizedDifficulty =
typeof difficulty === 'number' && Number.isFinite(difficulty)
? Math.max(1, Math.min(10, Math.round(difficulty)))
: 4;
if (normalizedDifficulty <= 2) return 3;
if (normalizedDifficulty <= 4) return 9;
if (normalizedDifficulty <= 6) return 15;
return 21;
if (normalizedDifficulty <= 2) return roundToSheet(3);
if (normalizedDifficulty <= 4) return roundToSheet(9);
if (normalizedDifficulty <= 6) return roundToSheet(15);
return roundToSheet(21);
}
export function buildBabyObjectMatchGenerationAnchorEntries(

View File

@@ -535,6 +535,45 @@ describe('puzzleLocalRuntime', () => {
).toBe('explicit-level');
});
test('本地试玩继承关卡 UI 背景和背景音乐资源', () => {
const workWithRuntimeAssets: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
backgroundMusic: {
taskId: 'audio-task-1',
provider: 'vector-engine',
assetObjectId: 'asset-audio-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio.mp3',
prompt: '雨夜猫街音乐',
title: '雨夜猫街',
updatedAt: '2026-05-12T00:00:00.000Z',
},
generationStatus: 'ready',
},
],
};
const run = startLocalPuzzleRun(workWithRuntimeAssets);
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background.png',
);
expect(run.currentLevel?.backgroundMusic?.audioSrc).toBe(
'/generated-puzzle-assets/session/audio.mp3',
);
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(

View File

@@ -802,6 +802,10 @@ function buildFallbackLocalLevel(
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
const nextCoverImageSrc =
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
const nextUiBackgroundImageSrc =
nextLevel?.uiBackgroundImageSrc ?? currentLevel.uiBackgroundImageSrc;
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
const nextRun: PuzzleRunSnapshot = {
...run,
@@ -830,6 +834,8 @@ function buildFallbackLocalLevel(
clearedAtMs: null,
elapsedMs: null,
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
},
@@ -854,6 +860,8 @@ export function startLocalPuzzleRun(
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
const firstUiBackgroundImageSrc = firstLevel?.uiBackgroundImageSrc ?? null;
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
runId,
@@ -873,6 +881,8 @@ export function startLocalPuzzleRun(
authorDisplayName: item.authorDisplayName,
themeTags: item.themeTags,
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',
startedAtMs,