Files
Genarrative/src/components/match3d-result/Match3DResultView.tsx
2026-05-14 01:11:58 +08:00

3489 lines
113 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeft,
CheckCircle2,
Eye,
ImageIcon,
ImagePlus,
Loader2,
Music,
Play,
Plus,
Send,
Trash2,
Wand2,
X,
} 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 {
Match3DGeneratedItemAsset,
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
createBackgroundMusicTask,
createSoundEffectTask,
publishBackgroundMusicAsset,
publishSoundEffectAsset,
waitForGeneratedAudioAsset,
} from '../../services/creation-audio';
import {
generateMatch3DBackgroundImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
generateMatch3DWorkTags,
publishMatch3DWork,
updateMatch3DGeneratedItemAssets,
updateMatch3DWork,
} from '../../services/match3d-works';
import {
getMatch3DGeneratedImageViewSources,
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import { useAuthUi } from '../auth/AuthUiContext';
import {
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_SPINNER_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from '../match3d-runtime/match3dRuntimeUiStyles';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type Match3DResultViewProps = {
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' | 'music';
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 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';
};
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_BACKGROUND_MUSIC_ASSET_KIND = 'match3d_background_music';
const MATCH3D_CLICK_SOUND_ASSET_KIND = 'match3d_click_sound';
const MATCH3D_BACKGROUND_MUSIC_POINTS_COST = 5;
const MATCH3D_CLICK_SOUND_POINTS_COST = 10;
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
{ id: 'work', label: '作品信息' },
{ id: 'config', label: '难度配置' },
{ id: 'assets', label: '素材配置' },
];
const MATCH3D_ASSET_CONFIG_TABS: Array<{
id: Match3DAssetConfigTab;
label: string;
}> = [
{ id: 'items', label: '物品' },
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '背景音乐' },
];
// 中文注释:结果页难度配置必须与创作入口页保持同一组派生参数。
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: 21,
},
] 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';
const MATCH3D_MATERIAL_TAB_BUTTON_CLASS =
'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition';
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 resolveMatch3DBackgroundPrompt(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.backgroundPrompt?.trim() ||
draft?.generatedBackgroundAsset?.prompt?.trim() ||
profile.backgroundPrompt?.trim() ||
profile.generatedBackgroundAsset?.prompt?.trim() ||
generatedItemAssets
.map((asset) => asset.backgroundAsset?.prompt?.trim() || '')
.find(Boolean) ||
buildFallbackMatch3DBackgroundPrompt(profile.themeText)
);
}
function resolveMatch3DContainerPreviewSource(
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || ''
);
}
function buildFallbackMatch3DBackgroundPrompt(themeText: string) {
const theme = themeText.trim() || '抓大鹅';
return `${theme}题材抓大鹅游戏竖屏纯背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
}
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<Match3DWorkProfile, 'themeText'>,
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 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<Match3DGeneratedItemAsset['imageViews']>[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?.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.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?.imageSrc?.trim() ?? '',
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
asset.backgroundAsset?.containerPrompt?.trim() ?? '',
asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.status?.trim() ?? '',
asset.backgroundAsset?.error?.trim() ?? '',
asset.clickSound?.audioSrc?.trim() ??
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,
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: '圆形空间周边装饰物',
},
].slice(0, MATCH3D_DEFAULT_ASSET_COUNT);
return seeds.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,
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 Match3DAudioProgress({
label,
progress,
}: {
label: string;
progress: number;
}) {
const normalizedProgress = Math.max(0, Math.min(1, progress));
return (
<div className="mt-3" role="status" aria-label={label}>
<div className="mb-1 flex items-center justify-between text-[11px] font-semibold text-[var(--platform-text-soft)]">
<span>{label}</span>
<span>{Math.round(normalizedProgress * 100)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/70">
<div
className="h-full rounded-full bg-[var(--platform-accent)] transition-[width] duration-300"
style={{ width: `${Math.round(normalizedProgress * 100)}%` }}
/>
</div>
</div>
);
}
function Match3DResolvedAudio({
ariaLabel,
src,
}: {
ariaLabel?: string;
src: string;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
expireSeconds: 300,
});
if (!resolvedUrl) {
return (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
);
}
return (
<audio
className="mt-3 w-full"
controls
src={resolvedUrl}
aria-label={ariaLabel}
/>
);
}
function getMatch3DBatchGenerationStatusLabel(
phase: Match3DBatchItemGenerationState['phase'],
) {
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 (
<div
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3"
role="status"
aria-label="物品素材生成进度"
>
<div className="mb-2 flex items-center justify-between text-xs font-bold text-[var(--platform-text-base)]">
<span>
{getMatch3DBatchGenerationStatusLabel(generationState.phase)}
</span>
{normalizedProgress !== null ? (
<span>{Math.round(normalizedProgress * 100)}%</span>
) : null}
</div>
{normalizedProgress !== null ? (
<div className="h-2 overflow-hidden rounded-full bg-white/70">
<div
className="h-full rounded-full bg-[var(--platform-accent)] transition-[width] duration-300"
style={{ width: `${Math.round(normalizedProgress * 100)}%` }}
/>
</div>
) : null}
{generationState.message ? (
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-base)]">
{generationState.message}
</div>
) : null}
{generationState.error ? (
<div className="mt-2 text-sm font-semibold text-rose-600">
{generationState.error}
</div>
) : null}
</div>
);
}
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)];
}
function readImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件。'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('封面图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function resolveMatch3DCoverSourceAssets(
assetDrafts: Match3DItemAssetDraft[],
backgroundPreviewSrc: 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,
},
]
: [];
return [...itemAssets, ...uiAssets];
}
function buildPlayableProfile(
profile: Match3DWorkProfile,
editState: Match3DResultEditState,
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
const payload = buildSavePayload(editState);
if (!payload) {
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
}
return 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 resolveMatch3DResultGeneratedItemAssets(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
) {
const profileAssets = profile.generatedItemAssets ?? [];
const draftAssets = draft?.generatedItemAssets ?? [];
if (draftAssets.length <= 0) {
return profileAssets;
}
if (profileAssets.length <= 0) {
return draftAssets;
}
const profileAssetsById = new Map(
profileAssets.map((asset) => [asset.itemId, asset]),
);
const mergedAssets = draftAssets.map((draftAsset) => {
const profileAsset = profileAssetsById.get(draftAsset.itemId);
return profileAsset
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
: draftAsset;
});
for (const profileAsset of profileAssets) {
if (!mergedAssets.some((asset) => asset.itemId === profileAsset.itemId)) {
mergedAssets.push(profileAsset);
}
}
return mergedAssets;
}
function attachMatch3DGeneratedItemAssets(
profile: Match3DWorkProfile,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (generatedItemAssets.length <= 0) {
return profile;
}
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
return {
...profile,
generatedItemAssets: [...generatedItemAssets],
};
}
function attachMatch3DGeneratedBackgroundAsset(
assets: readonly Match3DGeneratedItemAsset[],
backgroundAsset: NonNullable<Match3DWorkProfile['generatedBackgroundAsset']>,
) {
if (assets.length <= 0) {
return [];
}
return assets.map((asset, index) =>
index === 0 ? { ...asset, backgroundAsset } : asset,
);
}
function buildPersistableGeneratedItemAssets(
assetDrafts: Match3DItemAssetDraft[],
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (generatedItemAssets.length <= 0) {
return [];
}
return createGeneratedAssetsFromDrafts(
assetDrafts,
generatedItemAssets,
).filter(hasPersistableMatch3DGeneratedItemAsset);
}
function Match3DResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: Match3DAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const badge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
{badge}
</div>
);
}
function Match3DResultTabs({
activeTab,
onChange,
}: {
activeTab: Match3DResultTab;
onChange: (tab: Match3DResultTab) => 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">
{MATCH3D_RESULT_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[1rem] px-2 text-sm font-bold transition sm:px-3 ${
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 Match3DWorkInfoTab({
editState,
isBusy,
onChange,
onOpenCoverPanel,
onGenerateTags,
}: {
editState: Match3DResultEditState;
isBusy: boolean;
onChange: (nextState: Match3DResultEditState) => void;
onOpenCoverPanel: () => void;
onGenerateTags: () => void;
}) {
const [isAddingTag, setIsAddingTag] = useState(false);
const [newTagText, setNewTagText] = useState('');
const tags = normalizeTags(editState.tagsText);
const updateTags = (nextTags: string[]) => {
onChange({ ...editState, tagsText: nextTags.join('') });
};
const addTags = () => {
const nextTags = [
...new Set([...tags, ...normalizeMatch3DTagListText(newTagText)]),
].slice(0, MATCH3D_MAX_TAG_COUNT);
updateTags(nextTags);
setNewTagText('');
setIsAddingTag(false);
};
return (
<section className="platform-subpanel space-y-3 rounded-[1.35rem] p-4 sm:p-5">
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-2">
<button
type="button"
disabled={isBusy}
onClick={onOpenCoverPanel}
className={`h-20 w-28 shrink-0 overflow-hidden rounded-[0.85rem] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] ${isBusy ? 'cursor-not-allowed opacity-55' : 'transition hover:ring-2 hover:ring-emerald-200'}`}
aria-label="编辑碰面图"
>
{editState.coverImageSrc ? (
<ResolvedAssetImage
src={editState.coverImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-emerald-700">
<ImagePlus className="h-6 w-6" />
</div>
)}
</button>
<button
type="button"
disabled={isBusy}
onClick={onOpenCoverPanel}
className="platform-button platform-button--ghost flex min-h-10 items-center justify-center gap-2 px-3 py-2 text-sm"
>
<ImagePlus className="h-4 w-4" />
</button>
</div>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.gameName}
disabled={isBusy}
onChange={(event) =>
onChange({ ...editState, gameName: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="作品名称"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={editState.summary}
disabled={isBusy}
onChange={(event) =>
onChange({ ...editState, summary: event.target.value })
}
rows={6}
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>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={isBusy}
onClick={onGenerateTags}
className="platform-icon-button h-9 w-9"
aria-label="AI生成作品标签"
title="AI生成作品标签"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
</button>
{!isAddingTag ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增作品标签"
title="新增作品标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-100/68 px-3 py-1.5 text-xs font-semibold text-emerald-700"
>
{tag}
<button
type="button"
disabled={isBusy}
onClick={() => updateTags(tags.filter((item) => item !== tag))}
className="rounded-full text-emerald-800/70 transition hover:text-emerald-950 disabled:opacity-45"
aria-label={`删除标签 ${tag}`}
title="删除标签"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
{tags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
</span>
) : null}
</div>
{isAddingTag ? (
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
<input
autoFocus
value={newTagText}
disabled={isBusy}
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
setIsAddingTag(false);
setNewTagText('');
}
}}
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="输入新标签"
aria-label="新题材标签"
/>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={addTags}
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsAddingTag(false);
setNewTagText('');
}}
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
</div>
</div>
) : null}
</div>
</section>
);
}
function Match3DModalShell({
title,
children,
onClose,
}: {
title: string;
children: ReactNode;
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-[146] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-5xl 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="text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</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">
{children}
</div>
</div>
</div>,
document.body,
);
}
function Match3DCoverImagePanel({
editState,
sourceAssets,
isGenerating,
selectedReferenceSrc,
aiRedraw,
prompt,
error,
onAiRedrawChange,
onClose,
onFileChange,
onPromptChange,
onReferenceSelect,
onSubmit,
}: {
editState: Match3DResultEditState;
sourceAssets: Match3DCoverSourceAsset[];
isGenerating: boolean;
selectedReferenceSrc: string;
aiRedraw: boolean;
prompt: string;
error: string | null;
onAiRedrawChange: (enabled: boolean) => void;
onClose: () => void;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onPromptChange: (value: string) => void;
onReferenceSelect: (source: string) => void;
onSubmit: () => void;
}) {
const previewSrc = selectedReferenceSrc || editState.coverImageSrc;
const canSubmit = Boolean(previewSrc.trim() || prompt.trim());
return (
<Match3DModalShell title="碰面图" onClose={onClose}>
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(16rem,0.85fr)_minmax(0,1.15fr)]">
<div className="space-y-3">
<div className="aspect-square overflow-hidden rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/70">
{previewSrc ? (
<ResolvedAssetImage
src={previewSrc}
alt="碰面图预览"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-[var(--platform-text-soft)]">
<ImageIcon className="h-10 w-10" />
</div>
)}
</div>
<label className="platform-button platform-button--ghost flex min-h-11 cursor-pointer items-center justify-center gap-2 px-4 py-3 text-sm">
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={isGenerating}
onChange={onFileChange}
/>
</label>
</div>
<div className="space-y-3">
<label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3">
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
AI重绘
</span>
<input
type="checkbox"
checked={aiRedraw}
disabled={isGenerating}
onChange={(event) => onAiRedrawChange(event.target.checked)}
aria-label="碰面图AI重绘"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={isGenerating}
rows={5}
onChange={(event) => onPromptChange(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>
{sourceAssets.length > 0 ? (
<div>
<div className="mb-2 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{sourceAssets.map((asset) => (
<button
key={asset.id}
type="button"
disabled={isGenerating}
onClick={() => onReferenceSelect(asset.imageSrc)}
className={`overflow-hidden rounded-[1rem] border bg-white/74 text-left transition ${
selectedReferenceSrc === asset.imageSrc
? 'border-emerald-300 ring-2 ring-emerald-100'
: 'border-[var(--platform-subpanel-border)] hover:border-emerald-200'
}`}
aria-label={`引用${asset.label}`}
>
<div className="aspect-square overflow-hidden">
<ResolvedAssetImage
src={asset.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
</div>
<div className="truncate px-2 py-2 text-[11px] font-semibold text-[var(--platform-text-base)]">
{asset.label}
</div>
</button>
))}
</div>
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
<button
type="button"
disabled={!canSubmit || isGenerating}
onClick={onSubmit}
className={`platform-button platform-button--primary min-h-11 w-full justify-center gap-2 px-4 py-3 ${!canSubmit || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : aiRedraw ? (
<Wand2 className="h-4 w-4" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{aiRedraw ? '生成碰面图' : '使用当前图片'}
</button>
</div>
</div>
</Match3DModalShell>
);
}
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<HTMLInputElement>) => {
const nextIndex = Number.parseInt(event.target.value, 10);
const nextOption = MATCH3D_DIFFICULTY_OPTIONS[nextIndex];
if (nextOption) {
applyDifficultyOption(nextOption);
}
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="relative px-1 pb-1 pt-2">
<div className="relative mx-[1.35rem] h-10">
<div className="absolute left-0 right-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-white/75 shadow-[inset_0_0_0_1px_rgba(244,114,182,0.16)]" />
<div
className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,#ff8aac_0%,#ff5f7e_54%,#ff9b88_100%)] transition-[width] duration-200"
style={{ width: `${trackProgress * 100}%` }}
/>
{MATCH3D_DIFFICULTY_OPTIONS.map((option, index) => {
const selected = selectedOption.id === option.id;
return (
<div
key={option.id}
aria-hidden="true"
className={`absolute top-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'border-[#ff5f7e] bg-white shadow-[0_8px_18px_rgba(244,63,94,0.2)]'
: 'border-rose-100 bg-white/90 hover:border-rose-200'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
style={{
left: `${(index / (MATCH3D_DIFFICULTY_OPTIONS.length - 1)) * 100}%`,
}}
>
<span
className={`h-3.5 w-3.5 rounded-full ${
selected
? 'bg-[var(--platform-accent)]'
: 'bg-rose-100'
}`}
/>
</div>
);
})}
<input
type="range"
min={0}
max={MATCH3D_DIFFICULTY_OPTIONS.length - 1}
step={1}
value={selectedSliderIndex}
disabled={isBusy}
onChange={handleSliderChange}
className="absolute inset-x-0 top-1/2 z-10 h-10 -translate-y-1/2 cursor-pointer opacity-0 disabled:cursor-not-allowed"
aria-label="难度"
aria-valuetext={selectedOption.label}
/>
</div>
<div className="mt-3 grid grid-cols-4 gap-1">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = selectedOption.id === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() => applyDifficultyOption(option)}
className={`rounded-[0.9rem] px-1.5 py-2 text-center transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'bg-[#fff1f5] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(244,63,94,0.18)]'
: 'text-[var(--platform-text-base)] hover:bg-white/58'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
<div className="text-sm font-black">{option.label}</div>
<div className="mt-1 text-[10px] font-bold leading-4 text-[var(--platform-text-soft)]">
{option.clearCount} · {option.itemTypeCount}
</div>
</button>
);
})}
</div>
</div>
<div className="mt-3 rounded-[1rem] border border-rose-100/80 bg-white/62 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{selectedOption.label}
</div>
<div className="mt-1 text-xs font-bold text-[var(--platform-text-base)]">
{selectedOption.clearCount} · {selectedOption.itemTypeCount}{' '}
</div>
</div>
<div className="rounded-full bg-[var(--platform-accent)] px-3 py-1 text-xs font-black text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]">
{selectedOption.difficulty}
</div>
</div>
</div>
<div className="sr-only" aria-live="polite">
{selectedOption.label}
</div>
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['需要消除', `${selectedOption.clearCount}`],
['总物品数', `${totalItemCount}`],
['物品种类', `${runtimeTypeCount}`],
['已生成物品种类', `${readyItemTypeCount}`],
].map(([label, value]) => (
<div
key={label}
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3"
>
<div className="text-[11px] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
{label}
</div>
<div className="mt-1 text-lg font-black text-[var(--platform-text-strong)]">
{value}
</div>
</div>
))}
</div>
</section>
</div>
);
}
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 (
<div
className={`group min-w-0 rounded-[1.15rem] border p-2 text-left transition-colors ${
active
? 'border-rose-300/70 bg-rose-50/80'
: 'border-[var(--platform-subpanel-border)] bg-white/76 hover:border-rose-200 hover:bg-white'
}`}
>
<div className="grid min-h-full grid-rows-[minmax(0,1fr)_auto] gap-2">
<button
type="button"
onClick={onClick}
className="grid min-h-0 gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200"
aria-label={`打开${asset.name}物品素材`}
>
<div className="grid aspect-square min-h-0 place-items-center overflow-hidden rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/82">
{previewSource ? (
<ResolvedAssetImage
src={previewSource}
alt=""
aria-hidden="true"
className="h-full w-full object-contain p-1"
/>
) : (
<ImageIcon className="h-7 w-7 text-[var(--platform-text-soft)]" />
)}
</div>
<span className="truncate text-[13px] font-bold leading-5 text-[var(--platform-text-strong)]">
{asset.name}
</span>
</button>
<div className="flex min-w-0 justify-end">
<button
type="button"
onClick={onDelete}
className="platform-icon-button h-8 w-8 shrink-0 text-rose-500"
aria-label="删除物品素材"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
function Match3DItemAssetDetail({
asset,
busy,
soundBusy,
soundProgress,
onChange,
onGenerateClickSound,
}: {
asset: Match3DItemAssetDraft;
busy: boolean;
soundBusy: boolean;
soundProgress: number | null;
onChange: (asset: Match3DItemAssetDraft) => void;
onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
}) {
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
return (
<section className="platform-subpanel min-h-0 rounded-[1.5rem] p-4 sm:p-5">
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(18rem,0.95fr)_minmax(14rem,0.62fr)]">
<div
className="grid aspect-square min-h-[18rem] grid-cols-[repeat(5,minmax(0,1fr))] grid-rows-1 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3"
aria-label={`${asset.name}五视角预览`}
>
{previewSources.map((source, index) => (
<div
key={`${source}-${index}`}
className="grid aspect-square h-auto min-h-0 w-full self-center place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
>
<ResolvedAssetImage
src={source}
alt=""
aria-hidden="true"
className="h-full w-full object-contain"
/>
</div>
))}
{previewSources.length <= 0 ? (
<div
className="col-span-5 grid min-h-0 place-items-center text-[var(--platform-text-soft)]"
aria-hidden="true"
>
<ImageIcon className="h-10 w-10" />
</div>
) : null}
</div>
<div className="min-h-0 space-y-3">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={asset.name}
disabled={busy}
onChange={(event) =>
onChange({ ...asset, name: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<button
type="button"
disabled={busy || soundBusy}
onClick={() => onGenerateClickSound(asset)}
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label={`生成点击音效,${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
>
{soundBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Music className="h-4 w-4" />
)}
</button>
</div>
<label className="block">
<span className="sr-only"></span>
<textarea
value={asset.soundPrompt}
disabled={busy || soundBusy}
rows={4}
onChange={(event) =>
onChange({ ...asset, soundPrompt: event.target.value })
}
className="mb-3 w-full resize-none rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-2.5 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label={`${asset.name}点击音效提示词`}
/>
</label>
{soundBusy && soundProgress !== null ? (
<Match3DAudioProgress
label="音效生成中"
progress={soundProgress}
/>
) : null}
{asset.clickSound?.audioSrc ? (
<Match3DResolvedAudio
src={asset.clickSound.audioSrc}
ariaLabel={`${asset.name}点击音效`}
/>
) : (
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div>
</section>
);
}
function Match3DAssetsTab({
activeAssetId,
assets,
batchGenerationState,
soundBusyAssetId,
soundGenerationProgress,
onActiveAssetChange,
onAddBatch,
onAssetChange,
onDeleteAsset,
onGenerateClickSound,
}: {
activeAssetId: string | null;
assets: Match3DItemAssetDraft[];
batchGenerationState: Match3DBatchItemGenerationState;
soundBusyAssetId: string | null;
soundGenerationProgress: number | null;
onActiveAssetChange: (assetId: string | null) => void;
onAddBatch: () => void;
onAssetChange: (asset: Match3DItemAssetDraft) => void;
onDeleteAsset: (assetId: string) => void;
onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
}) {
const activeAsset =
assets.find((asset) => asset.id === activeAssetId) ?? null;
return (
<div className="min-h-0 space-y-3">
<div className="flex justify-end">
<button
type="button"
onClick={onAddBatch}
className="platform-button platform-button--ghost min-h-10 gap-2 px-4 py-2 text-sm"
>
<Plus className="h-4 w-4" />
</button>
</div>
<Match3DBatchGenerationProgress generationState={batchGenerationState} />
<section
className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4"
aria-label="抓大鹅 2D 素材列表"
>
{assets.map((asset) => (
<Match3DItemAssetListCard
key={asset.id}
asset={asset}
active={asset.id === activeAssetId}
onClick={() => onActiveAssetChange(asset.id)}
onDelete={() => onDeleteAsset(asset.id)}
/>
))}
</section>
{activeAsset ? (
<Match3DModalShell
title={activeAsset.name || '物品素材'}
onClose={() => onActiveAssetChange(null)}
>
<Match3DItemAssetDetail
asset={activeAsset}
busy={false}
soundBusy={soundBusyAssetId === activeAsset.id}
soundProgress={
soundBusyAssetId === activeAsset.id
? soundGenerationProgress
: null
}
onChange={onAssetChange}
onGenerateClickSound={onGenerateClickSound}
/>
</Match3DModalShell>
) : null}
</div>
);
}
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);
return (
<Match3DModalShell title="批量新增物品" onClose={onClose}>
<div className="space-y-4">
<div className="space-y-2">
{values.map((value, index) => (
<label key={index} className="block">
<span className="sr-only"> {index + 1}</span>
<input
value={value}
disabled={isGenerating}
onChange={(event) => onChangeValue(index, event.target.value)}
className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label={`物品名称 ${index + 1}`}
placeholder="物品名称"
/>
</label>
))}
<button
type="button"
disabled={isGenerating}
onClick={onAddInput}
className={`platform-button platform-button--ghost min-h-10 w-full justify-center gap-2 px-4 py-2 text-sm ${isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap gap-2">
{parsedNames.map((name) => (
<span
key={name}
className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]"
>
{name}
</span>
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
<Match3DBatchGenerationProgress generationState={generationState} />
<button
type="button"
disabled={parsedNames.length <= 0 || isGenerating}
onClick={onSubmit}
className={`platform-button platform-button--primary min-h-11 w-full justify-center gap-2 px-4 py-3 ${parsedNames.length <= 0 || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
· {pointsCost}
</button>
</div>
</Match3DModalShell>
);
}
function Match3DMusicTab({
assetDrafts,
editState,
profileId,
busy,
onMusicGenerated,
}: {
assetDrafts: Match3DItemAssetDraft[];
editState: Match3DResultEditState;
profileId: string;
busy: boolean;
onMusicGenerated: (
music: CreationAudioAsset,
metadata: {
prompt: string;
style: string;
title: string;
},
) => void;
}) {
const currentMusic = assetDrafts[0]?.backgroundMusic ?? null;
const [title, setTitle] = useState(
() =>
assetDrafts[0]?.backgroundMusicTitle?.trim() ||
currentMusic?.title?.trim() ||
`${editState.gameName.trim() || '抓大鹅'}音乐`.slice(0, 40),
);
const [tags, setTags] = useState(
assetDrafts[0]?.backgroundMusicStyle?.trim() ||
'轻快, 休闲, 消除, instrumental',
);
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState<number | null>(
null,
);
const canGenerate = title.trim().length > 0;
const generateMusic = async () => {
if (!canGenerate || isGenerating) {
return;
}
setIsGenerating(true);
setGenerationProgress(0.12);
setStatusText('提交中');
setErrorText(null);
try {
const task = await createBackgroundMusicTask({
prompt: '',
title: title.trim(),
tags: tags.trim() || null,
});
setGenerationProgress(0.35);
setStatusText('生成中');
const asset = await waitForGeneratedAudioAsset(task.taskId, async () => {
const result = await publishBackgroundMusicAsset(task.taskId, {
entityKind: 'match3d_work',
entityId: profileId,
slot: 'background_music',
assetKind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
profileId,
storagePrefix: 'match3d_assets',
});
setGenerationProgress(result.audioSrc?.trim() ? 0.92 : 0.58);
setStatusText(result.audioSrc?.trim() ? '转存中' : '生成中');
return result;
});
if (!asset.audioSrc) {
throw new Error('音频生成完成但缺少播放地址。');
}
onMusicGenerated(
{
taskId: asset.taskId,
provider: asset.provider,
assetObjectId: asset.assetObjectId ?? null,
assetKind: asset.assetKind ?? MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
audioSrc: asset.audioSrc,
prompt: '',
title: title.trim(),
updatedAt: new Date().toISOString(),
},
{
prompt: '',
style: tags.trim(),
title: title.trim(),
},
);
setGenerationProgress(1);
setStatusText('已生成');
} catch (caughtError) {
setErrorText(
caughtError instanceof Error
? caughtError.message
: '背景音乐生成失败。',
);
setStatusText(null);
setGenerationProgress(null);
} finally {
setIsGenerating(false);
}
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{statusText ? (
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
{statusText}
</span>
) : null}
</div>
{isGenerating && generationProgress !== null ? (
<Match3DAudioProgress
label="音乐生成中"
progress={generationProgress}
/>
) : null}
{currentMusic?.audioSrc ? (
<Match3DResolvedAudio
src={currentMusic.audioSrc}
ariaLabel="抓大鹅背景音乐"
/>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
</div>
)}
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={title}
disabled={busy || isGenerating}
onChange={(event) => setTitle(event.target.value)}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="抓大鹅背景音乐曲名"
/>
</label>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={tags}
disabled={busy || isGenerating}
onChange={(event) => setTags(event.target.value)}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="抓大鹅背景音乐风格"
/>
</label>
<button
type="button"
disabled={!canGenerate || busy || isGenerating}
onClick={() => void generateMusic()}
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}
</button>
</section>
{errorText ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{errorText}
</div>
) : null}
</div>
);
}
function Match3DAssetConfigTabs({
activeTab,
onChange,
}: {
activeTab: Match3DAssetConfigTab;
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">
{MATCH3D_ASSET_CONFIG_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`${MATCH3D_MATERIAL_TAB_BUTTON_CLASS} ${
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 Match3DUIAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
busy,
isGenerating,
error,
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
busy: boolean;
isGenerating: boolean;
error: string | null;
onGenerate: (prompt: string) => void;
}) {
const [prompt, setPrompt] = useState(backgroundPrompt);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
useEffect(() => {
setPrompt(backgroundPrompt);
}, [backgroundPrompt]);
const normalizedPrompt = prompt.trim();
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-[9/16] max-h-[min(62dvh,34rem)] w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 text-left shadow-sm"
aria-label="打开UI页面预览"
>
<ResolvedAssetImage
src={backgroundPreviewSrc}
alt="游戏背景图"
className="h-full w-full object-cover"
/>
<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背景图画面描述提示词"
/>
</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={() => onGenerate(normalizedPrompt)}
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>
</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={containerPreviewSrc}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
</div>
);
}
function Match3DUIRuntimePreviewPanel({
backgroundPreviewSrc,
containerPreviewSrc,
onClose,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
onClose: () => void;
}) {
return (
<Match3DModalShell title="UI预览" onClose={onClose}>
<div className="mx-auto aspect-[9/16] max-h-[min(78dvh,44rem)] w-full max-w-[24rem] overflow-hidden rounded-[1.4rem] border border-white/18 bg-[#16221f] 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">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
<ResolvedAssetImage
src={backgroundPreviewSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<header className="relative z-10 flex items-center justify-between gap-2">
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<ArrowLeft size={20} />
</span>
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
1:30
</span>
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
</span>
</header>
<section className="relative z-10 mt-3 flex min-h-0 flex-1 items-center justify-center">
<div
className="relative aspect-square max-w-full rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
style={{ width: 'min(92%, 58dvh, 100%)' }}
aria-hidden="true"
>
{containerPreviewSrc ? (
<ResolvedAssetImage
src={containerPreviewSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] h-[108%] w-[108%] object-contain"
/>
) : (
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
)}
</div>
</section>
<section className={`relative z-10 ${MATCH3D_RUNTIME_GLASS_TRAY_CLASS}`}>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: 7 }).map((_, index) => (
<span
key={index}
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
/>
))}
</div>
</section>
</div>
</div>
</Match3DModalShell>
);
}
function Match3DAssetConfigTab({
activeAssetConfigTab,
activeAssetId,
assetDrafts,
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
backgroundGenerationError,
batchGenerationState,
busy,
editState,
isGeneratingBackground,
profileId,
soundBusyAssetId,
soundGenerationProgress,
onActiveAssetChange,
onAddBatch,
onAssetChange,
onAssetConfigTabChange,
onDeleteAsset,
onGenerateBackground,
onGenerateClickSound,
onMusicGenerated,
}: {
activeAssetConfigTab: Match3DAssetConfigTab;
activeAssetId: string | null;
assetDrafts: Match3DItemAssetDraft[];
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
backgroundGenerationError: string | null;
batchGenerationState: Match3DBatchItemGenerationState;
busy: boolean;
editState: Match3DResultEditState;
isGeneratingBackground: boolean;
profileId: string;
soundBusyAssetId: string | null;
soundGenerationProgress: number | null;
onActiveAssetChange: (assetId: string | null) => void;
onAddBatch: () => void;
onAssetChange: (asset: Match3DItemAssetDraft) => void;
onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void;
onDeleteAsset: (assetId: string) => void;
onGenerateBackground: (prompt: string) => void;
onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
onMusicGenerated: (
music: CreationAudioAsset,
metadata: {
prompt: string;
style: string;
title: string;
},
) => void;
}) {
return (
<div className="min-h-0">
<Match3DAssetConfigTabs
activeTab={activeAssetConfigTab}
onChange={onAssetConfigTabChange}
/>
{activeAssetConfigTab === 'items' ? (
<Match3DAssetsTab
activeAssetId={activeAssetId}
assets={assetDrafts}
batchGenerationState={batchGenerationState}
soundBusyAssetId={soundBusyAssetId}
soundGenerationProgress={soundGenerationProgress}
onActiveAssetChange={onActiveAssetChange}
onAddBatch={onAddBatch}
onAssetChange={onAssetChange}
onDeleteAsset={onDeleteAsset}
onGenerateClickSound={onGenerateClickSound}
/>
) : null}
{activeAssetConfigTab === 'ui' ? (
<Match3DUIAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
busy={busy}
isGenerating={isGeneratingBackground}
error={backgroundGenerationError}
onGenerate={onGenerateBackground}
/>
) : null}
{activeAssetConfigTab === 'music' ? (
<Match3DMusicTab
assetDrafts={assetDrafts}
editState={editState}
profileId={profileId}
busy={busy}
onMusicGenerated={onMusicGenerated}
/>
) : null}
</div>
);
}
export function Match3DResultView({
profile,
draft = null,
isBusy = false,
error = null,
onBack,
onSaved,
onPublished,
onStartTestRun,
}: Match3DResultViewProps) {
const [editState, setEditState] = useState(() => createEditState(profile));
const [activeTab, setActiveTab] = useState<Match3DResultTab>('work');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<Match3DAssetConfigTab>('items');
const [assetDrafts, setAssetDrafts] = useState<Match3DItemAssetDraft[]>(() =>
createMatch3DAssetDrafts(profile, draft),
);
const [activeAssetId, setActiveAssetId] = useState<string | null>(null);
const [isCoverPanelOpen, setIsCoverPanelOpen] = useState(false);
const [coverReferenceSrc, setCoverReferenceSrc] = useState('');
const [coverPrompt, setCoverPrompt] = useState('');
const [coverAiRedraw, setCoverAiRedraw] = useState(false);
const [isGeneratingCover, setIsGeneratingCover] = useState(false);
const [coverPanelError, setCoverPanelError] = useState<string | null>(null);
const [isBatchAddPanelOpen, setIsBatchAddPanelOpen] = useState(false);
const [batchItemNameValues, setBatchItemNameValues] = useState(['']);
const [batchGenerationState, setBatchGenerationState] =
useState<Match3DBatchItemGenerationState>({
phase: 'idle',
progress: null,
itemNames: [],
message: null,
error: null,
});
const [batchAddError, setBatchAddError] = useState<string | null>(null);
const [isGeneratingBackground, setIsGeneratingBackground] = useState(false);
const [backgroundGenerationError, setBackgroundGenerationError] = useState<
string | null
>(null);
const [soundBusyAssetId, setSoundBusyAssetId] = useState<string | null>(null);
const [soundGenerationProgress, setSoundGenerationProgress] = useState<
number | null
>(null);
const [autoSaveState, setAutoSaveState] =
useState<Match3DAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const generatedItemAssets = useMemo(
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
[draft, profile],
);
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) ?? profile.clearCount) *
3;
const backgroundPreviewSrc = useMemo(
() =>
resolveMatch3DBackgroundPreviewSource(
profile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, profile],
);
const backgroundPrompt = useMemo(
() => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
[draft, generatedItemAssets, profile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(generatedItemAssets) ||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[generatedItemAssets],
);
const coverSourceAssets = useMemo(
() => resolveMatch3DCoverSourceAssets(assetDrafts, backgroundPreviewSrc),
[assetDrafts, backgroundPreviewSrc],
);
useEffect(() => {
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
setCoverReferenceSrc('');
setCoverPrompt('');
setCoverAiRedraw(false);
setCoverPanelError(null);
setBackgroundGenerationError(null);
setIsGeneratingBackground(false);
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
setAssetDrafts(createMatch3DAssetDrafts(profile, draft));
setActiveAssetId(null);
setSoundBusyAssetId(null);
setSoundGenerationProgress(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;
}
if (isGeneratingBackground) {
return;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, MATCH3D_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, generatedItemAssets, isGeneratingBackground, 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<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageAsDataUrl(file);
setCoverReferenceSrc(dataUrl);
setCoverAiRedraw(true);
setCoverPanelError(null);
} catch (caughtError) {
setCoverPanelError(
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
);
}
};
const handleOpenCoverPanel = () => {
setCoverReferenceSrc(editState.coverImageSrc);
setCoverPrompt(
[
editState.gameName.trim(),
editState.themeText.trim(),
'抓大鹅作品碰面图,主体清晰,适合作品卡片',
]
.filter(Boolean)
.join(''),
);
setCoverAiRedraw(false);
setCoverPanelError(null);
setIsCoverPanelOpen(true);
};
const handleSubmitCoverPanel = async () => {
const referenceSrc = coverReferenceSrc.trim();
const prompt = coverPrompt.trim();
if (!referenceSrc && !prompt) {
setCoverPanelError('请上传图片、引用素材或填写提示词。');
return;
}
if (!coverAiRedraw) {
setEditState((current) => ({
...current,
coverImageSrc: referenceSrc,
}));
setIsCoverPanelOpen(false);
setCoverPanelError(null);
return;
}
setIsGeneratingCover(true);
setCoverPanelError(null);
try {
const response = await generateMatch3DCoverImage(profile.profileId, {
prompt:
prompt ||
`${editState.gameName.trim() || '抓大鹅'}作品碰面图,${editState.themeText.trim() || '休闲消除'}题材`,
referenceImageSrc: referenceSrc || null,
});
setEditState((current) => ({
...current,
coverImageSrc: response.coverImageSrc,
}));
setIsCoverPanelOpen(false);
onSaved?.(response.item);
} 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 persistAudioAssetDrafts = async (
nextDrafts: Match3DItemAssetDraft[],
) => {
return persistGeneratedAssetDrafts(nextDrafts);
};
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 handleGenerateBackground = async (prompt: string) => {
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt || isGeneratingBackground) {
setBackgroundGenerationError('请填写画面描述提示词。');
return;
}
setIsGeneratingBackground(true);
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);
}
};
const patchAndPersistAudioAssetDrafts = async (
patcher: (drafts: Match3DItemAssetDraft[]) => Match3DItemAssetDraft[],
) => {
const nextDrafts = patcher(assetDrafts);
setAssetDrafts(nextDrafts);
await persistAudioAssetDrafts(nextDrafts);
};
const handleBackgroundMusicGenerated = async (
music: CreationAudioAsset,
metadata: {
prompt: string;
style: string;
title: string;
},
) => {
try {
await patchAndPersistAudioAssetDrafts((drafts) => {
if (drafts.length <= 0) {
return drafts;
}
return drafts.map((asset, index) =>
index === 0
? {
...asset,
backgroundMusic: music,
backgroundMusicPrompt: metadata.prompt,
backgroundMusicStyle: metadata.style,
backgroundMusicTitle: metadata.title,
}
: asset,
);
});
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '保存背景音乐失败。',
);
}
};
const handleGenerateClickSound = async (asset: Match3DItemAssetDraft) => {
if (soundBusyAssetId) {
return;
}
setSoundBusyAssetId(asset.id);
setSoundGenerationProgress(0.12);
setLocalError(null);
try {
const prompt =
asset.soundPrompt.trim() ||
buildFallbackMatch3DClickSoundPrompt(
{ themeText: editState.themeText },
asset.name,
);
const task = await createSoundEffectTask({
prompt,
duration: 3,
});
setSoundGenerationProgress(0.35);
const generated = await waitForGeneratedAudioAsset(
task.taskId,
async () => {
const result = await publishSoundEffectAsset(task.taskId, {
entityKind: 'match3d_item',
entityId: asset.id,
slot: 'click_sound',
assetKind: MATCH3D_CLICK_SOUND_ASSET_KIND,
profileId: profile.profileId,
storagePrefix: 'match3d_assets',
});
setSoundGenerationProgress(result.audioSrc?.trim() ? 0.92 : 0.58);
return result;
},
);
if (!generated.audioSrc) {
throw new Error('音效生成完成但缺少播放地址。');
}
const clickSound: CreationAudioAsset = {
taskId: generated.taskId,
provider: generated.provider,
assetObjectId: generated.assetObjectId ?? null,
assetKind: generated.assetKind ?? MATCH3D_CLICK_SOUND_ASSET_KIND,
audioSrc: generated.audioSrc,
prompt,
title: `${asset.name}点击音效`,
updatedAt: new Date().toISOString(),
};
await patchAndPersistAudioAssetDrafts((drafts) =>
drafts.map((draftAsset) =>
draftAsset.id === asset.id
? {
...draftAsset,
clickSound,
soundPrompt: prompt,
updatedAt: new Date().toISOString(),
}
: draftAsset,
),
);
setSoundGenerationProgress(1);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '点击音效生成失败。',
);
} finally {
setSoundBusyAssetId(null);
window.setTimeout(() => setSoundGenerationProgress(null), 350);
}
};
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 () => {
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);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '发布抓大鹅作品失败。',
);
} finally {
setIsPublishing(false);
}
};
const busy =
isBusy ||
isPublishing ||
isStartingTestRun ||
isGeneratingCover ||
isGeneratingBackground ||
Boolean(soundBusyAssetId);
const workBusy = busy || isGeneratingTags;
const displayError = error ?? localError;
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
<Match3DResultHeader
autoSaveState={autoSaveState}
isBusy={busy}
onBack={onBack}
/>
<Match3DResultTabs activeTab={activeTab} onChange={setActiveTab} />
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{activeTab === 'work' ? (
<Match3DWorkInfoTab
editState={editState}
isBusy={workBusy}
onChange={setEditState}
onOpenCoverPanel={handleOpenCoverPanel}
onGenerateTags={handleGenerateTags}
/>
) : null}
{activeTab === 'config' ? (
<Match3DConfigTab
editState={editState}
generatedItemAssets={generatedItemAssets}
isBusy={busy}
totalItemCount={totalItemCount}
onChange={setEditState}
/>
) : null}
{activeTab === 'assets' ? (
<Match3DAssetConfigTab
activeAssetConfigTab={activeAssetConfigTab}
activeAssetId={activeAssetId}
assetDrafts={assetDrafts}
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
backgroundGenerationError={backgroundGenerationError}
batchGenerationState={batchGenerationState}
busy={busy}
editState={editState}
isGeneratingBackground={isGeneratingBackground}
profileId={profile.profileId}
soundBusyAssetId={soundBusyAssetId}
soundGenerationProgress={soundGenerationProgress}
onActiveAssetChange={setActiveAssetId}
onAddBatch={() => {
setBatchAddError(null);
setBatchGenerationState((current) =>
current.phase === 'generating'
? current
: {
phase: 'idle',
progress: null,
itemNames: [],
message: null,
error: null,
},
);
setIsBatchAddPanelOpen(true);
}}
onAssetChange={updateItemAsset}
onAssetConfigTabChange={setActiveAssetConfigTab}
onDeleteAsset={(assetId) => {
void handleDeleteAssetDraft(assetId);
}}
onGenerateBackground={(prompt) => {
void handleGenerateBackground(prompt);
}}
onGenerateClickSound={(asset) => {
void handleGenerateClickSound(asset);
}}
onMusicGenerated={(music, metadata) => {
void handleBackgroundMusicGenerated(music, metadata);
}}
/>
) : null}
</div>
{displayError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{displayError}
</div>
) : null}
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
<button
type="button"
onClick={handleStartTestRun}
disabled={!canStartTestRun || busy}
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={handlePublish}
disabled={!canSubmit || busy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : profile.publicationStatus === 'published' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
</button>
</div>
{isCoverPanelOpen ? (
<Match3DCoverImagePanel
editState={editState}
sourceAssets={coverSourceAssets}
isGenerating={isGeneratingCover}
selectedReferenceSrc={coverReferenceSrc}
aiRedraw={coverAiRedraw}
prompt={coverPrompt}
error={coverPanelError}
onAiRedrawChange={setCoverAiRedraw}
onClose={() => {
if (!isGeneratingCover) {
setIsCoverPanelOpen(false);
}
}}
onFileChange={handleCoverImageChange}
onPromptChange={setCoverPrompt}
onReferenceSelect={(source) => {
setCoverReferenceSrc(source);
setCoverAiRedraw(true);
setCoverPanelError(null);
}}
onSubmit={() => {
void handleSubmitCoverPanel();
}}
/>
) : null}
{isBatchAddPanelOpen ? (
<Match3DBatchAddItemsPanel
values={batchItemNameValues}
generationState={batchGenerationState}
error={batchAddError}
onAddInput={() => {
setBatchItemNameValues((current) => [...current, '']);
}}
onChangeValue={(index, value) => {
setBatchItemNameValues((current) =>
current.map((item, itemIndex) =>
itemIndex === index ? value : item,
),
);
}}
onClose={() => {
setIsBatchAddPanelOpen(false);
}}
onSubmit={() => {
handleSubmitBatchAddItems();
}}
/>
) : null}
</div>
);
}
export default Match3DResultView;