import { ArrowLeft, CheckCircle2, Eye, ImageIcon, ImagePlus, Loader2, Play, Plus, Send, Settings, Trash2, Wand2, } from 'lucide-react'; import { type ChangeEvent, type ReactNode, useEffect, useMemo, useState, } from 'react'; import { createPortal } from 'react-dom'; import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio'; import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DGeneratedBackgroundAsset, Match3DGeneratedItemAsset, Match3DWorkProfile, PutMatch3DWorkRequest, } from '../../../packages/shared/src/contracts/match3dWorks'; import { isGeneratedLegacyPath } from '../../services/assetReadUrlService'; import { generateMatch3DCoverImage, generateMatch3DItemAssets, generateMatch3DWorkTags, publishMatch3DWork, updateMatch3DGeneratedItemAssets, updateMatch3DWork, } from '../../services/match3d-works'; import { getMatch3DGeneratedImageViewSources, mergeMatch3DGeneratedItemAssetsForRuntime, normalizeMatch3DGeneratedItemAssetsForRuntime, resolveMatch3DGeneratedImageAssetSource, resolveMatch3DGeneratedModelAssetSource, } from '../../services/match3dGeneratedModelCache'; import { buildMatch3DItemSpritesheetViewRegions, loadMatch3DSpritesheetAssetRegions, type Match3DDecodedSpritesheetRegion, } from '../../services/match3dSpritesheetParser'; import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; import { PlatformMediaTileGrid } from '../common/PlatformMediaTileGrid'; import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformPillSwitch } from '../common/PlatformPillSwitch'; import { PlatformProgressBar } from '../common/PlatformProgressBar'; import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import { PlatformStatGrid } from '../common/PlatformStatGrid'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformTagEditor } from '../common/PlatformTagEditor'; import { PlatformTextField } from '../common/PlatformTextField'; import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard'; import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog'; import { MATCH3D_RUNTIME_BOARD_BASE_CLASS, MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, MATCH3D_RUNTIME_BOARD_WIDTH, MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, MATCH3D_RUNTIME_HEADER_CARD_CLASS, MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, MATCH3D_RUNTIME_STAGE_CLASS, MATCH3D_RUNTIME_TIMER_CLASS, } from '../match3d-runtime/match3dRuntimeUiStyles'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type Match3DResultViewProps = { profile: Match3DWorkProfile; draft?: Match3DResultDraft | null; isBusy?: boolean; error?: string | null; onBack: () => void; onSaved?: (profile: Match3DWorkProfile) => void; onPublished?: (profile: Match3DWorkProfile) => void; onStartTestRun: ( profile: Match3DWorkProfile, options?: { itemTypeCountOverride?: number }, ) => void; }; type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error'; type Match3DResultTab = 'work' | 'config' | 'assets'; type Match3DAssetConfigTab = 'items' | 'ui'; type Match3DAssetTaskStatus = | 'idle' | 'submitting' | 'waiting' | 'generating' | 'image_ready' | 'done' | 'failed' | 'unknown'; type Match3DBatchItemGenerationState = { phase: 'idle' | 'generating' | 'done' | 'failed'; progress: number | null; itemNames: string[]; message: string | null; error: string | null; }; type Match3DItemSpritesheetPreviewGroup = { itemIndex: number; itemName: string; regions: Match3DDecodedSpritesheetRegion[]; }; type Match3DItemAssetDraft = { id: string; name: string; usage: string; prompt: string; referenceImageSrc: string; imageViews: Match3DGeneratedItemAsset['imageViews']; taskUuid: string | null; subscriptionKey: string | null; status: Match3DAssetTaskStatus; progress: number | null; downloads: Array<{ name: string; url: string }>; soundPrompt: string; backgroundMusicTitle: string | null; backgroundMusicStyle: string | null; backgroundMusicPrompt: string | null; backgroundMusic: CreationAudioAsset | null; clickSound: CreationAudioAsset | null; backgroundAsset: Match3DGeneratedItemAsset['backgroundAsset'] | null; error: string | null; updatedAt: string | null; }; type Match3DResultEditState = { gameName: string; summary: string; tagsText: string; coverImageSrc: string; themeText: string; clearCountText: string; difficultyText: string; }; type Match3DCoverSourceAsset = { id: string; label: string; imageSrc: string; kind: 'item' | 'ui'; }; type Match3DCoverReferenceDraft = { id: string; label: string; imageSrc: string; source: 'asset' | 'upload'; }; const MATCH3D_MIN_TAG_COUNT = 3; const MATCH3D_MAX_TAG_COUNT = 6; const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600; const MATCH3D_DEFAULT_ASSET_COUNT = 20; const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2; const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 20; const MATCH3D_COVER_REFERENCE_IMAGE_LIMIT = 6; const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [ { id: 'work', label: '作品信息' }, { id: 'config', label: '难度配置' }, { id: 'assets', label: '素材配置' }, ]; const MATCH3D_ASSET_CONFIG_TABS: Array<{ id: Match3DAssetConfigTab; label: string; }> = [ { id: 'items', label: '物品' }, { id: 'ui', label: 'UI素材' }, ]; // 中文注释:结果页难度配置必须与创作入口页保持同一组派生参数。 const MATCH3D_DIFFICULTY_OPTIONS = [ { id: 'easy', label: '轻松', clearCount: 8, difficulty: 2, itemTypeCount: 3 }, { id: 'standard', label: '标准', clearCount: 12, difficulty: 4, itemTypeCount: 9, }, { id: 'advanced', label: '进阶', clearCount: 16, difficulty: 6, itemTypeCount: 15, }, { id: 'hardcore', label: '硬核', clearCount: 21, difficulty: 8, itemTypeCount: 20, }, ] as const; type Match3DDifficultyOptionId = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id']; type Match3DDifficultyOption = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number]; const MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC = '/match3d-background-references/pot-fused-reference.png'; function resolveMatch3DDifficultyOptionId( difficulty: number | null | undefined, clearCount: number | null | undefined, ): Match3DDifficultyOptionId { const byClearCount = MATCH3D_DIFFICULTY_OPTIONS.find( (option) => option.clearCount === clearCount, ); if (byClearCount) { return byClearCount.id; } if (typeof difficulty !== 'number' || !Number.isFinite(difficulty)) { return 'standard'; } const normalizedDifficulty = Math.max( 1, Math.min(10, Math.round(difficulty)), ); return MATCH3D_DIFFICULTY_OPTIONS.reduce( (nearestOption, option) => Math.abs(option.difficulty - normalizedDifficulty) < Math.abs(nearestOption.difficulty - normalizedDifficulty) ? option : nearestOption, MATCH3D_DIFFICULTY_OPTIONS[1], ).id; } function getMatch3DDifficultyOption(optionId: Match3DDifficultyOptionId) { return ( MATCH3D_DIFFICULTY_OPTIONS.find((option) => option.id === optionId) ?? MATCH3D_DIFFICULTY_OPTIONS[1] ); } function getMatch3DReadyItemTypeCount( generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { return generatedItemAssets.filter(hasMatch3DGeneratedFiveViewImageSource) .length; } function getMatch3DPlayableItemTypeCount( targetItemTypeCount: number, generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { return Math.max( 1, Math.min( targetItemTypeCount, getMatch3DReadyItemTypeCount(generatedItemAssets), ), ); } function getMatch3DDifficultyOptionFromEditState( editState: Match3DResultEditState, ) { return getMatch3DDifficultyOption( resolveMatch3DDifficultyOptionId( normalizeDifficulty(editState.difficultyText), normalizePositiveInteger(editState.clearCountText), ), ); } function resolveMatch3DBackgroundPreviewSource( profile: Match3DWorkProfile, draft: Match3DResultDraft | null, generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { return ( draft?.backgroundImageSrc?.trim() || draft?.generatedBackgroundAsset?.imageSrc?.trim() || draft?.backgroundImageObjectKey?.trim() || draft?.generatedBackgroundAsset?.imageObjectKey?.trim() || profile.backgroundImageSrc?.trim() || profile.generatedBackgroundAsset?.imageSrc?.trim() || profile.backgroundImageObjectKey?.trim() || profile.generatedBackgroundAsset?.imageObjectKey?.trim() || generatedItemAssets .map( (asset) => asset.backgroundAsset?.imageSrc?.trim() || asset.backgroundAsset?.imageObjectKey?.trim() || '', ) .find(Boolean) || '' ); } function findMatch3DGeneratedBackgroundAsset( generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [], ): Match3DGeneratedBackgroundAsset | null { return ( generatedItemAssets.find((asset) => asset.backgroundAsset) ?.backgroundAsset ?? null ); } function promoteMatch3DGeneratedBackgroundAsset( profile: Match3DWorkProfile, ): Match3DWorkProfile { const fallbackBackground = profile.generatedBackgroundAsset ?? findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets ?? []); if (!fallbackBackground) { return profile; } return { ...profile, backgroundPrompt: profile.backgroundPrompt ?? fallbackBackground.prompt ?? null, backgroundImageSrc: profile.backgroundImageSrc ?? fallbackBackground.imageSrc ?? fallbackBackground.imageObjectKey ?? null, backgroundImageObjectKey: profile.backgroundImageObjectKey ?? fallbackBackground.imageObjectKey ?? fallbackBackground.imageSrc ?? null, generatedBackgroundAsset: profile.generatedBackgroundAsset ?? fallbackBackground, }; } function resolveMatch3DContainerPreviewSource( profile: Match3DWorkProfile, draft: Match3DResultDraft | null, generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { return ( draft?.generatedBackgroundAsset?.containerImageSrc?.trim() || draft?.generatedBackgroundAsset?.containerImageObjectKey?.trim() || profile.generatedBackgroundAsset?.containerImageSrc?.trim() || profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() || generatedItemAssets .map( (asset) => asset.backgroundAsset?.containerImageSrc?.trim() || asset.backgroundAsset?.containerImageObjectKey?.trim() || '', ) .find(Boolean) || '' ); } 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 normalizeTags(value: string) { return [ ...new Set( value .split(/[\n,,、]/u) .map((entry) => normalizeMatch3DTag(entry)) .filter(Boolean), ), ]; } function buildMatch3DAssetPrompt( profile: Match3DWorkProfile, assetName: string, usage: string, ) { return buildMatch3DAssetPromptFromDraft(profile.themeText, assetName, usage); } function buildMatch3DAssetPromptFromDraft( themeText: string, assetName: string, usage: string, ) { const normalizedTheme = themeText.trim() || '主题'; return [ `${normalizedTheme}题材抓大鹅游戏内2D素材:${assetName}`, usage, '适合移动端游戏直接显示,主体清晰,五视角一致,干净背景。', ] .filter(Boolean) .join(','); } function buildFallbackMatch3DClickSoundPrompt( profile: Pick, assetName: string, ) { const normalizedTheme = profile.themeText.trim() || '抓大鹅'; const normalizedName = assetName.trim() || '物品'; return `${normalizedTheme}题材抓大鹅中“${normalizedName}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。`; } function normalizeMatch3DTag(value: string) { return value .trim() .replace(/^[\s#"'`,。、“”《》;:!?,.、]+/u, '') .replace(/[\s#"'`,。、“”《》;:!?,.、]+$/u, '') .slice(0, 12); } function normalizeMatch3DTagListText(value: string) { return [ ...new Set( value .split(/[\n,,、]/u) .map((entry) => normalizeMatch3DTag(entry)) .filter(Boolean), ), ]; } function normalizeMatch3DItemName(value: string) { return value .trim() .replace(/^[-*•\d.、)\s]+/u, '') .slice(0, 12); } function normalizeMatch3DItemNameList(values: readonly string[]) { return [...new Set(values.map(normalizeMatch3DItemName).filter(Boolean))]; } function collectMatch3DRegenerateItemNames( values: readonly string[], assets: readonly Match3DItemAssetDraft[], ) { const existingNames = new Set( assets.map((asset) => asset.name.trim()).filter(Boolean), ); return normalizeMatch3DItemNameList(values).filter((name) => existingNames.has(name.trim()), ); } function calculateMatch3DItemAssetsPointsCost(itemCount: number) { if (itemCount <= 0) { return 0; } return ( Math.ceil(itemCount / MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE) * MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH ); } function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) { return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim()); } function hasMatch3DGeneratedImageSource(asset: Match3DGeneratedItemAsset) { return getMatch3DGeneratedImageViewSources(asset).length > 0; } function hasMatch3DGeneratedFiveViewImageSource( asset: Match3DGeneratedItemAsset, ) { return ( (asset.imageViews ?? []).filter( (view) => Boolean(view.imageSrc?.trim()) || Boolean(view.imageObjectKey?.trim()), ).length >= 5 ); } function resolveMatch3DGeneratedImageViewSourceFromDraft( view: NonNullable[number], ) { return view.imageObjectKey?.trim() || view.imageSrc?.trim() || ''; } function resolveMatch3DAssetDraftImageViewSources( asset: Match3DItemAssetDraft, ) { return [ ...new Set( (asset.imageViews ?? []) .map(resolveMatch3DGeneratedImageViewSourceFromDraft) .filter(Boolean), ), ]; } function resolveMatch3DAssetDraftPreviewSources(asset: Match3DItemAssetDraft) { const imageViewSources = resolveMatch3DAssetDraftImageViewSources(asset); if (imageViewSources.length > 0) { return imageViewSources.slice(0, 5); } const fallbackSource = asset.referenceImageSrc.trim(); return fallbackSource ? [fallbackSource] : []; } function hasPersistableMatch3DGeneratedItemAsset( asset: Match3DGeneratedItemAsset, ) { return Boolean( asset.imageSrc?.trim() || asset.imageObjectKey?.trim() || getMatch3DGeneratedImageViewSources(asset).length > 0 || asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || asset.taskUuid?.trim() || 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() || asset.backgroundMusic || asset.clickSound, ); } function getMatch3DGeneratedItemAssetPersistenceSignature( asset: Match3DGeneratedItemAsset, ) { return [ asset.itemId.trim(), asset.itemName.trim(), asset.itemSize?.trim() ?? '', asset.imageSrc?.trim() ?? '', asset.imageObjectKey?.trim() ?? '', ...(asset.imageViews ?? []).flatMap((view) => [ view.viewId.trim(), String(view.viewIndex), view.imageSrc?.trim() ?? '', view.imageObjectKey?.trim() ?? '', ]), asset.modelSrc?.trim() ?? '', asset.modelObjectKey?.trim() ?? '', asset.modelFileName?.trim() ?? '', asset.taskUuid?.trim() ?? '', asset.subscriptionKey?.trim() ?? '', asset.status.trim(), asset.soundPrompt?.trim() ?? '', asset.backgroundMusicTitle?.trim() ?? '', asset.backgroundMusicStyle?.trim() ?? '', asset.backgroundMusicPrompt?.trim() ?? '', asset.backgroundMusic?.audioSrc?.trim() ?? asset.backgroundMusic?.assetObjectId?.trim() ?? 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() ?? '', asset.backgroundAsset?.status?.trim() ?? '', asset.backgroundAsset?.error?.trim() ?? '', asset.clickSound?.audioSrc?.trim() ?? asset.clickSound?.assetObjectId?.trim() ?? asset.clickSound?.taskId?.trim() ?? '', asset.error?.trim() ?? '', ].join('\u001f'); } function shouldPersistGeneratedItemAssets( currentAssets: readonly Match3DGeneratedItemAsset[], savedAssets: readonly Match3DGeneratedItemAsset[] = [], ) { if (currentAssets.length <= 0) { return false; } if (currentAssets.length !== savedAssets.length) { return true; } return currentAssets.some((asset, index) => { const savedAsset = savedAssets[index]; return ( !savedAsset || getMatch3DGeneratedItemAssetPersistenceSignature(asset) !== getMatch3DGeneratedItemAssetPersistenceSignature(savedAsset) ); }); } function mergeMatch3DGeneratedItemAsset( base: Match3DGeneratedItemAsset, override: Match3DGeneratedItemAsset, ): Match3DGeneratedItemAsset { const overrideHasModel = hasMatch3DGeneratedModelSource(override); const overrideHasImages = hasMatch3DGeneratedImageSource(override); return { ...base, itemName: override.itemName.trim() || base.itemName, itemSize: override.itemSize ?? base.itemSize ?? null, imageSrc: override.imageSrc?.trim() ? override.imageSrc : (base.imageSrc ?? null), imageObjectKey: override.imageObjectKey?.trim() ? override.imageObjectKey : (base.imageObjectKey ?? null), imageViews: override.imageViews && override.imageViews.length > 0 ? override.imageViews : (base.imageViews ?? []), modelSrc: override.modelSrc?.trim() ? override.modelSrc : (base.modelSrc ?? null), modelObjectKey: override.modelObjectKey?.trim() ? override.modelObjectKey : (base.modelObjectKey ?? null), modelFileName: override.modelFileName?.trim() ? override.modelFileName : (base.modelFileName ?? null), taskUuid: override.taskUuid?.trim() ? override.taskUuid : (base.taskUuid ?? null), subscriptionKey: override.subscriptionKey?.trim() ? override.subscriptionKey : (base.subscriptionKey ?? null), backgroundMusic: override.backgroundMusic ?? base.backgroundMusic ?? null, clickSound: override.clickSound ?? base.clickSound ?? null, backgroundAsset: override.backgroundAsset ?? base.backgroundAsset ?? null, soundPrompt: override.soundPrompt?.trim() ? override.soundPrompt : (base.soundPrompt ?? null), backgroundMusicTitle: override.backgroundMusicTitle?.trim() ? override.backgroundMusicTitle : (base.backgroundMusicTitle ?? null), backgroundMusicStyle: override.backgroundMusicStyle?.trim() ? override.backgroundMusicStyle : (base.backgroundMusicStyle ?? null), backgroundMusicPrompt: override.backgroundMusicPrompt?.trim() ? override.backgroundMusicPrompt : (base.backgroundMusicPrompt ?? null), // 中文注释:新草稿以 2D 多视角图片为正式素材;历史模型字段只做兼容保留。 status: overrideHasModel && base.status !== 'model_ready' ? 'model_ready' : overrideHasImages ? 'image_ready' : base.status, error: override.error ?? base.error ?? null, }; } function createMatch3DAssetDrafts( profile: Match3DWorkProfile, draft: Match3DResultDraft | null = null, ): Match3DItemAssetDraft[] { const generatedAssets = resolveMatch3DResultGeneratedItemAssets( profile, draft, ); if (generatedAssets?.length) { return generatedAssets.map((asset) => createMatch3DAssetDraftFromGeneratedAsset(profile, asset), ); } const theme = profile.themeText.trim() || '主题'; const seeds = [ { id: 'primary-item', name: `${theme}核心物件`, usage: '局内主要点击消除物件', }, { id: 'rare-item', name: `${theme}稀有物件`, usage: '用于增强辨识度的高价值物件', }, { id: 'bonus-item', name: `${theme}奖励物件`, usage: '通关或连消反馈奖励', }, { id: 'obstacle-item', name: `${theme}干扰物件`, usage: '堆叠层中的视觉遮挡物', }, { id: 'tray-prop', name: `${theme}托盘道具`, usage: '备选栏和结算展示道具', }, { id: 'scene-prop', name: `${theme}场景小物`, usage: '圆形空间周边装饰物', }, ]; 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 fallbackSeeds.map((seed) => ({ ...seed, prompt: buildMatch3DAssetPrompt(profile, seed.name, seed.usage), referenceImageSrc: profile.referenceImageSrc ?? profile.coverImageSrc ?? '', imageViews: [], taskUuid: null, subscriptionKey: null, status: 'idle', progress: null, downloads: [], soundPrompt: buildFallbackMatch3DClickSoundPrompt(profile, seed.name), backgroundMusicTitle: null, backgroundMusicStyle: null, backgroundMusicPrompt: null, backgroundMusic: null, clickSound: null, backgroundAsset: null, error: null, updatedAt: null, })); } function createMatch3DAssetDraftFromGeneratedAsset( profile: Match3DWorkProfile, asset: Match3DGeneratedItemAsset, ): Match3DItemAssetDraft { const modelSource = resolveMatch3DGeneratedModelAssetSource(asset); const imageSource = resolveMatch3DGeneratedImageAssetSource(asset); const downloads = modelSource ? [ { name: asset.modelFileName ?? `${asset.itemName}.glb`, url: modelSource, }, ] : []; return { id: asset.itemId, name: asset.itemName, usage: '局内点击消除物件', prompt: buildMatch3DAssetPrompt( profile, asset.itemName, '局内点击消除物件', ), referenceImageSrc: imageSource || asset.imageSrc || profile.referenceImageSrc || profile.coverImageSrc || '', imageViews: asset.imageViews ?? [], taskUuid: asset.taskUuid ?? null, subscriptionKey: asset.subscriptionKey ?? null, status: asset.status === 'model_ready' ? 'done' : normalizeMatch3DAssetStatus(asset.status), progress: asset.status === 'model_ready' ? 1 : null, downloads, soundPrompt: asset.soundPrompt?.trim() || asset.clickSound?.prompt?.trim() || buildFallbackMatch3DClickSoundPrompt(profile, asset.itemName), backgroundMusicTitle: asset.backgroundMusicTitle ?? asset.backgroundMusic?.title ?? null, backgroundMusicStyle: asset.backgroundMusicStyle ?? null, backgroundMusicPrompt: asset.backgroundMusicPrompt ?? asset.backgroundMusic?.prompt ?? null, backgroundMusic: asset.backgroundMusic ?? null, backgroundAsset: asset.backgroundAsset ?? null, clickSound: asset.clickSound ?? null, error: asset.error ?? null, updatedAt: profile.updatedAt, }; } function createGeneratedAssetsFromDrafts( assetDrafts: Match3DItemAssetDraft[], existingAssets: readonly Match3DGeneratedItemAsset[] = [], ): Match3DGeneratedItemAsset[] { const existingById = new Map( existingAssets.map((asset) => [asset.itemId, asset]), ); return assetDrafts.map((asset) => { const existing = existingById.get(asset.id); const modelFile = asset.downloads.find((file) => file.url.trim()) ?? null; const modelSource = modelFile?.url.trim() || existing?.modelSrc?.trim() || existing?.modelObjectKey?.trim() || null; const modelObjectKey = modelFile?.url && isGeneratedLegacyPath(modelFile.url) ? modelFile.url.trim().replace(/^\/+/u, '') : (existing?.modelObjectKey ?? null); return { itemId: asset.id, itemName: asset.name, imageSrc: existing?.imageSrc ?? (asset.referenceImageSrc || null), imageObjectKey: existing?.imageObjectKey ?? null, imageViews: asset.imageViews ?? existing?.imageViews ?? [], modelSrc: modelSource, modelObjectKey, modelFileName: modelFile?.name?.trim() || existing?.modelFileName || null, taskUuid: asset.taskUuid, subscriptionKey: asset.subscriptionKey, soundPrompt: asset.soundPrompt.trim() || existing?.soundPrompt || null, backgroundMusicTitle: asset.backgroundMusicTitle ?? existing?.backgroundMusicTitle ?? null, backgroundMusicStyle: asset.backgroundMusicStyle ?? existing?.backgroundMusicStyle ?? null, backgroundMusicPrompt: asset.backgroundMusicPrompt ?? existing?.backgroundMusicPrompt ?? null, backgroundMusic: asset.backgroundMusic ?? existing?.backgroundMusic ?? null, clickSound: asset.clickSound, backgroundAsset: asset.backgroundAsset ?? existing?.backgroundAsset ?? (asset.id === assetDrafts[0]?.id ? (existingAssets .map((candidate) => candidate.backgroundAsset ?? null) .find(Boolean) ?? null) : null), // 中文注释:当前主链路只要求 2D 图片素材;但历史草稿若已有平台模型字段,保存时不能把模型状态降回图片态。 status: modelSource?.trim() ? 'model_ready' : hasMatch3DGeneratedImageSource( existing ?? ({} as Match3DGeneratedItemAsset), ) ? 'image_ready' : asset.status === 'done' ? 'model_ready' : asset.status, error: asset.error, }; }); } function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus { const normalized = status.trim().toLowerCase(); if ( normalized === 'waiting' || normalized === 'pending' || normalized === 'queued' ) { return 'waiting'; } if ( normalized === 'generating' || normalized === 'running' || normalized === 'processing' ) { return 'generating'; } if (normalized === 'image_ready') { return 'image_ready'; } if ( normalized === 'done' || normalized === 'succeeded' || normalized === 'success' || normalized === 'completed' ) { return 'done'; } if ( normalized === 'failed' || normalized === 'error' || normalized === 'canceled' || normalized === 'cancelled' ) { return 'failed'; } return 'unknown'; } function getMatch3DBatchGenerationStatusLabel( phase: Match3DBatchItemGenerationState['phase'], ) { if (phase === 'done') return '生成完成'; if (phase === 'failed') return '生成失败'; if (phase === 'generating') return '生成中'; return ''; } function Match3DBatchGenerationProgress({ generationState, }: { generationState: Match3DBatchItemGenerationState; }) { if (generationState.phase === 'idle') { return null; } const normalizedProgress = generationState.progress === null ? null : Math.max(0, Math.min(1, generationState.progress)); return (
{getMatch3DBatchGenerationStatusLabel(generationState.phase)} {normalizedProgress !== null ? ( {Math.round(normalizedProgress * 100)}% ) : null}
{normalizedProgress !== null ? ( ) : null} {generationState.message ? (
{generationState.message}
) : null} {generationState.error ? (
{generationState.error}
) : null}
); } function normalizePositiveInteger(value: string) { const parsed = Number.parseInt(value.trim(), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } function normalizeDifficulty(value: string) { const parsed = Number.parseInt(value.trim(), 10); return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10 ? parsed : null; } function createEditState(profile: Match3DWorkProfile): Match3DResultEditState { const difficultyOption = getMatch3DDifficultyOption( resolveMatch3DDifficultyOptionId(profile.difficulty, profile.clearCount), ); return { gameName: profile.gameName, summary: profile.summary, tagsText: profile.tags.join(','), coverImageSrc: profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '', themeText: profile.themeText, clearCountText: String(difficultyOption.clearCount), difficultyText: String(difficultyOption.difficulty), }; } function buildSavePayload( editState: Match3DResultEditState, ): PutMatch3DWorkRequest | null { const clearCount = normalizePositiveInteger(editState.clearCountText); const difficulty = normalizeDifficulty(editState.difficultyText); const gameName = editState.gameName.trim(); const themeText = editState.themeText.trim(); const summary = editState.summary.trim(); const tags = normalizeTags(editState.tagsText); if (!gameName || !themeText || !clearCount || !difficulty) { return null; } return { gameName, themeText, summary, tags, coverImageSrc: editState.coverImageSrc.trim() || null, clearCount, difficulty, }; } function buildPublishBlockers( editState: Match3DResultEditState, generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { const tags = normalizeTags(editState.tagsText); const selectedDifficulty = getMatch3DDifficultyOptionFromEditState(editState); const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets); const assetReadyBlocker = generatedItemAssets.length > 0 && readyItemTypeCount < selectedDifficulty.itemTypeCount ? [ `当前难度需要 ${selectedDifficulty.itemTypeCount} 种物品,已生成 ${readyItemTypeCount} 种,请先在素材配置中补齐。`, ] : []; const blockers = [ ...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']), ...(editState.themeText.trim() ? [] : ['题材主题不能为空。']), ...(editState.summary.trim() ? [] : ['简介不能为空。']), ...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']), ...(tags.length >= MATCH3D_MIN_TAG_COUNT && tags.length <= MATCH3D_MAX_TAG_COUNT ? [] : [ `标签数量需要在 ${MATCH3D_MIN_TAG_COUNT} 到 ${MATCH3D_MAX_TAG_COUNT} 个之间。`, ]), ...(normalizePositiveInteger(editState.clearCountText) ? [] : ['需要消除次数必须为正整数。']), ...(normalizeDifficulty(editState.difficultyText) ? [] : ['难度必须为 1 到 10。']), ...assetReadyBlocker, ]; return [...new Set(blockers)]; } function buildTestRunBlockers(editState: Match3DResultEditState) { const blockers = [ ...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']), ...(editState.themeText.trim() ? [] : ['题材主题不能为空。']), ...(normalizePositiveInteger(editState.clearCountText) ? [] : ['需要消除次数必须为正整数。']), ...(normalizeDifficulty(editState.difficultyText) ? [] : ['难度必须为 1 到 10。']), ]; return [...new Set(blockers)]; } async function readImageAsDataUrl(file: File) { return readPuzzleReferenceImageAsDataUrl(file); } async function readCoverReferenceImageAsDataUrl(file: File) { return readPuzzleReferenceImageAsDataUrl(file); } function resolveMatch3DCoverSourceAssets( assetDrafts: Match3DItemAssetDraft[], backgroundPreviewSrc: string, containerPreviewSrc: string, ): Match3DCoverSourceAsset[] { const itemAssets = assetDrafts .filter((asset) => asset.referenceImageSrc.trim()) .map((asset) => ({ id: `item:${asset.id}`, label: asset.name, imageSrc: asset.referenceImageSrc, kind: 'item' as const, })); const uiAssets = [ ...(backgroundPreviewSrc.trim() ? [ { id: 'ui:background', label: '游戏背景图', imageSrc: backgroundPreviewSrc, kind: 'ui' as const, }, ] : []), ...(containerPreviewSrc.trim() && containerPreviewSrc.trim() !== backgroundPreviewSrc.trim() ? [ { id: 'ui:container', label: '游戏容器图', imageSrc: containerPreviewSrc, kind: 'ui' as const, }, ] : []), ]; return [...itemAssets, ...uiAssets]; } function createMatch3DCoverReferenceDraftFromSource( asset: Match3DCoverSourceAsset, ): Match3DCoverReferenceDraft { return { id: asset.id, label: asset.label, imageSrc: asset.imageSrc, source: 'asset', }; } function addMatch3DCoverReferenceDraft( currentReferences: Match3DCoverReferenceDraft[], nextReference: Match3DCoverReferenceDraft, ) { const deduped = currentReferences.filter( (reference) => reference.imageSrc !== nextReference.imageSrc, ); return [nextReference, ...deduped].slice( 0, MATCH3D_COVER_REFERENCE_IMAGE_LIMIT, ); } function buildPlayableProfile( profile: Match3DWorkProfile, editState: Match3DResultEditState, generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [], ) { const payload = buildSavePayload(editState); if (!payload) { return promoteMatch3DGeneratedBackgroundAsset( attachMatch3DGeneratedItemAssets(profile, generatedItemAssets), ); } return promoteMatch3DGeneratedBackgroundAsset( attachMatch3DGeneratedItemAssets( { ...profile, gameName: payload.gameName, themeText: payload.themeText ?? profile.themeText, summary: payload.summary, tags: payload.tags, coverImageSrc: payload.coverImageSrc, clearCount: payload.clearCount, difficulty: payload.difficulty, }, generatedItemAssets, ), ); } function buildCoverImageUpdatedProfile( profile: Match3DWorkProfile, editState: Match3DResultEditState, responseItem: Match3DWorkProfile, coverImageSrc: string, generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [], ) { // 中文注释:封面生成只允许更新封面字段;接口回包如果来自旧快照,不能覆盖当前物品素材或难度配置。 const visibleProfile = buildPlayableProfile( profile, { ...editState, coverImageSrc, }, generatedItemAssets.length > 0 ? generatedItemAssets : (profile.generatedItemAssets ?? []), ); return { ...visibleProfile, coverImageSrc, updatedAt: responseItem.updatedAt || visibleProfile.updatedAt, }; } function resolveMatch3DResultGeneratedItemAssets( profile: Match3DWorkProfile, draft: Match3DResultDraft | null, ) { const profileAssets = profile.generatedItemAssets ?? []; const draftAssets = draft?.generatedItemAssets ?? []; if (draftAssets.length <= 0) { return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); } if (profileAssets.length <= 0) { return normalizeMatch3DGeneratedItemAssetsForRuntime(draftAssets); } return mergeMatch3DGeneratedItemAssetsForRuntime( draftAssets.map((draftAsset) => { const profileAsset = profileAssets.find( (asset) => asset.itemId === draftAsset.itemId, ); return profileAsset ? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset) : draftAsset; }), profileAssets, ); } function attachMatch3DGeneratedItemAssets( profile: Match3DWorkProfile, generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { if (generatedItemAssets.length <= 0) { return promoteMatch3DGeneratedBackgroundAsset(profile); } // 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。 return promoteMatch3DGeneratedBackgroundAsset({ ...profile, generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets), }); } function buildPersistableGeneratedItemAssets( assetDrafts: Match3DItemAssetDraft[], generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { if (generatedItemAssets.length <= 0) { return []; } return normalizeMatch3DGeneratedItemAssetsForRuntime( createGeneratedAssetsFromDrafts(assetDrafts, generatedItemAssets).filter( hasPersistableMatch3DGeneratedItemAsset, ), ); } function Match3DResultHeader({ autoSaveState, isBusy, onBack, }: { autoSaveState: Match3DAutoSaveState; isBusy: boolean; onBack: () => void; }) { const badge = autoSaveState === 'saving' ? ( 保存中 ) : autoSaveState === 'saved' ? ( 已自动保存 ) : autoSaveState === 'error' ? ( 保存失败 ) : null; return (
返回 {badge}
); } function Match3DResultTabs({ activeTab, onChange, }: { activeTab: Match3DResultTab; onChange: (tab: Match3DResultTab) => void; }) { return ( ); } function Match3DWorkInfoTab({ editState, isBusy, onChange, onGenerateTags, }: { editState: Match3DResultEditState; isBusy: boolean; onChange: (nextState: Match3DResultEditState) => void; onGenerateTags: () => void; }) { const tags = normalizeTags(editState.tagsText); const updateTags = (nextTags: string[]) => { onChange({ ...editState, tagsText: nextTags.join(',') }); }; return ( } tone="warm" padding="none" /> ); } function Match3DModalShell({ title, children, onClose, }: { title: string; children: ReactNode; onClose: () => void; }) { const platformTheme = useAuthUi()?.platformTheme ?? 'light'; if (typeof document === 'undefined') { return null; } return createPortal(
{ if (event.target === event.currentTarget) { onClose(); } }} >
event.stopPropagation()} >
{title}
{children}
, document.body, ); } type Match3DCoverImageEditorProps = { editState: Match3DResultEditState; sourceAssets: Match3DCoverSourceAsset[]; isGenerating: boolean; uploadedImageSrc: string; referenceImages: Match3DCoverReferenceDraft[]; aiRedraw: boolean; prompt: string; error: string | null; onAiRedrawChange: (enabled: boolean) => void; onFileChange: (event: ChangeEvent) => void; onPromptChange: (value: string) => void; onReferenceSelect: (source: string) => void; onReferenceFileChange: (event: ChangeEvent) => void; onReferenceRemove: (referenceId: string) => void; onUploadedImageRemove: () => void; onSubmit: () => void; }; function Match3DCoverImageEditor({ editState, sourceAssets, isGenerating, uploadedImageSrc, referenceImages, aiRedraw, prompt, error, onAiRedrawChange, onFileChange, onPromptChange, onReferenceSelect, onReferenceFileChange, onReferenceRemove, onUploadedImageRemove, onSubmit, }: Match3DCoverImageEditorProps) { const previewSrc = uploadedImageSrc || editState.coverImageSrc; const promptLabel = uploadedImageSrc ? 'AI重绘要求' : '封面描述'; const canSubmit = Boolean(uploadedImageSrc.trim() || prompt.trim()); return (
{previewSrc ? ( ) : (
)}
{uploadedImageSrc ? ( <> onAiRedrawChange(event.target.checked)} className="absolute bottom-3 left-3 z-10" /> } disabled={isGenerating} onClick={onUploadedImageRemove} className="absolute left-3 top-3 z-10 h-10 w-10" /> ) : ( )}
{!uploadedImageSrc ? (
参考图 } />
{referenceImages.length > 0 ? (
{referenceImages.map((reference) => ( onReferenceRemove(reference.id)} disabled={isGenerating} resolveAsset className="h-auto w-full rounded-[1rem] border-[var(--platform-warm-border)] bg-white/74" removeIcon={} removeButtonProps={{ title: '移除参考图', className: 'bottom-1.5 right-1.5 top-auto h-6 w-6 bg-white/92 text-[var(--platform-text-strong)] shadow-sm hover:bg-white hover:text-[var(--platform-accent)]', }} /> ))}
) : null} {sourceAssets.length > 0 ? ( asset.id} getImageSrc={(asset) => asset.imageSrc} getImageAlt={() => ''} getTitle={(asset) => asset.label} getAriaLabel={(asset) => `引用${asset.label}`} isSelected={(asset) => referenceImages.some( (reference) => reference.imageSrc === asset.imageSrc, ) } onSelect={(asset) => onReferenceSelect(asset.imageSrc)} gridClassName="grid grid-cols-3 gap-2 sm:grid-cols-4" cardClassName="bg-white/74" cardRadiusClassName="rounded-[1rem]" imageShellClassName="aspect-square" bodyClassName="truncate px-2 py-2 text-[11px] font-semibold text-[var(--platform-text-base)]" /> ) : null}
) : null} {error ? ( {error} ) : null} {isGenerating ? ( ) : uploadedImageSrc && !aiRedraw ? ( ) : ( )} {uploadedImageSrc && !aiRedraw ? '使用当前图片' : '生成封面图'}
); } function Match3DPublishDialog({ blockers, editState, isBusy, isGeneratingCover, isPublishing, sourceAssets, uploadedImageSrc, referenceImages, aiRedraw, prompt, coverError, publishError, onAiRedrawChange, onClose, onFileChange, onPromptChange, onPublish, onReferenceSelect, onReferenceFileChange, onReferenceRemove, onUploadedImageRemove, onSubmitCover, }: { blockers: string[]; editState: Match3DResultEditState; isBusy: boolean; isGeneratingCover: boolean; isPublishing: boolean; sourceAssets: Match3DCoverSourceAsset[]; uploadedImageSrc: string; referenceImages: Match3DCoverReferenceDraft[]; aiRedraw: boolean; prompt: string; coverError: string | null; publishError: string | null; onAiRedrawChange: (enabled: boolean) => void; onClose: () => void; onFileChange: (event: ChangeEvent) => void; onPromptChange: (value: string) => void; onPublish: () => void; onReferenceSelect: (source: string) => void; onReferenceFileChange: (event: ChangeEvent) => void; onReferenceRemove: (referenceId: string) => void; onUploadedImageRemove: () => void; onSubmitCover: () => void; }) { const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const publishReady = blockers.length === 0; if (typeof document === 'undefined') { return null; } return createPortal(
{ if (event.target === event.currentTarget && !isGeneratingCover) { onClose(); } }} >
event.stopPropagation()} >
发布抓大鹅作品
发布检查 {publishError ? ( {publishError} ) : publishReady ? ( 当前作品已满足发布条件。 ) : (
{blockers.map((blocker, index) => ( {blocker} ))}
)}
封面图
取消 {isPublishing ? ( ) : ( )} 发布到广场
, document.body, ); } function Match3DConfigTab({ editState, isBusy, generatedItemAssets, totalItemCount, onChange, }: { editState: Match3DResultEditState; isBusy: boolean; generatedItemAssets: readonly Match3DGeneratedItemAsset[]; totalItemCount: number; onChange: (nextState: Match3DResultEditState) => void; }) { const selectedOption = getMatch3DDifficultyOptionFromEditState(editState); const selectedOptionIndex = MATCH3D_DIFFICULTY_OPTIONS.findIndex( (option) => option.id === selectedOption.id, ); const selectedSliderIndex = Math.max(0, selectedOptionIndex); const runtimeTypeCount = selectedOption.itemTypeCount; const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets); const trackProgress = selectedSliderIndex / Math.max(1, MATCH3D_DIFFICULTY_OPTIONS.length - 1); const applyDifficultyOption = (option: Match3DDifficultyOption) => { onChange({ ...editState, clearCountText: String(option.clearCount), difficultyText: String(option.difficulty), }); }; const handleSliderChange = (event: ChangeEvent) => { const nextIndex = Number.parseInt(event.target.value, 10); const nextOption = MATCH3D_DIFFICULTY_OPTIONS[nextIndex]; if (nextOption) { applyDifficultyOption(nextOption); } }; return (
{MATCH3D_DIFFICULTY_OPTIONS.map((option, index) => { const selected = selectedOption.id === option.id; return ( ); })}
({ id: option.id, label: ( <>
{option.label}
{option.clearCount}次 · {option.itemTypeCount}种
), ariaLabel: `${option.label} ${option.clearCount}次 · ${option.itemTypeCount}种`, }))} activeId={selectedOption.id} onChange={(optionId) => applyDifficultyOption(getMatch3DDifficultyOption(optionId)) } columns="four" gap="sm" size="choice" surface="transparent" tone="warm" frame="bare" disabled={isBusy} className="mt-3" />
{selectedOption.label}
{selectedOption.clearCount} 次 · {selectedOption.itemTypeCount}{' '} 种
难度 {selectedOption.difficulty}
当前难度 {selectedOption.label}
); } function Match3DItemAssetListCard({ asset, active, onClick, onDelete, }: { asset: Match3DItemAssetDraft; active: boolean; onClick: () => void; onDelete: () => void; }) { const previewSources = resolveMatch3DAssetDraftPreviewSources(asset); const previewSource = previewSources[0] ?? asset.referenceImageSrc.trim(); return (
} />
); } function Match3DItemAssetDetail({ asset, busy, onChange, }: { asset: Match3DItemAssetDraft; busy: boolean; onChange: (asset: Match3DItemAssetDraft) => void; }) { const previewSources = resolveMatch3DAssetDraftPreviewSources(asset); const [activePreviewIndex, setActivePreviewIndex] = useState(() => Math.floor(previewSources.length / 2), ); const safeActivePreviewIndex = previewSources[activePreviewIndex] ? activePreviewIndex : 0; const thumbnailPreviewSources = previewSources.length > 0 ? previewSources : Array.from({ length: 4 }, () => undefined as string | undefined); const activePreviewSource = previewSources[safeActivePreviewIndex]; useEffect(() => { setActivePreviewIndex(Math.floor(previewSources.length / 2)); }, [asset.id, previewSources.length]); return (
} aspect="square" surface="bright" imageClassName="h-full w-full object-contain p-3 sm:p-4" className="mx-auto w-full max-w-[22rem] rounded-[1.15rem]" fallbackClassName="tracking-normal" data-testid="match3d-item-preview-stage" />
{thumbnailPreviewSources.map((source, index) => { const hasSource = Boolean(source); const isActive = previewSources.length > 0 && index === safeActivePreviewIndex; return ( ); })}
); } function Match3DAssetsTab({ activeAssetId, assets, batchGenerationState, onActiveAssetChange, onAddBatch, onRegenerateBatch, onAssetChange, onDeleteAsset, }: { activeAssetId: string | null; assets: Match3DItemAssetDraft[]; batchGenerationState: Match3DBatchItemGenerationState; onActiveAssetChange: (assetId: string | null) => void; onAddBatch: () => void; onRegenerateBatch: () => void; onAssetChange: (asset: Match3DItemAssetDraft) => void; onDeleteAsset: (assetId: string) => void; }) { const activeAsset = assets.find((asset) => asset.id === activeAssetId) ?? null; return (
批量重新生成 批量新增
{assets.map((asset) => ( onActiveAssetChange(asset.id)} onDelete={() => onDeleteAsset(asset.id)} /> ))}
{activeAsset ? ( onActiveAssetChange(null)} > ) : null}
); } function Match3DBatchAddItemsPanel({ values, generationState, error, onAddInput, onChangeValue, onClose, onSubmit, }: { values: string[]; generationState: Match3DBatchItemGenerationState; error: string | null; onAddInput: () => void; onChangeValue: (index: number, value: string) => void; onClose: () => void; onSubmit: () => void; }) { const parsedNames = normalizeMatch3DItemNameList(values); const isGenerating = generationState.phase === 'generating'; const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length); const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false); return (
{values.map((value, index) => ( ))} 新增物品名称
{parsedNames.map((name) => ( {name} ))}
{error ? ( {error} ) : null} setIsCostConfirmOpen(true)} size="md" fullWidth className="min-h-11 gap-2" > {isGenerating ? ( ) : ( )} 生成物品素材 · {pointsCost}泥点 setIsCostConfirmOpen(false)} onConfirm={() => { setIsCostConfirmOpen(false); onSubmit(); }} confirmLabel="确定" confirmDisabled={parsedNames.length <= 0 || isGenerating} showCancel showCloseButton={false} portal={false} overlayClassName="platform-modal-backdrop z-[90]" panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]" >
消耗 {pointsCost} 泥点
); } function Match3DBatchRegenerateItemsPanel({ values, targetItemNames, generationState, error, onChangeValue, onClose, onSubmit, }: { values: string[]; targetItemNames: string[]; generationState: Match3DBatchItemGenerationState; error: string | null; onChangeValue: (index: number, value: string) => void; onClose: () => void; onSubmit: () => void; }) { const isGenerating = generationState.phase === 'generating'; const pointsCost = calculateMatch3DItemAssetsPointsCost( targetItemNames.length, ); const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false); return (
{values.map((value, index) => ( ))}
{targetItemNames.map((name) => ( {name} ))}
{error ? ( {error} ) : null} setIsCostConfirmOpen(true)} size="md" fullWidth className="min-h-11 gap-2" > {isGenerating ? ( ) : ( )} 重新生成物品素材 · {pointsCost}泥点 setIsCostConfirmOpen(false)} onConfirm={() => { setIsCostConfirmOpen(false); onSubmit(); }} confirmLabel="确定" confirmDisabled={targetItemNames.length <= 0 || isGenerating} showCancel showCloseButton={false} portal={false} overlayClassName="platform-modal-backdrop z-[90]" panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]" >
消耗 {pointsCost} 泥点
); } function Match3DAssetConfigTabs({ activeTab, onChange, }: { activeTab: Match3DAssetConfigTab; onChange: (tab: Match3DAssetConfigTab) => void; }) { return ( ); } function Match3DUIAssetsTab({ backgroundPreviewSrc, uiSpritesheetPreviewSrc, itemSpritesheetPreviewSrc, itemNames, error, }: { backgroundPreviewSrc: string; uiSpritesheetPreviewSrc: string; itemSpritesheetPreviewSrc: string; itemNames: readonly string[]; error: string | null; }) { const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [itemSpritesheetGroups, setItemSpritesheetGroups] = useState< Match3DItemSpritesheetPreviewGroup[] >([]); useEffect(() => { if (!itemSpritesheetPreviewSrc) { setItemSpritesheetGroups((current) => current.length > 0 ? [] : current, ); return undefined; } 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 (
} aspect="square" surface="none" imageClassName="h-full w-full object-contain" className="h-full w-full rounded-none" />
setIsPreviewOpen(true)} tone="ghost" size="md" className="min-h-11 gap-2" > 预览UI页面
{itemSpritesheetPreviewSrc ? (
} aspect="square" surface="none" imageClassName="h-full w-full object-contain" className="h-full w-full rounded-none" />
{itemSpritesheetGroups.length > 0 ? (
{itemSpritesheetGroups.map((group) => (
{group.itemName}
({ id: `${group.itemIndex}-${regionIndex}-${region.imageSrc}`, src: region.imageSrc, testId: `match3d-item-spritesheet-preview-${group.itemIndex}-${regionIndex}`, fallbackLabel: group.itemName, }))} />
))}
) : null}
) : null} {error ? ( {error} ) : null} {isPreviewOpen ? ( setIsPreviewOpen(false)} /> ) : null}
); } function Match3DUIRuntimePreviewPanel({ backgroundPreviewSrc, containerPreviewSrc, onClose, }: { backgroundPreviewSrc: string; containerPreviewSrc: string; onClose: () => void; }) { return (
); } function Match3DAssetConfigTab({ activeAssetConfigTab, activeAssetId, assetDrafts, backgroundPreviewSrc, uiSpritesheetPreviewSrc, itemSpritesheetPreviewSrc, itemNames, backgroundGenerationError, batchGenerationState, onActiveAssetChange, onAddBatch, onRegenerateBatch, onAssetChange, onAssetConfigTabChange, onDeleteAsset, }: { activeAssetConfigTab: Match3DAssetConfigTab; activeAssetId: string | null; assetDrafts: Match3DItemAssetDraft[]; backgroundPreviewSrc: string; uiSpritesheetPreviewSrc: string; itemSpritesheetPreviewSrc: string; itemNames: readonly string[]; backgroundGenerationError: string | null; batchGenerationState: Match3DBatchItemGenerationState; onActiveAssetChange: (assetId: string | null) => void; onAddBatch: () => void; onRegenerateBatch: () => void; onAssetChange: (asset: Match3DItemAssetDraft) => void; onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void; onDeleteAsset: (assetId: string) => void; }) { return (
{activeAssetConfigTab === 'items' ? ( ) : null} {activeAssetConfigTab === 'ui' ? ( ) : null}
); } export function Match3DResultView({ profile, draft = null, isBusy = false, error = null, onBack, onSaved, onPublished, onStartTestRun, }: Match3DResultViewProps) { const promotedProfile = useMemo( () => promoteMatch3DGeneratedBackgroundAsset(profile), [profile], ); const [editState, setEditState] = useState(() => createEditState(profile)); const [activeTab, setActiveTab] = useState('work'); const [activeAssetConfigTab, setActiveAssetConfigTab] = useState('items'); const [assetDrafts, setAssetDrafts] = useState(() => createMatch3DAssetDrafts(profile, draft), ); const [activeAssetId, setActiveAssetId] = useState(null); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false); const [coverUploadedImageSrc, setCoverUploadedImageSrc] = useState(''); const [coverReferenceImages, setCoverReferenceImages] = useState< Match3DCoverReferenceDraft[] >([]); const [coverPrompt, setCoverPrompt] = useState(''); const [coverAiRedraw, setCoverAiRedraw] = useState(false); const [isGeneratingCover, setIsGeneratingCover] = useState(false); const [coverPanelError, setCoverPanelError] = useState(null); const [isBatchAddPanelOpen, setIsBatchAddPanelOpen] = useState(false); const [isBatchRegeneratePanelOpen, setIsBatchRegeneratePanelOpen] = useState(false); const [batchItemNameValues, setBatchItemNameValues] = useState(['']); const [batchRegenerateItemNameValues, setBatchRegenerateItemNameValues] = useState([]); const [batchGenerationState, setBatchGenerationState] = useState({ phase: 'idle', progress: null, itemNames: [], message: null, error: null, }); const [batchAddError, setBatchAddError] = useState(null); const [batchRegenerateError, setBatchRegenerateError] = useState< string | null >(null); const [backgroundGenerationError, setBackgroundGenerationError] = useState< string | null >(null); const [autoSaveState, setAutoSaveState] = useState('idle'); const [localError, setLocalError] = useState(null); const [isPublishing, setIsPublishing] = useState(false); const [isStartingTestRun, setIsStartingTestRun] = useState(false); const [isGeneratingTags, setIsGeneratingTags] = useState(false); const generatedItemAssets = useMemo( () => resolveMatch3DResultGeneratedItemAssets(promotedProfile, draft), [draft, promotedProfile], ); const blockers = useMemo( () => buildPublishBlockers(editState, generatedItemAssets), [editState, generatedItemAssets], ); const testRunBlockers = useMemo( () => buildTestRunBlockers(editState), [editState], ); const canStartTestRun = testRunBlockers.length === 0; const canSubmit = blockers.length === 0; const totalItemCount = (normalizePositiveInteger(editState.clearCountText) ?? promotedProfile.clearCount) * 3; const backgroundPreviewSrc = useMemo( () => resolveMatch3DBackgroundPreviewSource( promotedProfile, draft, generatedItemAssets, ), [draft, generatedItemAssets, promotedProfile], ); const containerPreviewSrc = useMemo( () => resolveMatch3DContainerPreviewSource( promotedProfile, draft, generatedItemAssets, ) || 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( assetDrafts, backgroundPreviewSrc, containerPreviewSrc, ), [assetDrafts, backgroundPreviewSrc, containerPreviewSrc], ); useEffect(() => { setEditState(createEditState(profile)); setAutoSaveState('idle'); setLocalError(null); setIsPublishDialogOpen(false); setHasAttemptedPublish(false); setCoverUploadedImageSrc(''); setCoverReferenceImages([]); setCoverPrompt(''); setCoverAiRedraw(false); setCoverPanelError(null); setBackgroundGenerationError(null); // 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [profile.profileId, profile.updatedAt]); useEffect(() => { setAssetDrafts(createMatch3DAssetDrafts(profile, draft)); setActiveAssetId(null); // 中文注释:素材草稿只跟随持久化素材字段和作品切换重建,避免无关 profile 字段刷新关闭当前面板。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [ draft?.generatedItemAssets, profile.generatedItemAssets, profile.profileId, ]); useEffect(() => { const payload = buildSavePayload(editState); if (!payload) { return undefined; } const currentTags = normalizeTags(profile.tags.join(',')); const nextTags = payload.tags; const changed = payload.gameName !== profile.gameName || payload.themeText !== profile.themeText || payload.summary !== profile.summary || (payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') || payload.clearCount !== profile.clearCount || payload.difficulty !== profile.difficulty || nextTags.length !== currentTags.length || nextTags.some((tag, index) => tag !== currentTags[index]); if (!changed) { return undefined; } setAutoSaveState('saving'); setLocalError(null); let cancelled = false; const timer = window.setTimeout(() => { void updateMatch3DWork(profile.profileId, payload) .then(({ item }) => { if (cancelled) { return; } const playableItem = attachMatch3DGeneratedItemAssets( item, generatedItemAssets, ); setAutoSaveState('saved'); onSaved?.(playableItem); }) .catch((saveError) => { if (cancelled) { return; } setAutoSaveState('error'); setLocalError( saveError instanceof Error ? saveError.message : '自动保存失败。', ); }); }, MATCH3D_AUTOSAVE_DEBOUNCE_MS); return () => { cancelled = true; window.clearTimeout(timer); }; }, [editState, generatedItemAssets, onSaved, profile]); const saveNow = async () => { const payload = buildSavePayload(editState); if (!payload) { setLocalError(testRunBlockers[0] ?? '请补全作品信息。'); return null; } setAutoSaveState('saving'); setLocalError(null); const { item } = await updateMatch3DWork(profile.profileId, payload); const currentGeneratedItemAssets = buildPersistableGeneratedItemAssets( assetDrafts, generatedItemAssets, ); let playableItem = attachMatch3DGeneratedItemAssets( item, currentGeneratedItemAssets.length > 0 ? currentGeneratedItemAssets : generatedItemAssets, ); if ( shouldPersistGeneratedItemAssets( currentGeneratedItemAssets, item.generatedItemAssets ?? [], ) ) { // 中文注释:试玩和发布前必须先把当前可见 2D 多视角素材写回 profile。 const { item: persistedItem } = await updateMatch3DGeneratedItemAssets( profile.profileId, { generatedItemAssets: currentGeneratedItemAssets, }, ); playableItem = attachMatch3DGeneratedItemAssets( persistedItem, currentGeneratedItemAssets, ); } setAutoSaveState('saved'); onSaved?.(playableItem); return playableItem; }; const handleCoverImageChange = async ( event: ChangeEvent, ) => { const file = event.target.files?.[0] ?? null; event.target.value = ''; if (!file) { return; } try { const dataUrl = await readImageAsDataUrl(file); setCoverUploadedImageSrc(dataUrl); setCoverAiRedraw(true); setCoverPanelError(null); } catch (caughtError) { setCoverPanelError( caughtError instanceof Error ? caughtError.message : '封面图读取失败。', ); } }; const handleCoverReferenceImageChange = async ( event: ChangeEvent, ) => { const file = event.target.files?.[0] ?? null; event.target.value = ''; if (!file) { return; } try { const dataUrl = await readCoverReferenceImageAsDataUrl(file); setCoverReferenceImages((current) => addMatch3DCoverReferenceDraft(current, { id: `upload:${Date.now()}:${file.name}`, label: file.name.trim() || '自定义参考图', imageSrc: dataUrl, source: 'upload', }), ); setCoverPanelError(null); } catch (caughtError) { setCoverPanelError( caughtError instanceof Error ? caughtError.message : '参考图读取失败。', ); } }; const resetCoverEditor = () => { setCoverUploadedImageSrc(''); setCoverReferenceImages([]); setCoverPrompt( [ editState.gameName.trim(), editState.themeText.trim(), '抓大鹅作品封面图,主体清晰,适合作品卡片', ] .filter(Boolean) .join(','), ); setCoverAiRedraw(true); setCoverPanelError(null); }; const openPublishDialog = () => { setHasAttemptedPublish(false); resetCoverEditor(); setIsPublishDialogOpen(true); }; const closePublishDialog = () => { if (isGeneratingCover || isPublishing) { return; } setHasAttemptedPublish(false); setIsPublishDialogOpen(false); }; const handleSubmitCoverPanel = async () => { const uploadedImageSrc = coverUploadedImageSrc.trim(); const prompt = coverPrompt.trim(); const referenceImageSrcs = coverReferenceImages .map((reference) => reference.imageSrc.trim()) .filter(Boolean); if (!uploadedImageSrc && !prompt) { setCoverPanelError('请上传图片或填写封面描述。'); return; } if (uploadedImageSrc && !coverAiRedraw) { setEditState((current) => ({ ...current, coverImageSrc: uploadedImageSrc, })); setCoverPanelError(null); setCoverUploadedImageSrc(''); setCoverAiRedraw(true); return; } setIsGeneratingCover(true); setCoverPanelError(null); try { const response = await generateMatch3DCoverImage(profile.profileId, { prompt: prompt || `${editState.gameName.trim() || '抓大鹅'}作品封面图,${editState.themeText.trim() || '休闲消除'}题材`, uploadedImageSrc: uploadedImageSrc || null, referenceImageSrcs: uploadedImageSrc ? [] : referenceImageSrcs, }); setEditState((current) => ({ ...current, coverImageSrc: response.coverImageSrc, })); onSaved?.( buildCoverImageUpdatedProfile( profile, editState, response.item, response.coverImageSrc, generatedItemAssets, ), ); setCoverUploadedImageSrc(''); setCoverReferenceImages([]); setCoverAiRedraw(true); } catch (caughtError) { setCoverPanelError( caughtError instanceof Error ? caughtError.message : '封面图生成失败。', ); } finally { setIsGeneratingCover(false); } }; const handleGenerateTags = async () => { const gameName = editState.gameName.trim(); const themeText = editState.themeText.trim(); if (!gameName || !themeText) { setLocalError('请先补齐作品名称和题材主题。'); return; } setIsGeneratingTags(true); try { const response = await generateMatch3DWorkTags({ gameName, themeText, summary: editState.summary.trim(), }); const nextTags = normalizeTags(response.tags.join(',')); if (nextTags.length <= 0) { throw new Error('未生成有效标签。'); } setEditState((current) => ({ ...current, tagsText: nextTags.join(','), })); setLocalError(null); } catch (caughtError) { setLocalError( caughtError instanceof Error ? caughtError.message : 'AI 生成标签失败。', ); } finally { setIsGeneratingTags(false); } }; const updateItemAsset = (nextAsset: Match3DItemAssetDraft) => { setAssetDrafts((currentAssets) => currentAssets.map((asset) => asset.id === nextAsset.id ? nextAsset : asset, ), ); }; const persistGeneratedAssetDrafts = async ( nextDrafts: Match3DItemAssetDraft[], ) => { const nextAssets = createGeneratedAssetsFromDrafts( nextDrafts, profile.generatedItemAssets ?? [], ); const { item } = await updateMatch3DGeneratedItemAssets(profile.profileId, { generatedItemAssets: nextAssets, }); onSaved?.(item); return item; }; const handleDeleteAssetDraft = async (assetId: string) => { if (busy) { return; } const nextDrafts = assetDrafts.filter((asset) => asset.id !== assetId); setAssetDrafts(nextDrafts); if (activeAssetId === assetId) { setActiveAssetId(null); } try { await persistGeneratedAssetDrafts(nextDrafts); setLocalError(null); } catch (caughtError) { setAssetDrafts(assetDrafts); setLocalError( caughtError instanceof Error ? caughtError.message : '删除物品素材失败。', ); } }; const handleSubmitBatchAddItems = () => { const itemNames = normalizeMatch3DItemNameList(batchItemNameValues); if (itemNames.length <= 0 || batchGenerationState.phase === 'generating') { setBatchAddError('请填写至少一个物品名称。'); return; } setBatchAddError(null); setBatchGenerationState({ phase: 'generating', progress: 0.08, itemNames, message: `正在生成 ${itemNames.length} 种物品素材`, error: null, }); let progressTick = 0; const progressTimer = window.setInterval(() => { progressTick += 1; setBatchGenerationState((current) => { if (current.phase !== 'generating') { return current; } const currentProgress = current.progress ?? 0.08; const ceiling = progressTick < 4 ? 0.42 : 0.88; return { ...current, progress: Math.min(ceiling, currentProgress + 0.08), }; }); }, 1200); void generateMatch3DItemAssets(profile.profileId, { itemNames }) .then((response) => { window.clearInterval(progressTimer); const refreshedProfile = attachMatch3DGeneratedItemAssets( response.item, response.generatedItemAssets, ); setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null)); setBatchItemNameValues(['']); setBatchGenerationState({ phase: 'done', progress: 1, itemNames, message: `已生成 ${itemNames.length} 种物品素材`, error: null, }); setLocalError(null); onSaved?.(refreshedProfile); }) .catch((caughtError) => { window.clearInterval(progressTimer); const message = caughtError instanceof Error ? caughtError.message : '批量新增物品素材失败。'; setBatchAddError(message); setBatchGenerationState({ phase: 'failed', progress: null, itemNames, message: null, error: message, }); }); }; const handleOpenBatchRegeneratePanel = () => { setBatchRegenerateError(null); setBatchRegenerateItemNameValues( assetDrafts.length > 0 ? assetDrafts.map((asset) => asset.name) : [''], ); setBatchGenerationState((current) => current.phase === 'generating' ? current : { phase: 'idle', progress: null, itemNames: [], message: null, error: null, }, ); setIsBatchRegeneratePanelOpen(true); }; const handleSubmitBatchRegenerateItems = () => { const itemNames = collectMatch3DRegenerateItemNames( batchRegenerateItemNameValues, assetDrafts, ); if (itemNames.length <= 0 || batchGenerationState.phase === 'generating') { setBatchRegenerateError('请填写至少一个物品名称。'); return; } setBatchRegenerateError(null); setBatchGenerationState({ phase: 'generating', progress: 0.08, itemNames, message: `正在重新生成 ${itemNames.length} 种物品素材`, error: null, }); let progressTick = 0; const progressTimer = window.setInterval(() => { progressTick += 1; setBatchGenerationState((current) => { if (current.phase !== 'generating') { return current; } const currentProgress = current.progress ?? 0.08; const ceiling = progressTick < 4 ? 0.42 : 0.88; return { ...current, progress: Math.min(ceiling, currentProgress + 0.08), }; }); }, 1200); void generateMatch3DItemAssets(profile.profileId, { itemNames, mode: 'replace', }) .then((response) => { window.clearInterval(progressTimer); const refreshedProfile = attachMatch3DGeneratedItemAssets( response.item, response.generatedItemAssets, ); setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null)); setBatchRegenerateItemNameValues( response.generatedItemAssets.map((asset) => asset.itemName), ); setBatchGenerationState({ phase: 'done', progress: 1, itemNames, message: `已重新生成 ${itemNames.length} 种物品素材`, error: null, }); setLocalError(null); onSaved?.(refreshedProfile); }) .catch((caughtError) => { window.clearInterval(progressTimer); const message = caughtError instanceof Error ? caughtError.message : '批量重新生成物品素材失败。'; setBatchRegenerateError(message); setBatchGenerationState({ phase: 'failed', progress: null, itemNames, message: null, error: message, }); }); }; const handleStartTestRun = async () => { if (!canStartTestRun || isStartingTestRun) { setLocalError(testRunBlockers[0] ?? null); return; } setIsStartingTestRun(true); try { const savedProfile = await saveNow(); const playableProfile = savedProfile ?? buildPlayableProfile(profile, editState, generatedItemAssets); const playableAssets = playableProfile.generatedItemAssets ?? generatedItemAssets; const targetItemTypeCount = getMatch3DDifficultyOptionFromEditState(editState).itemTypeCount; const playableItemTypeCount = getMatch3DPlayableItemTypeCount( targetItemTypeCount, playableAssets, ); onStartTestRun(playableProfile, { itemTypeCountOverride: playableItemTypeCount, }); } catch (caughtError) { setLocalError( caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。', ); } finally { setIsStartingTestRun(false); } }; const handlePublish = async () => { setHasAttemptedPublish(true); if (!canSubmit || isPublishing) { setLocalError(blockers[0] ?? null); return; } setIsPublishing(true); try { const savedProfile = await saveNow(); const { item } = await publishMatch3DWork( savedProfile?.profileId ?? profile.profileId, ); onPublished?.( attachMatch3DGeneratedItemAssets( item, savedProfile?.generatedItemAssets ?? generatedItemAssets, ), ); setLocalError(null); setIsPublishDialogOpen(false); setHasAttemptedPublish(false); } catch (caughtError) { setLocalError( caughtError instanceof Error ? caughtError.message : '发布抓大鹅作品失败。', ); } finally { setIsPublishing(false); } }; const busy = isBusy || isPublishing || isStartingTestRun || isGeneratingCover; const workBusy = busy || isGeneratingTags; const displayError = error ?? localError; const dialogPublishError = hasAttemptedPublish ? (error ?? localError) : null; return (
{activeTab === 'work' ? ( ) : null} {activeTab === 'config' ? ( ) : null} {activeTab === 'assets' ? ( { setBatchAddError(null); setBatchGenerationState((current) => current.phase === 'generating' ? current : { phase: 'idle', progress: null, itemNames: [], message: null, error: null, }, ); setIsBatchAddPanelOpen(true); }} onRegenerateBatch={handleOpenBatchRegeneratePanel} onAssetChange={updateItemAsset} onAssetConfigTabChange={setActiveAssetConfigTab} onDeleteAsset={(assetId) => { void handleDeleteAssetDraft(assetId); }} /> ) : null}
{displayError ? ( {displayError} ) : null}
{isStartingTestRun ? ( ) : ( )} 试玩 {isPublishing ? ( ) : profile.publicationStatus === 'published' ? ( ) : ( )} {profile.publicationStatus === 'published' ? '更新发布' : '发布'}
{isPublishDialogOpen ? ( { void handlePublish(); }} onReferenceSelect={(source) => { const sourceAsset = coverSourceAssets.find( (asset) => asset.imageSrc === source, ); if (!sourceAsset) { return; } setCoverReferenceImages((current) => addMatch3DCoverReferenceDraft( current, createMatch3DCoverReferenceDraftFromSource(sourceAsset), ), ); setCoverPanelError(null); }} onReferenceFileChange={handleCoverReferenceImageChange} onReferenceRemove={(referenceId) => { setCoverReferenceImages((current) => current.filter((reference) => reference.id !== referenceId), ); }} onUploadedImageRemove={() => { setCoverUploadedImageSrc(''); setCoverAiRedraw(true); }} onSubmitCover={() => { void handleSubmitCoverPanel(); }} /> ) : null} {isBatchAddPanelOpen ? ( { setBatchItemNameValues((current) => [...current, '']); }} onChangeValue={(index, value) => { setBatchItemNameValues((current) => current.map((item, itemIndex) => itemIndex === index ? value : item, ), ); }} onClose={() => { setIsBatchAddPanelOpen(false); }} onSubmit={() => { handleSubmitBatchAddItems(); }} /> ) : null} {isBatchRegeneratePanelOpen ? ( { setBatchRegenerateItemNameValues((current) => current.map((item, itemIndex) => itemIndex === index ? value : item, ), ); }} onClose={() => { setIsBatchRegeneratePanelOpen(false); }} onSubmit={() => { handleSubmitBatchRegenerateItems(); }} /> ) : null}
); } export default Match3DResultView;