Switch to VectorEngine gpt-image-2 and edits

Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
2026-05-22 03:06:41 +08:00
parent 321e1ea33a
commit ae014ac881
90 changed files with 7078 additions and 3389 deletions

View File

@@ -275,3 +275,64 @@ test('creative image input panel can show an image without exposing AI redraw co
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.getByLabelText('画面描述')).toBeTruthy();
});
test('creative image input panel can upload prompt references while showing a main image', () => {
const onPromptReferenceFilesSelect = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canUploadPromptReferences
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'prompt-ref-1',
label: '描述参考图 1',
imageSrc: 'data:image/png;base64,prompt-ref-1',
},
]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传主图',
replaceImage: '更换主图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除主图',
removeImageConfirmTitle: '移除主图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onSubmit={() => {}}
/>,
);
const promptReferenceInput = screen.getByLabelText('上传参考图', {
selector: 'input',
});
fireEvent.change(promptReferenceInput, {
target: {
files: [new File(['a'], 'prompt-reference.png', { type: 'image/png' })],
},
});
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([
expect.any(File),
]);
expect(
screen.getByRole('button', { name: '预览参考图 描述参考图 1' }),
).toBeTruthy();
});

View File

@@ -38,6 +38,7 @@ export type CreativeImageInputPanelProps = {
mainImageMode?: 'edit' | 'preview';
canRemoveMainImage?: boolean;
canToggleAiRedraw?: boolean;
canUploadPromptReferences?: boolean;
uploadedImageSrc: string;
uploadedImageAlt: string;
uploadedImageRefreshKey?: string | number | null;
@@ -79,6 +80,7 @@ export function CreativeImageInputPanel({
mainImageMode = 'edit',
canRemoveMainImage = true,
canToggleAiRedraw = true,
canUploadPromptReferences,
uploadedImageSrc,
uploadedImageAlt,
uploadedImageRefreshKey = null,
@@ -114,6 +116,8 @@ export function CreativeImageInputPanel({
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
const shouldShowPromptReferences =
canUploadPromptReferences ?? !uploadedImageSrc;
const promptReferenceUploadDisabled =
disabled || promptReferenceImages.length >= promptReferenceLimit;
const canEditMainImage = mainImageMode === 'edit';
@@ -157,7 +161,7 @@ export function CreativeImageInputPanel({
{labels.imageField}
</div>
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full">
{canEditMainImage ? (
<>
<input
@@ -292,7 +296,7 @@ export function CreativeImageInputPanel({
aria-label={promptAriaLabel ?? promptLabel}
/>
{imageModelPicker}
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
{shouldShowPromptReferences && onPromptReferenceFilesSelect ? (
<label
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[var(--platform-accent)] ${
promptReferenceUploadDisabled
@@ -321,7 +325,7 @@ export function CreativeImageInputPanel({
</label>
) : null}
</div>
{!uploadedImageSrc && promptReferenceImages.length > 0 ? (
{shouldShowPromptReferences && promptReferenceImages.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{promptReferenceImages.map((reference) => (
<div

View File

@@ -80,9 +80,9 @@ 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.queryByText('2D素材风格')).toBeNull();
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
expect(screen.queryByRole('button', { name: '自定义' })).toBeNull();
expect(screen.getByText('消耗10泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
expect(screen.queryByText('参考图')).toBeNull();
@@ -107,54 +107,12 @@ test('match3d workspace submits derived entry form payload instead of agent chat
referenceImageSrc: null,
clearCount: 16,
difficulty: 6,
assetStyleId: 'flat-icon',
assetStyleLabel: '扁平图标',
assetStylePrompt:
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
generateClickSound: false,
});
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace supports custom 2d asset 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: '自定义' }));
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('自定义2D素材风格描述'), {
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
});
fireEvent.click(screen.getByRole('button', { name: '应用' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
seedText: '海底甜品店题材消除12次难度4',
themeText: '海底甜品店',
clearCount: 12,
difficulty: 4,
assetStyleId: 'custom',
assetStyleLabel: '自定义风格',
assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光',
}),
);
});
test('match3d workspace submits strict pixel-retro style prompt', () => {
test('match3d workspace omits legacy asset style fields from entry payload', () => {
const onCreateFromForm = vi.fn();
render(
@@ -169,22 +127,13 @@ test('match3d workspace submits strict pixel-retro style prompt', () => {
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '复古水果铺' },
});
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStyleId: 'pixel-retro',
assetStyleLabel: '像素复古',
assetStylePrompt: expect.stringContaining('64x64'),
}),
);
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStylePrompt: expect.stringContaining('禁止抗锯齿'),
}),
);
const payload = onCreateFromForm.mock.calls[0]?.[0] ?? {};
expect('assetStyleId' in payload).toBe(false);
expect('assetStyleLabel' in payload).toBe(false);
expect('assetStylePrompt' in payload).toBe(false);
});
test('match3d workspace keeps click sound generation disabled from entry form', () => {
@@ -231,11 +180,8 @@ test('match3d workspace falls back to compile action when restored from the lega
expect(
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '赛璐璐卡通' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.queryByText('2D素材风格')).toBeNull();
expect(screen.queryByRole('button', { name: '赛璐璐卡通' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCost();

View File

@@ -1,4 +1,4 @@
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
import { Loader2, Sparkles, WandSparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
@@ -24,15 +24,11 @@ type Match3DAgentWorkspaceProps = {
type Match3DFormState = {
themeText: string;
difficultyOptionId: Match3DDifficultyOptionId;
assetStyleId: Match3DAssetStyleOptionId;
customAssetStylePrompt: string;
};
const EMPTY_FORM_STATE: Match3DFormState = {
themeText: '',
difficultyOptionId: 'standard',
assetStyleId: 'flat-icon',
customAssetStylePrompt: '',
};
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
@@ -46,60 +42,6 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
type Match3DDifficultyOptionId =
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
const MATCH3D_ASSET_STYLE_OPTIONS = [
{
id: 'flat-icon',
label: '扁平图标',
imageSrc: '/match3d-style-references/flat-icon.png',
prompt:
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
},
{
id: 'cel-cartoon',
label: '赛璐璐卡通',
imageSrc: '/match3d-style-references/cel-cartoon.png',
prompt:
'明亮赛璐璐卡通2D游戏道具风格清晰线稿硬边阴影饱和配色轮廓醒目。',
},
{
id: 'pixel-retro',
label: '像素复古',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'64x64 复古像素 2D 游戏道具 sprite 风格,限制调色板,硬像素边缘,清晰正面剪影,禁止抗锯齿,禁止柔光渐变,透明背景。',
},
{
id: 'watercolor',
label: '手绘水彩',
imageSrc: '/match3d-style-references/watercolor.png',
prompt:
'手绘水彩2D道具素材风格',
},
{
id: 'sticker-outline',
label: '贴纸描边',
imageSrc: '/match3d-style-references/sticker-outline.png',
prompt:
'贴纸描边2D游戏道具素材风格粗白边与深色外轮廓',
},
{
id: 'painterly-icon',
label: '厚涂图标',
imageSrc: '/match3d-style-references/painterly-icon.png',
prompt:
'厚涂2D游戏道具图标风格笔触细腻体积光影明确中心构图保持图标级清晰剪影。',
},
{
id: 'custom',
label: '自定义',
imageSrc: null,
prompt: '',
},
] as const;
type Match3DAssetStyleOptionId =
(typeof MATCH3D_ASSET_STYLE_OPTIONS)[number]['id'];
function normalizeDifficulty(value: number) {
return Math.max(1, Math.min(10, Math.round(value)));
}
@@ -137,27 +79,6 @@ function getDifficultyOption(optionId: Match3DDifficultyOptionId) {
);
}
function getAssetStyleOption(optionId: Match3DAssetStyleOptionId) {
return (
MATCH3D_ASSET_STYLE_OPTIONS.find((option) => option.id === optionId) ??
MATCH3D_ASSET_STYLE_OPTIONS[0]
);
}
function resolveAssetStyleOptionId(
assetStyleId: string | null | undefined,
assetStylePrompt: string | null | undefined,
): Match3DAssetStyleOptionId {
const matchedOption = MATCH3D_ASSET_STYLE_OPTIONS.find(
(option) => option.id === assetStyleId,
);
if (matchedOption) {
return matchedOption.id;
}
return assetStylePrompt?.trim() ? 'custom' : 'flat-icon';
}
function resolveInitialFormState(
session: Match3DAgentSessionSnapshot | null,
initialFormPayload: CreateMatch3DSessionRequest | null = null,
@@ -173,17 +94,11 @@ function resolveInitialFormState(
initialFormPayload?.clearCount ?? config?.clearCount ?? null;
const difficulty =
initialFormPayload?.difficulty ?? config?.difficulty ?? null;
const assetStyleId =
initialFormPayload?.assetStyleId ?? config?.assetStyleId ?? null;
const assetStylePrompt =
initialFormPayload?.assetStylePrompt ?? config?.assetStylePrompt ?? '';
return {
...EMPTY_FORM_STATE,
themeText,
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
customAssetStylePrompt: assetStylePrompt,
};
}
@@ -205,9 +120,7 @@ export function Match3DAgentWorkspace({
const [formState, setFormState] = useState<Match3DFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
@@ -219,30 +132,14 @@ export function Match3DAgentWorkspace({
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setIsCustomStylePanelOpen(false);
setIsPointCostConfirmOpen(false);
setDraftCustomStylePrompt('');
}, [initialFormPayload, session]);
const themeText = formState.themeText.trim();
const selectedDifficultyOption = getDifficultyOption(
formState.difficultyOptionId,
);
const selectedAssetStyleOption = getAssetStyleOption(formState.assetStyleId);
const assetStylePrompt =
formState.assetStyleId === 'custom'
? formState.customAssetStylePrompt.trim()
: selectedAssetStyleOption.prompt;
const assetStyleLabel =
formState.assetStyleId === 'custom'
? '自定义风格'
: selectedAssetStyleOption.label;
const canSubmit = Boolean(
themeText &&
!isBusy &&
(formState.assetStyleId !== 'custom' ||
formState.customAssetStylePrompt.trim()),
);
const canSubmit = Boolean(themeText && !isBusy);
const formPayload = useMemo<CreateMatch3DSessionRequest>(
() => ({
seedText: themeText
@@ -252,34 +149,11 @@ export function Match3DAgentWorkspace({
referenceImageSrc: null,
clearCount: selectedDifficultyOption.clearCount,
difficulty: selectedDifficultyOption.difficulty,
assetStyleId: formState.assetStyleId,
assetStyleLabel,
assetStylePrompt,
generateClickSound: false,
}),
[
assetStyleLabel,
assetStylePrompt,
formState.assetStyleId,
selectedDifficultyOption,
themeText,
],
[selectedDifficultyOption, themeText],
);
const openCustomStylePanel = () => {
setDraftCustomStylePrompt(formState.customAssetStylePrompt);
setIsCustomStylePanelOpen(true);
};
const applyCustomStylePrompt = () => {
setFormState((current) => ({
...current,
assetStyleId: 'custom',
customAssetStylePrompt: draftCustomStylePrompt.trim(),
}));
setIsCustomStylePanelOpen(false);
};
const submitForm = () => {
if (!canSubmit) {
return;
@@ -362,76 +236,6 @@ export function Match3DAgentWorkspace({
</label>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="min-h-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/52 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
2D素材风格
</div>
<div
className="flex snap-x gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
aria-label="2D素材风格"
>
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
const selected = formState.assetStyleId === option.id;
const isCustom = option.id === 'custom';
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() => {
if (isCustom) {
openCustomStylePanel();
return;
}
setFormState((current) => ({
...current,
assetStyleId: option.id,
}));
}}
className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] sm:h-[5.45rem] sm:w-[6.4rem] ${
selected
? 'border-[var(--platform-surface-hover-border)] bg-white shadow-[0_8px_18px_rgba(112,57,30,0.10)] ring-2 ring-[var(--platform-warm-border)]'
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-[var(--platform-surface-hover-border)] hover:bg-white/95'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
aria-label={option.label}
>
{option.imageSrc ? (
<img
src={option.imageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
) : (
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
)}
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.02)_0%,rgba(255,255,255,0.18)_44%,rgba(255,255,255,0.82)_100%)]" />
{selected ? (
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-[var(--platform-accent)] shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
) : null}
{isCustom ? (
<span className="absolute inset-0 flex items-center justify-center text-[var(--platform-accent)]">
<span className="grid h-8 w-8 place-items-center rounded-full bg-white/82 shadow-[0_6px_18px_rgba(112,57,30,0.12)]">
<Plus className="h-5 w-5" />
</span>
</span>
) : null}
<span
className={`absolute inset-x-2 bottom-1.5 truncate rounded-full px-1.5 py-0.5 text-center text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.10)] ${
selected
? 'bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'bg-white/88 text-[var(--platform-text-strong)]'
}`}
>
{option.label}
</span>
</button>
);
})}
</div>
</div>
<div className="shrink-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/44 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
@@ -498,60 +302,6 @@ export function Match3DAgentWorkspace({
</button>
</div>
{isCustomStylePanelOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="match3d-custom-style-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div className="flex items-center justify-between gap-3">
<div
id="match3d-custom-style-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<button
type="button"
aria-label="关闭自定义风格"
onClick={() => setIsCustomStylePanelOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<textarea
value={draftCustomStylePrompt}
onChange={(event) =>
setDraftCustomStylePrompt(event.target.value)
}
rows={4}
className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
aria-label="自定义2D素材风格描述"
/>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCustomStylePanelOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!draftCustomStylePrompt.trim()}
onClick={applyCustomStylePrompt}
className={`platform-button platform-button--primary justify-center ${!draftCustomStylePrompt.trim() ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
{isPointCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div

View File

@@ -14,6 +14,10 @@ import * as match3dWorksService from '../../services/match3d-works';
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
import { Match3DResultView } from './Match3DResultView';
const match3dSpritesheetParser = vi.hoisted(() => ({
loadMatch3DSpritesheetAssetRegions: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
@@ -45,9 +49,20 @@ vi.mock('../../services/match3d-works', () => ({
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
return {
...actual,
loadMatch3DSpritesheetAssetRegions:
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
};
});
afterEach(() => {
clearMatch3DGeneratedModelBytesCache();
vi.clearAllMocks();
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
vi.unstubAllGlobals();
});
@@ -687,8 +702,10 @@ describe('Match3DResultView', () => {
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByRole('button', { name: '物品' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'UI' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'UI素材' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.getAllByRole('button', { name: /打开.+物品素材/u }))
.toHaveLength(20);
fireEvent.click(
screen.getByRole('button', { name: '打开水果核心物件物品素材' }),
@@ -1075,7 +1092,7 @@ describe('Match3DResultView', () => {
);
});
expect(screen.getByText('63 件')).toBeTruthy();
expect(screen.getAllByText('21 种').length).toBeGreaterThan(0);
expect(screen.getAllByText('20 种').length).toBeGreaterThan(0);
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
clearCount: 21,
@@ -1323,13 +1340,32 @@ describe('Match3DResultView', () => {
).toBeNull();
});
test('素材配置 UI 子 Tab 展示默认提示词并支持预览UI页面', () => {
test('素材配置 UI素材子 Tab 仅预览背景图和UI spritesheet', () => {
render(
<Match3DResultView
profile={createProfile({
backgroundPrompt: '果园主题抓大鹅竖屏背景',
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: {
prompt: '果园主题抓大鹅竖屏背景',
levelScenePrompt: '果园完整关卡画面',
levelSceneImageSrc:
'/generated-match3d-assets/session/profile/level-scene/scene.png',
levelSceneImageObjectKey: null,
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: 'UI spritesheet',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
containerPrompt: null,
containerImageSrc: null,
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
@@ -1337,15 +1373,18 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
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',
'果园主题抓大鹅竖屏背景',
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
);
expect(
screen.queryByLabelText('UI背景图画面描述提示词'),
).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
@@ -1359,7 +1398,7 @@ describe('Match3DResultView', () => {
'img[src="/match3d-background-references/pot-fused-reference.png"]',
);
expect(containerImage).toBeTruthy();
expect(containerImage?.className).toContain('w-[min(108vw,38rem)]');
expect(containerImage?.className).toContain('w-[min(116vw,42rem)]');
expect(containerImage?.className).toContain('-translate-x-1/2');
expect(
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
@@ -1371,7 +1410,7 @@ describe('Match3DResultView', () => {
).toBeTruthy();
});
test('素材配置 UI 子 Tab 从物品挂载资产展示生成背景和容器', async () => {
test('素材配置 UI素材子 Tab 从物品挂载资产展示生成背景和UI spritesheet', async () => {
const onStartTestRun = vi.fn();
const profile = createProfile({
backgroundPrompt: null,
@@ -1388,11 +1427,14 @@ describe('Match3DResultView', () => {
'/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',
uiSpritesheetPrompt: '果园UI素材',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
containerPrompt: null,
containerImageSrc: null,
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
@@ -1417,15 +1459,16 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
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',
'果园背景',
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
);
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(
@@ -1435,14 +1478,14 @@ describe('Match3DResultView', () => {
).toBeTruthy();
expect(
document.querySelector(
'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]',
'img[src="/generated-match3d-assets/session/profile/ui-spritesheet/ui.png"]',
),
).toBeTruthy();
expect(
document.querySelector(
'img[src="/match3d-background-references/pot-fused-reference.png"]',
),
).toBeNull();
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -1455,8 +1498,8 @@ describe('Match3DResultView', () => {
generatedBackgroundAsset: expect.objectContaining({
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
}),
}),
{
@@ -1466,7 +1509,81 @@ describe('Match3DResultView', () => {
});
});
test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => {
test('素材配置 UI素材子 Tab 预览物品spritesheet解析结果', async () => {
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
Array.from({ length: 100 }, (_, index) => ({
label: `${index + 1}`,
x: index * 2,
y: 0,
width: 2,
height: 2,
sheetWidth: 200,
sheetHeight: 2,
imageSrc: `data:image/png;base64,item-${index + 1}`,
})),
);
render(
<Match3DResultView
profile={createProfile({
generatedItemAssets: [
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
],
generatedBackgroundAsset: {
prompt: '果园主题抓大鹅竖屏背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: 'UI spritesheet',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
itemSpritesheetPrompt: '物品 spritesheet',
itemSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
itemSpritesheetImageObjectKey: null,
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
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/item-spritesheet/items.png',
);
await waitFor(() => {
expect(
screen
.getByTestId('match3d-item-spritesheet-preview-0-0')
.getAttribute('src'),
).toBe('data:image/png;base64,item-1');
expect(
screen
.getByTestId('match3d-item-spritesheet-preview-1-4')
.getAttribute('src'),
).toBe('data:image/png;base64,item-10');
});
expect(screen.getByText('草莓')).toBeTruthy();
expect(screen.getByText('苹果')).toBeTruthy();
expect(
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
).toHaveBeenCalledWith(
expect.objectContaining({
maxRegions: 100,
source:
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
}),
);
});
test('素材配置 UI素材子 Tab 不提供背景或容器重新生成入口', () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -1500,267 +1617,24 @@ describe('Match3DResultView', () => {
error: null,
},
});
const nextProfile = createProfile({
...profile,
backgroundPrompt: '新背景提示词',
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
generatedItemAssets: [
{
...profile.generatedItemAssets![0]!,
backgroundAsset: {
prompt: '新背景提示词',
imageSrc:
'/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,
},
},
],
generatedBackgroundAsset: {
prompt: '新背景提示词',
imageSrc:
'/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,
},
});
const onSaved = vi.fn();
vi.mocked(
match3dWorksService.generateMatch3DBackgroundImage,
).mockResolvedValue({
item: nextProfile,
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
backgroundImageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
generatedBackgroundAsset: nextProfile.generatedBackgroundAsset!,
prompt: '新背景提示词',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
target: { value: '新背景提示词' },
});
expect(
screen.getByRole('button', { name: /重新生成 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
expect(screen.getByRole('dialog', { name: '确认消耗泥点' })).toBeTruthy();
confirmPointCost();
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DBackgroundImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '新背景提示词',
});
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundAsset: expect.objectContaining({
prompt: '新背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
}),
);
});
});
test('素材配置 UI 子 Tab 重新生成后显示90秒倒计时进度', async () => {
const deferred =
createDeferred<
Awaited<
ReturnType<typeof match3dWorksService.generateMatch3DBackgroundImage>
>
>();
vi.mocked(
match3dWorksService.generateMatch3DBackgroundImage,
).mockReturnValue(deferred.promise);
render(
<Match3DResultView
profile={createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/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,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
screen.getByRole('progressbar', { name: 'UI背景图生成进度' }),
).toBeTruthy();
expect(screen.getByText('预计剩余 90 秒')).toBeTruthy();
});
});
test('素材配置容器形象子 Tab 单独调用容器图生成接口并刷新素材', async () => {
const profile = createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/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,
},
generatedItemAssets: [
{
...createReadyGeneratedItemAsset(1),
backgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/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,
},
},
],
});
const nextBackgroundAsset = {
prompt: '旧背景提示词',
imageSrc:
'/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/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
};
const nextProfile = createProfile({
...profile,
generatedBackgroundAsset: nextBackgroundAsset,
generatedItemAssets: [
{
...profile.generatedItemAssets![0]!,
backgroundAsset: nextBackgroundAsset,
},
],
});
const onSaved = vi.fn();
vi.mocked(
match3dWorksService.generateMatch3DContainerImage,
).mockResolvedValue({
item: nextProfile,
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
generatedBackgroundAsset: nextBackgroundAsset,
prompt: '新容器提示词',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '容器形象' }));
fireEvent.change(screen.getByLabelText('容器形象画面描述提示词'), {
target: { value: '新容器提示词' },
});
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DContainerImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '新容器提示词',
});
expect(
match3dWorksService.generateMatch3DBackgroundImage,
).not.toHaveBeenCalled();
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundAsset: expect.objectContaining({
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
}),
);
});
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
expect(screen.queryByLabelText('容器形象画面描述提示词')).toBeNull();
expect(screen.queryByRole('button', { name: '容器形象' })).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
expect(match3dWorksService.generateMatch3DBackgroundImage).not.toHaveBeenCalled();
expect(match3dWorksService.generateMatch3DContainerImage).not.toHaveBeenCalled();
});
test('历史草稿同时带旧 draft 和 profile 素材时以 profile 多视角素材补齐试玩资产', async () => {

View File

@@ -32,8 +32,6 @@ import type {
} from '../../../packages/shared/src/contracts/match3dWorks';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
generateMatch3DBackgroundImage,
generateMatch3DContainerImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
generateMatch3DWorkTags,
@@ -48,6 +46,11 @@ import {
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import {
buildMatch3DItemSpritesheetViewRegions,
loadMatch3DSpritesheetAssetRegions,
type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import {
@@ -83,7 +86,7 @@ type Match3DResultViewProps = {
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type Match3DResultTab = 'work' | 'config' | 'assets';
type Match3DAssetConfigTab = 'items' | 'ui' | 'container';
type Match3DAssetConfigTab = 'items' | 'ui';
type Match3DAssetTaskStatus =
| 'idle'
| 'submitting'
@@ -102,6 +105,12 @@ type Match3DBatchItemGenerationState = {
error: string | null;
};
type Match3DItemSpritesheetPreviewGroup = {
itemIndex: number;
itemName: string;
regions: Match3DDecodedSpritesheetRegion[];
};
type Match3DTimedGenerationProgress = {
startedAtMs: number;
nowMs: number;
@@ -158,11 +167,9 @@ type Match3DCoverReferenceDraft = {
const MATCH3D_MIN_TAG_COUNT = 3;
const MATCH3D_MAX_TAG_COUNT = 6;
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
const MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS = 90;
const MATCH3D_DEFAULT_ASSET_COUNT = 20;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 20;
const MATCH3D_COVER_REFERENCE_IMAGE_LIMIT = 6;
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
@@ -176,8 +183,7 @@ const MATCH3D_ASSET_CONFIG_TABS: Array<{
label: string;
}> = [
{ id: 'items', label: '物品' },
{ id: 'ui', label: 'UI' },
{ id: 'container', label: '容器形象' },
{ id: 'ui', label: 'UI素材' },
];
// 中文注释:结果页难度配置必须与创作入口页保持同一组派生参数。
@@ -202,7 +208,7 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
label: '硬核',
clearCount: 21,
difficulty: 8,
itemTypeCount: 21,
itemTypeCount: 20,
},
] as const;
@@ -385,6 +391,56 @@ function resolveMatch3DContainerPreviewSource(
);
}
function resolveMatch3DUiSpritesheetPreviewSource(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
draft?.generatedBackgroundAsset?.containerImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
profile.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DItemSpritesheetPreviewSource(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
profile.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DContainerPrompt(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
@@ -589,6 +645,12 @@ function hasPersistableMatch3DGeneratedItemAsset(
asset.subscriptionKey?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.levelSceneImageSrc?.trim() ||
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
asset.backgroundAsset?.prompt?.trim() ||
@@ -627,8 +689,17 @@ function getMatch3DGeneratedItemAssetPersistenceSignature(
asset.backgroundMusic?.taskId?.trim() ??
'',
asset.backgroundAsset?.prompt?.trim() ?? '',
asset.backgroundAsset?.levelScenePrompt?.trim() ?? '',
asset.backgroundAsset?.levelSceneImageSrc?.trim() ?? '',
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.imageSrc?.trim() ?? '',
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetPrompt?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetPrompt?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.containerPrompt?.trim() ?? '',
asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
@@ -770,9 +841,22 @@ function createMatch3DAssetDrafts(
name: `${theme}场景小物`,
usage: '圆形空间周边装饰物',
},
].slice(0, MATCH3D_DEFAULT_ASSET_COUNT);
];
const fallbackSeeds = Array.from(
{ length: MATCH3D_DEFAULT_ASSET_COUNT },
(_, index) => {
const seed = seeds[index];
return (
seed ?? {
id: `generated-item-${index + 1}`,
name: `${theme}物品${index + 1}`,
usage: '局内点击消除物件',
}
);
},
);
return seeds.map((seed) => ({
return fallbackSeeds.map((seed) => ({
...seed,
prompt: buildMatch3DAssetPrompt(profile, seed.name, seed.usage),
referenceImageSrc: profile.referenceImageSrc ?? profile.coverImageSrc ?? '',
@@ -2675,7 +2759,7 @@ function Match3DAssetConfigTabs({
onChange: (tab: Match3DAssetConfigTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
{MATCH3D_ASSET_CONFIG_TABS.map((tab) => (
<button
key={tab.id}
@@ -2697,39 +2781,62 @@ function Match3DAssetConfigTabs({
function Match3DUIAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
busy,
isGenerating,
uiSpritesheetPreviewSrc,
itemSpritesheetPreviewSrc,
itemNames,
error,
progressRuntime,
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
busy: boolean;
isGenerating: boolean;
uiSpritesheetPreviewSrc: string;
itemSpritesheetPreviewSrc: string;
itemNames: readonly string[];
error: string | null;
progressRuntime: Match3DTimedGenerationProgress | null;
onGenerate: (prompt: string) => void;
}) {
const [prompt, setPrompt] = useState(backgroundPrompt);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [itemSpritesheetGroups, setItemSpritesheetGroups] = useState<
Match3DItemSpritesheetPreviewGroup[]
>([]);
useEffect(() => {
setPrompt(backgroundPrompt);
}, [backgroundPrompt]);
if (!itemSpritesheetPreviewSrc) {
setItemSpritesheetGroups((current) =>
current.length > 0 ? [] : current,
);
return undefined;
}
const normalizedPrompt = prompt.trim();
const generationProgress =
resolveMatch3DTimedGenerationProgress(progressRuntime);
let cancelled = false;
const controller = new AbortController();
void loadMatch3DSpritesheetAssetRegions({
source: itemSpritesheetPreviewSrc,
maxRegions: 100,
minArea: 16,
alphaThreshold: 8,
signal: controller.signal,
})
.then((regions) => {
if (!cancelled) {
setItemSpritesheetGroups(
buildMatch3DItemSpritesheetViewRegions(regions, itemNames),
);
}
})
.catch(() => {
if (!cancelled) {
setItemSpritesheetGroups([]);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [itemNames, itemSpritesheetPreviewSrc]);
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(14rem,0.72fr)_minmax(0,1fr)]">
<div className="grid gap-4 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
@@ -2744,66 +2851,67 @@ function Match3DUIAssetsTab({
<span className="sr-only">UI页面预览</span>
</button>
<div className="flex min-h-0 flex-col">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={busy || isGenerating}
rows={7}
onChange={(event) => setPrompt(event.target.value)}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="UI背景图画面描述提示词"
<div className="flex min-h-0 flex-col gap-3">
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
<ResolvedAssetImage
src={uiSpritesheetPreviewSrc}
alt="UI素材图"
className="h-full w-full object-contain"
/>
</label>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI页面
</button>
<button
type="button"
disabled={!normalizedPrompt || busy || isGenerating}
onClick={() => setIsCostConfirmOpen(true)}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
{isGenerating ? (
<div
role="progressbar"
aria-label="UI背景图生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgress.progressPercent}
className="platform-progress-track relative mt-3 h-10 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgress.progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-xs font-bold text-white">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{generationProgress.secondsLeft}
</div>
</div>
) : null}
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI页面
</button>
</div>
</div>
</section>
{itemSpritesheetPreviewSrc ? (
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
<ResolvedAssetImage
src={itemSpritesheetPreviewSrc}
alt="物品素材图"
className="h-full w-full object-contain"
/>
</div>
{itemSpritesheetGroups.length > 0 ? (
<div className="grid max-h-[24rem] content-start gap-3 overflow-y-auto pr-1 sm:grid-cols-2 xl:grid-cols-3">
{itemSpritesheetGroups.map((group) => (
<div
key={`${group.itemIndex}-${group.itemName}`}
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
>
<div className="mb-2 truncate text-xs font-black text-[var(--platform-text-strong)]">
{group.itemName}
</div>
<div className="grid grid-cols-5 gap-1.5">
{group.regions.map((region, regionIndex) => (
<img
key={`${group.itemIndex}-${regionIndex}-${region.imageSrc}`}
src={region.imageSrc}
alt=""
aria-hidden="true"
data-testid={`match3d-item-spritesheet-preview-${group.itemIndex}-${regionIndex}`}
className="aspect-square w-full rounded-[0.55rem] border border-white/70 bg-white/82 object-contain p-1"
draggable={false}
/>
))}
</div>
</div>
))}
</div>
) : null}
</div>
</section>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
@@ -2813,217 +2921,10 @@ function Match3DUIAssetsTab({
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
containerPreviewSrc={MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
{isCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="match3d-ui-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="match3d-ui-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{MATCH3D_UI_BACKGROUND_POINTS_COST}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!normalizedPrompt || busy || isGenerating}
onClick={() => {
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function Match3DContainerAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
containerPrompt,
busy,
isGenerating,
error,
progressRuntime,
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
containerPrompt: string;
busy: boolean;
isGenerating: boolean;
error: string | null;
progressRuntime: Match3DTimedGenerationProgress | null;
onGenerate: (prompt: string) => void;
}) {
const [prompt, setPrompt] = useState(containerPrompt);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const hasContainerPreview = Boolean(containerPreviewSrc.trim());
useEffect(() => {
setPrompt(containerPrompt);
}, [containerPrompt]);
const normalizedPrompt = prompt.trim();
const generationProgress =
resolveMatch3DTimedGenerationProgress(progressRuntime);
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(14rem,0.72fr)_minmax(0,1fr)]">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="mx-auto aspect-square w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 text-left shadow-sm"
aria-label="打开容器形象预览"
>
<ResolvedAssetImage
src={containerPreviewSrc}
alt="容器形象"
className="h-full w-full object-contain"
/>
<span className="sr-only"></span>
</button>
<div className="flex min-h-0 flex-col">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={busy || isGenerating}
rows={7}
onChange={(event) => setPrompt(event.target.value)}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="容器形象画面描述提示词"
/>
</label>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
disabled={!normalizedPrompt || busy || isGenerating}
onClick={() => setIsCostConfirmOpen(true)}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
{isGenerating ? (
<div
role="progressbar"
aria-label="容器形象生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgress.progressPercent}
className="platform-progress-track relative mt-3 h-10 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgress.progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-xs font-bold text-white">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{generationProgress.secondsLeft}
</div>
</div>
) : null}
</div>
</div>
</section>
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={hasContainerPreview ? containerPreviewSrc : ''}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
{isCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="match3d-container-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="match3d-container-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{MATCH3D_UI_BACKGROUND_POINTS_COST}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!normalizedPrompt || busy || isGenerating}
onClick={() => {
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -3115,49 +3016,33 @@ function Match3DAssetConfigTab({
activeAssetId,
assetDrafts,
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
containerPrompt,
uiSpritesheetPreviewSrc,
itemSpritesheetPreviewSrc,
itemNames,
backgroundGenerationError,
containerGenerationError,
batchGenerationState,
busy,
backgroundGenerationProgress,
containerGenerationProgress,
isGeneratingBackground,
isGeneratingContainer,
onActiveAssetChange,
onAddBatch,
onRegenerateBatch,
onAssetChange,
onAssetConfigTabChange,
onDeleteAsset,
onGenerateBackground,
onGenerateContainer,
}: {
activeAssetConfigTab: Match3DAssetConfigTab;
activeAssetId: string | null;
assetDrafts: Match3DItemAssetDraft[];
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
containerPrompt: string;
uiSpritesheetPreviewSrc: string;
itemSpritesheetPreviewSrc: string;
itemNames: readonly string[];
backgroundGenerationError: string | null;
containerGenerationError: string | null;
batchGenerationState: Match3DBatchItemGenerationState;
busy: boolean;
backgroundGenerationProgress: Match3DTimedGenerationProgress | null;
containerGenerationProgress: Match3DTimedGenerationProgress | null;
isGeneratingBackground: boolean;
isGeneratingContainer: boolean;
onActiveAssetChange: (assetId: string | null) => void;
onAddBatch: () => void;
onRegenerateBatch: () => void;
onAssetChange: (asset: Match3DItemAssetDraft) => void;
onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void;
onDeleteAsset: (assetId: string) => void;
onGenerateBackground: (prompt: string) => void;
onGenerateContainer: (prompt: string) => void;
}) {
return (
<div className="min-h-0">
@@ -3180,25 +3065,10 @@ function Match3DAssetConfigTab({
{activeAssetConfigTab === 'ui' ? (
<Match3DUIAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
busy={busy}
isGenerating={isGeneratingBackground}
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
itemNames={itemNames}
error={backgroundGenerationError}
progressRuntime={backgroundGenerationProgress}
onGenerate={onGenerateBackground}
/>
) : null}
{activeAssetConfigTab === 'container' ? (
<Match3DContainerAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
containerPrompt={containerPrompt}
busy={busy}
isGenerating={isGeneratingContainer}
error={containerGenerationError}
progressRuntime={containerGenerationProgress}
onGenerate={onGenerateContainer}
/>
) : null}
</div>
@@ -3255,18 +3125,9 @@ export function Match3DResultView({
const [batchRegenerateError, setBatchRegenerateError] = useState<
string | null
>(null);
const [isGeneratingBackground, setIsGeneratingBackground] = useState(false);
const [backgroundGenerationError, setBackgroundGenerationError] = useState<
string | null
>(null);
const [backgroundGenerationProgress, setBackgroundGenerationProgress] =
useState<Match3DTimedGenerationProgress | null>(null);
const [isGeneratingContainer, setIsGeneratingContainer] = useState(false);
const [containerGenerationError, setContainerGenerationError] = useState<
string | null
>(null);
const [containerGenerationProgress, setContainerGenerationProgress] =
useState<Match3DTimedGenerationProgress | null>(null);
const [autoSaveState, setAutoSaveState] =
useState<Match3DAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
@@ -3299,24 +3160,6 @@ export function Match3DResultView({
),
[draft, generatedItemAssets, promotedProfile],
);
const backgroundPrompt = useMemo(
() =>
resolveMatch3DBackgroundPrompt(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const containerPrompt = useMemo(
() =>
resolveMatch3DContainerPrompt(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(
@@ -3327,6 +3170,28 @@ export function Match3DResultView({
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[draft, generatedItemAssets, promotedProfile],
);
const uiSpritesheetPreviewSrc = useMemo(
() =>
resolveMatch3DUiSpritesheetPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const itemSpritesheetPreviewSrc = useMemo(
() =>
resolveMatch3DItemSpritesheetPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const generatedItemNames = useMemo(
() => generatedItemAssets.map((asset) => asset.itemName),
[generatedItemAssets],
);
const coverSourceAssets = useMemo(
() =>
resolveMatch3DCoverSourceAssets(
@@ -3349,11 +3214,6 @@ export function Match3DResultView({
setCoverAiRedraw(false);
setCoverPanelError(null);
setBackgroundGenerationError(null);
setIsGeneratingBackground(false);
setBackgroundGenerationProgress(null);
setContainerGenerationError(null);
setIsGeneratingContainer(false);
setContainerGenerationProgress(null);
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile.profileId, profile.updatedAt]);
@@ -3369,56 +3229,6 @@ export function Match3DResultView({
profile.profileId,
]);
useEffect(() => {
if (!isGeneratingBackground) {
return undefined;
}
const startedAtMs = Date.now();
setBackgroundGenerationProgress({
startedAtMs,
nowMs: startedAtMs,
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
const timer = window.setInterval(() => {
setBackgroundGenerationProgress((current) =>
current
? {
...current,
nowMs: Date.now(),
}
: current,
);
}, 1000);
return () => window.clearInterval(timer);
}, [isGeneratingBackground]);
useEffect(() => {
if (!isGeneratingContainer) {
return undefined;
}
const startedAtMs = Date.now();
setContainerGenerationProgress({
startedAtMs,
nowMs: startedAtMs,
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
const timer = window.setInterval(() => {
setContainerGenerationProgress((current) =>
current
? {
...current,
nowMs: Date.now(),
}
: current,
);
}, 1000);
return () => window.clearInterval(timer);
}, [isGeneratingContainer]);
useEffect(() => {
const payload = buildSavePayload(editState);
if (!payload) {
@@ -3461,12 +3271,6 @@ export function Match3DResultView({
if (cancelled) {
return;
}
if (isGeneratingBackground) {
return;
}
if (isGeneratingContainer) {
return;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
@@ -3481,8 +3285,6 @@ export function Match3DResultView({
}, [
editState,
generatedItemAssets,
isGeneratingBackground,
isGeneratingContainer,
onSaved,
profile,
]);
@@ -3906,92 +3708,6 @@ export function Match3DResultView({
});
};
const handleGenerateBackground = async (prompt: string) => {
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt || isGeneratingBackground) {
setBackgroundGenerationError('请填写画面描述提示词。');
return;
}
setIsGeneratingBackground(true);
setBackgroundGenerationProgress({
startedAtMs: Date.now(),
nowMs: Date.now(),
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
setBackgroundGenerationError(null);
try {
const response = await generateMatch3DBackgroundImage(profile.profileId, {
prompt: normalizedPrompt,
});
const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
response.item.generatedItemAssets?.length
? response.item.generatedItemAssets
: generatedItemAssets,
response.generatedBackgroundAsset,
);
const refreshedProfile = attachMatch3DGeneratedItemAssets(
response.item,
nextGeneratedAssets,
);
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
onSaved?.(refreshedProfile);
setLocalError(null);
} catch (caughtError) {
setBackgroundGenerationError(
caughtError instanceof Error
? caughtError.message
: 'UI背景图生成失败。',
);
} finally {
setIsGeneratingBackground(false);
setBackgroundGenerationProgress(null);
}
};
const handleGenerateContainer = async (prompt: string) => {
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt || isGeneratingContainer) {
setContainerGenerationError('请填写容器形象提示词。');
return;
}
setIsGeneratingContainer(true);
setContainerGenerationProgress({
startedAtMs: Date.now(),
nowMs: Date.now(),
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
setContainerGenerationError(null);
try {
const response = await generateMatch3DContainerImage(profile.profileId, {
prompt: normalizedPrompt,
});
const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
response.item.generatedItemAssets?.length
? response.item.generatedItemAssets
: generatedItemAssets,
response.generatedBackgroundAsset,
);
const refreshedProfile = attachMatch3DGeneratedItemAssets(
response.item,
nextGeneratedAssets,
);
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
onSaved?.(refreshedProfile);
setLocalError(null);
} catch (caughtError) {
setContainerGenerationError(
caughtError instanceof Error
? caughtError.message
: '容器形象生成失败。',
);
} finally {
setIsGeneratingContainer(false);
setContainerGenerationProgress(null);
}
};
const handleStartTestRun = async () => {
if (!canStartTestRun || isStartingTestRun) {
setLocalError(testRunBlockers[0] ?? null);
@@ -4063,9 +3779,7 @@ export function Match3DResultView({
isBusy ||
isPublishing ||
isStartingTestRun ||
isGeneratingCover ||
isGeneratingBackground ||
isGeneratingContainer;
isGeneratingCover;
const workBusy = busy || isGeneratingTags;
const displayError = error ?? localError;
const dialogPublishError = hasAttemptedPublish ? error ?? localError : null;
@@ -4104,17 +3818,11 @@ export function Match3DResultView({
activeAssetId={activeAssetId}
assetDrafts={assetDrafts}
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
containerPrompt={containerPrompt}
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
itemNames={generatedItemNames}
backgroundGenerationError={backgroundGenerationError}
containerGenerationError={containerGenerationError}
batchGenerationState={batchGenerationState}
busy={busy}
backgroundGenerationProgress={backgroundGenerationProgress}
containerGenerationProgress={containerGenerationProgress}
isGeneratingBackground={isGeneratingBackground}
isGeneratingContainer={isGeneratingContainer}
onActiveAssetChange={setActiveAssetId}
onAddBatch={() => {
setBatchAddError(null);
@@ -4137,12 +3845,6 @@ export function Match3DResultView({
onDeleteAsset={(assetId) => {
void handleDeleteAssetDraft(assetId);
}}
onGenerateBackground={(prompt) => {
void handleGenerateBackground(prompt);
}}
onGenerateContainer={(prompt) => {
void handleGenerateContainer(prompt);
}}
/>
) : null}
</div>

View File

@@ -173,7 +173,7 @@ const MATCH3D_BOARD_CENTER = 0.5;
const MATCH3D_PHYSICS_STEP = 1 / 60;
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
const MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE = 1.9;
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 25;
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 20;
const MATCH3D_SELECTED_MODEL_SCALE = 1.1;
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
new Set(['ring', 'arch']);

View File

@@ -55,6 +55,9 @@ import { resolveGeometryAsset } from './match3dVisualAssets';
const runtimeAudioFeedback = vi.hoisted(() => ({
playRuntimeMergeSound: vi.fn(),
}));
const match3dSpritesheetParser = vi.hoisted(() => ({
loadMatch3DSpritesheetAssetRegions: vi.fn(),
}));
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
const actual =
@@ -65,6 +68,16 @@ vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
};
});
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
return {
...actual,
loadMatch3DSpritesheetAssetRegions:
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
};
});
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
return {
@@ -96,6 +109,7 @@ afterEach(() => {
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
vi.restoreAllMocks();
});
@@ -1493,6 +1507,194 @@ test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
);
});
test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () => {
const run = startLocalMatch3DRun(3);
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
['返回', '设置', '方格', '移出', '凑齐', '打乱'].map((label, index) => ({
label,
x: index * 10,
y: 0,
width: 8,
height: 8,
sheetWidth: 64,
sheetHeight: 64,
imageSrc: `data:image/png;base64,${label}`,
})),
);
render(
<Match3DRuntimeShell
run={run}
generatedItemAssets={[]}
generatedBackgroundAsset={{
prompt: '果园纯背景',
imageSrc: '/match3d/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: '果园UI',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
status: 'image_ready',
error: null,
}}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
await waitFor(() => {
expect(
screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'),
).toBe('data:image/png;base64,返回');
expect(
screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'),
).toBe('data:image/png;base64,设置');
expect(
screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'),
).toBe('data:image/png;base64,移出');
expect(
screen.getByTestId('match3d-ui-sprite-prop-collect').getAttribute('src'),
).toBe('data:image/png;base64,凑齐');
expect(
screen.getByTestId('match3d-ui-sprite-prop-shuffle').getAttribute('src'),
).toBe('data:image/png;base64,打乱');
});
expect(
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
).toHaveBeenCalledWith(
expect.objectContaining({
labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
maxRegions: 6,
source: '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
}),
);
});
test('运行态不把兼容写入的UI spritesheet当中心容器图', async () => {
const run = startLocalMatch3DRun(3);
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
[],
);
render(
<Match3DRuntimeShell
run={run}
generatedItemAssets={[]}
generatedBackgroundAsset={{
prompt: '果园纯背景',
imageSrc: '/match3d/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: '果园UI',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
containerPrompt: '兼容UI素材',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
containerImageObjectKey: null,
status: 'image_ready',
error: null,
}}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
await waitFor(() => {
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('/match3d-background-references/pot-fused-reference.png');
});
});
test('运行态缺少imageViews时从物品spritesheet解析五视角图片', async () => {
const run = startLocalMatch3DRun(1);
const selectedItem = run.items[0]!;
const nextRun: Match3DRunSnapshot = {
...run,
items: run.items.map((item, index) =>
index === 0
? {
...item,
state: 'InTray' as const,
clickable: false,
traySlotIndex: 0,
}
: item,
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === 0
? {
slotIndex: 0,
itemInstanceId: selectedItem.itemInstanceId,
itemTypeId: selectedItem.itemTypeId,
visualKey: selectedItem.visualKey,
}
: slot,
),
};
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockImplementation(
async ({ source }: { source: string }) => {
if (source.includes('item-spritesheet')) {
return Array.from({ length: 100 }, (_, index) => ({
label: `素材${index + 1}`,
x: index * 2,
y: 0,
width: 2,
height: 2,
sheetWidth: 200,
sheetHeight: 2,
imageSrc: `data:image/png;base64,item-${index + 1}`,
}));
}
return [];
},
);
render(
<Match3DRuntimeShell
run={nextRun}
generatedItemAssets={[
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
]}
generatedBackgroundAsset={{
prompt: '果园纯背景',
imageSrc: '/match3d/background.png',
imageObjectKey: null,
itemSpritesheetPrompt: '果园物品',
itemSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
itemSpritesheetImageObjectKey: null,
status: 'image_ready',
error: null,
}}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
await waitFor(() => {
expect(
(screen.getByTestId('match3d-tray-image') as HTMLImageElement).src,
).toBe('data:image/png;base64,item-1');
});
});
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
const run = startLocalMatch3DRun(3);
const playSpy = vi
@@ -1570,10 +1772,10 @@ test('本地试玩按难度档位生成类型并兼容历史硬核消除数', ()
expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3);
expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9);
expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15);
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(21);
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(21);
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(20);
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(20);
expect(countTypes(smallRun)).toBe(9);
expect(countTypes(hardRun)).toBe(21);
expect(countTypes(hardRun)).toBe(20);
expect(hardRun.clearCount).toBe(21);
expect(hardRun.items).toHaveLength(63);
});
@@ -1593,9 +1795,9 @@ test('硬核档位生成不重复积木视觉签名', () => {
}),
);
expect(firstItemByType.size).toBe(21);
expect(visualKeys.size).toBe(21);
expect(signatures.size).toBe(21);
expect(firstItemByType.size).toBe(20);
expect(visualKeys.size).toBe(20);
expect(signatures.size).toBe(20);
});
test('积木池覆盖参考图里的特殊件', () => {
@@ -1690,7 +1892,7 @@ test('硬核档位按五档体积比例生成尺寸', () => {
}
expect(tierCounts.get('XL')).toBe(4);
expect(tierCounts.get('L')).toBe(7);
expect(tierCounts.get('L')).toBe(6);
expect(tierCounts.get('M')).toBe(6);
expect(tierCounts.get('XS')).toBe(3);
expect(tierCounts.get('S')).toBe(1);
@@ -1706,7 +1908,7 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
radiiByVisualKey.set(item.visualKey, radii);
}
expect(radiiByVisualKey.size).toBe(21);
expect(radiiByVisualKey.size).toBe(20);
expect(
[...radiiByVisualKey.values()].every((radii) => radii.size === 1),
).toBe(true);

View File

@@ -37,6 +37,11 @@ import {
getMatch3DGeneratedImageViewSources,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
buildMatch3DItemSpritesheetViewRegions,
loadMatch3DSpritesheetAssetRegions,
type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser';
import {
buildMatch3DTrayInsertionPlan,
resolveMatch3DTrayItemIdToSlotIndexMap,
@@ -163,6 +168,12 @@ type Match3DTrayClearAnimation = {
centerY: number;
};
type Match3DItemSpritesheetViewGroup = {
itemIndex: number;
itemName: string;
regions: Match3DDecodedSpritesheetRegion[];
};
function resolveTrayPreviewItem(
run: Match3DRunSnapshot,
slot: Match3DTraySlot,
@@ -187,6 +198,19 @@ const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
const EMPTY_MATCH3D_GENERATED_ITEM_ASSETS: Match3DGeneratedItemAsset[] = [];
const MATCH3D_CONTAINER_REFERENCE_SRC =
'/match3d-background-references/pot-fused-reference.png';
const MATCH3D_UI_SPRITESHEET_LABELS = [
'返回',
'设置',
'方格',
'移出',
'凑齐',
'打乱',
] as const;
const MATCH3D_PROP_BUTTONS = [
['移出', 'match3d-ui-sprite-prop-remove'],
['凑齐', 'match3d-ui-sprite-prop-collect'],
['打乱', 'match3d-ui-sprite-prop-shuffle'],
] as const;
function formatTimer(value: number) {
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
@@ -228,9 +252,9 @@ function resolveBoardPointFromPointerEvent(
}
function compareMatch3DGeneratedTypeId(left: string, right: string) {
const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) {
const leftIndex = resolveMatch3DGeneratedItemIndex(left);
const rightIndex = resolveMatch3DGeneratedItemIndex(right);
if (leftIndex !== null && rightIndex !== null) {
return leftIndex - rightIndex;
}
return left.localeCompare(right);
@@ -243,13 +267,17 @@ function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
}
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
const parsed = Number.parseInt(value?.match(/(\d+)$/u)?.[1] ?? '', 10);
const parsed = Number.parseInt(
value?.match(/^match3d-(?:item|type)-0*(\d+)$/u)?.[1] ?? '',
10,
);
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
}
function buildMatch3DImageSourcesByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
itemSpritesheetViewGroups: readonly Match3DItemSpritesheetViewGroup[] = [],
) {
if (!run) {
return new Map<string, string[]>();
@@ -267,6 +295,20 @@ function buildMatch3DImageSourcesByType(
]
: [];
});
const parsedAssets = itemSpritesheetViewGroups.flatMap((group) => {
const sources = group.regions
.map((region) => region.imageSrc.trim())
.filter(Boolean);
return sources.length > 0
? [
{
fallbackIndex: group.itemIndex,
itemIndex: group.itemIndex,
sources,
},
]
: [];
});
return new Map(
typeIds.flatMap((typeId, index) => {
@@ -274,12 +316,99 @@ function buildMatch3DImageSourcesByType(
const asset =
readyAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ?? readyAssets.find((entry) => entry.fallbackIndex === index);
) ??
readyAssets.find((entry) => entry.fallbackIndex === index) ??
parsedAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ??
parsedAssets.find((entry) => entry.fallbackIndex === index);
return asset ? [[typeId, asset.sources] as const] : [];
}),
);
}
function resolveMatch3DUiSpritesheetSource(
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DItemSpritesheetSource(
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DGeneratedContainerSource(
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
const normalize = (value: string | null | undefined) =>
value?.trim().replace(/^\/+/u, '') ?? '';
const resolveAssetContainerSource = (
asset: Match3DGeneratedBackgroundAsset | null | undefined,
) => {
const containerSrc = asset?.containerImageSrc?.trim() || '';
const containerObjectKey = asset?.containerImageObjectKey?.trim() || '';
const uiSrc = asset?.uiSpritesheetImageSrc?.trim() || '';
const uiObjectKey = asset?.uiSpritesheetImageObjectKey?.trim() || '';
if (
normalize(containerSrc) &&
normalize(containerSrc) !== normalize(uiSrc)
) {
return containerSrc;
}
if (
normalize(containerObjectKey) &&
normalize(containerObjectKey) !== normalize(uiObjectKey)
) {
return containerObjectKey;
}
return '';
};
return (
resolveAssetContainerSource(generatedBackgroundAsset) ||
generatedItemAssets
.map((asset) => resolveAssetContainerSource(asset.backgroundAsset))
.find(Boolean) ||
''
);
}
function indexMatch3DUiSpritesheetRegions(
regions: readonly Match3DDecodedSpritesheetRegion[],
) {
return new Map(regions.map((region) => [region.label, region]));
}
function resolveMatch3DImageReadUrlCacheKey(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
@@ -598,6 +727,31 @@ function Match3DToken({
);
}
function Match3DSpriteImage({
region,
testId,
className = 'h-full w-full object-contain',
}: {
region: Match3DDecodedSpritesheetRegion | null | undefined;
testId: string;
className?: string;
}) {
if (!region?.imageSrc) {
return null;
}
return (
<img
src={region.imageSrc}
alt=""
aria-hidden="true"
data-testid={testId}
className={className}
draggable={false}
/>
);
}
function Match3DTrayToken({
slot,
imageSrc,
@@ -883,6 +1037,32 @@ export function Match3DRuntimeShell({
() => normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
[generatedItemAssets],
);
const uiSpritesheetSource = useMemo(
() =>
resolveMatch3DUiSpritesheetSource(
generatedBackgroundAsset,
runtimeGeneratedItemAssets,
),
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
);
const itemSpritesheetSource = useMemo(
() =>
resolveMatch3DItemSpritesheetSource(
generatedBackgroundAsset,
runtimeGeneratedItemAssets,
),
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
);
const [uiSpritesheetRegions, setUiSpritesheetRegions] = useState<
Match3DDecodedSpritesheetRegion[]
>([]);
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
Match3DItemSpritesheetViewGroup[]
>([]);
const uiSpritesheetRegionByLabel = useMemo(
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
[uiSpritesheetRegions],
);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
@@ -904,6 +1084,80 @@ export function Match3DRuntimeShell({
return () => window.clearInterval(timer);
}, [onTimeExpired, run]);
useEffect(() => {
if (!uiSpritesheetSource) {
setUiSpritesheetRegions((current) =>
current.length > 0 ? [] : current,
);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void loadMatch3DSpritesheetAssetRegions({
source: uiSpritesheetSource,
labels: MATCH3D_UI_SPRITESHEET_LABELS,
maxRegions: MATCH3D_UI_SPRITESHEET_LABELS.length,
minArea: 16,
alphaThreshold: 8,
signal: controller.signal,
})
.then((regions) => {
if (!cancelled) {
setUiSpritesheetRegions(regions);
}
})
.catch(() => {
if (!cancelled) {
setUiSpritesheetRegions([]);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [uiSpritesheetSource]);
useEffect(() => {
if (!itemSpritesheetSource) {
setItemSpritesheetViewGroups((current) =>
current.length > 0 ? [] : current,
);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void loadMatch3DSpritesheetAssetRegions({
source: itemSpritesheetSource,
maxRegions: 100,
minArea: 16,
alphaThreshold: 8,
signal: controller.signal,
})
.then((regions) => {
if (!cancelled) {
setItemSpritesheetViewGroups(
buildMatch3DItemSpritesheetViewRegions(
regions,
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
),
);
}
})
.catch(() => {
if (!cancelled) {
setItemSpritesheetViewGroups([]);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [itemSpritesheetSource, runtimeGeneratedItemAssets]);
useEffect(() => {
if (!feedbackEvent) {
return undefined;
@@ -1058,23 +1312,20 @@ export function Match3DRuntimeShell({
)
.find(Boolean) ||
'';
const generatedContainerAssetSrc =
generatedBackgroundAsset?.containerImageSrc?.trim() ||
generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
'';
const generatedContainerAssetSrc = resolveMatch3DGeneratedContainerSource(
generatedBackgroundAsset,
runtimeGeneratedItemAssets,
);
const containerAssetSrc =
generatedContainerAssetSrc || MATCH3D_CONTAINER_REFERENCE_SRC;
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
() =>
buildMatch3DImageSourcesByType(
run,
runtimeGeneratedItemAssets,
itemSpritesheetViewGroups,
),
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
);
const itemSizeByType = useMemo(
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
@@ -1731,7 +1982,14 @@ export function Match3DRuntimeShell({
onClick={onBack}
aria-label="返回"
>
<ArrowLeft size={20} />
<Match3DSpriteImage
region={uiSpritesheetRegionByLabel.get('返回')}
testId="match3d-ui-sprite-back"
className="h-7 w-7 object-contain"
/>
{!uiSpritesheetRegionByLabel.get('返回') ? (
<ArrowLeft size={20} />
) : null}
</button>
)}
<div className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
@@ -1752,7 +2010,14 @@ export function Match3DRuntimeShell({
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开抓大鹅设置"
>
<Settings size={18} />
<Match3DSpriteImage
region={uiSpritesheetRegionByLabel.get('设置')}
testId="match3d-ui-sprite-settings"
className="h-7 w-7 object-contain"
/>
{!uiSpritesheetRegionByLabel.get('设置') ? (
<Settings size={18} />
) : null}
</button>
</header>
@@ -1849,6 +2114,11 @@ export function Match3DRuntimeShell({
traySlotRefs.current[slot.slotIndex] = element;
}}
>
<Match3DSpriteImage
region={uiSpritesheetRegionByLabel.get('方格')}
testId={`match3d-ui-sprite-grid-${slot.slotIndex}`}
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
/>
<Match3DTrayToken
slot={slot}
isArriving={
@@ -1889,6 +2159,30 @@ export function Match3DRuntimeShell({
})}
</div>
</section>
<section
className="relative z-10 mt-2 grid grid-cols-3 gap-2"
aria-label="抓大鹅道具"
>
{MATCH3D_PROP_BUTTONS.map(([label, testId]) => {
const region = uiSpritesheetRegionByLabel.get(label);
return (
<button
key={label}
type="button"
className="flex min-h-12 items-center justify-center overflow-hidden rounded-[1rem] border border-white/58 bg-white/50 px-2 py-2 text-sm font-black text-slate-800 shadow-[0_10px_24px_rgba(15,23,42,0.14)] backdrop-blur-md"
aria-label={label}
>
<Match3DSpriteImage
region={region}
testId={testId}
className="h-10 w-full object-contain"
/>
{!region ? label : null}
</button>
);
})}
</section>
</div>
{feedbackEvent?.kind === 'rejected' ? (

View File

@@ -1595,10 +1595,23 @@ function parseDraftGenerationStartedAtMs(value: string | null | undefined) {
function createMiniGameDraftGenerationStateFromStartedAt(
kind: MiniGameDraftGenerationKind,
startedAtMs: number,
metadata?: MiniGameDraftGenerationState['metadata'],
): MiniGameDraftGenerationState {
return {
...createMiniGameDraftGenerationState(kind),
startedAtMs,
...(metadata ? { metadata } : {}),
};
}
function createPuzzleDraftGenerationStateFromPayload(
payload: CreatePuzzleAgentSessionRequest | null | undefined,
): MiniGameDraftGenerationState {
return {
...createMiniGameDraftGenerationState('puzzle'),
metadata: {
puzzleAiRedraw: payload?.aiRedraw ?? true,
},
};
}
@@ -4499,7 +4512,9 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftGenerating('puzzle', session.sessionId);
selectionStageRef.current = 'puzzle-generating';
setSelectionStage('puzzle-generating');
const nextGenerationState = createMiniGameDraftGenerationState('puzzle');
const nextGenerationState = createPuzzleDraftGenerationStateFromPayload(
formPayload ?? buildPuzzleFormPayloadFromSession(session),
);
setPuzzleGenerationState(nextGenerationState);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
@@ -4515,14 +4530,14 @@ export function PlatformEntryFlowShellImpl({
if (payload.action !== 'compile_puzzle_draft') {
return;
}
const generationState =
puzzleBackgroundCompileTasks[session.sessionId]?.generationState ??
puzzleGenerationState ??
createMiniGameDraftGenerationState('puzzle');
const formPayload =
buildPuzzleFormPayloadFromAction(payload) ??
puzzleBackgroundCompileTasks[session.sessionId]?.payload ??
buildPuzzleFormPayloadFromSession(session);
const generationState =
puzzleBackgroundCompileTasks[session.sessionId]?.generationState ??
puzzleGenerationState ??
createPuzzleDraftGenerationStateFromPayload(formPayload);
const recovered = await recoverCompletedPuzzleDraftGeneration({
sessionId: session.sessionId,
payload: formPayload,
@@ -5017,7 +5032,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
const generationState = createMiniGameDraftGenerationState('puzzle');
const generationState = createPuzzleDraftGenerationStateFromPayload(payload);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
@@ -7497,7 +7512,10 @@ export function PlatformEntryFlowShellImpl({
);
const startPuzzleTestRunFromDraft = useCallback(
async (draft: PuzzleResultDraft) => {
async (
draft: PuzzleResultDraft,
options: { levelId?: string | null } = {},
) => {
if (isPuzzleBusy) {
return false;
}
@@ -7526,7 +7544,7 @@ export function PlatformEntryFlowShellImpl({
coverAssetId: draft.coverAssetId,
levels: draft.levels ?? [],
});
const run = startLocalPuzzleRun(item);
const run = startLocalPuzzleRun(item, options.levelId ?? null);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
@@ -9286,6 +9304,11 @@ export function PlatformEntryFlowShellImpl({
createMiniGameDraftGenerationStateFromStartedAt(
'puzzle',
parseDraftGenerationStartedAtMs(item.updatedAt),
{
puzzleAiRedraw:
buildPuzzleFormPayloadFromSession(latestSession).aiRedraw ??
true,
},
),
);
enterCreateTab();

View File

@@ -60,9 +60,25 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function stubReferenceImageUpload(dataUrl: string) {
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = dataUrl;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
@@ -102,6 +118,17 @@ function createSession(
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
uiBackgroundPrompt: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
levelSceneImageSrc: null,
levelSceneImageObjectKey: null,
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
levelBackgroundImageSrc: null,
levelBackgroundImageObjectKey: null,
backgroundMusic: null,
candidates: [
{
candidateId: 'candidate-1',
@@ -170,7 +197,7 @@ function openPuzzleLevelsTab() {
}
describe('PuzzleResultView', () => {
test('renders level list and work info tabs', () => {
test('renders level list and work info tabs without asset config tab', () => {
render(
<PuzzleResultView
session={createSession()}
@@ -182,18 +209,13 @@ describe('PuzzleResultView', () => {
const workInfoTab = screen.getByRole('button', { name: '作品信息' });
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
const assetsTab = screen.getByRole('button', { name: '素材配置' });
expect(workInfoTab).toBeTruthy();
expect(levelsTab).toBeTruthy();
expect(assetsTab).toBeTruthy();
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(
workInfoTab.compareDocumentPosition(levelsTab) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
levelsTab.compareDocumentPosition(assetsTab) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
@@ -236,6 +258,62 @@ describe('PuzzleResultView', () => {
);
});
test('level detail trial keeps the complete draft and only selects the target level', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const firstLevel = base.draft!.levels![0]!;
const secondLevel = {
...firstLevel,
levelId: 'puzzle-level-2',
levelName: '钟楼猫街',
pictureDescription: '发光钟楼下的猫咪。',
candidates: [
{
...firstLevel.candidates[0]!,
candidateId: 'candidate-2',
imageSrc: '/puzzle/candidate-2.png',
assetId: 'asset-2',
},
],
selectedCandidateId: 'candidate-2',
coverImageSrc: '/puzzle/candidate-2.png',
coverAssetId: 'asset-2',
};
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [firstLevel, secondLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('钟楼猫街'));
fireEvent.click(
within(screen.getByRole('dialog', { name: '关卡详情' })).getByRole(
'button',
{ name: '关卡测试' },
),
);
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelId: 'puzzle-level-2' }),
],
}),
{ levelId: 'puzzle-level-2' },
);
});
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -294,6 +372,8 @@ describe('PuzzleResultView', () => {
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(dialog.className).toContain('max-w-[56rem]');
expect(dialog.querySelector('.puzzle-level-detail-list')).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
target: { value: '暖灯猫街' },
});
@@ -316,6 +396,7 @@ describe('PuzzleResultView', () => {
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
referenceImageSrcs: [],
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
@@ -334,7 +415,7 @@ describe('PuzzleResultView', () => {
generationStatus: 'generating',
}),
]);
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
expect(within(dialog).getByText('预计剩余 270 秒')).toBeTruthy();
expect(
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
).toBeNull();
@@ -343,7 +424,15 @@ describe('PuzzleResultView', () => {
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
const formalImageCard = formalImageTitle
.closest('.creative-image-input-panel__image-field')
?.querySelector('.puzzle-image-upload-card');
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
expect(pictureDescriptionInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageCard).toBeTruthy();
expect(formalImageCard?.className).toContain('min-h-[');
expect(
levelNameInput.compareDocumentPosition(formalImageTitle) &
Node.DOCUMENT_POSITION_FOLLOWING,
@@ -365,6 +454,7 @@ describe('PuzzleResultView', () => {
}),
],
}),
{ levelId: 'puzzle-level-1' },
);
});
@@ -463,6 +553,7 @@ describe('PuzzleResultView', () => {
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
referenceImageSrcs: [],
candidateCount: 1,
shouldAutoNameLevel: true,
workTitle: '暖灯猫街作品',
@@ -552,7 +643,7 @@ describe('PuzzleResultView', () => {
expect(
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
expect(within(reopenedDialog).getByText('预计剩余 270 秒')).toBeTruthy();
});
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
@@ -586,6 +677,7 @@ describe('PuzzleResultView', () => {
expect.objectContaining({
levelName: '继续编辑的猫街',
}),
{ levelId: 'puzzle-level-1' },
);
fireEvent.click(screen.getByLabelText('关闭'));
@@ -602,7 +694,7 @@ describe('PuzzleResultView', () => {
).toHaveProperty('disabled', true);
});
test('keeps level controls enabled while regenerating the UI background', () => {
test('asset config tab is removed and cannot trigger the legacy UI background action', () => {
const onExecuteAction = vi.fn();
render(
@@ -614,76 +706,20 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '雨夜猫街竖屏拼图UI背景' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
promptText: '雨夜猫街竖屏拼图UI背景',
}),
);
expect(
screen.getByRole('button', { name: /生成中/u }),
).toHaveProperty('disabled', true);
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
openPuzzleLevelsTab();
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
expect(addLevelButton).toHaveProperty('disabled', false);
fireEvent.click(addLevelButton);
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
});
test('restores UI background generate button when background generation fails', () => {
const onExecuteAction = vi.fn();
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
expect(onExecuteAction).not.toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
}),
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '雨夜猫街竖屏拼图UI背景' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
'disabled',
true,
);
rerender(
<PuzzleResultView
session={createSession()}
error="UI背景生成失败"
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
expect(generateButton).toHaveProperty('disabled', false);
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
});
test('keeps the current level dialog open when another level generation completes', () => {
@@ -899,7 +935,8 @@ describe('PuzzleResultView', () => {
});
});
test('renders UI background tab with saved prompt and runtime preview', () => {
test('preserves generated level asset bundle in test run draft', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const level = base.draft!.levels![0]!;
@@ -911,133 +948,53 @@ describe('PuzzleResultView', () => {
levels: [
{
...level,
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background.png',
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background.png',
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background.png',
);
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
'value',
'雨夜猫街竖屏拼图UI背景',
);
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();
});
test('UI背景只有 objectKey 时草稿页仍显示生成图', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background-object-key.png',
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background-object-key.png',
);
expect(screen.getByRole('button', { name: /重新生成/u })).toBeTruthy();
});
test('does not display local fallback as saved UI background prompt', () => {
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
'value',
'',
);
});
test('generates UI background with edited prompt and current levels snapshot', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新拼图UI背景提示词' },
});
expect(screen.getByRole('button', { name: /生成UI背景.*2泥点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_ui_background',
levelId: 'puzzle-level-1',
promptText: '新拼图UI背景提示词',
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelId: 'puzzle-level-1',
uiBackgroundPrompt: '新拼图UI背景提示词',
levels: [
expect.objectContaining({
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background.png',
}),
],
}),
]);
);
});
test('素材配置隐藏背景音乐入口', () => {
test('does not expose music or standalone UI asset controls', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
@@ -1068,30 +1025,39 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
});
test('生成完成回包合并历史音乐和UI背景后试玩使用最新资源', () => {
test('生成完成回包合并历史音乐和关卡资产后试玩使用最新资源', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const localLevel = {
...base.draft!.levels![0]!,
generationStatus: 'generating' as const,
uiBackgroundPrompt: '旧的UI背景提示词',
uiBackgroundImageSrc: null,
levelSceneImageSrc: null,
uiSpritesheetImageSrc: null,
levelBackgroundImageSrc: null,
backgroundMusic: null,
};
const incomingLevel = {
...localLevel,
generationStatus: 'ready' as const,
uiBackgroundPrompt: '水果乐园UI背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/fruit-background.png',
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene-fruit.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene-fruit.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet-fruit.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background-fruit.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background-fruit.png',
backgroundMusic: {
taskId: 'music-task-fruit',
provider: 'vector-engine-suno',
@@ -1135,20 +1101,18 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/fruit-background.png',
);
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene-fruit.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background-fruit.png',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
}),
@@ -1158,24 +1122,42 @@ describe('PuzzleResultView', () => {
);
});
test('auto saves UI background prompt edits through levels', async () => {
test('auto saves generated level asset bundle through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession()}
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
},
],
},
})}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新的自动保存UI背景提示词' },
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.change(screen.getByLabelText('关卡名称'), {
target: { value: '雨夜猫街新版' },
});
await act(async () => {
@@ -1188,7 +1170,13 @@ describe('PuzzleResultView', () => {
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
uiBackgroundPrompt: '新的自动保存UI背景提示词',
levelName: '雨夜猫街新版',
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
}),
],
}),
@@ -1222,10 +1210,11 @@ describe('PuzzleResultView', () => {
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getByLabelText('上传参考图', {
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
selector: 'input',
});
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
})[0]!;
expect(uploadInput.closest('.platform-subpanel')).toBeNull();
expect(uploadInput.closest('.puzzle-level-detail-list')).toBeTruthy();
const historyButton = within(dialog).getByRole('button', {
name: '选择历史图片',
});
@@ -1245,7 +1234,9 @@ describe('PuzzleResultView', () => {
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
expect(screen.getByText('历史素材 · image.png')).toBeTruthy();
expect(within(dialog).getByAltText('拼图参考图').getAttribute('src')).toBe(
'/generated-puzzle-assets/history/image.png',
);
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
@@ -1261,6 +1252,7 @@ describe('PuzzleResultView', () => {
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
aiRedraw: true,
referenceImageSrcs: [],
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
@@ -1461,6 +1453,110 @@ describe('PuzzleResultView', () => {
);
});
test('level image editor submits uploaded image directly when AI redraw is off', async () => {
const onExecuteAction = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,level-upload';
stubReferenceImageUpload(uploadedDataUrl);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
selector: 'input',
})[0]!;
fireEvent.change(uploadInput, {
target: {
files: [new File(['x'], 'level-upload.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
});
expect(within(dialog).getByAltText('拼图参考图')).toHaveProperty(
'src',
uploadedDataUrl,
);
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
referenceImageSrc: uploadedDataUrl,
referenceImageSrcs: [],
aiRedraw: false,
}),
);
});
test('level image editor uploads prompt reference images from the description box', async () => {
const onExecuteAction = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,level-reference';
stubReferenceImageUpload(uploadedDataUrl);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const referenceInputs = within(dialog).getAllByLabelText('上传参考图', {
selector: 'input',
});
expect(referenceInputs.length).toBeGreaterThanOrEqual(2);
fireEvent.change(referenceInputs[referenceInputs.length - 1]!, {
target: {
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(
within(dialog).getByRole('button', {
name: /预览参考图 prompt-reference\.png/u,
}),
).toBeTruthy();
});
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
referenceImageSrc: undefined,
referenceImageSrcs: [uploadedDataUrl],
aiRedraw: true,
}),
);
});
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
const onExecuteAction = vi.fn();
@@ -1480,7 +1576,7 @@ describe('PuzzleResultView', () => {
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
});
test('UI background generator reuses common image input UI without sharing level image fields', () => {
test('standalone UI background generator stays removed from the result page', () => {
const onExecuteAction = vi.fn();
render(
@@ -1491,40 +1587,11 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByText('UI背景预览')).toBeTruthy();
expect(screen.getByLabelText('UI背景提示词')).toBeTruthy();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
fireEvent.change(screen.getByLabelText('UI背景提示词'), {
target: { value: '独立的草稿UI背景提示词' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
promptText: '独立的草稿UI背景提示词',
}),
);
const payload = onExecuteAction.mock.calls[0]![0];
expect(payload).not.toHaveProperty('referenceImageSrc');
expect(payload).not.toHaveProperty('aiRedraw');
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
pictureDescription: '屋檐下的猫与暖灯街角。',
uiBackgroundPrompt: '独立的草稿UI背景提示词',
}),
]);
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(screen.queryByText('UI背景预览')).toBeNull();
expect(screen.queryByLabelText('UI背景提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('shows creative agent draft edit bar and submits the current draft', () => {

View File

@@ -1,8 +1,6 @@
import {
ArrowLeft,
CheckCircle2,
Eye,
LayoutTemplate,
Loader2,
MessageSquareText,
Play,
@@ -21,7 +19,6 @@ import type {
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
@@ -42,7 +39,10 @@ type PuzzleResultViewProps = {
error?: string | null;
onBack: () => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onStartTestRun?: (draft: PuzzleResultDraft) => void;
onStartTestRun?: (
draft: PuzzleResultDraft,
options?: { levelId?: string | null },
) => void;
creativeDraftEdit?: {
isBusy: boolean;
error: string | null;
@@ -54,8 +54,7 @@ type PuzzleResultViewProps = {
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui';
type PuzzleResultTab = 'levels' | 'work';
type DraftEditState = {
workTitle: string;
@@ -70,20 +69,15 @@ const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_PUBLISH_POINT_COST = 1;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
const PUZZLE_LEVEL_ASSET_BUNDLE_ESTIMATE_SECONDS =
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS * 3;
const PUZZLE_LEVEL_DIRECT_UPLOAD_ESTIMATE_SECONDS =
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS * 2;
const PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT = 5;
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
{ id: 'work', label: '作品信息' },
{ id: 'levels', label: '拼图关卡' },
{ id: 'assets', label: '素材配置' },
];
const PUZZLE_ASSET_CONFIG_TABS: Array<{
id: PuzzleAssetConfigTabId;
label: string;
}> = [
{ id: 'ui', label: 'UI' },
];
type PuzzleLevelGenerationRuntime = {
@@ -91,10 +85,13 @@ type PuzzleLevelGenerationRuntime = {
estimateSeconds: number;
};
type PuzzleUiBackgroundGenerationState = {
levelId: string;
prompt: string;
} | null;
function resolvePuzzleLevelGenerationEstimateSeconds(options?: {
aiRedraw?: boolean | null;
}) {
return options?.aiRedraw === false
? PUZZLE_LEVEL_DIRECT_UPLOAD_ESTIMATE_SECONDS
: PUZZLE_LEVEL_ASSET_BUNDLE_ESTIMATE_SECONDS;
}
function resolvePuzzleLevelGenerationProgress(
level: PuzzleDraftLevel,
@@ -141,26 +138,6 @@ function normalizeThemeTagInput(value: string) {
];
}
function buildDefaultPuzzleUiBackgroundPrompt(
editState: DraftEditState,
level: PuzzleDraftLevel | null,
) {
const tags = editState.themeTags
.map((tag) => tag.trim())
.filter(Boolean)
.join('');
return [
editState.workTitle.trim(),
editState.workDescription.trim(),
level?.levelName.trim(),
level?.pictureDescription.trim(),
tags,
'移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰,拼图区外氛围与作品名称一致',
]
.filter(Boolean)
.join('。');
}
function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
const selectedCandidate =
level.candidates.find(
@@ -208,6 +185,12 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
uiBackgroundPrompt: level.uiBackgroundPrompt ?? null,
uiBackgroundImageSrc: level.uiBackgroundImageSrc ?? null,
uiBackgroundImageObjectKey: level.uiBackgroundImageObjectKey ?? null,
levelSceneImageSrc: level.levelSceneImageSrc ?? null,
levelSceneImageObjectKey: level.levelSceneImageObjectKey ?? null,
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc ?? null,
uiSpritesheetImageObjectKey: level.uiSpritesheetImageObjectKey ?? null,
levelBackgroundImageSrc: level.levelBackgroundImageSrc ?? null,
levelBackgroundImageObjectKey: level.levelBackgroundImageObjectKey ?? null,
candidates: level.candidates ?? [],
selectedCandidateId: level.selectedCandidateId ?? null,
coverImageSrc: level.coverImageSrc ?? null,
@@ -296,6 +279,21 @@ function mergeDraftEditStateWithIncomingState(
uiBackgroundImageObjectKey:
incomingLevel.uiBackgroundImageObjectKey ??
level.uiBackgroundImageObjectKey,
levelSceneImageSrc:
incomingLevel.levelSceneImageSrc ?? level.levelSceneImageSrc,
levelSceneImageObjectKey:
incomingLevel.levelSceneImageObjectKey ??
level.levelSceneImageObjectKey,
uiSpritesheetImageSrc:
incomingLevel.uiSpritesheetImageSrc ?? level.uiSpritesheetImageSrc,
uiSpritesheetImageObjectKey:
incomingLevel.uiSpritesheetImageObjectKey ??
level.uiSpritesheetImageObjectKey,
levelBackgroundImageSrc:
incomingLevel.levelBackgroundImageSrc ?? level.levelBackgroundImageSrc,
levelBackgroundImageObjectKey:
incomingLevel.levelBackgroundImageObjectKey ??
level.levelBackgroundImageObjectKey,
backgroundMusic: incomingLevel.backgroundMusic ?? level.backgroundMusic,
generationStatus: incomingLevel.generationStatus || 'ready',
};
@@ -323,6 +321,12 @@ function createBlankPuzzleLevel(
uiBackgroundPrompt: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
levelSceneImageSrc: null,
levelSceneImageObjectKey: null,
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
levelBackgroundImageSrc: null,
levelBackgroundImageObjectKey: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
@@ -431,7 +435,7 @@ function PuzzleResultTabs({
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
<div className="mb-3 grid grid-cols-2 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}
@@ -451,34 +455,6 @@ 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,
@@ -656,11 +632,13 @@ function PuzzleLevelDetailDialog({
level: PuzzleDraftLevel,
promptText?: string | null,
referenceImageSrc?: string | null,
referenceImageSrcs?: string[],
imageModel?: PuzzleImageModelId | null,
aiRedraw?: boolean | null,
estimateSeconds?: number,
) => void;
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (level: PuzzleDraftLevel) => void;
onStartTestRun?: (levelId: string) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [referenceImageSrc, setReferenceImageSrc] = useState('');
@@ -668,6 +646,9 @@ function PuzzleLevelDetailDialog({
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [promptReferenceImages, setPromptReferenceImages] = useState<
Array<{ id: string; label: string; imageSrc: string }>
>([]);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
@@ -676,12 +657,23 @@ function PuzzleLevelDetailDialog({
const [aiRedraw, setAiRedraw] = useState(true);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const explicitReferenceImageSrc = referenceImageSrc.trim();
const savedReferenceImageSrc = level.pictureReference?.trim() || '';
const effectiveReferenceImageSrc =
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
const displayImageSrc = formalImageSrc || effectiveReferenceImageSrc;
const displayImageAlt = formalImageSrc
? level.levelName || draft.workTitle || '拼图关卡'
: '拼图参考图';
explicitReferenceImageSrc || savedReferenceImageSrc;
const promptReferenceImageSrcs = effectiveReferenceImageSrc
? []
: promptReferenceImages.map((image) => image.imageSrc);
const displayImageSrc =
explicitReferenceImageSrc || formalImageSrc || savedReferenceImageSrc;
const displayImageAlt = explicitReferenceImageSrc
? '拼图参考图'
: formalImageSrc
? level.levelName || draft.workTitle || '拼图关卡'
: '拼图参考图';
const shouldShowReferenceMeta = Boolean(
effectiveReferenceImageSrc && displayImageSrc !== effectiveReferenceImageSrc,
);
const generationProgress = resolvePuzzleLevelGenerationProgress(
level,
generationRuntime,
@@ -706,6 +698,7 @@ function PuzzleLevelDetailDialog({
const executeGeneration = () => {
const nextLevel = {
...level,
pictureReference: effectiveReferenceImageSrc || null,
generationStatus: 'generating' as const,
};
setIsCostConfirmOpen(false);
@@ -713,11 +706,46 @@ function PuzzleLevelDetailDialog({
nextLevel,
nextLevel.pictureDescription.trim() || undefined,
effectiveReferenceImageSrc || undefined,
promptReferenceImageSrcs,
imageModel,
aiRedraw,
resolvePuzzleLevelGenerationEstimateSeconds({ aiRedraw }),
);
};
const handlePromptReferenceImageFiles = async (files: File[]) => {
if (files.length === 0) {
return;
}
const remainingSlots =
PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT - promptReferenceImages.length;
if (remainingSlots <= 0) {
setReferenceImageError('参考图最多上传 5 张。');
return;
}
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `level-prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
})),
);
setPromptReferenceImages((current) => [...current, ...images].slice(0, 5));
setReferenceImageError(
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
if (typeof document === 'undefined') {
return null;
}
@@ -735,7 +763,7 @@ function PuzzleLevelDetailDialog({
role="dialog"
aria-modal="true"
aria-label="关卡详情"
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-2xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-[56rem] flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
@@ -753,24 +781,29 @@ function PuzzleLevelDetailDialog({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-4">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<div className="puzzle-level-detail-list divide-y divide-[var(--platform-subpanel-border)]">
<section className="grid gap-2 pb-4 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:items-center">
<label
htmlFor={`puzzle-level-name-${level.levelId}`}
className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]"
>
</div>
</label>
<input
id={`puzzle-level-name-${level.levelId}`}
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="关卡名称"
/>
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4">
<section className="pt-4">
<CreativeImageInputPanel
className="puzzle-level-detail-image-editor"
disabled={isBusy || generationProgress.isGenerating}
isSubmitting={generationProgress.isGenerating}
uploadedImageSrc={displayImageSrc}
@@ -778,8 +811,9 @@ function PuzzleLevelDetailDialog({
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
canUploadPromptReferences={!effectiveReferenceImageSrc}
mainImageMeta={
effectiveReferenceImageSrc ? (
shouldShowReferenceMeta ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
@@ -804,7 +838,8 @@ function PuzzleLevelDetailDialog({
}
promptRows={7}
aiRedraw={aiRedraw}
promptReferenceImages={[]}
promptReferenceImages={promptReferenceImages}
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
imageModelPicker={
<PuzzleImageModelPicker
value={imageModel}
@@ -850,6 +885,15 @@ function PuzzleLevelDetailDialog({
pictureDescription: value,
})
}
onPromptReferenceFilesSelect={(files) => {
void handlePromptReferenceImageFiles(files);
}}
onPromptReferenceRemove={(referenceId) => {
setPromptReferenceImages((current) =>
current.filter((image) => image.id !== referenceId),
);
setReferenceImageError(null);
}}
onHistoryClick={() => setIsHistoryPickerOpen(true)}
onSubmit={() => setIsCostConfirmOpen(true)}
/>
@@ -862,7 +906,7 @@ function PuzzleLevelDetailDialog({
<button
type="button"
disabled={isBusy}
onClick={() => onStartTestRun(level)}
onClick={() => onStartTestRun(level.levelId)}
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
@@ -946,6 +990,7 @@ function PuzzleLevelDetailDialog({
setReferenceImageLabel(
getPuzzleHistoryAssetReferenceLabel(asset.imageSrc),
);
setAiRedraw(true);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
@@ -1342,344 +1387,6 @@ function PuzzleWorkInfoTab({
);
}
function PuzzleUiAssetsTab({
editState,
imageRefreshKey,
isBusy,
uiBackgroundGeneration,
onChange,
onGenerate,
}: {
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
onChange: (nextState: DraftEditState) => void;
onGenerate: (prompt: string) => void;
}) {
const firstLevel = editState.levels[0] ?? null;
const isGeneratingUiBackground = Boolean(
firstLevel &&
uiBackgroundGeneration?.levelId === firstLevel.levelId,
);
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
editState,
firstLevel,
);
const prompt = firstLevel?.uiBackgroundPrompt ?? '';
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
const backgroundPreviewSrc =
resolvePuzzleUiBackgroundSource(firstLevel) || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
const hasGeneratedUiBackground = Boolean(resolvePuzzleUiBackgroundSource(firstLevel));
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const updateFirstLevel = (nextLevel: PuzzleDraftLevel) => {
onChange({
...editState,
levels: [nextLevel, ...editState.levels.slice(1)],
});
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<CreativeImageInputPanel
mainImageMode="preview"
disabled={isBusy || !firstLevel || isGeneratingUiBackground}
isSubmitting={isGeneratingUiBackground}
uploadedImageSrc={backgroundPreviewSrc}
uploadedImageAlt="拼图UI背景图"
uploadedImageRefreshKey={`${imageRefreshKey}:ui-background`}
mainImageInputId="puzzle-ui-background-preview"
promptTextareaId="puzzle-ui-background-prompt-input"
prompt={prompt}
promptLabel="UI背景提示词"
promptAriaLabel="拼图UI背景提示词"
promptRows={8}
aiRedraw={false}
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel={
isGeneratingUiBackground
? '生成中'
: hasGeneratedUiBackground
? '重新生成'
: '生成UI背景'
}
submitCostLabel={`· ${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
submitDisabled={
!firstLevel ||
!normalizedPrompt ||
isBusy ||
isGeneratingUiBackground
}
labels={{
imageField: 'UI背景预览',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={(value) => {
if (!firstLevel) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: value,
});
}}
onSubmit={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
setIsCostConfirmOpen(true);
}}
/>
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost mt-3 min-h-11 w-full justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI
</button>
</section>
{isPreviewOpen ? (
<PuzzleUiRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
imageRefreshKey={imageRefreshKey}
puzzleImageSrc={formalImageSrc}
title={editState.workTitle || firstLevel?.levelName || '拼图'}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
{isCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-ui-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-ui-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={
!firstLevel ||
!normalizedPrompt ||
isBusy ||
isGeneratingUiBackground
}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function PuzzleUiRuntimePreviewPanel({
backgroundPreviewSrc,
imageRefreshKey,
puzzleImageSrc,
title,
onClose,
}: {
backgroundPreviewSrc: string;
imageRefreshKey: string;
puzzleImageSrc: string;
title: string;
onClose: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[139] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="UI预览"
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-sm flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
UI预览
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="mx-auto aspect-[9/16] max-h-[min(78dvh,42rem)] w-full max-w-[22rem] overflow-hidden rounded-[1.4rem] border border-white/22 bg-[#16211f] shadow-[0_18px_55px_rgba(15,23,42,0.24)]">
<div className="relative flex h-full w-full flex-col overflow-hidden px-3 pb-4 pt-3 text-white">
<ResolvedAssetImage
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"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18)_0%,rgba(15,23,42,0.05)_45%,rgba(15,23,42,0.24)_100%)]" />
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-2">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
<ArrowLeft size={20} />
</span>
<span className="min-w-0 truncate rounded-full border border-white/18 bg-black/26 px-3 py-2 text-center text-sm font-black backdrop-blur">
{title}
</span>
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
<LayoutTemplate className="h-4 w-4" />
</span>
</header>
<section className="relative z-10 mt-4 flex min-h-0 flex-1 items-center justify-center">
<div
className="relative aspect-square max-w-full overflow-hidden rounded-[1.25rem] border-[8px] border-white/88 bg-white/92 shadow-[0_20px_44px_rgba(15,23,42,0.32),inset_0_0_0_2px_rgba(15,23,42,0.12)]"
style={{ width: 'min(88%, 52dvh, 100%)' }}
aria-label="拼图区边界"
>
{puzzleImageSrc ? (
<ResolvedAssetImage
src={puzzleImageSrc}
refreshKey={`${imageRefreshKey}:ui-runtime-board`}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full grid-cols-3 grid-rows-3 gap-1 bg-slate-100 p-2">
{Array.from({ length: 9 }).map((_, index) => (
<span
key={index}
className="rounded-[0.45rem] bg-slate-300/70"
/>
))}
</div>
)}
<div className="pointer-events-none absolute inset-0 rounded-[0.82rem] border-2 border-black/18" />
</div>
</section>
<footer className="relative z-10 mt-3 rounded-[1.35rem] border border-white/16 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<span
key={index}
className="h-12 rounded-xl bg-white/14 sm:h-14"
/>
))}
</div>
</footer>
</div>
</div>
</div>
</section>
</div>,
document.body,
);
}
function PuzzleAssetConfigTab({
activeAssetConfigTab,
editState,
imageRefreshKey,
isBusy,
uiBackgroundGeneration,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
}: {
activeAssetConfigTab: PuzzleAssetConfigTabId;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
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}
uiBackgroundGeneration={uiBackgroundGeneration}
onChange={onChange}
onGenerate={onGenerateUiBackground}
/>
) : null}
</div>
);
}
function PuzzleResultActionBar({
actionError,
editState,
@@ -1770,8 +1477,6 @@ export function PuzzleResultView({
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('work');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<PuzzleAssetConfigTabId>('ui');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
@@ -1782,8 +1487,6 @@ export function PuzzleResultView({
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
null,
);
const [uiBackgroundGeneration, setUiBackgroundGeneration] =
useState<PuzzleUiBackgroundGenerationState>(null);
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
Record<string, PuzzleLevelGenerationRuntime>
>({});
@@ -1799,18 +1502,11 @@ export function PuzzleResultView({
latestEditStateRef.current = editState;
}, [editState]);
useEffect(() => {
if (error) {
setUiBackgroundGeneration(null);
}
}, [error]);
useEffect(() => {
if (!draft) {
setEditState(null);
latestEditStateRef.current = null;
setActiveLevelId(null);
setUiBackgroundGeneration(null);
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
@@ -1831,7 +1527,7 @@ export function PuzzleResultView({
nextRuntimes[level.levelId] =
current[level.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
estimateSeconds: resolvePuzzleLevelGenerationEstimateSeconds(),
};
}
});
@@ -1846,19 +1542,6 @@ export function PuzzleResultView({
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
setUiBackgroundGeneration((current) => {
if (
current &&
mergedState.levels.some(
(level) =>
level.levelId === current.levelId &&
resolvePuzzleUiBackgroundSource(level),
)
) {
return null;
}
return current;
});
}, [draft]);
const syncedDraft = useMemo(() => {
@@ -1933,6 +1616,16 @@ export function PuzzleResultView({
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
uiBackgroundImageObjectKey:
level.uiBackgroundImageObjectKey?.trim() || null,
levelSceneImageSrc: level.levelSceneImageSrc?.trim() || null,
levelSceneImageObjectKey:
level.levelSceneImageObjectKey?.trim() || null,
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
uiSpritesheetImageObjectKey:
level.uiSpritesheetImageObjectKey?.trim() || null,
levelBackgroundImageSrc:
level.levelBackgroundImageSrc?.trim() || null,
levelBackgroundImageObjectKey:
level.levelBackgroundImageObjectKey?.trim() || null,
generationStatus: level.generationStatus || 'idle',
})),
};
@@ -2006,16 +1699,18 @@ export function PuzzleResultView({
);
}
const updateLevel = (nextLevel: PuzzleDraftLevel) => {
const updateLevel = (
nextLevel: PuzzleDraftLevel,
estimateSeconds = resolvePuzzleLevelGenerationEstimateSeconds(),
) => {
setGenerationRuntimeByLevelId((current) => {
if (nextLevel.generationStatus === 'generating') {
return {
...current,
[nextLevel.levelId]:
current[nextLevel.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
},
[nextLevel.levelId]: {
startedAtMs: current[nextLevel.levelId]?.startedAtMs ?? Date.now(),
estimateSeconds,
},
};
}
@@ -2039,17 +1734,6 @@ export function PuzzleResultView({
);
};
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
...syncedDraft,
levelName: level.levelName,
summary: editState.workDescription.trim(),
candidates: level.candidates,
selectedCandidateId: level.selectedCandidateId,
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
coverAssetId: level.coverAssetId,
generationStatus: level.generationStatus,
levels: [level],
});
const canStartTestRun = Boolean(onStartTestRun && primaryImageSrc);
return (
@@ -2132,46 +1816,6 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'assets' ? (
<PuzzleAssetConfigTab
activeAssetConfigTab={activeAssetConfigTab}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
uiBackgroundGeneration={uiBackgroundGeneration}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerateUiBackground={(prompt) => {
const firstLevel = editState.levels[0] ?? null;
if (!firstLevel) {
return;
}
setUiBackgroundGeneration({
levelId: firstLevel.levelId,
prompt,
});
onExecuteAction({
action: 'generate_puzzle_ui_background',
levelId: firstLevel.levelId,
promptText: prompt,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(
editState.levels.map((level, index) =>
index === 0
? {
...level,
uiBackgroundPrompt: prompt,
}
: level,
),
),
});
}}
/>
) : null}
</div>
{error ? (
@@ -2230,15 +1874,18 @@ export function PuzzleResultView({
nextLevel,
promptText,
referenceImageSrc,
referenceImageSrcs,
imageModel,
aiRedraw,
estimateSeconds,
) => {
updateLevel(nextLevel);
updateLevel(nextLevel, estimateSeconds);
onExecuteAction({
action: 'generate_puzzle_images',
levelId: nextLevel.levelId,
promptText,
referenceImageSrc,
referenceImageSrcs,
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: aiRedraw ?? true,
candidateCount: 1,
@@ -2257,7 +1904,7 @@ export function PuzzleResultView({
onLevelChange={updateLevel}
onStartTestRun={
onStartTestRun
? (level) => onStartTestRun(buildLevelDraft(level))
? (levelId) => onStartTestRun(syncedDraft, { levelId })
: undefined
}
/>

View File

@@ -1,6 +1,13 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
@@ -21,6 +28,31 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
}),
}));
vi.mock('../../services/puzzle-runtime/puzzleUiSpritesheetParser', async () => {
const actual = await vi.importActual<
typeof import('../../services/puzzle-runtime/puzzleUiSpritesheetParser')
>('../../services/puzzle-runtime/puzzleUiSpritesheetParser');
return {
...actual,
loadPuzzleUiSpritesheetLayout: vi.fn(async () => ({
width: 32,
height: 24,
regions: {
back: { x: 3, y: 2, width: 4, height: 4 },
settings: { x: 24, y: 3, width: 5, height: 5 },
next: { x: 11, y: 11, width: 10, height: 5 },
hint: { x: 2, y: 20, width: 5, height: 3 },
reference: { x: 13, y: 19, width: 6, height: 4 },
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
},
hitRegions: {
back: { x: 4, y: 3, width: 2, height: 2 },
hint: { x: 3, y: 21, width: 3, height: 1 },
},
})),
};
});
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
@@ -183,7 +215,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
allTilesResolved: false,
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
piece.pieceId === 'piece-0'
? {...piece, currentRow: 0, currentCol: 0}
? { ...piece, currentRow: 0, currentCol: 0 }
: piece,
),
},
@@ -200,11 +232,15 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
/>,
);
const piece = container.querySelector('[data-piece-id="piece-0"]') as HTMLElement | null;
const piece = container.querySelector(
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
if (!piece) {
throw new Error('缺少测试拼图片');
}
const board = container.querySelector('[data-testid="puzzle-board"]') as HTMLElement | null;
const board = container.querySelector(
'[data-testid="puzzle-board"]',
) as HTMLElement | null;
if (!board) {
throw new Error('缺少测试棋盘');
}
@@ -213,17 +249,18 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
configurable: true,
value: requestAnimationFrame,
});
board.getBoundingClientRect = () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 300,
bottom: 300,
width: 300,
height: 300,
toJSON: () => ({}),
} as DOMRect);
board.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: 300,
bottom: 300,
width: 300,
height: 300,
toJSON: () => ({}),
}) as DOMRect;
act(() => {
dispatchPointerEvent(piece, 'pointerdown', {
@@ -259,7 +296,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
expect(onDragPiece).toHaveBeenCalledTimes(1);
expect(onDragPiece).toHaveBeenCalledWith(
expect.objectContaining({pieceId: 'piece-0'}),
expect.objectContaining({ pieceId: 'piece-0' }),
);
Object.defineProperty(window, 'requestAnimationFrame', {
@@ -468,9 +505,7 @@ test('拖拽合并大块时底层单格不显示选中色块', () => {
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
expect(basePiece?.className).toContain('puzzle-runtime-piece--merged');
expect(basePiece?.className).not.toContain(
'puzzle-runtime-piece--selected',
);
expect(basePiece?.className).not.toContain('puzzle-runtime-piece--selected');
unmount();
Object.defineProperty(window, 'requestAnimationFrame', {
@@ -657,7 +692,7 @@ test('首次退出引导的作品改造按钮进入改造流程', () => {
expect(screen.queryByRole('dialog')).toBeNull();
});
test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
test('顶部不显示作者,关卡标题和倒计时使用游戏铭牌结构', () => {
const runWithoutNext: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
@@ -681,12 +716,26 @@ test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
expect(screen.queryByText('测试作者')).toBeNull();
expect(screen.getByText('第 1 关')).toBeTruthy();
expect(screen.getByText('潮雾拼图')).toBeTruthy();
const levelLogo = screen.getByTestId(
'puzzle-runtime-level-logo',
) as HTMLImageElement;
expect(levelLogo.getAttribute('src')).toContain('logo.png');
expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy();
expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull();
expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy();
expect(
screen.getByText('潮雾拼图').closest('.puzzle-runtime-level-title-card'),
).toBeTruthy();
expect(timer.className).toContain('puzzle-runtime-timer');
expect(timer.className).toContain('text-lg');
expect(timer.className).not.toContain('text-2xl');
expect(hintButton.className).toContain('h-16');
expect(referenceButton.className).toContain('h-16');
expect(freezeButton.className).toContain('h-16');
expect(hintButton.textContent).not.toContain('提示');
expect(referenceButton.textContent).not.toContain('原图');
expect(freezeButton.textContent).not.toContain('冻结');
expect(hintButton.parentElement?.className).toContain('grid-cols-3');
expect(hintButton.parentElement?.className).not.toContain(
'puzzle-runtime-toolbar',
);
expect(screen.queryByText('等待下一关候选')).toBeNull();
});
@@ -720,6 +769,167 @@ test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
expect(backgroundImage).toBeTruthy();
});
test('运行态优先把关卡背景图渲染为舞台背景', async () => {
const runWithLevelBackground: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
uiBackgroundImageSrc: '/generated-puzzle-assets/session/ui/legacy.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background/background.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
remainingMs: 300_000,
timeLimitMs: 300_000,
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithLevelBackground}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
expect(
container.querySelector(
'img[src="/generated-puzzle-assets/session/level-background/background.png"]',
),
).toBeTruthy();
expect(
container.querySelector(
'img[src="/generated-puzzle-assets/session/ui/legacy.png"]',
),
).toBeNull();
await waitFor(() => {
expect(
screen
.getByRole('button', { name: '提示' })
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]'),
).toBeTruthy();
});
});
test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关和道具按钮', async () => {
const runWithSpritesheet: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'cleared',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
remainingMs: 120_000,
timeLimitMs: 300_000,
},
nextLevelMode: 'sameWork',
nextLevelProfileId: 'profile-1',
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithSpritesheet}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
await screen.findByRole('button', { name: '下一关' });
expect(
screen
.getByRole('button', { name: '返回上一页' })
.querySelector('[data-puzzle-ui-sprite="back"]'),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '返回上一页' }).className,
).toContain('puzzle-runtime-icon-button--sprite');
expect(
screen.getByRole('button', { name: '返回上一页' }).className,
).toContain('puzzle-runtime-icon-button--precise-hit');
expect(
screen
.getByRole('button', { name: '返回上一页' })
.querySelector('[data-puzzle-ui-sprite-hit-zone="back"]'),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '返回上一页' }).className,
).not.toContain('rounded-full');
expect(
screen
.getByRole('button', { name: '打开拼图设置' })
.querySelector('[data-puzzle-ui-sprite="settings"]'),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '打开拼图设置' }).className,
).toContain('puzzle-runtime-icon-button--sprite');
expect(
screen.getByRole('button', { name: '打开拼图设置' }).className,
).toContain('puzzle-runtime-icon-button--precise-hit');
expect(
screen.getByRole('button', { name: '打开拼图设置' }).className,
).not.toContain('rounded-full');
const nextSprite = screen
.getByRole('button', { name: '下一关' })
.querySelector('[data-puzzle-ui-sprite="next"]') as HTMLElement | null;
expect(nextSprite).toBeTruthy();
expect(nextSprite?.style.backgroundSize).toBe('320% 480%');
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%');
expect(
screen
.getByRole('button', { name: '提示' })
.querySelector('[data-puzzle-ui-sprite="hint"]'),
).toBeTruthy();
const hintHitZone = screen
.getByRole('button', { name: '提示' })
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]') as HTMLElement | null;
expect(hintHitZone).toBeTruthy();
expect(screen.getByRole('button', { name: '提示' }).className).toContain(
'puzzle-runtime-sprite-tool-button--precise-hit',
);
expect(hintHitZone?.className).toContain(
'puzzle-runtime-ui-sprite-hit-zone',
);
expect(hintHitZone?.style.left).toBe('20%');
expect(hintHitZone?.style.top).toBe('33.33333333333333%');
expect(hintHitZone?.style.width).toBe('60%');
expect(hintHitZone?.style.height).toBe('33.33333333333333%');
expect(
screen
.getByRole('button', { name: '提示' })
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
).toContain('puzzle-runtime-bottom-ui-sprite');
expect(
screen
.getByRole('button', { name: '提示' })
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
).not.toContain('w-full');
expect(screen.getByRole('button', { name: '提示' }).textContent).toBe('');
const referenceSprite = screen
.getByRole('button', { name: '原图' })
.querySelector('[data-puzzle-ui-sprite="reference"]') as HTMLElement | null;
expect(referenceSprite).toBeTruthy();
expect(referenceSprite?.style.backgroundSize).toBe('533.3333333333333% 600%');
expect(referenceSprite?.style.backgroundPosition).toBe('50% 95%');
expect(referenceSprite?.style.aspectRatio).toBe('6 / 4');
expect(referenceSprite?.className).toContain('puzzle-runtime-bottom-ui-sprite');
expect(referenceSprite?.className).not.toContain('w-full');
expect(screen.getByRole('button', { name: '原图' }).textContent).toBe('');
expect(
screen
.getByRole('button', { name: '冻结' })
.querySelector('[data-puzzle-ui-sprite="freezeTime"]'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe('');
});
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
const runWithUiBackground: PuzzleRunSnapshot = {
...clearedRun,
@@ -994,6 +1204,36 @@ test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
expect(within(dialog).queryByText('保存并退出')).toBeNull();
});
test('推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪', () => {
vi.useFakeTimers();
renderPuzzleRuntime(
<div className="platform-recommend-runtime-viewport">
<PuzzleRuntimeShell
run={clearedRun}
embedded
hideExitControls
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>
</div>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
const dialog = screen.getByRole('dialog', { name: '通关完成' });
const overlay = dialog.closest('.puzzle-runtime-modal-overlay');
expect(overlay?.className).toContain('puzzle-runtime-modal-overlay--fixed');
expect(overlay?.closest('.platform-recommend-runtime-viewport')).toBeNull();
vi.useRealTimers();
});
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
@@ -1066,11 +1306,11 @@ test('合并块不叠加可见轮廓和单块阴影', () => {
const mergedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
coverImageSrc: '/puzzle.png',
board: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
coverImageSrc: '/puzzle.png',
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
mergedGroups: [
@@ -1531,7 +1771,9 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
);
const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' }));
fireEvent.click(
within(failedDialog).getByRole('button', { name: '重新开始' }),
);
expect(onRestartLevel).toHaveBeenCalledTimes(1);
fireEvent.click(

View File

@@ -2,17 +2,23 @@ import {
ArrowLeft,
ArrowRight,
Clock,
Eye,
Lightbulb,
Loader2,
Settings,
Snowflake,
Sparkles,
Trophy,
X,
} from 'lucide-react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import puzzleLevelLogo from '../../../media/logo.png';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
@@ -34,6 +40,13 @@ import {
type RuntimeInputPoint,
} from '../../services/input-devices';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import {
buildPuzzleUiSpriteBackgroundStyle,
buildPuzzleUiSpriteHitZoneStyle,
loadPuzzleUiSpritesheetLayout,
type PuzzleUiSpriteKind,
type PuzzleUiSpritesheetLayout,
} from '../../services/puzzle-runtime/puzzleUiSpritesheetParser';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
@@ -113,6 +126,42 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
}));
}
function PuzzleUiSprite({
src,
kind,
layout,
className = '',
withHitZone = false,
}: {
src: string | null;
kind: PuzzleUiSpriteKind;
layout: PuzzleUiSpritesheetLayout | null;
className?: string;
withHitZone?: boolean;
}) {
if (!src) {
return null;
}
return (
<span
aria-hidden="true"
data-puzzle-ui-sprite={kind}
className={`relative inline-block shrink-0 bg-no-repeat ${className}`}
style={buildPuzzleUiSpriteBackgroundStyle({ src, kind, layout })}
>
{withHitZone ? (
<span
aria-hidden="true"
data-puzzle-ui-sprite-hit-zone={kind}
className="puzzle-runtime-ui-sprite-hit-zone absolute"
style={buildPuzzleUiSpriteHitZoneStyle({ kind, layout })}
/>
) : null}
</span>
);
}
function buildMergedGroupViewModels(
groups: PuzzleMergedGroupState[],
pieces: PuzzleBoardPieceViewModel[],
@@ -225,7 +274,9 @@ function resolveRuntimeElapsedMs(
) {
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
if (level.status !== 'playing') {
return level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs);
return (
level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs)
);
}
const timeLimitMs = level.timeLimitMs || level.remainingMs;
@@ -369,8 +420,7 @@ export function PuzzleRuntimeShell({
const selectedPieceIdRef = useRef<string | null>(null);
const selectedPieceBeforeInputRef = useRef<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = useState(false);
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
null,
);
@@ -414,6 +464,8 @@ export function PuzzleRuntimeShell({
pieceId: string;
groupId: string | null;
} | null>(null);
const [uiSpritesheetLayout, setUiSpritesheetLayout] =
useState<PuzzleUiSpritesheetLayout | null>(null);
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController<string>(),
);
@@ -461,16 +513,27 @@ export function PuzzleRuntimeShell({
const currentLevelStartedAtMs = currentLevel?.startedAtMs ?? null;
const currentLevelStatus = currentLevel?.status ?? null;
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
const backgroundMusicSrc = currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
const backgroundMusicSrc =
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const { resolvedUrl: resolvedBackgroundMusicSrc } = useResolvedAssetReadUrl(backgroundMusicSrc);
const { resolvedUrl: resolvedBackgroundMusicSrc } =
useResolvedAssetReadUrl(backgroundMusicSrc);
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
resolvePuzzleUiBackgroundSource(currentLevel) ?? null,
);
const rawUiSpritesheetImage =
currentLevel?.uiSpritesheetImageSrc?.trim() ||
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
: null);
const { resolvedUrl: resolvedUiSpritesheetImage } = useResolvedAssetReadUrl(
rawUiSpritesheetImage,
);
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
@@ -483,6 +546,34 @@ export function PuzzleRuntimeShell({
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
useEffect(() => {
if (!rawUiSpritesheetImage) {
setUiSpritesheetLayout(null);
return;
}
const controller = new AbortController();
setUiSpritesheetLayout(null);
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
signal: controller.signal,
})
.then((layout) => {
if (!controller.signal.aborted) {
setUiSpritesheetLayout(layout);
}
})
.catch(() => {
if (!controller.signal.aborted) {
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
setUiSpritesheetLayout(null);
}
});
return () => {
controller.abort();
};
}, [rawUiSpritesheetImage]);
useEffect(() => {
currentLevelRef.current = currentLevel;
}, [currentLevel]);
@@ -608,16 +699,16 @@ export function PuzzleRuntimeShell({
}, PUZZLE_MERGE_FLASH_DURATION_MS);
}, [board, currentLevel?.status, mergedGroups]);
const resolvePieceCellElement = (pieceId: string) => {
const resolvePieceCellElement = useCallback((pieceId: string) => {
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
const pieceCellElement =
(pieceElement?.parentElement as HTMLDivElement | null) ??
pieceCellElementRefMap.current.get(pieceId) ??
null;
return pieceCellElement;
};
}, []);
const resetDragVisualTarget = () => {
const resetDragVisualTarget = useCallback(() => {
const dragVisualTarget = dragVisualTargetRef.current;
setDragRenderTarget(null);
if (!dragVisualTarget) {
@@ -653,7 +744,7 @@ export function PuzzleRuntimeShell({
}
dragVisualTargetRef.current = null;
};
}, [resolvePieceCellElement]);
const resetDragInteractionState = () => {
dragSessionRef.current = null;
@@ -728,7 +819,7 @@ export function PuzzleRuntimeShell({
() => () => {
resetDragVisualTarget();
},
[],
[resetDragVisualTarget],
);
const clearPresentationTimeouts = () => {
@@ -773,7 +864,7 @@ export function PuzzleRuntimeShell({
}, [isUiPauseActive]);
useEffect(() => {
if (!currentLevel || currentLevel.status !== 'playing') {
if (currentLevelStatus !== 'playing') {
return;
}
@@ -782,28 +873,33 @@ export function PuzzleRuntimeShell({
}, 250);
return () => window.clearInterval(timerId);
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
}, [currentLevelIndex, currentLevelStatus, runtimeRunId]);
useEffect(() => {
if (!run || !currentLevel || currentLevel.status === 'cleared') {
if (
!runtimeRunId ||
currentLevelIndex === null ||
currentLevelStartedAtMs === null ||
currentLevelStatus === 'cleared'
) {
return;
}
if (displayRemainingMs > 0) {
return;
}
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
const syncKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}`;
if (timeExpiredSyncKeyRef.current === syncKey) {
return;
}
timeExpiredSyncKeyRef.current = syncKey;
void onTimeExpiredRef.current?.();
}, [
currentLevel?.levelIndex,
currentLevel?.startedAtMs,
currentLevel?.status,
currentLevelIndex,
currentLevelStartedAtMs,
currentLevelStatus,
displayRemainingMs,
run?.runId,
runtimeRunId,
]);
useEffect(() => {
@@ -945,9 +1041,7 @@ export function PuzzleRuntimeShell({
return;
}
const targetCell = board
? resolveRuntimeInputGridCell(point, board)
: null;
const targetCell = board ? resolveRuntimeInputGridCell(point, board) : null;
if (!targetCell) {
return;
}
@@ -959,10 +1053,7 @@ export function PuzzleRuntimeShell({
});
};
const resolveBoardInputPointFromClient = (
clientX: number,
clientY: number,
) =>
const resolveBoardInputPointFromClient = (clientX: number, clientY: number) =>
createRuntimeInputPointFromClient(
clientX,
clientY,
@@ -976,7 +1067,9 @@ export function PuzzleRuntimeShell({
return;
}
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
dragSessionRef.current = {
pieceId: session.targetId,
inputId: session.inputId,
@@ -995,14 +1088,18 @@ export function PuzzleRuntimeShell({
runtimeDragInputControllerRef.current.setOptions({
dragThresholdPx: 8,
onPress: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
commitSelectedPieceId(session.targetId);
triggerPuzzlePiecePressFeedback(musicVolume);
},
onDragStart: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
setDragRenderTarget({
pieceId: session.targetId,
@@ -1014,7 +1111,9 @@ export function PuzzleRuntimeShell({
syncRuntimeDragFromController(session);
},
onDrop: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint);
commitSelectedPieceId(null);
@@ -1073,7 +1172,9 @@ export function PuzzleRuntimeShell({
});
};
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const handlePiecePointerMove = (
event: React.PointerEvent<HTMLDivElement>,
) => {
event.preventDefault();
runtimeDragInputControllerRef.current.move({
inputId: `pointer:${event.pointerId}`,
@@ -1094,8 +1195,7 @@ export function PuzzleRuntimeShell({
: runtimeStatus === 'failed'
? '失败'
: '进行中';
const nextLevelMode =
run.nextLevelMode ?? 'none';
const nextLevelMode = run.nextLevelMode ?? 'none';
const recommendedNextWorks = run.recommendedNextWorks ?? [];
const hasSimilarWorkChoices =
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
@@ -1106,8 +1206,7 @@ export function PuzzleRuntimeShell({
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
!hasSimilarWorkChoices
: Boolean(run.recommendedNextProfileId)));
const canShowNextAction =
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
const canShowNextAction = canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
const levelLabel = ` ${currentLevel.levelIndex} `;
const exitPromptProfileId = currentLevel.profileId.trim();
const shouldHideBackButton = hideBackButton || hideExitControls;
@@ -1119,6 +1218,9 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const clearResultOverlayClassName = embedded
? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm`
: 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm';
const handleBackRequest = () => {
if (hideExitControls) {
return;
@@ -1230,6 +1332,157 @@ export function PuzzleRuntimeShell({
}
};
const clearResultDialog = isClearResultOpen ? (
<div className={clearResultOverlayClassName}>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-clear-result-title"
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
>
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
<div className="min-w-0">
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
<Trophy className="h-4 w-4" />
</div>
<h2
id="puzzle-clear-result-title"
className="truncate text-lg font-black"
>
通关完成
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
{currentLevel.levelName}
</div>
</div>
<button
type="button"
aria-label="关闭通关弹窗"
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
onClick={() => {
setDismissedClearKey(clearResultKey);
}}
>
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
<div className="flex items-center gap-3">
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
<Clock className="h-4 w-4" />
</span>
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
通关时间
</span>
</div>
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
{formatElapsedMs(currentLevel.elapsedMs)}
</span>
</div>
<div className="mt-4">
<div className="mb-2 text-sm font-bold">排行榜</div>
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
<span>名次</span>
<span>昵称</span>
<span className="text-right">通关时间</span>
</div>
<div className="max-h-56 overflow-y-auto">
{leaderboardEntries.length > 0 ? (
leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'puzzle-runtime-leaderboard-row--active'
: 'puzzle-runtime-leaderboard-row border-t'
}`}
>
<span className="font-mono font-black">
#{entry.rank}
</span>
<span className="min-w-0">
<span className="block truncate font-semibold leading-tight">
{entry.nickname}
</span>
{entry.visibleTags?.length ? (
<span className="puzzle-runtime-leaderboard-tags">
{entry.visibleTags.map((tag) => (
<span
className="puzzle-runtime-leaderboard-tag"
key={tag}
>
{tag}
</span>
))}
</span>
) : null}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
</div>
))
) : (
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
</div>
)}
</div>
</div>
</div>
{hasSimilarWorkChoices ? (
<div className="mt-4">
<div className="grid gap-2 sm:grid-cols-3">
{recommendedNextWorks.slice(0, 3).map((item) => (
<PuzzleNextWorkCard
key={item.profileId}
item={item}
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({ profileId: item.profileId });
}}
/>
))}
</div>
</div>
) : null}
</div>
{canAdvanceDefaultNextLevel ? (
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
下一关
</button>
</footer>
) : null}
</section>
</div>
) : null;
const clearResultLayer =
embedded && clearResultDialog && typeof document !== 'undefined'
? createPortal(clearResultDialog, document.body)
: clearResultDialog;
return (
<div
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
@@ -1274,26 +1527,50 @@ export function PuzzleRuntimeShell({
onClick={handleBackRequest}
aria-label="返回上一页"
disabled={shouldHideBackButton}
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
shouldHideBackButton
? 'invisible pointer-events-none'
: 'inline-flex'
}`}
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center sm:h-11 sm:w-11 ${
hasUiSpritesheet
? 'puzzle-runtime-icon-button--sprite'
: 'rounded-full'
} ${
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
} ${shouldHideBackButton ? 'invisible pointer-events-none' : 'inline-flex'}`}
>
<ArrowLeft className="h-4 w-4" />
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="back"
layout={uiSpritesheetLayout}
className={`${
hasUiSpritesheet
? 'puzzle-runtime-top-ui-sprite'
: 'h-7 w-7 rounded-full'
}`}
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<ArrowLeft className="h-4 w-4" />
)}
</button>
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] px-3 py-2 text-center sm:max-w-[18rem] sm:px-4">
<div className="flex max-w-full items-center justify-center gap-1.5">
<span className="puzzle-runtime-level-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold sm:text-[11px]">
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
<span aria-hidden="true" className="puzzle-runtime-level-logo">
<img
src={puzzleLevelLogo}
alt=""
data-testid="puzzle-runtime-level-logo"
className="puzzle-runtime-level-logo__image"
draggable={false}
/>
</span>
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
{levelLabel}
</span>
<span className="min-w-0 truncate text-sm font-black sm:text-base">
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
{currentLevel.levelName}
</span>
</div>
<div
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 font-mono text-lg font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.2)] sm:text-xl ${
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none sm:text-xl ${
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
? 'puzzle-runtime-timer--urgent'
: 'puzzle-runtime-timer'
@@ -1309,9 +1586,28 @@ export function PuzzleRuntimeShell({
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开拼图设置"
title="打开拼图设置"
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
className={`puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11 ${
hasUiSpritesheet
? 'puzzle-runtime-icon-button--sprite'
: 'rounded-full'
} ${
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
}`}
>
<Settings className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.25)] sm:h-[1.4rem] sm:w-[1.4rem]" />
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="settings"
layout={uiSpritesheetLayout}
className={`${
hasUiSpritesheet
? 'puzzle-runtime-top-ui-sprite'
: 'h-7 w-7 rounded-full'
}`}
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<Settings className="h-4 w-4" />
)}
</button>
</div>
</div>
@@ -1408,9 +1704,7 @@ export function PuzzleRuntimeShell({
}`}
style={{
clipPath: isMerged ? undefined : singlePieceClipUrl,
WebkitClipPath: isMerged
? undefined
: singlePieceClipUrl,
WebkitClipPath: isMerged ? undefined : singlePieceClipUrl,
zIndex: resolveDraggedPieceLayer(
piece?.pieceId,
draggingPieceId,
@@ -1526,10 +1820,7 @@ export function PuzzleRuntimeShell({
</defs>
<g clipPath={`url(#${mergedGroupClipId})`}>
{group.pieces.map((piece) => (
<g
key={piece.pieceId}
data-merged-piece-visual="true"
>
<g key={piece.pieceId} data-merged-piece-visual="true">
<clipPath
id={sanitizeSvgId(
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
@@ -1656,23 +1947,47 @@ export function PuzzleRuntimeShell({
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
>
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
<ArrowRight className="h-4 w-4" />
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="next"
layout={uiSpritesheetLayout}
className="h-8 w-12 rounded-full"
/>
{resolvedUiSpritesheetImage ? null : (
<ArrowRight className="h-4 w-4" />
)}
</button>
) : null}
<div className="puzzle-runtime-toolbar flex items-center justify-center gap-2 rounded-full p-2 sm:gap-3">
<div className="grid w-full max-w-[23rem] grid-cols-3 items-center justify-items-center gap-3 px-1 sm:max-w-[26rem] sm:gap-4">
<button
type="button"
disabled={isInteractionLocked}
aria-label="提示"
onClick={() => openPropDialog('hint', '使用提示')}
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
resolvedUiSpritesheetImage
? 'puzzle-runtime-sprite-tool-button--precise-hit'
: ''
}`}
>
<Lightbulb className="puzzle-runtime-tool-button__warm h-6 w-6" />
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="hint"
layout={uiSpritesheetLayout}
className="puzzle-runtime-bottom-ui-sprite"
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<span className="puzzle-runtime-tool-button__warm text-lg font-black">
?
</span>
)}
</button>
<button
type="button"
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
aria-label="原图"
aria-pressed={isOriginalImageViewerVisible}
onClick={() => {
if (isOriginalImageViewerVisible) {
@@ -1681,23 +1996,50 @@ export function PuzzleRuntimeShell({
}
openPropDialog('reference', '查看原图');
}}
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45 ${
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
isOriginalImageViewerVisible
? 'puzzle-runtime-tool-button--active'
: 'puzzle-runtime-tool-button'
} ${
resolvedUiSpritesheetImage
? 'puzzle-runtime-sprite-tool-button--precise-hit'
: ''
}`}
>
<Eye className="h-6 w-6" />
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="reference"
layout={uiSpritesheetLayout}
className="puzzle-runtime-bottom-ui-sprite"
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<span className="text-lg font-black">□</span>
)}
</button>
<button
type="button"
disabled={isInteractionLocked}
aria-label="冻结"
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
resolvedUiSpritesheetImage
? 'puzzle-runtime-sprite-tool-button--precise-hit'
: ''
}`}
>
<Snowflake className="puzzle-runtime-tool-button__cool h-6 w-6" />
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="freezeTime"
layout={uiSpritesheetLayout}
className="puzzle-runtime-bottom-ui-sprite"
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<span className="puzzle-runtime-tool-button__cool text-lg font-black">
*
</span>
)}
</button>
</div>
</div>
@@ -1863,9 +2205,7 @@ export function PuzzleRuntimeShell({
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
音频
</div>
<div className="mt-2 text-sm font-semibold">
</div>
<div className="mt-2 text-sm font-semibold">音乐音量</div>
</div>
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
{Math.round(musicVolume * 100)}%
@@ -1897,24 +2237,26 @@ export function PuzzleRuntimeShell({
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft">关卡</span>
<span className="font-semibold">
{levelLabel}
</span>
<span className="font-semibold">{levelLabel}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft"></span>
<span className="puzzle-runtime-dialog__soft">
已完成关卡
</span>
<span className="font-semibold">
{run.clearedLevelCount}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft"></span>
<span className="font-semibold">
{statusLabel}
<span className="puzzle-runtime-dialog__soft">
当前状态
</span>
<span className="font-semibold">{statusLabel}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft"></span>
<span className="puzzle-runtime-dialog__soft">
当前用时
</span>
<span className="font-mono font-semibold">
{formatElapsedMs(displayElapsedMs)}
</span>
@@ -1951,9 +2293,7 @@ export function PuzzleRuntimeShell({
) : null}
{isExitRemodelPromptOpen && !hideExitControls ? (
<div
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
>
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md">
<section
role="dialog"
aria-modal="true"
@@ -2011,10 +2351,7 @@ export function PuzzleRuntimeShell({
className="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
>
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
<h2
id="puzzle-failed-title"
className="text-lg font-black"
>
<h2 id="puzzle-failed-title" className="text-lg font-black">
关卡失败
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
@@ -2045,156 +2382,7 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{isClearResultOpen ? (
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-clear-result-title"
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
>
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
<div className="min-w-0">
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
<Trophy className="h-4 w-4" />
</div>
<h2
id="puzzle-clear-result-title"
className="truncate text-lg font-black"
>
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
{currentLevel.levelName}
</div>
</div>
<button
type="button"
aria-label="关闭通关弹窗"
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
onClick={() => {
setDismissedClearKey(clearResultKey);
}}
>
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
<div className="flex items-center gap-3">
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
<Clock className="h-4 w-4" />
</span>
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
</span>
</div>
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
{formatElapsedMs(currentLevel.elapsedMs)}
</span>
</div>
<div className="mt-4">
<div className="mb-2 text-sm font-bold">
</div>
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
<span></span>
<span></span>
<span className="text-right"></span>
</div>
<div className="max-h-56 overflow-y-auto">
{leaderboardEntries.length > 0 ? (
leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'puzzle-runtime-leaderboard-row--active'
: 'puzzle-runtime-leaderboard-row border-t'
}`}
>
<span className="font-mono font-black">
#{entry.rank}
</span>
<span className="min-w-0">
<span className="block truncate font-semibold leading-tight">
{entry.nickname}
</span>
{entry.visibleTags?.length ? (
<span className="puzzle-runtime-leaderboard-tags">
{entry.visibleTags.map((tag) => (
<span
className="puzzle-runtime-leaderboard-tag"
key={tag}
>
{tag}
</span>
))}
</span>
) : null}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
</div>
))
) : (
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
{isBusy
? '正在同步真实排行榜…'
: '暂无真实排行榜成绩'}
</div>
)}
</div>
</div>
</div>
{hasSimilarWorkChoices ? (
<div className="mt-4">
<div className="grid gap-2 sm:grid-cols-3">
{recommendedNextWorks.slice(0, 3).map((item) => (
<PuzzleNextWorkCard
key={item.profileId}
item={item}
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({ profileId: item.profileId });
}}
/>
))}
</div>
</div>
) : null}
</div>
{canAdvanceDefaultNextLevel ? (
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</footer>
) : null}
</section>
</div>
) : null}
{clearResultLayer}
</div>
</div>
);
@@ -2229,9 +2417,7 @@ function PuzzleNextWorkCard({
<div className="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
</div>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-black">
{item.levelName}
</div>
<div className="truncate text-sm font-black">{item.levelName}</div>
<div className="puzzle-runtime-dialog__soft mt-1 truncate text-xs font-semibold">
{item.authorDisplayName}
</div>

View File

@@ -2100,6 +2100,7 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
const nav = container.querySelector('.platform-bottom-nav');
expect(nav).toBeTruthy();
expect(nav?.classList.contains('platform-bottom-nav')).toBe(true);
const buttons = within(nav as HTMLElement).getAllByRole('button');
expect(buttons.map((button) => button.textContent)).toEqual([
@@ -2110,6 +2111,12 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
expect(
buttons[1]?.querySelector('.platform-bottom-nav__primary-action'),
).toBeTruthy();
expect(
buttons[0]?.querySelector('.platform-bottom-nav__active-mark'),
).toBeTruthy();
});
test('logged in draft bottom tab shows unread marker', () => {

View File

@@ -1166,13 +1166,21 @@ function PlatformTabButton({
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell">
<span
className={`platform-bottom-nav__icon-shell ${emphasized ? 'platform-bottom-nav__primary-action' : ''}`}
>
<Icon className="platform-bottom-nav__icon" />
{showDot ? (
<span aria-hidden="true" className="platform-nav-unread-dot" />
) : null}
</span>
<span className="platform-bottom-nav__label">{label}</span>
{active ? (
<span
aria-hidden="true"
className="platform-bottom-nav__active-mark"
/>
) : null}
</span>
</button>
);

View File

@@ -250,26 +250,22 @@ body {
62% {
opacity: 1;
transform:
translate3d(
transform: translate3d(
calc(-50% + var(--match3d-fly-dx, 0px) * 0.82),
calc(-50% + var(--match3d-fly-dy, 0px) * 0.82 - 10px),
0
)
scale(calc(var(--match3d-fly-scale, 0.68) * 1.06))
rotate(4deg);
scale(calc(var(--match3d-fly-scale, 0.68) * 1.06)) rotate(4deg);
}
100% {
opacity: 0;
transform:
translate3d(
transform: translate3d(
calc(-50% + var(--match3d-fly-dx, 0px)),
calc(-50% + var(--match3d-fly-dy, 0px)),
0
)
scale(var(--match3d-fly-scale, 0.68))
rotate(0deg);
scale(var(--match3d-fly-scale, 0.68)) rotate(0deg);
}
}
@@ -296,8 +292,7 @@ body {
}
.match3d-tray-token-shift {
animation: match3d-tray-token-shift 0.24s cubic-bezier(0.2, 0.8, 0.2, 1)
both;
animation: match3d-tray-token-shift 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) both;
will-change: transform;
}
@@ -309,8 +304,7 @@ body {
62% {
opacity: 1;
transform:
translate3d(
transform: translate3d(
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
0
@@ -320,8 +314,7 @@ body {
100% {
opacity: 0;
transform:
translate3d(
transform: translate3d(
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
0
@@ -359,8 +352,11 @@ body {
width: 4rem;
height: 4rem;
border-radius: 9999px;
background:
radial-gradient(circle, rgba(255, 255, 255, 0.95) 0 10%, transparent 12%),
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.95) 0 10%,
transparent 12%
),
radial-gradient(circle, rgba(255, 236, 157, 0.48) 0 42%, transparent 64%);
box-shadow:
0 0 22px rgba(255, 255, 255, 0.62),
@@ -388,8 +384,11 @@ body {
.match3d-merge-feedback-pulse {
animation: match3d-merge-feedback-pulse 0.52s ease-out both;
background:
radial-gradient(circle, rgba(255, 255, 255, 0.56) 0 18%, transparent 20%),
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.56) 0 18%,
transparent 20%
),
radial-gradient(circle, rgba(255, 255, 255, 0.28) 0 45%, transparent 68%);
}
@@ -640,6 +639,15 @@ body {
--platform-nav-item-icon-active-fill: rgba(255, 254, 252, 0.98);
--platform-nav-item-icon-active-text: #c7653d;
--platform-nav-icon-active-shadow: 0 12px 24px rgba(182, 98, 63, 0.16);
--platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef);
--platform-bottom-nav-border: rgba(224, 181, 139, 0.58);
--platform-bottom-nav-divider: rgba(225, 185, 145, 0.52);
--platform-bottom-nav-text: #5b2b19;
--platform-bottom-nav-muted-text: #7a4b37;
--platform-bottom-nav-active-text: #f15a17;
--platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12);
--platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.28);
--platform-bottom-nav-primary-text: #fffaf2;
--platform-profile-hero-fill: linear-gradient(
180deg,
rgba(255, 254, 252, 0.96),
@@ -897,6 +905,15 @@ body {
);
--platform-nav-item-icon-active-text: rgb(238 248 255);
--platform-nav-icon-active-shadow: 0 12px 24px rgba(8, 14, 42, 0.42);
--platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef);
--platform-bottom-nav-border: rgba(224, 181, 139, 0.64);
--platform-bottom-nav-divider: rgba(225, 185, 145, 0.56);
--platform-bottom-nav-text: #5b2b19;
--platform-bottom-nav-muted-text: #7a4b37;
--platform-bottom-nav-active-text: #f15a17;
--platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12);
--platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.3);
--platform-bottom-nav-primary-text: #fffaf2;
--platform-profile-hero-fill: linear-gradient(
180deg,
rgba(20, 24, 58, 0.96),
@@ -1832,7 +1849,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.creation-work-card.platform-interactive-card:hover {
transform: translateX(var(--creation-work-card-swipe-offset, 0)) translateY(-2px);
transform: translateX(var(--creation-work-card-swipe-offset, 0))
translateY(-2px);
border-color: var(--platform-surface-hover-border);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.14);
}
@@ -1849,7 +1867,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
justify-content: flex-end;
overflow: hidden;
border-radius: inherit;
background: linear-gradient(90deg, transparent 0%, rgba(244, 63, 94, 0.12) 62%);
background: linear-gradient(
90deg,
transparent 0%,
rgba(244, 63, 94, 0.12) 62%
);
opacity: var(--creation-work-card-action-opacity, 0);
pointer-events: none;
transition: opacity 160ms ease;
@@ -1915,7 +1937,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background-size: cover;
}
.creation-work-card__side-cover .custom-world-cover-artwork > div:first-of-type {
.creation-work-card__side-cover
.custom-world-cover-artwork
> div:first-of-type {
opacity: 0.18;
}
@@ -2451,22 +2475,24 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.platform-bottom-nav {
position: relative;
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: var(--platform-bottom-nav-height);
gap: var(--platform-bottom-nav-gap);
border: 1px solid var(--platform-desktop-panel-border);
border: 1px solid var(--platform-bottom-nav-border);
border-radius: var(--platform-bottom-nav-radius);
background: var(--platform-nav-fill);
background: var(--platform-bottom-nav-fill);
padding: var(--platform-bottom-nav-padding);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 16px 40px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px);
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 10px 24px rgba(119, 63, 25, 0.1);
backdrop-filter: blur(12px);
}
.platform-bottom-nav__button {
position: relative;
display: flex;
box-sizing: border-box;
min-width: 0;
@@ -2488,7 +2514,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transform 180ms ease;
}
.platform-bottom-nav__button:not(:first-child)::before {
position: absolute;
top: 50%;
left: calc(var(--platform-bottom-nav-gap) * -0.5 - 0.5px);
width: 1px;
height: 48%;
content: '';
background: var(--platform-bottom-nav-divider);
transform: translateY(-50%);
}
.platform-bottom-nav__button-content {
position: relative;
display: flex;
min-height: 100%;
min-width: 0;
@@ -2500,37 +2538,40 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.platform-bottom-nav__button:hover {
color: var(--platform-text-strong);
background: var(--platform-nav-item-hover-fill);
color: var(--platform-bottom-nav-active-text);
background: transparent;
}
.platform-bottom-nav__button--active {
border: 1px solid var(--platform-nav-active-border);
background: var(--platform-nav-active-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-bottom-nav-active-shadow);
border-color: transparent;
background: transparent;
color: var(--platform-bottom-nav-active-text);
box-shadow: none;
}
.platform-bottom-nav__button--primary {
transform: translateY(-0.18rem);
color: var(--platform-text-strong);
color: var(--platform-bottom-nav-active-text);
}
.platform-bottom-nav__button--primary .platform-bottom-nav__button-content {
transform: translateY(-0.32rem);
}
.platform-bottom-nav__button--primary .platform-bottom-nav__icon-shell {
width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
background: var(--platform-nav-active-fill);
box-shadow: var(--platform-nav-icon-active-shadow);
background: var(--platform-bottom-nav-primary-fill);
box-shadow: var(--platform-bottom-nav-primary-shadow);
}
.platform-bottom-nav__button--primary .platform-bottom-nav__icon {
width: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
height: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
color: var(--platform-nav-item-icon-active-text);
color: var(--platform-bottom-nav-primary-text);
}
.platform-bottom-nav__button--primary .platform-bottom-nav__label {
color: var(--platform-nav-item-text-active);
color: var(--platform-bottom-nav-active-text);
font-weight: 800;
}
@@ -2550,6 +2591,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transform 180ms ease;
}
.platform-bottom-nav__icon-shell {
background: transparent;
color: var(--platform-bottom-nav-text);
}
.platform-bottom-nav__icon-shell {
width: var(--platform-bottom-nav-icon-shell-size);
height: var(--platform-bottom-nav-icon-shell-size);
@@ -2568,6 +2614,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transition: color 180ms ease;
}
.platform-bottom-nav__icon {
color: var(--platform-bottom-nav-text);
stroke-width: 2.1;
}
.platform-nav-unread-dot {
position: absolute;
right: 0.16rem;
@@ -2591,11 +2642,32 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transition: color 180ms ease;
}
.platform-bottom-nav__label {
color: var(--platform-bottom-nav-muted-text);
font-weight: 500;
}
.platform-bottom-nav__active-mark {
position: absolute;
bottom: -0.32rem;
left: 50%;
width: 2rem;
height: 0.16rem;
border-radius: 9999px;
background: var(--platform-bottom-nav-active-text);
transform: translateX(-50%);
box-shadow: 0 5px 10px rgba(241, 90, 23, 0.22);
}
.platform-bottom-nav__button:hover .platform-bottom-nav__icon-shell,
.platform-desktop-rail__button:hover .platform-desktop-rail__icon-shell {
background: var(--platform-nav-item-hover-fill);
}
.platform-bottom-nav__button:hover .platform-bottom-nav__icon-shell {
background: transparent;
}
.platform-bottom-nav__button:hover .platform-bottom-nav__icon,
.platform-bottom-nav__button:hover .platform-bottom-nav__label,
.platform-desktop-rail__button:hover .platform-desktop-rail__icon,
@@ -2603,22 +2675,52 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: var(--platform-text-strong);
}
.platform-bottom-nav__button:hover .platform-bottom-nav__icon,
.platform-bottom-nav__button:hover .platform-bottom-nav__label {
color: var(--platform-bottom-nav-active-text);
}
.platform-bottom-nav__button--active .platform-bottom-nav__icon-shell,
.platform-desktop-rail__button--active .platform-desktop-rail__icon-shell {
background: var(--platform-nav-item-icon-active-fill);
box-shadow: var(--platform-nav-icon-active-shadow);
}
.platform-bottom-nav__button--active .platform-bottom-nav__icon-shell {
background: transparent;
box-shadow: none;
}
.platform-bottom-nav__button--active .platform-bottom-nav__icon,
.platform-desktop-rail__button--active .platform-desktop-rail__icon {
color: var(--platform-nav-item-icon-active-text);
}
.platform-bottom-nav__button--active .platform-bottom-nav__icon {
color: var(--platform-bottom-nav-active-text);
}
.platform-bottom-nav__button--active .platform-bottom-nav__label,
.platform-desktop-rail__button--active .platform-desktop-rail__label {
color: var(--platform-nav-item-text-active);
}
.platform-bottom-nav__button--active .platform-bottom-nav__label {
color: var(--platform-bottom-nav-active-text);
font-weight: 700;
}
.platform-bottom-nav__button--primary.platform-bottom-nav__button--active
.platform-bottom-nav__icon-shell {
background: var(--platform-bottom-nav-primary-fill);
box-shadow: var(--platform-bottom-nav-primary-shadow);
}
.platform-bottom-nav__button--primary.platform-bottom-nav__button--active
.platform-bottom-nav__icon {
color: var(--platform-bottom-nav-primary-text);
}
.platform-modal-shell {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
@@ -2691,23 +2793,107 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: var(--puzzle-runtime-text-strong);
}
.puzzle-runtime-icon-button--sprite {
border-color: transparent;
background: transparent;
color: inherit;
backdrop-filter: none;
}
.puzzle-runtime-icon-button--sprite:hover {
background: transparent;
}
.puzzle-runtime-icon-button--precise-hit {
pointer-events: none;
}
.puzzle-runtime-top-ui-sprite {
height: 100%;
max-width: 100%;
width: auto;
}
.puzzle-runtime-header-card {
background: var(--puzzle-runtime-surface-fill-strong);
border: 0;
background: transparent;
color: #fff7ed;
filter: none;
backdrop-filter: none;
}
.puzzle-runtime-level-title-card {
position: relative;
min-width: min(14rem, calc(100vw - 7.75rem));
min-height: 2.9rem;
border: 2px solid rgba(255, 216, 173, 0.64);
border-radius: 0.62rem 1.35rem 1.35rem 0.62rem;
background: linear-gradient(180deg, rgba(255, 164, 92, 0.18), transparent 46%),
linear-gradient(135deg, #c86a34 0%, #b54f25 56%, #a84622 100%);
}
.puzzle-runtime-level-title-card::before {
position: absolute;
inset: 0.2rem 0.58rem auto 3.05rem;
height: 1px;
content: '';
border-radius: 9999px;
background: rgba(255, 232, 199, 0.36);
}
.puzzle-runtime-level-logo {
position: relative;
z-index: 1;
display: block;
width: 2.95rem;
height: 2.95rem;
flex: 0 0 auto;
margin: -0.62rem 0 -0.62rem -1.18rem;
overflow: visible;
}
.puzzle-runtime-level-logo__image {
position: absolute;
left: -1.1rem;
top: -1.24rem;
display: block;
width: 5.3rem;
height: 5.3rem;
max-width: none;
object-fit: contain;
user-select: none;
pointer-events: none;
}
.puzzle-runtime-level-badge {
background: var(--puzzle-runtime-control-fill);
color: var(--puzzle-runtime-accent-text);
color: #fffaf2;
text-shadow: 0 1px 0 rgba(84, 33, 15, 0.3);
}
.puzzle-runtime-timer {
background: var(--puzzle-runtime-control-fill);
color: var(--puzzle-runtime-text-strong);
min-width: 6.15rem;
min-height: 2.05rem;
border: 1px solid rgba(223, 185, 145, 0.62);
border-top: 0;
border-radius: 0 0 0.82rem 0.82rem;
background: linear-gradient(180deg, #fff8ed, #fffdf7);
color: #4e2a1d;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 8px 18px rgba(86, 43, 18, 0.14);
}
.puzzle-runtime-timer--urgent {
background: var(--puzzle-runtime-danger-fill);
color: var(--puzzle-runtime-danger-text);
min-width: 6.15rem;
min-height: 2.05rem;
border: 1px solid rgba(242, 101, 33, 0.36);
border-top: 0;
border-radius: 0 0 0.82rem 0.82rem;
background: linear-gradient(180deg, #fff2e6, #fffaf1);
color: #d84012;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.88),
0 8px 18px rgba(211, 64, 18, 0.16);
}
.puzzle-runtime-board {
@@ -2783,6 +2969,25 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: var(--puzzle-runtime-text-base);
}
.puzzle-runtime-sprite-tool-button {
max-width: 7rem;
color: var(--puzzle-runtime-text-base);
}
.puzzle-runtime-sprite-tool-button--precise-hit {
pointer-events: none;
}
.puzzle-runtime-bottom-ui-sprite {
height: 100%;
max-width: 100%;
width: auto;
}
.puzzle-runtime-ui-sprite-hit-zone {
pointer-events: auto;
}
.puzzle-runtime-tool-button:hover {
background: var(--puzzle-runtime-control-hover-fill);
}
@@ -2815,6 +3020,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: var(--puzzle-runtime-backdrop-fill);
}
.puzzle-runtime-modal-overlay--fixed {
position: fixed;
inset: 0;
z-index: 130;
}
.puzzle-runtime-dialog {
position: relative;
border: 1px solid var(--puzzle-runtime-dialog-border);
@@ -2832,8 +3043,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.16),
transparent 36%
@@ -2910,11 +3120,27 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.baby-object-runtime {
--baby-object-background-image: linear-gradient(180deg, transparent, transparent);
--baby-object-ui-frame-image: linear-gradient(180deg, transparent, transparent);
--baby-object-gift-box-image: linear-gradient(180deg, transparent, transparent);
--baby-object-background-image: linear-gradient(
180deg,
transparent,
transparent
);
--baby-object-ui-frame-image: linear-gradient(
180deg,
transparent,
transparent
);
--baby-object-gift-box-image: linear-gradient(
180deg,
transparent,
transparent
);
--baby-object-basket-image: linear-gradient(180deg, transparent, transparent);
--baby-object-smoke-image: radial-gradient(circle, rgba(255, 255, 255, 0.9), transparent 68%);
--baby-object-smoke-image: radial-gradient(
circle,
rgba(255, 255, 255, 0.9),
transparent 68%
);
--baby-object-left-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png');
--baby-object-right-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png');
--baby-object-sky: #cfefff;
@@ -2930,10 +3156,23 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
min-height: 100dvh;
width: 100%;
overflow: hidden;
background:
radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.9) 0 6%, transparent 6.4%),
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.78) 0 7%, transparent 7.4%),
linear-gradient(180deg, #f8fcff 0%, var(--baby-object-sky) 56%, #dff2cf 57%, #b8df9d 100%);
background: radial-gradient(
circle at 18% 16%,
rgba(255, 255, 255, 0.9) 0 6%,
transparent 6.4%
),
radial-gradient(
circle at 80% 10%,
rgba(255, 255, 255, 0.78) 0 7%,
transparent 7.4%
),
linear-gradient(
180deg,
#f8fcff 0%,
var(--baby-object-sky) 56%,
#dff2cf 57%,
#b8df9d 100%
);
color: var(--baby-object-text);
touch-action: none;
}
@@ -2977,9 +3216,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
inset: auto -10% 0;
height: 39%;
border-radius: 50% 50% 0 0 / 24% 24% 0 0;
background:
radial-gradient(ellipse at 30% 12%, rgba(255, 255, 255, 0.3) 0 7%, transparent 7.4%),
linear-gradient(180deg, var(--baby-object-ground), var(--baby-object-ground-deep));
background: radial-gradient(
ellipse at 30% 12%,
rgba(255, 255, 255, 0.3) 0 7%,
transparent 7.4%
),
linear-gradient(
180deg,
var(--baby-object-ground),
var(--baby-object-ground-deep)
);
box-shadow: inset 0 24px 42px rgba(255, 255, 255, 0.24);
}
@@ -3053,8 +3299,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transform: translateX(-50%);
border: 0.45rem solid #ffe7a8;
border-radius: 1.35rem;
background:
linear-gradient(90deg, transparent 42%, rgba(255, 255, 255, 0.35) 42% 58%, transparent 58%),
background: linear-gradient(
90deg,
transparent 42%,
rgba(255, 255, 255, 0.35) 42% 58%,
transparent 58%
),
linear-gradient(180deg, #ff8f70, #ff5d78);
color: #fff7d7;
box-shadow:
@@ -3156,12 +3406,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
aspect-ratio: 1;
transform: translate(-50%, -50%) scale(0.68);
border-radius: 50%;
background:
radial-gradient(circle at 24% 62%, rgba(255, 255, 255, 0.92) 0 12%, transparent 12.8%),
radial-gradient(circle at 40% 36%, rgba(255, 255, 255, 0.95) 0 16%, transparent 16.8%),
radial-gradient(circle at 62% 38%, rgba(255, 255, 255, 0.9) 0 14%, transparent 14.8%),
radial-gradient(circle at 74% 62%, rgba(255, 255, 255, 0.86) 0 12%, transparent 12.8%),
radial-gradient(circle at 50% 54%, rgba(255, 242, 202, 0.72) 0 28%, transparent 29%);
background: radial-gradient(
circle at 24% 62%,
rgba(255, 255, 255, 0.92) 0 12%,
transparent 12.8%
),
radial-gradient(
circle at 40% 36%,
rgba(255, 255, 255, 0.95) 0 16%,
transparent 16.8%
),
radial-gradient(
circle at 62% 38%,
rgba(255, 255, 255, 0.9) 0 14%,
transparent 14.8%
),
radial-gradient(
circle at 74% 62%,
rgba(255, 255, 255, 0.86) 0 12%,
transparent 12.8%
),
radial-gradient(
circle at 50% 54%,
rgba(255, 242, 202, 0.72) 0 28%,
transparent 29%
);
filter: drop-shadow(0 18px 28px rgba(98, 83, 52, 0.12));
opacity: 0;
pointer-events: none;
@@ -3172,7 +3441,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
border-radius: 0;
background:
var(--baby-object-smoke-image) center / contain no-repeat,
radial-gradient(circle at 50% 54%, rgba(255, 255, 255, 0.48), transparent 66%);
radial-gradient(
circle at 50% 54%,
rgba(255, 255, 255, 0.48),
transparent 66%
);
}
.baby-object-runtime__smoke--releasing {
@@ -3337,7 +3610,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
100% {
opacity: 0;
transform: translate(var(--baby-object-intro-target-x), var(--baby-object-intro-target-y))
transform: translate(
var(--baby-object-intro-target-x),
var(--baby-object-intro-target-y)
)
scale(0.68);
}
}
@@ -3463,15 +3739,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.baby-object-runtime__hand--holding {
opacity: 1;
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.08);
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg))
scale(1.08);
}
.baby-object-runtime__hand--holding-left-corner {
transform: translate(-112%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
transform: translate(-112%, -6%) rotate(var(--baby-object-hand-rotate, 0deg))
scale(1.02);
}
.baby-object-runtime__hand--holding-right-corner {
transform: translate(12%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
transform: translate(12%, -6%) rotate(var(--baby-object-hand-rotate, 0deg))
scale(1.02);
}
.baby-object-runtime__feedback {
@@ -3529,7 +3808,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
display: flex;
align-items: end;
justify-content: space-between;
padding: 0 max(3vw, env(safe-area-inset-right)) 0 max(3vw, env(safe-area-inset-left));
padding: 0 max(3vw, env(safe-area-inset-right)) 0
max(3vw, env(safe-area-inset-left));
pointer-events: none;
}
@@ -3549,11 +3829,26 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
z-index: 4;
inset: -18% -16% 6%;
border-radius: 50%;
background:
radial-gradient(circle at 50% 42%, rgba(255, 247, 181, 0.55) 0 20%, transparent 21%),
radial-gradient(circle at 20% 22%, rgba(255, 255, 255, 0.95) 0 5%, transparent 5.5%),
radial-gradient(circle at 82% 26%, rgba(255, 255, 255, 0.86) 0 4%, transparent 4.5%),
radial-gradient(circle at 68% 76%, rgba(255, 226, 103, 0.9) 0 5%, transparent 5.5%);
background: radial-gradient(
circle at 50% 42%,
rgba(255, 247, 181, 0.55) 0 20%,
transparent 21%
),
radial-gradient(
circle at 20% 22%,
rgba(255, 255, 255, 0.95) 0 5%,
transparent 5.5%
),
radial-gradient(
circle at 82% 26%,
rgba(255, 255, 255, 0.86) 0 4%,
transparent 4.5%
),
radial-gradient(
circle at 68% 76%,
rgba(255, 226, 103, 0.9) 0 5%,
transparent 5.5%
);
pointer-events: none;
animation: baby-object-basket-spark 1.08s ease-out;
}
@@ -3665,8 +3960,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
border: 0.28rem solid rgba(139, 84, 40, 0.72);
border-top-width: 0.42rem;
border-radius: 0.8rem 0.8rem 2rem 2rem;
background:
repeating-linear-gradient(90deg, rgba(139, 84, 40, 0.18) 0 0.7rem, transparent 0.7rem 1.4rem),
background: repeating-linear-gradient(
90deg,
rgba(139, 84, 40, 0.18) 0 0.7rem,
transparent 0.7rem 1.4rem
),
linear-gradient(180deg, #ffd980, #d99845);
box-shadow: 0 18px 28px rgba(95, 84, 54, 0.2);
}
@@ -3742,10 +4040,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
overflow: hidden;
border: 1px solid rgba(72, 118, 72, 0.18);
border-radius: 1.25rem;
background:
radial-gradient(circle at 16% 18%, rgba(255, 244, 184, 0.72) 0 12%, transparent 12.6%),
radial-gradient(circle at 84% 20%, rgba(130, 212, 255, 0.58) 0 11%, transparent 11.8%),
linear-gradient(135deg, rgba(255, 253, 244, 0.96), rgba(217, 247, 229, 0.92));
background: radial-gradient(
circle at 16% 18%,
rgba(255, 244, 184, 0.72) 0 12%,
transparent 12.6%
),
radial-gradient(
circle at 84% 20%,
rgba(130, 212, 255, 0.58) 0 11%,
transparent 11.8%
),
linear-gradient(
135deg,
rgba(255, 253, 244, 0.96),
rgba(217, 247, 229, 0.92)
);
padding: 1.05rem;
text-align: left;
color: #24422b;
@@ -3805,14 +4114,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
grid-template-rows: 1fr auto;
gap: clamp(0.65rem, 1.6vw, 1.1rem);
overflow: hidden;
padding:
max(0.85rem, env(safe-area-inset-top))
padding: max(0.85rem, env(safe-area-inset-top))
max(0.85rem, env(safe-area-inset-right))
max(0.85rem, env(safe-area-inset-bottom))
max(0.85rem, env(safe-area-inset-left));
background:
radial-gradient(circle at 14% 18%, rgba(255, 244, 184, 0.72) 0 9%, transparent 9.5%),
radial-gradient(circle at 88% 16%, rgba(151, 224, 255, 0.58) 0 8%, transparent 8.6%),
background: radial-gradient(
circle at 14% 18%,
rgba(255, 244, 184, 0.72) 0 9%,
transparent 9.5%
),
radial-gradient(
circle at 88% 16%,
rgba(151, 224, 255, 0.58) 0 8%,
transparent 8.6%
),
linear-gradient(180deg, #f5fbff 0%, #d8f0e8 52%, #f7edc7 100%);
color: var(--baby-drawing-ink);
touch-action: none;
@@ -3902,8 +4217,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
overflow: hidden;
border: clamp(0.35rem, 1vw, 0.7rem) solid rgba(255, 255, 255, 0.82);
border-radius: 1.35rem;
background:
linear-gradient(90deg, rgba(245, 216, 145, 0.16) 1px, transparent 1px),
background: linear-gradient(
90deg,
rgba(245, 216, 145, 0.16) 1px,
transparent 1px
),
linear-gradient(180deg, rgba(245, 216, 145, 0.16) 1px, transparent 1px),
var(--baby-drawing-paper);
background-size: 2.4rem 2.4rem;
@@ -4323,15 +4641,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.platform-mobile-bottom-dock .platform-bottom-nav {
width: min(100%, 24rem);
pointer-events: auto;
border-color: color-mix(
in srgb,
var(--platform-desktop-panel-border) 76%,
transparent
);
background: var(--platform-nav-fill);
border-color: var(--platform-bottom-nav-border);
background: var(--platform-bottom-nav-fill);
box-shadow:
0 20px 48px rgba(0, 0, 0, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
0 16px 34px rgba(101, 52, 19, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
}
.platform-mobile-bottom-dock .platform-bottom-nav__button {
@@ -4340,7 +4654,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
);
}
.platform-mobile-bottom-dock .platform-bottom-nav__button--primary {
.platform-mobile-bottom-dock
.platform-bottom-nav__button--primary
.platform-bottom-nav__button-content {
transform: translateY(-0.5rem);
}
@@ -4349,23 +4665,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.platform-bottom-nav__icon-shell {
width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.72rem);
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.72rem);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
box-shadow:
0 14px 28px rgba(0, 0, 0, 0.18),
var(--platform-profile-action-shadow);
background: var(--platform-bottom-nav-primary-fill);
color: var(--platform-bottom-nav-primary-text);
box-shadow: var(--platform-bottom-nav-primary-shadow);
}
.platform-mobile-bottom-dock
.platform-bottom-nav__button--primary
.platform-bottom-nav__icon {
color: var(--platform-button-primary-text);
color: var(--platform-bottom-nav-primary-text);
}
.platform-mobile-bottom-dock
.platform-bottom-nav__button--primary
.platform-bottom-nav__label {
color: var(--platform-nav-item-text-active);
color: var(--platform-bottom-nav-active-text);
font-weight: 900;
}
@@ -4916,7 +5230,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
min-height: min(58vh, 28rem);
}
.platform-public-work-card--immersive .platform-public-work-card__cover::before {
.platform-public-work-card--immersive
.platform-public-work-card__cover::before {
padding-top: 122%;
}
@@ -4972,7 +5287,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.creation-work-card__side-cover {
width: 62%;
}
}
@media (min-width: 1180px) {
@@ -7998,7 +8312,8 @@ button {
z-index: 2;
height: 30%;
border-radius: 0;
background: var(--child-motion-asset-floor) center bottom / 100% auto no-repeat;
background: var(--child-motion-asset-floor) center bottom / 100% auto
no-repeat;
box-shadow: none;
}
@@ -8339,7 +8654,8 @@ button {
transform-origin: center;
}
.child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-track {
.child-motion-gesture-guide__arm-swing--right
.child-motion-gesture-guide__arm-swing-track {
border-inline-start-color: transparent;
border-inline-end-color: rgba(255, 221, 124, 0.78);
}
@@ -8391,11 +8707,13 @@ button {
transform-origin: center;
}
.child-motion-gesture-guide__arm-swing--left .child-motion-gesture-guide__arm-swing-paw-asset {
.child-motion-gesture-guide__arm-swing--left
.child-motion-gesture-guide__arm-swing-paw-asset {
background-image: var(--child-motion-asset-wave-cat-paw-left);
}
.child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-paw-asset {
.child-motion-gesture-guide__arm-swing--right
.child-motion-gesture-guide__arm-swing-paw-asset {
background-image: var(--child-motion-asset-wave-cat-paw-right);
}
@@ -8421,7 +8739,9 @@ button {
@keyframes child-motion-wave-cat-arm {
from {
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * -15deg));
transform: rotate(
calc(var(--child-motion-wave-hand-direction, 1) * -15deg)
);
}
to {
@@ -8486,7 +8806,8 @@ button {
height: clamp(3.1rem, 7.6%, 4.55rem);
border: 0;
border-radius: 999px;
background: var(--child-motion-asset-calibration) center center / cover no-repeat;
background: var(--child-motion-asset-calibration) center center / cover
no-repeat;
padding: clamp(0.4rem, 1.1vw, 0.56rem) clamp(0.66rem, 1.5vw, 0.9rem);
backdrop-filter: none;
box-shadow: none;
@@ -8536,7 +8857,8 @@ button {
gap: 0.85rem;
border: 0;
border-radius: 1.4rem;
background: var(--child-motion-asset-start-panel) center center / cover no-repeat;
background: var(--child-motion-asset-start-panel) center center / cover
no-repeat;
padding: clamp(0.45rem, 1.2vw, 0.7rem);
box-shadow: none;
backdrop-filter: none;

View File

@@ -13,7 +13,7 @@ import {
const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_LOCAL_DURATION_MS = 600_000;
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
const MATCH3D_MAX_ITEM_TYPE_COUNT = 20;
const MATCH3D_ITEMS_PER_CLEAR = 3;
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
const MATCH3D_LOCAL_BOARD_CENTER = 0.5;
@@ -50,7 +50,7 @@ const MATCH3D_SIZE_TIER_RULES: Array<{
];
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
// 中文注释:默认 25使用参考图中的积木件,形状、尺寸和颜色都要能区分
// 中文注释:默认 20对齐 10*10 物品 Sprite可由生成素材替换显示
{
itemTypeId: 'block-red-2x4',
visualKey: 'block-red-2x4',
@@ -99,12 +99,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-amber-100 to-yellow-600',
label: '米色二乘三',
},
{
itemTypeId: 'block-lime-1x2',
visualKey: 'block-lime-1x2',
colorClassName: 'from-lime-300 to-lime-700',
label: '青柠一乘二',
},
{
itemTypeId: 'block-darkred-2x2',
visualKey: 'block-darkred-2x2',
@@ -141,18 +135,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-teal-300 to-teal-700',
label: '青色长光板',
},
{
itemTypeId: 'block-mint-tile-1x4',
visualKey: 'block-mint-tile-1x4',
colorClassName: 'from-emerald-100 to-emerald-400',
label: '薄荷长光板',
},
{
itemTypeId: 'block-magenta-tile-2x2',
visualKey: 'block-magenta-tile-2x2',
colorClassName: 'from-fuchsia-500 to-pink-800',
label: '洋红光板',
},
{
itemTypeId: 'block-orange-tile-2x2-stud',
visualKey: 'block-orange-tile-2x2-stud',
@@ -165,18 +147,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-violet-400 to-violet-900',
label: '紫色斜坡',
},
{
itemTypeId: 'block-brown-slope-1x2',
visualKey: 'block-brown-slope-1x2',
colorClassName: 'from-orange-900 to-stone-700',
label: '棕色斜坡',
},
{
itemTypeId: 'block-sky-slope-2x2',
visualKey: 'block-sky-slope-2x2',
colorClassName: 'from-sky-300 to-sky-600',
label: '天蓝斜坡',
},
{
itemTypeId: 'block-green-cylinder',
visualKey: 'block-green-cylinder',
@@ -236,13 +206,14 @@ export function resolveLocalMatch3DItemTypeCount(clearCount: number) {
if (normalizedClearCount === 8) return 3;
if (normalizedClearCount === 12) return 9;
if (normalizedClearCount === 16) return 15;
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 21;
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 20;
return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount);
}
export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) {
const normalizedClearCount = Math.max(1, Math.round(clearCount));
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩按新硬核 21 组三消执行。
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩保留硬核 21 组三消节奏,
// 但物品类型池最多加载 20 种,避免超过 10*10 Sprite 解析素材上限。
return normalizedClearCount === 20 ? 21 : normalizedClearCount;
}

View File

@@ -270,7 +270,7 @@ describe('match3dGeneratedModelCache', () => {
);
});
test('运行态预加载同时解析背景和容器 UI 资产', async () => {
test('运行态预加载同时解析背景和spritesheet资产', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
@@ -309,6 +309,14 @@ describe('match3dGeneratedModelCache', () => {
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/task/background.png',
uiSpritesheetPrompt: '果园 UI',
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
itemSpritesheetPrompt: '果园物品',
itemSpritesheetImageSrc: null,
itemSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
containerPrompt: '果园浅盘',
containerImageSrc: null,
containerImageObjectKey:
@@ -321,13 +329,15 @@ describe('match3dGeneratedModelCache', () => {
expect(getMatch3DGeneratedRuntimeUiAssetSources(assets)).toEqual([
'generated-match3d-assets/session/profile/background/task/background.png',
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
'generated-match3d-assets/session/profile/ui-container/task/container.png',
]);
await preloadMatch3DGeneratedRuntimeAssets(assets, null, {
expireSeconds: 300,
});
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
expect(globalThis.fetch).toHaveBeenCalledTimes(5);
expect(
vi
.mocked(globalThis.fetch)
@@ -336,6 +346,8 @@ describe('match3dGeneratedModelCache', () => {
expect.arrayContaining([
expect.stringContaining('/items/item-1/views/view-01.png'),
expect.stringContaining('/background/task/background.png'),
expect.stringContaining('/ui-spritesheet/task/ui.png'),
expect.stringContaining('/item-spritesheet/task/items.png'),
expect.stringContaining('/ui-container/task/container.png'),
]),
);

View File

@@ -129,11 +129,19 @@ export function getMatch3DGeneratedRuntimeUiAssetSources(
[
backgroundAsset?.imageObjectKey,
backgroundAsset?.imageSrc,
backgroundAsset?.uiSpritesheetImageObjectKey,
backgroundAsset?.uiSpritesheetImageSrc,
backgroundAsset?.itemSpritesheetImageObjectKey,
backgroundAsset?.itemSpritesheetImageSrc,
backgroundAsset?.containerImageObjectKey,
backgroundAsset?.containerImageSrc,
...assets.flatMap((asset) => [
asset.backgroundAsset?.imageObjectKey,
asset.backgroundAsset?.imageSrc,
asset.backgroundAsset?.uiSpritesheetImageObjectKey,
asset.backgroundAsset?.uiSpritesheetImageSrc,
asset.backgroundAsset?.itemSpritesheetImageObjectKey,
asset.backgroundAsset?.itemSpritesheetImageSrc,
asset.backgroundAsset?.containerImageObjectKey,
asset.backgroundAsset?.containerImageSrc,
]),

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from 'vitest';
import {
buildMatch3DItemSpritesheetViewRegions,
detectMatch3DSpritesheetRegions,
} from './match3dSpritesheetParser';
describe('match3dSpritesheetParser', () => {
test('按透明像素连通域检测素材并按从上到下从左到右排序', () => {
const width = 12;
const height = 10;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(8, 1, 10, 3);
paint(1, 1, 3, 2);
paint(5, 6, 7, 8);
const regions = detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels: ['返回', '设置', '移出'],
});
expect(regions).toEqual([
{ height: 2, label: '返回', width: 3, x: 1, y: 1 },
{ height: 3, label: '设置', width: 3, x: 8, y: 1 },
{ height: 3, label: '移出', width: 3, x: 5, y: 6 },
]);
});
test('忽略小噪点,只返回可用矩形素材', () => {
const width = 8;
const height = 8;
const alpha = new Uint8ClampedArray(width * height);
alpha[0] = 255;
for (let y = 2; y <= 5; y += 1) {
for (let x = 2; x <= 5; x += 1) {
alpha[y * width + x] = 255;
}
}
const regions = detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels: ['方格'],
minArea: 4,
});
expect(regions).toEqual([
{ height: 4, label: '方格', width: 4, x: 2, y: 2 },
]);
});
test('按10行10列图集顺序把每行两种物品拆成五视角', () => {
const regions = Array.from({ length: 100 }, (_, index) => ({
label: `素材${index + 1}`,
x: (index % 10) * 10,
y: Math.floor(index / 10) * 10,
width: 8,
height: 8,
}));
const grouped = buildMatch3DItemSpritesheetViewRegions(regions, [
'草莓',
'苹果',
'毛肚',
]);
expect(grouped).toHaveLength(20);
expect(grouped[0]).toEqual({
itemIndex: 0,
itemName: '草莓',
regions: regions.slice(0, 5).map((region, index) => ({
...region,
label: `草莓-形态${index + 1}`,
})),
});
expect(grouped[1]).toEqual({
itemIndex: 1,
itemName: '苹果',
regions: regions.slice(5, 10).map((region, index) => ({
...region,
label: `苹果-形态${index + 1}`,
})),
});
expect(grouped[2]?.itemName).toBe('毛肚');
expect(grouped[19]?.regions).toHaveLength(5);
});
});

View File

@@ -0,0 +1,352 @@
import { readAssetBytes } from './assetReadUrlService';
export type Match3DSpritesheetRegion = {
label: string;
x: number;
y: number;
width: number;
height: number;
};
export type Match3DItemSpritesheetViewRegion = Match3DSpritesheetRegion & {
itemIndex: number;
itemName: string;
viewIndex: number;
};
export type DetectMatch3DSpritesheetRegionsInput = {
alpha: ArrayLike<number>;
width: number;
height: number;
labels?: readonly string[];
minArea?: number;
alphaThreshold?: number;
};
export type Match3DDecodedSpritesheetRegion = Match3DSpritesheetRegion & {
imageSrc: string;
sheetWidth: number;
sheetHeight: number;
};
export type LoadMatch3DSpritesheetAssetRegionsInput = {
source: string;
labels?: readonly string[];
minArea?: number;
alphaThreshold?: number;
maxRegions?: number;
signal?: AbortSignal;
};
type Match3DDetectedComponent = Omit<Match3DSpritesheetRegion, 'label'> & {
area: number;
};
/**
* 中文注释AI spritesheet 只保证透明背景,不保证固定坐标;运行态和编辑器统一按 alpha 连通域识别独立素材矩形。
*/
export function detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels = [],
minArea = 1,
alphaThreshold = 0,
}: DetectMatch3DSpritesheetRegionsInput): Match3DSpritesheetRegion[] {
const pixelCount = width * height;
if (width <= 0 || height <= 0 || alpha.length < pixelCount) {
return [];
}
const visited = new Uint8Array(pixelCount);
const components: Match3DDetectedComponent[] = [];
for (let start = 0; start < pixelCount; start += 1) {
if (visited[start] || (alpha[start] ?? 0) <= alphaThreshold) {
continue;
}
const component = floodFillMatch3DSpritesheetComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
});
if (component.area >= minArea) {
components.push(component);
}
}
return components
.sort((left, right) => left.y - right.y || left.x - right.x)
.map((component, index) => ({
label: labels[index] ?? `素材${index + 1}`,
x: component.x,
y: component.y,
width: component.width,
height: component.height,
}));
}
export function buildMatch3DItemSpritesheetViewRegions<
Region extends Match3DSpritesheetRegion,
>(
regions: readonly Region[],
itemNames: readonly string[],
): Array<{
itemIndex: number;
itemName: string;
regions: Region[];
}> {
if (regions.length <= 0 || itemNames.length <= 0) {
return [];
}
const itemCount = Math.min(20, Math.floor(regions.length / 5));
return Array.from({ length: itemCount }, (_, itemIndex) => {
const itemName = itemNames[itemIndex]?.trim() || `物品${itemIndex + 1}`;
return {
itemIndex,
itemName,
regions: regions.slice(itemIndex * 5, itemIndex * 5 + 5).map(
(region, viewIndex): Region => ({
...region,
label: `${itemName}-形态${viewIndex + 1}`,
}) as Region,
),
};
});
}
export async function loadMatch3DSpritesheetAssetRegions({
source,
labels = [],
minArea = 16,
alphaThreshold = 8,
maxRegions,
signal,
}: LoadMatch3DSpritesheetAssetRegionsInput): Promise<
Match3DDecodedSpritesheetRegion[]
> {
const decoded = await decodeMatch3DSpritesheetImage(source, signal);
if (signal?.aborted) {
throw new DOMException('spritesheet 读取已取消', 'AbortError');
}
const regions = detectMatch3DSpritesheetRegions({
alpha: decoded.alpha,
width: decoded.width,
height: decoded.height,
labels,
minArea,
alphaThreshold,
}).slice(0, maxRegions ?? Number.POSITIVE_INFINITY);
return regions.map((region) => ({
...region,
imageSrc: cropMatch3DSpritesheetRegionToDataUrl(decoded.image, region),
sheetWidth: decoded.width,
sheetHeight: decoded.height,
}));
}
function loadMatch3DSpritesheetImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
image.src = source;
});
}
async function readMatch3DSpritesheetImageSource(
source: string,
signal?: AbortSignal,
) {
const response = await readAssetBytes(source, {
signal,
expireSeconds: 300,
});
const blob = await response.blob();
const canCreateObjectUrl =
typeof URL.createObjectURL === 'function' &&
typeof URL.revokeObjectURL === 'function';
if (canCreateObjectUrl) {
return {
imageSource: URL.createObjectURL(blob),
revoke: (imageSource: string) => URL.revokeObjectURL(imageSource),
};
}
return {
imageSource: await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
reader.readAsDataURL(blob);
}),
revoke: () => {},
};
}
async function decodeMatch3DSpritesheetImage(
source: string,
signal?: AbortSignal,
) {
const { imageSource, revoke } = await readMatch3DSpritesheetImageSource(
source,
signal,
);
try {
const image = await loadMatch3DSpritesheetImage(imageSource);
if (signal?.aborted) {
throw new DOMException('spritesheet 读取已取消', 'AbortError');
}
const width = Math.max(1, image.naturalWidth || image.width || 1);
const height = Math.max(1, image.naturalHeight || image.height || 1);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', {
willReadFrequently: true,
});
if (!context) {
throw new Error('浏览器不支持解析抓大鹅 spritesheet');
}
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
const alpha = new Uint8ClampedArray(width * height);
for (let index = 0; index < alpha.length; index += 1) {
alpha[index] = pixels[index * 4 + 3] ?? 0;
}
return {
alpha,
height,
image,
width,
};
} finally {
revoke(imageSource);
}
}
function cropMatch3DSpritesheetRegionToDataUrl(
image: HTMLImageElement,
region: Match3DSpritesheetRegion,
) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(region.width));
canvas.height = Math.max(1, Math.round(region.height));
const context = canvas.getContext('2d');
if (!context) {
throw new Error('浏览器不支持裁切抓大鹅 spritesheet');
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(
image,
region.x,
region.y,
region.width,
region.height,
0,
0,
canvas.width,
canvas.height,
);
return canvas.toDataURL('image/png');
}
function floodFillMatch3DSpritesheetComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
}: {
alpha: ArrayLike<number>;
visited: Uint8Array;
width: number;
height: number;
start: number;
alphaThreshold: number;
}): Match3DDetectedComponent {
const stack = [start];
visited[start] = 1;
let minX = start % width;
let maxX = minX;
let minY = Math.floor(start / width);
let maxY = minY;
let area = 0;
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % width;
const y = Math.floor(index / width);
area += 1;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
visitMatch3DSpritesheetNeighbor(
index - 1,
x > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index + 1,
x + 1 < width,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index - width,
y > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index + width,
y + 1 < height,
alpha,
visited,
stack,
alphaThreshold,
);
}
return {
area,
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
}
function visitMatch3DSpritesheetNeighbor(
index: number,
inBounds: boolean,
alpha: ArrayLike<number>,
visited: Uint8Array,
stack: number[],
alphaThreshold: number,
) {
if (!inBounds || visited[index] || (alpha[index] ?? 0) <= alphaThreshold) {
return;
}
visited[index] = 1;
stack.push(index);
}

View File

@@ -26,15 +26,16 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'并行生成素材',
'校验背景资源',
'生成拼图首图',
'生成关卡画面',
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'读取画面描述,建立可编辑草稿与首关结构。',
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.estimatedRemainingMs).toBe(298_500);
expect(progress?.estimatedRemainingMs).toBe(296_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
@@ -50,22 +51,52 @@ describe('miniGameDraftGenerationProgress', () => {
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 282_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(275_000);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.estimatedRemainingMs).toBe(273_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[3]?.status).toBe('completed');
expect(writeBackProgress?.steps[4]?.status).toBe('active');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
});
test('puzzle direct upload generation skips the first image generation step', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: false,
},
};
const progress = buildMiniGameDraftGenerationProgress(state, 20_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成关卡画面',
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseId).toBe('puzzle-level-scene');
expect(progress?.steps[2]?.detail).toContain('直接使用上传图作为参考');
expect(progress?.estimatedRemainingMs).toBe(189_000);
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
@@ -78,12 +109,12 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 360_000);
const progress = buildMiniGameDraftGenerationProgress(state, 480_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[4]?.completed).toBe(1);
expect(progress?.steps[5]?.completed).toBe(1);
});
test('puzzle ready copy points to result page work info completion', () => {
@@ -111,7 +142,7 @@ describe('miniGameDraftGenerationProgress', () => {
finishedAtMs: 151_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: 'VectorEngine 图片编辑请求超时',
error: 'VectorEngine 图片生成请求超时',
};
const progress = buildMiniGameDraftGenerationProgress(state, 500_000);
@@ -177,7 +208,7 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('match3d draft generation exposes item sheet and image asset steps', () => {
test('match3d draft generation exposes level scene derived asset steps', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
@@ -188,17 +219,14 @@ describe('miniGameDraftGenerationProgress', () => {
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-image',
'match3d-level-scene',
'match3d-derived-assets',
'match3d-parse-spritesheet',
'match3d-write-draft',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('分批生成素材图');
expect(progress?.estimatedRemainingMs).toBe(480_000);
expect(progress?.phaseId).toBe('match3d-level-scene');
expect(progress?.phaseLabel).toBe('生成关卡整图');
expect(progress?.estimatedRemainingMs).toBe(430_000);
});
test('match3d draft generation starts from title generation', () => {
@@ -229,26 +257,26 @@ describe('miniGameDraftGenerationProgress', () => {
state.startedAtMs + 20_000,
);
expect(progress?.phaseId).toBe('match3d-generate-views');
expect(progress?.steps[6]?.detail).toContain('五视角图片');
expect(progress?.steps[6]?.completed).toBe(1);
expect(progress?.steps[6]?.total).toBe(3);
expect(progress?.phaseId).toBe('match3d-parse-spritesheet');
expect(progress?.steps[4]?.detail).toContain('解析 20 个物品');
expect(progress?.steps[4]?.completed).toBe(1);
expect(progress?.steps[4]?.total).toBe(3);
});
test('match3d draft generation reaches background image and writeback phases', () => {
const state = createMiniGameDraftGenerationState('match3d');
const backgroundProgress = buildMiniGameDraftGenerationProgress(
const derivedProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 400_000,
state.startedAtMs + 150_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 500_000,
state.startedAtMs + 460_000,
);
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
expect(derivedProgress?.phaseId).toBe('match3d-derived-assets');
expect(derivedProgress?.phaseLabel).toBe('生成三张派生图');
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
});
@@ -269,8 +297,8 @@ describe('miniGameDraftGenerationProgress', () => {
},
{
id: 'match3d-items',
label: '物品数量',
value: '25 件',
label: '素材数量',
value: '20 种素材',
},
]);
});

View File

@@ -49,6 +49,9 @@ export type MiniGameDraftGenerationPhase =
| 'match3d-upload-images'
| 'match3d-generate-views'
| 'match3d-background-image'
| 'match3d-level-scene'
| 'match3d-derived-assets'
| 'match3d-parse-spritesheet'
| 'match3d-write-draft'
| 'match3d-ready'
| 'baby-object-draft'
@@ -59,6 +62,9 @@ export type MiniGameDraftGenerationPhase =
| 'jump-hop-tile-atlas'
| 'jump-hop-slice-tiles'
| 'jump-hop-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-images'
| 'puzzle-ui-background'
| 'puzzle-select-image'
@@ -73,6 +79,9 @@ export type MiniGameDraftGenerationState = {
completedAssetCount: number;
totalAssetCount: number;
error: string | null;
metadata?: {
puzzleAiRedraw?: boolean;
};
};
type MiniGameStepDefinition = {
@@ -82,65 +91,134 @@ type MiniGameStepDefinition = {
weight: number;
};
type TimedMiniGameStepDefinition = Omit<MiniGameStepDefinition, 'weight'> & {
durationMs: number;
};
type MiniGameAnchorSource = {
key: string;
label: string;
value: string;
};
const PUZZLE_STEPS = [
{
id: 'compile',
label: '编译首关草稿',
detail: '读取画面描述,建立可编辑草稿与首关结构。',
weight: 10,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据画面描述和图像语义整理首关题目。',
weight: 8,
},
{
id: 'puzzle-images',
label: '并行生成素材',
detail: '同时生成首关画面与 9:16 纯背景。',
weight: 74,
},
{
id: 'puzzle-ui-background',
label: '校验背景资源',
detail: '确认首关图和 UI 背景都已写入资产库。',
weight: 0,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '写入首图、UI背景和首关数据。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_IMAGE_GENERATION_EXPECTED_MS = 90_000;
const PUZZLE_COMPILE_EXPECTED_MS = 8_000;
const PUZZLE_LEVEL_NAME_EXPECTED_MS = 10_000;
const PUZZLE_WRITE_DRAFT_EXPECTED_MS = 10_000;
function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) {
return state.metadata?.puzzleAiRedraw === false;
}
function buildWeightedPuzzleSteps(
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
) {
const totalDuration = steps.reduce((sum, step) => sum + step.durationMs, 0);
let usedWeight = 0;
return steps.map((step, index) => {
const weight =
index === steps.length - 1
? Math.max(1, 100 - usedWeight)
: Math.max(1, Math.round((step.durationMs / totalDuration) * 100));
usedWeight += weight;
return {
id: step.id,
label: step.label,
detail: step.detail,
weight,
} satisfies MiniGameStepDefinition;
});
}
function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
const steps: TimedMiniGameStepDefinition[] = [
{
id: 'compile',
label: '编译首关草稿',
detail: '建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
durationMs: PUZZLE_COMPILE_EXPECTED_MS,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据描述生成关卡名、作品描述和标签,约 10 秒。',
durationMs: PUZZLE_LEVEL_NAME_EXPECTED_MS,
},
];
if (!shouldSkipPuzzleCoverGeneration(state)) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
});
}
steps.push(
{
id: 'puzzle-level-scene',
label: '生成关卡画面',
detail: shouldSkipPuzzleCoverGeneration(state)
? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。'
: '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-ui-assets',
label: '生成UI与背景',
detail:
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '校验资产并写入正式首关、作品摘要和草稿投影,约 10 秒。',
durationMs: PUZZLE_WRITE_DRAFT_EXPECTED_MS,
},
);
return steps;
}
function buildPuzzleSteps(state: MiniGameDraftGenerationState) {
return buildWeightedPuzzleSteps(buildPuzzleTimedSteps(state));
}
function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
return buildPuzzleTimedSteps(state).reduce(
(sum, step) => sum + step.durationMs,
0,
);
}
const PUZZLE_ESTIMATED_WAIT_MS = 5 * 60_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
const PUZZLE_PHASE_TIMELINE: Array<{
function buildPuzzlePhaseTimeline(state: MiniGameDraftGenerationState): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-images'
| 'puzzle-ui-background'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>;
durationMs: number;
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 260_000 },
{ phase: 'puzzle-ui-background', durationMs: 10_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];
}> {
return buildPuzzleTimedSteps(state).map((step) => ({
phase: step.id as Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>,
durationMs: step.durationMs,
}));
}
const BIG_FISH_STEPS = [
{
@@ -198,65 +276,69 @@ const MATCH3D_STEPS = [
weight: 10,
},
{
id: 'match3d-background-prompt',
label: '生成背景提示词',
detail: '整理纯背景图与容器 UI 图提示词。',
weight: 6,
id: 'match3d-level-scene',
label: '生成关卡整图',
detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。',
weight: 28,
},
{
id: 'match3d-material-sheet',
label: '分批生成素材图',
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
weight: 24,
id: 'match3d-derived-assets',
label: '生成三张派生图',
detail: '以关卡整图为参考,并发生成 UI、背景和 10x10 物品 Sprite。',
weight: 34,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成每个物品的五个视角。',
weight: 12,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '上传每个物品的 2D 五视角素材。',
weight: 14,
},
{
id: 'match3d-generate-views',
label: '校验素材结构',
detail: '确认物品顺序和五视角图片。',
weight: 8,
},
{
id: 'match3d-background-image',
label: '生成UI背景',
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
weight: 16,
id: 'match3d-parse-spritesheet',
label: '解析物品Sprite',
detail: '解析 20 个物品和每个物品的 5 个形态,并上传透明 PNG。',
weight: 18,
},
{
id: 'match3d-write-draft',
label: '写入草稿页',
detail: '保存素材、背景、容器和作品草稿。',
detail: '保存关卡整图、派生图集、20 种物品素材和作品草稿。',
weight: 2,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_ESTIMATED_WAIT_MS = 510_000;
const MATCH3D_ESTIMATED_WAIT_MS = 460_000;
const MATCH3D_PHASE_ORDER: Partial<
Record<MiniGameDraftGenerationPhase, number>
> = {
'match3d-work-title': 0,
'match3d-item-names': 1,
'match3d-background-prompt': 2,
'match3d-level-scene': 2,
'match3d-derived-assets': 3,
'match3d-parse-spritesheet': 4,
'match3d-write-draft': 5,
// 中文注释:旧生成页阶段在恢复生成中草稿时归并到新流程对应阶段。
'match3d-background-prompt': 1,
'match3d-material-sheet': 3,
'match3d-slice-images': 4,
'match3d-upload-images': 5,
'match3d-generate-views': 6,
'match3d-background-image': 7,
'match3d-write-draft': 8,
'match3d-upload-images': 4,
'match3d-generate-views': 4,
'match3d-background-image': 3,
};
function normalizeMatch3DGenerationPhase(
phase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
switch (phase) {
case 'match3d-background-prompt':
return 'match3d-item-names';
case 'match3d-material-sheet':
case 'match3d-background-image':
return 'match3d-derived-assets';
case 'match3d-slice-images':
case 'match3d-upload-images':
case 'match3d-generate-views':
return 'match3d-parse-spritesheet';
default:
return phase;
}
}
const BABY_OBJECT_MATCH_STEPS = [
{
id: 'baby-object-draft',
@@ -319,7 +401,7 @@ function clampProgress(value: number) {
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'puzzle') {
return PUZZLE_STEPS;
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
}
if (kind === 'square-hole') {
return SQUARE_HOLE_STEPS;
@@ -429,26 +511,21 @@ function resolveMatch3DPhaseByElapsedMs(
currentPhase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
const elapsedPhase =
elapsedMs >= 492_000
elapsedMs >= 450_000
? 'match3d-write-draft'
: elapsedMs >= 370_000
? 'match3d-background-image'
: 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';
: elapsedMs >= 360_000
? 'match3d-parse-spritesheet'
: elapsedMs >= 118_000
? 'match3d-derived-assets'
: elapsedMs >= 28_000
? 'match3d-level-scene'
: elapsedMs >= 8_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;
const normalizedCurrentPhase = normalizeMatch3DGenerationPhase(currentPhase);
const currentOrder = MATCH3D_PHASE_ORDER[normalizedCurrentPhase] ?? -1;
return currentOrder > elapsedOrder ? normalizedCurrentPhase : elapsedPhase;
}
function resolveBabyObjectMatchPhaseByElapsedMs(
@@ -481,10 +558,13 @@ function resolveJumpHopPhaseByElapsedMs(
return 'jump-hop-draft';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
function resolvePuzzleTimelineByElapsedMs(
elapsedMs: number,
state: MiniGameDraftGenerationState,
) {
let elapsedBeforePhase = 0;
for (const item of PUZZLE_PHASE_TIMELINE) {
for (const item of buildPuzzlePhaseTimeline(state)) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
@@ -523,7 +603,7 @@ export function buildMiniGameDraftGenerationProgress(
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
: null;
const normalizedState =
puzzleTimeline != null
@@ -568,7 +648,10 @@ export function buildMiniGameDraftGenerationProgress(
}
: state;
const steps = getStepDefinitions(normalizedState.kind);
const steps =
normalizedState.kind === 'puzzle'
? buildPuzzleSteps(normalizedState)
: getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const completedWeight = steps
.slice(
@@ -641,7 +724,7 @@ export function buildMiniGameDraftGenerationProgress(
normalizedState.phase === 'ready'
? 0
: normalizedState.kind === 'puzzle'
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
? Math.max(0, resolvePuzzleEstimatedWaitMs(normalizedState) - elapsedMs)
: normalizedState.kind === 'big-fish'
? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
@@ -812,9 +895,6 @@ export function buildMatch3DGenerationAnchorEntries(
}
const config = session?.config;
const clearCount = formPayload?.clearCount ?? config?.clearCount ?? null;
const difficulty = formPayload?.difficulty ?? config?.difficulty ?? null;
const itemCount = resolveMatch3DGeneratedItemCount(clearCount, difficulty);
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'match3d-theme',
@@ -827,8 +907,8 @@ export function buildMatch3DGenerationAnchorEntries(
},
{
key: 'match3d-items',
label: '物品数量',
value: `${itemCount} `,
label: '素材数量',
value: `${resolveMatch3DGeneratedItemCount()} 种素材`,
},
];
@@ -843,22 +923,10 @@ export function buildMatch3DGenerationAnchorEntries(
}
function resolveMatch3DGeneratedItemCount(
clearCount: number | null | undefined,
difficulty: number | null | undefined,
_clearCount: number | null | undefined = null,
_difficulty: number | null | undefined = null,
) {
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 roundToSheet(3);
if (normalizedDifficulty <= 4) return roundToSheet(9);
if (normalizedDifficulty <= 6) return roundToSheet(15);
return roundToSheet(21);
return 20;
}
export function buildBabyObjectMatchGenerationAnchorEntries(

View File

@@ -0,0 +1,33 @@
import { beforeEach, expect, test, vi } from 'vitest';
const { createCreationAgentClientMock, executeActionMock } = vi.hoisted(() => ({
createCreationAgentClientMock: vi.fn(),
executeActionMock: vi.fn(),
}));
vi.mock('../creation-agent', () => ({
createCreationAgentClient: createCreationAgentClientMock,
}));
beforeEach(() => {
vi.resetModules();
executeActionMock.mockReset();
createCreationAgentClientMock.mockReset();
createCreationAgentClientMock.mockReturnValue({
createSession: vi.fn(),
getSession: vi.fn(),
sendMessage: vi.fn(),
streamMessage: vi.fn(),
executeAction: executeActionMock,
});
});
test('puzzle compile action keeps the draft generation request alive for 30 minutes', async () => {
await import('./puzzleAgentClient');
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
expect.objectContaining({
executeActionTimeoutMs: 30 * 60 * 1000,
}),
);
});

View File

@@ -12,6 +12,8 @@ import type { TextStreamOptions } from '../aiTypes';
import { createCreationAgentClient } from '../creation-agent';
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
// 拼图草稿生成会串起多段图片生成,请求层保持 30 分钟等待窗口。
const PUZZLE_DRAFT_ACTION_TIMEOUT_MS = 30 * 60 * 1000;
const puzzleAgentHttpClient = createCreationAgentClient<
CreatePuzzleAgentSessionRequest,
CreatePuzzleAgentSessionResponse,
@@ -30,6 +32,7 @@ const puzzleAgentHttpClient = createCreationAgentClient<
streamIncomplete: '拼图共创消息流式结果不完整',
executeAction: '执行拼图共创操作失败',
},
executeActionTimeoutMs: PUZZLE_DRAFT_ACTION_TIMEOUT_MS,
});
/**

View File

@@ -4,14 +4,14 @@ import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/pu
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
applyLocalPuzzleFreezeTime,
advanceLocalPuzzleLevel,
applyLocalPuzzleFreezeTime,
dragLocalPuzzlePiece,
extendLocalPuzzleTime,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
restartLocalPuzzleLevel,
resolvePuzzleRestartLevelId,
restartLocalPuzzleLevel,
setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
@@ -575,6 +575,43 @@ describe('puzzleLocalRuntime', () => {
);
});
test('本地试玩优先把关卡背景和 UI spritesheet 带入运行态', () => {
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/legacy-background.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background/background.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
backgroundMusic: null,
generationStatus: 'ready',
} as PuzzleDraftLevel,
],
};
const run = startLocalPuzzleRun(workWithRuntimeAssets);
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/level-background/background.png',
);
expect(run.currentLevel?.levelBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/level-background/background.png',
);
expect(run.currentLevel?.uiSpritesheetImageSrc).toBe(
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
);
});
test('本地试玩在只有 UI 背景 objectKey 时也能继承生成图', () => {
const workWithRuntimeAssets: PuzzleWorkSummary = {
...baseWork,
@@ -641,6 +678,10 @@ describe('puzzleLocalRuntime', () => {
const run = startLocalPuzzleRun(workWithLevels, 'puzzle-level-2');
expect(run.currentLevel?.levelId).toBe('puzzle-level-2');
expect(run.currentLevelIndex).toBe(2);
expect(run.currentLevel?.levelIndex).toBe(2);
expect(run.currentLevel?.gridSize).toBe(4);
expect(run.currentGridSize).toBe(4);
expect(run.currentLevel?.coverImageSrc).toBe('/level-2.png');
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background.png',

View File

@@ -1,3 +1,4 @@
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
@@ -10,7 +11,6 @@ import type {
PuzzleRuntimeLevelSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
resolvePuzzleUiBackgroundFields,
@@ -773,6 +773,51 @@ function resolvePuzzleWorkUiBackgroundCarrier(
);
}
function resolvePuzzleWorkUiSpritesheetCarrier(
work: PuzzleWorkSummary | null | undefined,
) {
return (
work?.levels?.find(
(level) =>
level.uiSpritesheetImageSrc?.trim() ||
level.uiSpritesheetImageObjectKey?.trim(),
) ?? null
);
}
function resolvePuzzleUiSpritesheetFields(
...sources: Array<
| Pick<
PuzzleDraftLevel,
'uiSpritesheetImageSrc' | 'uiSpritesheetImageObjectKey'
>
| Pick<
PuzzleRuntimeLevelSnapshot,
'uiSpritesheetImageSrc' | 'uiSpritesheetImageObjectKey'
>
| null
| undefined
>
) {
for (const source of sources) {
const imageSrc = source?.uiSpritesheetImageSrc?.trim();
const objectKey = source?.uiSpritesheetImageObjectKey
?.trim()
.replace(/^\/+/u, '');
if (imageSrc || objectKey) {
return {
uiSpritesheetImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
uiSpritesheetImageObjectKey: objectKey || null,
};
}
}
return {
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
};
}
function applyLocalNextLevelHandoff(
run: PuzzleRunSnapshot,
work: PuzzleWorkSummary | null | undefined,
@@ -820,6 +865,11 @@ function buildFallbackLocalLevel(
resolvePuzzleWorkUiBackgroundCarrier(work),
currentLevel,
);
const nextUiSpritesheet = resolvePuzzleUiSpritesheetFields(
nextLevel,
resolvePuzzleWorkUiSpritesheetCarrier(work),
currentLevel,
);
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
@@ -852,6 +902,12 @@ function buildFallbackLocalLevel(
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackground.uiBackgroundImageObjectKey,
levelBackgroundImageSrc: nextUiBackground.levelBackgroundImageSrc,
levelBackgroundImageObjectKey:
nextUiBackground.levelBackgroundImageObjectKey,
uiSpritesheetImageSrc: nextUiSpritesheet.uiSpritesheetImageSrc,
uiSpritesheetImageObjectKey:
nextUiSpritesheet.uiSpritesheetImageObjectKey,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
@@ -869,11 +925,12 @@ export function startLocalPuzzleRun(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRunSnapshot {
const gridSize = resolvePuzzleLevelConfig(1).gridSize;
const runId = buildLocalPuzzleRunId(item.profileId);
const startedAtMs = Date.now();
const requestedLevelIndex = resolveWorkLevelIndexById(item.levels, levelId);
const currentLevelIndex = requestedLevelIndex >= 0 ? requestedLevelIndex : 0;
const currentLevelNumber = currentLevelIndex + 1;
const { gridSize } = resolvePuzzleLevelConfig(currentLevelNumber);
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
@@ -881,19 +938,23 @@ export function startLocalPuzzleRun(
firstLevel,
resolvePuzzleWorkUiBackgroundCarrier(item),
);
const firstUiSpritesheet = resolvePuzzleUiSpritesheetFields(
firstLevel,
resolvePuzzleWorkUiSpritesheetCarrier(item),
);
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
runId,
entryProfileId: item.profileId,
clearedLevelCount: 0,
currentLevelIndex: 1,
clearedLevelCount: Math.max(0, currentLevelIndex),
currentLevelIndex: currentLevelNumber,
currentGridSize: gridSize,
playedProfileIds: [item.profileId],
previousLevelTags: item.themeTags,
currentLevel: {
runId,
levelIndex: 1,
levelIndex: currentLevelNumber,
levelId: firstLevel?.levelId ?? null,
gridSize,
profileId: item.profileId,
@@ -903,13 +964,19 @@ export function startLocalPuzzleRun(
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackground.uiBackgroundImageObjectKey,
levelBackgroundImageSrc: firstUiBackground.levelBackgroundImageSrc,
levelBackgroundImageObjectKey:
firstUiBackground.levelBackgroundImageObjectKey,
uiSpritesheetImageSrc: firstUiSpritesheet.uiSpritesheetImageSrc,
uiSpritesheetImageObjectKey:
firstUiSpritesheet.uiSpritesheetImageObjectKey,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
board: buildInitialBoard(gridSize, runId, item.profileId, currentLevelNumber),
status: 'playing',
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(1),
...buildLevelTimerFields(currentLevelNumber),
leaderboardEntries: [],
},
recommendedNextProfileId: nextSameWorkLevel ? item.profileId : null,

View File

@@ -1,6 +1,8 @@
type PuzzleUiBackgroundFields = {
uiBackgroundImageSrc?: string | null;
uiBackgroundImageObjectKey?: string | null;
levelBackgroundImageSrc?: string | null;
levelBackgroundImageObjectKey?: string | null;
};
export function resolvePuzzleUiBackgroundSource(
@@ -13,14 +15,21 @@ export function resolvePuzzleUiBackgroundFields(
...sources: Array<PuzzleUiBackgroundFields | null | undefined>
) {
for (const source of sources) {
const imageSrc = source?.uiBackgroundImageSrc?.trim();
const objectKey = source?.uiBackgroundImageObjectKey
?.trim()
.replace(/^\/+/u, '');
const imageSrc =
source?.levelBackgroundImageSrc?.trim() ||
source?.uiBackgroundImageSrc?.trim();
const objectKey = (
source?.levelBackgroundImageObjectKey?.trim() ||
source?.uiBackgroundImageObjectKey?.trim() ||
''
).replace(/^\/+/u, '');
if (imageSrc || objectKey) {
return {
uiBackgroundImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
uiBackgroundImageObjectKey: objectKey || null,
levelBackgroundImageSrc:
imageSrc || (objectKey ? `/${objectKey}` : null),
levelBackgroundImageObjectKey: objectKey || null,
};
}
}
@@ -28,5 +37,7 @@ export function resolvePuzzleUiBackgroundFields(
return {
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
levelBackgroundImageSrc: null,
levelBackgroundImageObjectKey: null,
};
}

View File

@@ -0,0 +1,166 @@
import { describe, expect, test } from 'vitest';
import {
buildPuzzleUiSpriteBackgroundStyle,
buildPuzzleUiSpriteHitZoneStyle,
detectPuzzleUiSpritesheetLayout,
} from './puzzleUiSpritesheetParser';
describe('puzzleUiSpritesheetParser', () => {
test('按透明像素边界检测 UI 按钮矩形并按从左到右从上到下映射', () => {
const width = 32;
const height = 24;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(3, 2, 6, 5);
paint(24, 3, 28, 7);
paint(11, 11, 20, 15);
paint(2, 20, 6, 22);
paint(13, 19, 18, 22);
paint(25, 20, 29, 23);
alpha[0] = 255;
const layout = detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea: 4,
});
expect(layout).toEqual({
width,
height,
regions: {
back: { x: 3, y: 2, width: 4, height: 4 },
settings: { x: 24, y: 3, width: 5, height: 5 },
next: { x: 11, y: 11, width: 10, height: 5 },
hint: { x: 2, y: 20, width: 5, height: 3 },
reference: { x: 13, y: 19, width: 6, height: 4 },
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
},
hitRegions: {
back: { x: 3, y: 2, width: 4, height: 4 },
settings: { x: 24, y: 3, width: 5, height: 5 },
next: { x: 11, y: 11, width: 10, height: 5 },
hint: { x: 2, y: 20, width: 5, height: 3 },
reference: { x: 13, y: 19, width: 6, height: 4 },
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
},
});
});
test('检测不到完整六个按钮矩形时返回 null 交给固定六宫格兜底', () => {
const width = 20;
const height = 12;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(1, 1, 4, 4);
paint(8, 1, 11, 4);
paint(14, 1, 17, 4);
expect(
detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea: 4,
}),
).toBeNull();
});
test('按检测到的原图矩形生成 background 裁切样式', () => {
const style = buildPuzzleUiSpriteBackgroundStyle({
src: '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
kind: 'reference',
layout: {
width: 32,
height: 24,
regions: {
reference: { x: 13, y: 19, width: 6, height: 4 },
},
},
});
expect(style).toEqual({
backgroundImage:
'url("/generated-puzzle-assets/session/ui-spritesheet/sheet.png")',
backgroundSize: '533.3333333333333% 600%',
backgroundPosition: '50% 95%',
aspectRatio: '6 / 4',
});
});
test('点击热区优先使用高 alpha 像素的紧致矩形,减少透明边缘误触', () => {
const width = 48;
const height = 32;
const alpha = new Uint8ClampedArray(width * height);
const paint = (
x0: number,
y0: number,
x1: number,
y1: number,
value: number,
) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = value;
}
}
};
paint(2, 2, 12, 12, 48);
paint(5, 5, 9, 9, 255);
paint(20, 2, 26, 8, 255);
paint(34, 2, 42, 8, 255);
paint(2, 20, 8, 26, 255);
paint(19, 20, 27, 26, 255);
paint(34, 20, 42, 26, 255);
const layout = detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea: 4,
alphaThreshold: 16,
hitAlphaThreshold: 192,
});
expect(layout?.regions.back).toEqual({
x: 2,
y: 2,
width: 11,
height: 11,
});
expect(layout?.hitRegions?.back).toEqual({
x: 5,
y: 5,
width: 5,
height: 5,
});
expect(
buildPuzzleUiSpriteHitZoneStyle({
kind: 'back',
layout,
}),
).toEqual({
left: '27.27272727272727%',
top: '27.27272727272727%',
width: '45.45454545454545%',
height: '45.45454545454545%',
});
});
});

View File

@@ -0,0 +1,471 @@
import type { CSSProperties } from 'react';
import { readAssetBytes } from '../assetReadUrlService';
export type PuzzleUiSpriteKind =
| 'back'
| 'settings'
| 'next'
| 'hint'
| 'reference'
| 'freezeTime';
export type PuzzleUiSpriteRegion = {
x: number;
y: number;
width: number;
height: number;
};
export type PuzzleUiSpritesheetLayout = {
width: number;
height: number;
regions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>>;
hitRegions?: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>>;
};
export type DetectPuzzleUiSpritesheetLayoutInput = {
alpha: ArrayLike<number>;
width: number;
height: number;
minArea?: number;
alphaThreshold?: number;
hitAlphaThreshold?: number;
};
export type BuildPuzzleUiSpriteBackgroundStyleInput = {
src: string;
kind: PuzzleUiSpriteKind;
layout: PuzzleUiSpritesheetLayout | null;
};
export type BuildPuzzleUiSpriteHitZoneStyleInput = {
kind: PuzzleUiSpriteKind;
layout: PuzzleUiSpritesheetLayout | null;
};
export type LoadPuzzleUiSpritesheetLayoutOptions = {
signal?: AbortSignal;
expireSeconds?: number;
minArea?: number;
alphaThreshold?: number;
hitAlphaThreshold?: number;
};
type PuzzleUiDetectedComponent = PuzzleUiSpriteRegion & {
area: number;
hitRegion?: PuzzleUiSpriteRegion;
};
const PUZZLE_UI_SPRITE_ORDER = [
'back',
'settings',
'next',
'hint',
'reference',
'freezeTime',
] as const satisfies readonly PuzzleUiSpriteKind[];
const PUZZLE_UI_FIXED_GRID_INDEX: Record<PuzzleUiSpriteKind, number> = {
back: 0,
settings: 1,
next: 2,
hint: 3,
reference: 4,
freezeTime: 5,
};
/**
* 中文注释AI 生成的拼图 UI spritesheet 不稳定落在固定六宫格内,
* 因此这里以 alpha 连通域检测真实按钮矩形,再按原图位置映射到按钮语义。
*/
export function detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea = 1,
alphaThreshold = 0,
hitAlphaThreshold = Math.max(192, alphaThreshold),
}: DetectPuzzleUiSpritesheetLayoutInput): PuzzleUiSpritesheetLayout | null {
const pixelCount = width * height;
if (width <= 0 || height <= 0 || alpha.length < pixelCount) {
return null;
}
const visited = new Uint8Array(pixelCount);
const components: PuzzleUiDetectedComponent[] = [];
for (let start = 0; start < pixelCount; start += 1) {
const alphaValue = alpha[start];
if (
visited[start] ||
alphaValue === undefined ||
alphaValue <= alphaThreshold
) {
continue;
}
const component = floodFillPuzzleUiSpriteComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
hitAlphaThreshold,
});
if (component.area >= minArea) {
components.push(component);
}
}
if (components.length < PUZZLE_UI_SPRITE_ORDER.length) {
return null;
}
const sortedComponents = sortPuzzleUiSpriteComponentsByOriginalPosition(
components,
).slice(0, PUZZLE_UI_SPRITE_ORDER.length);
const regions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>> = {};
const hitRegions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>> =
{};
sortedComponents.forEach((component, index) => {
const kind = PUZZLE_UI_SPRITE_ORDER[index];
if (!kind) {
return;
}
const region = {
x: component.x,
y: component.y,
width: component.width,
height: component.height,
};
regions[kind] = region;
if (component.hitRegion) {
hitRegions[kind] = component.hitRegion;
}
});
return {
width,
height,
regions,
hitRegions,
};
}
export function buildPuzzleUiSpriteBackgroundStyle({
src,
kind,
layout,
}: BuildPuzzleUiSpriteBackgroundStyleInput): CSSProperties {
const region = layout?.regions[kind];
if (!layout || !region) {
const index = PUZZLE_UI_FIXED_GRID_INDEX[kind];
return {
backgroundImage: `url("${src}")`,
backgroundSize: '200% 300%',
backgroundPosition: `${(index % 2) * 100}% ${
Math.floor(index / 2) * 50
}%`,
};
}
return {
backgroundImage: `url("${src}")`,
backgroundSize: `${(layout.width / region.width) * 100}% ${
(layout.height / region.height) * 100
}%`,
backgroundPosition: `${resolvePuzzleUiSpriteBackgroundAxisPosition(
region.x,
layout.width,
region.width,
)}% ${resolvePuzzleUiSpriteBackgroundAxisPosition(
region.y,
layout.height,
region.height,
)}%`,
aspectRatio: `${region.width} / ${region.height}`,
};
}
export function buildPuzzleUiSpriteHitZoneStyle({
kind,
layout,
}: BuildPuzzleUiSpriteHitZoneStyleInput): CSSProperties {
const region = layout?.regions[kind];
const hitRegion = layout?.hitRegions?.[kind];
if (!region || !hitRegion) {
return {
inset: 0,
};
}
return {
left: `${resolvePuzzleUiSpriteHitZoneOffset(
hitRegion.x,
region.x,
region.width,
)}%`,
top: `${resolvePuzzleUiSpriteHitZoneOffset(
hitRegion.y,
region.y,
region.height,
)}%`,
width: `${resolvePuzzleUiSpriteHitZoneSize(
hitRegion.width,
region.width,
)}%`,
height: `${resolvePuzzleUiSpriteHitZoneSize(
hitRegion.height,
region.height,
)}%`,
};
}
export async function loadPuzzleUiSpritesheetLayout(
source: string,
options: LoadPuzzleUiSpritesheetLayoutOptions = {},
) {
const response = await readAssetBytes(source, {
signal: options.signal,
expireSeconds: options.expireSeconds,
});
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
try {
const image = await loadPuzzleUiSpritesheetImage(objectUrl);
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (width <= 0 || height <= 0) {
return null;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
const alpha = new Uint8ClampedArray(width * height);
for (let index = 0; index < alpha.length; index += 1) {
alpha[index] = imageData.data[index * 4 + 3] ?? 0;
}
return detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea:
options.minArea ?? Math.max(16, Math.floor(width * height * 0.0002)),
alphaThreshold: options.alphaThreshold ?? 16,
hitAlphaThreshold: options.hitAlphaThreshold ?? 192,
});
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function sortPuzzleUiSpriteComponentsByOriginalPosition(
components: PuzzleUiDetectedComponent[],
) {
const averageHeight =
components.reduce((total, component) => total + component.height, 0) /
components.length;
const rowTolerance = Math.max(2, averageHeight * 0.65);
const rows: PuzzleUiDetectedComponent[][] = [];
for (const component of components
.slice()
.sort((left, right) => left.y - right.y)) {
const centerY = component.y + component.height / 2;
const row = rows.find((items) => {
const rowCenter =
items.reduce((total, item) => total + item.y + item.height / 2, 0) /
items.length;
return Math.abs(rowCenter - centerY) <= rowTolerance;
});
if (row) {
row.push(component);
} else {
rows.push([component]);
}
}
return rows.flatMap((row) => row.sort((left, right) => left.x - right.x));
}
function resolvePuzzleUiSpriteBackgroundAxisPosition(
offset: number,
imageSize: number,
regionSize: number,
) {
const movableSize = imageSize - regionSize;
if (movableSize <= 0) {
return 0;
}
return (offset / movableSize) * 100;
}
function resolvePuzzleUiSpriteHitZoneOffset(
hitOffset: number,
regionOffset: number,
regionSize: number,
) {
if (regionSize <= 0) {
return 0;
}
return clampPuzzleUiSpritePercent(
((hitOffset - regionOffset) / regionSize) * 100,
);
}
function resolvePuzzleUiSpriteHitZoneSize(
hitSize: number,
regionSize: number,
) {
if (regionSize <= 0) {
return 100;
}
return clampPuzzleUiSpritePercent((hitSize / regionSize) * 100);
}
function clampPuzzleUiSpritePercent(value: number) {
return Math.min(100, Math.max(0, value));
}
function loadPuzzleUiSpritesheetImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('拼图 UI spritesheet 图片解码失败'));
image.src = src;
});
}
function floodFillPuzzleUiSpriteComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
hitAlphaThreshold,
}: {
alpha: ArrayLike<number>;
visited: Uint8Array;
width: number;
height: number;
start: number;
alphaThreshold: number;
hitAlphaThreshold: number;
}): PuzzleUiDetectedComponent {
const stack = [start];
visited[start] = 1;
let minX = start % width;
let maxX = minX;
let minY = Math.floor(start / width);
let maxY = minY;
let area = 0;
let hitMinX = Number.POSITIVE_INFINITY;
let hitMaxX = Number.NEGATIVE_INFINITY;
let hitMinY = Number.POSITIVE_INFINITY;
let hitMaxY = Number.NEGATIVE_INFINITY;
let hitArea = 0;
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % width;
const y = Math.floor(index / width);
const alphaValue = alpha[index] ?? 0;
area += 1;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
if (alphaValue > hitAlphaThreshold) {
hitArea += 1;
hitMinX = Math.min(hitMinX, x);
hitMaxX = Math.max(hitMaxX, x);
hitMinY = Math.min(hitMinY, y);
hitMaxY = Math.max(hitMaxY, y);
}
visitPuzzleUiSpriteNeighbor(
index - 1,
x > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitPuzzleUiSpriteNeighbor(
index + 1,
x + 1 < width,
alpha,
visited,
stack,
alphaThreshold,
);
visitPuzzleUiSpriteNeighbor(
index - width,
y > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitPuzzleUiSpriteNeighbor(
index + width,
y + 1 < height,
alpha,
visited,
stack,
alphaThreshold,
);
}
const component: PuzzleUiDetectedComponent = {
area,
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
if (hitArea > 0) {
component.hitRegion = {
x: hitMinX,
y: hitMinY,
width: hitMaxX - hitMinX + 1,
height: hitMaxY - hitMinY + 1,
};
}
return component;
}
function visitPuzzleUiSpriteNeighbor(
index: number,
inBounds: boolean,
alpha: ArrayLike<number>,
visited: Uint8Array,
stack: number[],
alphaThreshold: number,
) {
const alphaValue = alpha[index];
if (
!inBounds ||
visited[index] ||
alphaValue === undefined ||
alphaValue <= alphaThreshold
) {
return;
}
visited[index] = 1;
stack.push(index);
}