Match3D & Puzzle: runtime UI, assets, drag fix
Backend: stop treating background music as a required draft asset and remove auto-submit/plan for background music; load persisted generated UI/assets into Match3D agent session responses (added helpers to resolve profile id and fetch existing generated assets). Frontend: make Match3D result preview reuse runtime UI styles, unify runtime settings entry, update PuzzleRuntime to apply immediate pointermove transforms (disable drag transition), use SVG clipPath for merged piece rounding, ensure PuzzleRuntimeShell supplies platform theme classes, and adjust related tests. Docs & logs: update decision log, pitfalls and product docs to reflect these changes.
This commit is contained in:
@@ -1349,12 +1349,121 @@ describe('Match3DResultView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
|
||||
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
|
||||
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('抓大鹅')).toBeTruthy();
|
||||
expect(screen.getByText('1:30')).toBeTruthy();
|
||||
const previewBoard = screen.getByTestId('match3d-ui-preview-board');
|
||||
expect(previewBoard.className).toContain('bg-transparent');
|
||||
expect(previewBoard.className).not.toContain('rounded-full');
|
||||
const containerImage = document.querySelector(
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
);
|
||||
expect(containerImage).toBeTruthy();
|
||||
expect(containerImage?.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage?.className).toContain('-translate-x-1/2');
|
||||
expect(
|
||||
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'svg[class*="lucide-settings"], [data-lucide="settings"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 从物品挂载资产展示生成背景和容器', async () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const profile = createProfile({
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...createReadyGeneratedItemAsset(1),
|
||||
itemName: '草莓',
|
||||
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(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: [] }),
|
||||
});
|
||||
vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mockResolvedValue({
|
||||
item: profile,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
|
||||
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty(
|
||||
'value',
|
||||
'果园背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/generated-match3d-assets/session/profile/background/background.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backgroundPrompt: '果园背景',
|
||||
backgroundImageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
generatedBackgroundAsset: expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
itemTypeCountOverride: 1,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Play,
|
||||
Plus,
|
||||
Send,
|
||||
Settings,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
@@ -24,6 +25,7 @@ import { createPortal } from 'react-dom';
|
||||
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
PutMatch3DWorkRequest,
|
||||
@@ -49,11 +51,19 @@ import {
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
|
||||
MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS,
|
||||
MATCH3D_RUNTIME_BOARD_WIDTH,
|
||||
MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS,
|
||||
MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS,
|
||||
MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS,
|
||||
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,
|
||||
MATCH3D_RUNTIME_HEADER_CARD_CLASS,
|
||||
MATCH3D_RUNTIME_LEVEL_BADGE_CLASS,
|
||||
MATCH3D_RUNTIME_STAGE_CLASS,
|
||||
MATCH3D_RUNTIME_TIMER_CLASS,
|
||||
} from '../match3d-runtime/match3dRuntimeUiStyles';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
@@ -299,6 +309,44 @@ function resolveMatch3DBackgroundPreviewSource(
|
||||
);
|
||||
}
|
||||
|
||||
function findMatch3DGeneratedBackgroundAsset(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
): Match3DGeneratedBackgroundAsset | null {
|
||||
return (
|
||||
generatedItemAssets.find((asset) => asset.backgroundAsset)?.backgroundAsset ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function promoteMatch3DGeneratedBackgroundAsset(
|
||||
profile: Match3DWorkProfile,
|
||||
): Match3DWorkProfile {
|
||||
const fallbackBackground =
|
||||
profile.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets ?? []);
|
||||
if (!fallbackBackground) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
backgroundPrompt:
|
||||
profile.backgroundPrompt ?? fallbackBackground.prompt ?? null,
|
||||
backgroundImageSrc:
|
||||
profile.backgroundImageSrc ??
|
||||
fallbackBackground.imageSrc ??
|
||||
fallbackBackground.imageObjectKey ??
|
||||
null,
|
||||
backgroundImageObjectKey:
|
||||
profile.backgroundImageObjectKey ??
|
||||
fallbackBackground.imageObjectKey ??
|
||||
fallbackBackground.imageSrc ??
|
||||
null,
|
||||
generatedBackgroundAsset:
|
||||
profile.generatedBackgroundAsset ?? fallbackBackground,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatch3DBackgroundPrompt(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null,
|
||||
@@ -1144,11 +1192,14 @@ function buildPlayableProfile(
|
||||
) {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
|
||||
return promoteMatch3DGeneratedBackgroundAsset(
|
||||
attachMatch3DGeneratedItemAssets(profile, generatedItemAssets),
|
||||
);
|
||||
}
|
||||
|
||||
return attachMatch3DGeneratedItemAssets(
|
||||
{
|
||||
return promoteMatch3DGeneratedBackgroundAsset(
|
||||
attachMatch3DGeneratedItemAssets(
|
||||
{
|
||||
...profile,
|
||||
gameName: payload.gameName,
|
||||
themeText: payload.themeText ?? profile.themeText,
|
||||
@@ -1157,8 +1208,9 @@ function buildPlayableProfile(
|
||||
coverImageSrc: payload.coverImageSrc,
|
||||
clearCount: payload.clearCount,
|
||||
difficulty: payload.difficulty,
|
||||
},
|
||||
generatedItemAssets,
|
||||
},
|
||||
generatedItemAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1219,15 +1271,15 @@ function attachMatch3DGeneratedItemAssets(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
if (generatedItemAssets.length <= 0) {
|
||||
return profile;
|
||||
return promoteMatch3DGeneratedBackgroundAsset(profile);
|
||||
}
|
||||
|
||||
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
|
||||
return {
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
...profile,
|
||||
generatedItemAssets:
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function attachMatch3DGeneratedBackgroundAsset(
|
||||
@@ -2996,35 +3048,46 @@ function Match3DUIRuntimePreviewPanel({
|
||||
aria-hidden="true"
|
||||
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">
|
||||
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem]">
|
||||
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
|
||||
<ArrowLeft size={20} />
|
||||
</span>
|
||||
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>1:30</span>
|
||||
<span className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
|
||||
<span className="flex max-w-full items-center justify-center gap-1.5">
|
||||
<span className={MATCH3D_RUNTIME_LEVEL_BADGE_CLASS}>
|
||||
第 1 关
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||||
抓大鹅
|
||||
</span>
|
||||
</span>
|
||||
<span className={MATCH3D_RUNTIME_TIMER_CLASS}>1:30</span>
|
||||
</span>
|
||||
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
|
||||
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
|
||||
<Settings size={18} />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="relative z-10 mt-3 flex min-h-0 flex-1 items-center justify-center">
|
||||
<section className={`z-10 ${MATCH3D_RUNTIME_STAGE_CLASS}`}>
|
||||
<div
|
||||
className={`relative aspect-square max-w-full ${
|
||||
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
|
||||
containerPreviewSrc
|
||||
? 'overflow-visible bg-transparent'
|
||||
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
|
||||
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
|
||||
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
|
||||
}`}
|
||||
style={{ width: 'min(96%, 60dvh, 100%)' }}
|
||||
style={{ width: MATCH3D_RUNTIME_BOARD_WIDTH }}
|
||||
aria-hidden="true"
|
||||
data-testid="match3d-ui-preview-board"
|
||||
>
|
||||
{containerPreviewSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={containerPreviewSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-[-10%] h-[120%] w-[120%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]"
|
||||
className={MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS}
|
||||
/>
|
||||
) : (
|
||||
<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 className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -3152,6 +3215,10 @@ export function Match3DResultView({
|
||||
onPublished,
|
||||
onStartTestRun,
|
||||
}: Match3DResultViewProps) {
|
||||
const promotedProfile = useMemo(
|
||||
() => promoteMatch3DGeneratedBackgroundAsset(profile),
|
||||
[profile],
|
||||
);
|
||||
const [editState, setEditState] = useState(() => createEditState(profile));
|
||||
const [activeTab, setActiveTab] = useState<Match3DResultTab>('work');
|
||||
const [activeAssetConfigTab, setActiveAssetConfigTab] =
|
||||
@@ -3207,8 +3274,8 @@ export function Match3DResultView({
|
||||
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
|
||||
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
|
||||
const generatedItemAssets = useMemo(
|
||||
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
|
||||
[draft, profile],
|
||||
() => resolveMatch3DResultGeneratedItemAssets(promotedProfile, draft),
|
||||
[draft, promotedProfile],
|
||||
);
|
||||
const blockers = useMemo(
|
||||
() => buildPublishBlockers(editState, generatedItemAssets),
|
||||
@@ -3221,34 +3288,44 @@ export function Match3DResultView({
|
||||
const canStartTestRun = testRunBlockers.length === 0;
|
||||
const canSubmit = blockers.length === 0;
|
||||
const totalItemCount =
|
||||
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
|
||||
3;
|
||||
(normalizePositiveInteger(editState.clearCountText) ??
|
||||
promotedProfile.clearCount) * 3;
|
||||
const backgroundPreviewSrc = useMemo(
|
||||
() =>
|
||||
resolveMatch3DBackgroundPreviewSource(
|
||||
profile,
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, profile],
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const backgroundPrompt = useMemo(
|
||||
() => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
|
||||
[draft, generatedItemAssets, profile],
|
||||
() =>
|
||||
resolveMatch3DBackgroundPrompt(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const containerPrompt = useMemo(
|
||||
() => resolveMatch3DContainerPrompt(profile, draft, generatedItemAssets),
|
||||
[draft, generatedItemAssets, profile],
|
||||
() =>
|
||||
resolveMatch3DContainerPrompt(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const containerPreviewSrc = useMemo(
|
||||
() =>
|
||||
resolveMatch3DContainerPreviewSource(
|
||||
profile,
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
) ||
|
||||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
|
||||
[draft, generatedItemAssets, profile],
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const coverSourceAssets = useMemo(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user