This commit is contained in:
2026-05-11 20:27:41 +08:00
parent e30b733b17
commit 481a27fc53
60 changed files with 6357 additions and 1100 deletions

View File

@@ -5,6 +5,7 @@ import {
ImagePlus,
ListChecks,
Loader2,
Music,
Play,
Plus,
Send,
@@ -18,6 +19,7 @@ import {
useState,
} from 'react';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type {
Hyper3dDownloadFilePayload,
Hyper3dGenerationMode,
@@ -33,12 +35,23 @@ import {
getHyper3dTaskStatus,
submitHyper3dImageToModel,
} from '../../services/hyper3dModelGenerationService';
import {
createBackgroundMusicTask,
createSoundEffectTask,
publishBackgroundMusicAsset,
publishSoundEffectAsset,
waitForGeneratedAudioAsset,
} from '../../services/creation-audio';
import {
publishMatch3DWork,
generateMatch3DWorkTags,
updateMatch3DGeneratedItemAssets,
updateMatch3DWork,
} from '../../services/match3d-works';
import { readAssetBytes } from '../../services/assetReadUrlService';
import {
isGeneratedLegacyPath,
readAssetBytes,
} from '../../services/assetReadUrlService';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { Match3DModelPreview } from './Match3DModelPreview';
@@ -54,7 +67,7 @@ type Match3DResultViewProps = {
};
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type Match3DResultTab = 'work' | 'config' | 'assets';
type Match3DResultTab = 'work' | 'config' | 'assets' | 'music';
type Match3DAssetGenerationMode = Hyper3dGenerationMode;
type Match3DAssetTaskStatus =
| 'idle'
@@ -78,6 +91,8 @@ type Match3DRodinAssetDraft = {
status: Match3DAssetTaskStatus;
progress: number | null;
downloads: Hyper3dDownloadFilePayload[];
backgroundMusic: CreationAudioAsset | null;
clickSound: CreationAudioAsset | null;
error: string | null;
updatedAt: string | null;
};
@@ -96,11 +111,14 @@ 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_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
{ id: 'work', label: '作品信息' },
{ id: 'config', label: '玩法配置' },
{ id: 'assets', label: '3D素材' },
{ id: 'music', label: '音乐' },
];
function normalizeTags(value: string) {
@@ -156,14 +174,58 @@ function normalizeMatch3DTagListText(value: string) {
];
}
function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
}
function mergeMatch3DGeneratedItemAsset(
base: Match3DGeneratedItemAsset,
override: Match3DGeneratedItemAsset,
): Match3DGeneratedItemAsset {
const overrideHasModel = hasMatch3DGeneratedModelSource(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,
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,
// 中文注释:草稿 response 可能只有图片profile 里若已有模型,结果页和试玩不能被旧草稿快照覆盖回 image_ready。
status:
overrideHasModel && base.status !== 'model_ready'
? 'model_ready'
: base.status,
error: override.error ?? base.error ?? null,
};
}
function createMatch3DAssetDrafts(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null = null,
): Match3DRodinAssetDraft[] {
const generatedAssets =
draft?.generatedItemAssets?.length
? draft.generatedItemAssets
: profile.generatedItemAssets;
const generatedAssets = resolveMatch3DResultGeneratedItemAssets(
profile,
draft,
);
if (generatedAssets?.length) {
return generatedAssets.map((asset) =>
createMatch3DAssetDraftFromGeneratedAsset(profile, asset),
@@ -214,6 +276,8 @@ function createMatch3DAssetDrafts(
status: 'idle',
progress: null,
downloads: [],
backgroundMusic: null,
clickSound: null,
error: null,
updatedAt: null,
}));
@@ -223,11 +287,13 @@ function createMatch3DAssetDraftFromGeneratedAsset(
profile: Match3DWorkProfile,
asset: Match3DGeneratedItemAsset,
): Match3DRodinAssetDraft {
const downloads = asset.modelSrc
const modelSource =
asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
const downloads = modelSource
? [
{
name: asset.modelFileName ?? `${asset.itemName}.glb`,
url: asset.modelSrc,
url: modelSource,
},
]
: [];
@@ -248,11 +314,50 @@ function createMatch3DAssetDraftFromGeneratedAsset(
: normalizeMatch3DAssetStatus(asset.status),
progress: asset.status === 'model_ready' ? 1 : null,
downloads,
backgroundMusic: asset.backgroundMusic ?? null,
clickSound: asset.clickSound ?? null,
error: asset.error ?? null,
updatedAt: profile.updatedAt,
};
}
function createGeneratedAssetsFromDrafts(
assetDrafts: Match3DRodinAssetDraft[],
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, '')
: modelFile
? null
: existing?.modelObjectKey ?? null;
return {
itemId: asset.id,
itemName: asset.name,
imageSrc: existing?.imageSrc ?? (asset.referenceImageSrc || null),
imageObjectKey: existing?.imageObjectKey ?? null,
modelSrc: modelSource,
modelObjectKey,
modelFileName: modelFile?.name?.trim() || existing?.modelFileName || null,
taskUuid: asset.taskUuid,
subscriptionKey: asset.subscriptionKey,
backgroundMusic: asset.backgroundMusic,
clickSound: asset.clickSound,
status: asset.status === 'done' ? 'model_ready' : asset.status,
error: asset.error,
};
});
}
function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
const normalized = status.trim().toLowerCase();
if (
@@ -467,13 +572,14 @@ async function resolveRodinReferenceImageDataUrl(source: string) {
function buildPlayableProfile(
profile: Match3DWorkProfile,
editState: Match3DResultEditState,
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
const payload = buildSavePayload(editState);
if (!payload) {
return profile;
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
}
return {
return attachMatch3DGeneratedItemAssets({
...profile,
gameName: payload.gameName,
themeText: payload.themeText ?? profile.themeText,
@@ -482,6 +588,51 @@ function buildPlayableProfile(
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],
};
}
@@ -535,7 +686,7 @@ function Match3DResultTabs({
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">
<div className="mb-3 grid grid-cols-4 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}
@@ -918,12 +1069,16 @@ function Match3DRodinAssetListCard({
function Match3DRodinAssetDetail({
asset,
busy,
soundBusy,
onChange,
onGenerateClickSound,
onSubmit,
}: {
asset: Match3DRodinAssetDraft;
busy: boolean;
soundBusy: boolean;
onChange: (asset: Match3DRodinAssetDraft) => void;
onGenerateClickSound: (asset: Match3DRodinAssetDraft) => void;
onSubmit: () => void;
}) {
const canSubmit = Boolean(asset.referenceImageSrc.trim());
@@ -963,6 +1118,35 @@ function Match3DRodinAssetDetail({
</button>
</div>
<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="生成点击音效"
title="生成点击音效"
>
{soundBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Music className="h-4 w-4" />
)}
</button>
</div>
{asset.clickSound?.audioSrc ? (
<audio className="w-full" controls src={asset.clickSound.audioSrc} />
) : (
<div className="text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div>
</section>
@@ -973,15 +1157,19 @@ function Match3DAssetsTab({
activeAssetId,
assets,
busyAssetId,
soundBusyAssetId,
onActiveAssetChange,
onAssetChange,
onGenerateClickSound,
onSubmitAsset,
}: {
activeAssetId: string | null;
assets: Match3DRodinAssetDraft[];
busyAssetId: string | null;
soundBusyAssetId: string | null;
onActiveAssetChange: (assetId: string | null) => void;
onAssetChange: (asset: Match3DRodinAssetDraft) => void;
onGenerateClickSound: (asset: Match3DRodinAssetDraft) => void;
onSubmitAsset: (assetId: string) => void;
}) {
const activeAsset = assets.find((asset) => asset.id === activeAssetId) ?? null;
@@ -1006,7 +1194,9 @@ function Match3DAssetsTab({
<Match3DRodinAssetDetail
asset={activeAsset}
busy={busyAssetId === activeAsset.id}
soundBusy={soundBusyAssetId === activeAsset.id}
onChange={onAssetChange}
onGenerateClickSound={onGenerateClickSound}
onSubmit={() => onSubmitAsset(activeAsset.id)}
/>
) : (
@@ -1021,6 +1211,171 @@ function Match3DAssetsTab({
);
}
function Match3DMusicTab({
assetDrafts,
editState,
profileId,
busy,
onMusicGenerated,
}: {
assetDrafts: Match3DRodinAssetDraft[];
editState: Match3DResultEditState;
profileId: string;
busy: boolean;
onMusicGenerated: (music: CreationAudioAsset) => void;
}) {
const currentMusic = assetDrafts[0]?.backgroundMusic ?? null;
const [prompt, setPrompt] = useState(() =>
[
editState.gameName.trim(),
editState.themeText.trim(),
editState.summary.trim(),
'轻快、适合抓大鹅消除游戏循环播放的背景音乐',
]
.filter(Boolean)
.join(''),
);
const [title, setTitle] = useState(() =>
`${editState.gameName.trim() || '抓大鹅'}背景音乐`.slice(0, 40),
);
const [tags, setTags] = useState('轻快, 休闲, 消除, instrumental');
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const canGenerate = prompt.trim().length > 0 && title.trim().length > 0;
const generateMusic = async () => {
if (!canGenerate || isGenerating) {
return;
}
setIsGenerating(true);
setStatusText('生成中');
setErrorText(null);
try {
const task = await createBackgroundMusicTask({
prompt: prompt.trim(),
title: title.trim(),
tags: tags.trim() || null,
});
const asset = await waitForGeneratedAudioAsset(task.taskId, () =>
publishBackgroundMusicAsset(task.taskId, {
entityKind: 'match3d_work',
entityId: profileId,
slot: 'background_music',
assetKind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
profileId,
storagePrefix: 'match3d_assets',
}),
);
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: prompt.trim(),
title: title.trim(),
updatedAt: new Date().toISOString(),
});
setStatusText('已生成');
} catch (caughtError) {
setErrorText(
caughtError instanceof Error ? caughtError.message : '背景音乐生成失败。',
);
setStatusText(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>
{currentMusic?.audioSrc ? (
<audio className="mt-3 w-full" controls src={currentMusic.audioSrc} />
) : (
<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>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={busy || isGenerating}
rows={5}
onChange={(event) => setPrompt(event.target.value)}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="抓大鹅背景音乐提示词"
/>
</label>
<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" />
)}
</button>
</section>
{errorText ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{errorText}
</div>
) : null}
</div>
);
}
export function Match3DResultView({
profile,
draft = null,
@@ -1038,6 +1393,7 @@ export function Match3DResultView({
);
const [activeAssetId, setActiveAssetId] = useState<string | null>(null);
const [busyAssetId, setBusyAssetId] = useState<string | null>(null);
const [soundBusyAssetId, setSoundBusyAssetId] = useState<string | null>(null);
const [autoSaveState, setAutoSaveState] =
useState<Match3DAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
@@ -1051,6 +1407,10 @@ export function Match3DResultView({
);
const canStartTestRun = testRunBlockers.length === 0;
const canSubmit = blockers.length === 0;
const generatedItemAssets = useMemo(
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
[draft?.generatedItemAssets, profile],
);
const totalItemCount =
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
3;
@@ -1065,6 +1425,7 @@ export function Match3DResultView({
setAssetDrafts(createMatch3DAssetDrafts(profile, draft));
setActiveAssetId(null);
setBusyAssetId(null);
setSoundBusyAssetId(null);
}, [draft?.generatedItemAssets, profile.generatedItemAssets, profile.profileId]);
useEffect(() => {
@@ -1128,9 +1489,13 @@ export function Match3DResultView({
setAutoSaveState('saving');
setLocalError(null);
const { item } = await updateMatch3DWork(profile.profileId, payload);
const playableItem = attachMatch3DGeneratedItemAssets(
item,
generatedItemAssets,
);
setAutoSaveState('saved');
onSaved?.(item);
return item;
onSaved?.(playableItem);
return playableItem;
};
const handleCoverImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
@@ -1211,6 +1576,102 @@ export function Match3DResultView({
const getRodinAsset = (assetId: string) =>
assetDrafts.find((asset) => asset.id === assetId) ?? null;
const persistAudioAssetDrafts = async (
nextDrafts: Match3DRodinAssetDraft[],
) => {
return persistGeneratedAssetDrafts(nextDrafts);
};
const persistGeneratedAssetDrafts = async (
nextDrafts: Match3DRodinAssetDraft[],
) => {
const { item } = await updateMatch3DGeneratedItemAssets(profile.profileId, {
generatedItemAssets: createGeneratedAssetsFromDrafts(
nextDrafts,
profile.generatedItemAssets ?? [],
),
});
onSaved?.(item);
return item;
};
const patchAndPersistAudioAssetDrafts = async (
patcher: (drafts: Match3DRodinAssetDraft[]) => Match3DRodinAssetDraft[],
) => {
const nextDrafts = patcher(assetDrafts);
setAssetDrafts(nextDrafts);
await persistAudioAssetDrafts(nextDrafts);
};
const handleBackgroundMusicGenerated = async (music: CreationAudioAsset) => {
try {
await patchAndPersistAudioAssetDrafts((drafts) => {
if (drafts.length <= 0) {
return drafts;
}
return drafts.map((asset, index) =>
index === 0 ? { ...asset, backgroundMusic: music } : asset,
);
});
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '保存背景音乐失败。',
);
}
};
const handleGenerateClickSound = async (asset: Match3DRodinAssetDraft) => {
if (soundBusyAssetId) {
return;
}
setSoundBusyAssetId(asset.id);
setLocalError(null);
try {
const prompt = `${editState.themeText.trim() || '抓大鹅'}物体${asset.name.trim()}被点击消除时的短促反馈音效,清脆、可爱、适合移动端休闲游戏。`;
const task = await createSoundEffectTask({
prompt,
duration: 3,
});
const generated = await waitForGeneratedAudioAsset(task.taskId, () =>
publishSoundEffectAsset(task.taskId, {
entityKind: 'match3d_item',
entityId: asset.id,
slot: 'click_sound',
assetKind: MATCH3D_CLICK_SOUND_ASSET_KIND,
profileId: profile.profileId,
storagePrefix: 'match3d_assets',
}),
);
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, updatedAt: new Date().toISOString() }
: draftAsset,
),
);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '点击音效生成失败。',
);
} finally {
setSoundBusyAssetId(null);
}
};
const handleSubmitRodinAsset = async (assetId: string) => {
const asset = getRodinAsset(assetId);
if (!asset || busyAssetId) {
@@ -1302,11 +1763,24 @@ export function Match3DResultView({
if (!modelFile) {
throw new Error('Hyper3D 已完成但未返回可下载模型文件。');
}
patchRodinAsset(assetId, {
status: 'done',
downloads: [modelFile],
updatedAt: new Date().toISOString(),
});
const updatedAt = new Date().toISOString();
const nextDrafts = assetDrafts.map((draftAsset) =>
draftAsset.id === assetId
? {
...draftAsset,
mode: 'image-to-model' as const,
taskUuid: response.taskUuid,
subscriptionKey: response.subscriptionKey,
status: 'done' as const,
progress: 1,
downloads: [modelFile],
error: null,
updatedAt,
}
: draftAsset,
);
setAssetDrafts(nextDrafts);
await persistGeneratedAssetDrafts(nextDrafts);
} catch (caughtError) {
patchRodinAsset(assetId, {
status: 'failed',
@@ -1330,7 +1804,10 @@ export function Match3DResultView({
setIsStartingTestRun(true);
try {
const savedProfile = await saveNow();
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
onStartTestRun(
savedProfile ??
buildPlayableProfile(profile, editState, generatedItemAssets),
);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。',
@@ -1363,7 +1840,12 @@ export function Match3DResultView({
}
};
const busy = isBusy || isPublishing || isStartingTestRun || Boolean(busyAssetId);
const busy =
isBusy ||
isPublishing ||
isStartingTestRun ||
Boolean(busyAssetId) ||
Boolean(soundBusyAssetId);
const workBusy = busy || isGeneratingTags;
const displayError = error ?? localError;
@@ -1402,13 +1884,28 @@ export function Match3DResultView({
activeAssetId={activeAssetId}
assets={assetDrafts}
busyAssetId={busyAssetId}
soundBusyAssetId={soundBusyAssetId}
onActiveAssetChange={setActiveAssetId}
onAssetChange={updateRodinAsset}
onGenerateClickSound={(asset) => {
void handleGenerateClickSound(asset);
}}
onSubmitAsset={(assetId) => {
void handleSubmitRodinAsset(assetId);
}}
/>
) : null}
{activeTab === 'music' ? (
<Match3DMusicTab
assetDrafts={assetDrafts}
editState={editState}
profileId={profile.profileId}
busy={busy}
onMusicGenerated={(music) => {
void handleBackgroundMusicGenerated(music);
}}
/>
) : null}
</div>
{displayError ? (