1
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
15
src/components/match3d-runtime/match3dRuntimeUiStyles.ts
Normal file
15
src/components/match3d-runtime/match3dRuntimeUiStyles.ts
Normal 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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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背景提示词' },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 件',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user