Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
import { ArrowLeft } from 'lucide-react';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||
import type {
|
||
BarkBattleAssetSlot,
|
||
BarkBattleGeneratedImageAssets,
|
||
BarkBattleImageGenerationBatchResult,
|
||
BarkBattleImageGenerationFailures,
|
||
} from '../../services/bark-battle-creation';
|
||
import {
|
||
generateAllBarkBattleImageAssets,
|
||
updateBarkBattleDraftConfig,
|
||
} from '../../services/bark-battle-creation';
|
||
import {
|
||
GenerationCurrentStepCard,
|
||
GenerationPageBackdrop,
|
||
GenerationProgressHero,
|
||
} from '../GenerationProgressHero';
|
||
|
||
type BarkBattleGeneratingViewProps = {
|
||
draft: BarkBattleDraftConfig;
|
||
isBusy?: boolean;
|
||
error?: string | null;
|
||
onBack: () => void;
|
||
onComplete: (draft: BarkBattleDraftConfig, partialFailed: boolean) => void;
|
||
onError: (message: string | null) => void;
|
||
};
|
||
|
||
type BarkBattleGeneratingSlotStatus = 'generating' | 'ready' | 'failed';
|
||
|
||
const GENERATION_STEPS = [
|
||
{ slot: 'player-character', label: '玩家形象' },
|
||
{ slot: 'opponent-character', label: '对手形象' },
|
||
{ slot: 'ui-background', label: '竞技背景' },
|
||
] as const satisfies readonly {
|
||
slot: BarkBattleAssetSlot;
|
||
label: string;
|
||
}[];
|
||
|
||
const activeBarkBattleGenerationTasks = new Map<
|
||
string,
|
||
Promise<BarkBattleImageGenerationBatchResult>
|
||
>();
|
||
|
||
function applyGeneratedAssets(
|
||
draft: BarkBattleDraftConfig,
|
||
assets: BarkBattleGeneratedImageAssets,
|
||
): BarkBattleDraftConfig {
|
||
const nextDraft: BarkBattleDraftConfig = {
|
||
...draft,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
if (assets['player-character']?.imageSrc) {
|
||
nextDraft.playerCharacterImageSrc = assets['player-character'].imageSrc;
|
||
}
|
||
if (assets['opponent-character']?.imageSrc) {
|
||
nextDraft.opponentCharacterImageSrc = assets['opponent-character'].imageSrc;
|
||
}
|
||
if (assets['ui-background']?.imageSrc) {
|
||
nextDraft.uiBackgroundImageSrc = assets['ui-background'].imageSrc;
|
||
}
|
||
|
||
return nextDraft;
|
||
}
|
||
|
||
function hasSlotAsset(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
|
||
if (slot === 'player-character') {
|
||
return Boolean(draft.playerCharacterImageSrc?.trim());
|
||
}
|
||
if (slot === 'opponent-character') {
|
||
return Boolean(draft.opponentCharacterImageSrc?.trim());
|
||
}
|
||
return Boolean(draft.uiBackgroundImageSrc?.trim());
|
||
}
|
||
|
||
function mergeSlotAsset(
|
||
draft: BarkBattleDraftConfig,
|
||
slot: BarkBattleAssetSlot,
|
||
imageSrc: string,
|
||
): BarkBattleDraftConfig {
|
||
if (slot === 'player-character') {
|
||
return { ...draft, playerCharacterImageSrc: imageSrc };
|
||
}
|
||
if (slot === 'opponent-character') {
|
||
return { ...draft, opponentCharacterImageSrc: imageSrc };
|
||
}
|
||
return { ...draft, uiBackgroundImageSrc: imageSrc };
|
||
}
|
||
|
||
function isDraftPersistable(draft: BarkBattleDraftConfig) {
|
||
return Boolean(draft.draftId?.trim() && draft.workId?.trim());
|
||
}
|
||
|
||
function resolvePrimaryFailureMessage(
|
||
failures: BarkBattleImageGenerationFailures,
|
||
) {
|
||
for (const step of GENERATION_STEPS) {
|
||
const message = failures[step.slot]?.trim();
|
||
if (message) {
|
||
return message;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildDraftGenerationKey(draft: BarkBattleDraftConfig) {
|
||
return [
|
||
draft.draftId,
|
||
draft.playerCharacterImageSrc ?? '',
|
||
draft.opponentCharacterImageSrc ?? '',
|
||
draft.uiBackgroundImageSrc ?? '',
|
||
].join('|');
|
||
}
|
||
|
||
function getSlotStatusLabel(status: BarkBattleGeneratingSlotStatus) {
|
||
if (status === 'ready') {
|
||
return '完成';
|
||
}
|
||
if (status === 'failed') {
|
||
return '失败';
|
||
}
|
||
return '进行中';
|
||
}
|
||
|
||
function formatGenerationDuration(ms: number) {
|
||
const totalSeconds = Math.max(1, Math.ceil(Math.max(0, ms) / 1000));
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
|
||
if (minutes <= 0) {
|
||
return `${seconds} 秒`;
|
||
}
|
||
|
||
if (seconds === 0) {
|
||
return `${minutes} 分钟`;
|
||
}
|
||
|
||
return `${minutes} 分 ${seconds} 秒`;
|
||
}
|
||
|
||
function resolveBarkBattleProgressValue(
|
||
slotStatuses: Partial<
|
||
Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
|
||
>,
|
||
) {
|
||
const readyCount = GENERATION_STEPS.filter(
|
||
(step) => slotStatuses[step.slot] === 'ready',
|
||
).length;
|
||
return Math.round((readyCount / GENERATION_STEPS.length) * 100);
|
||
}
|
||
|
||
function resolveCurrentBarkBattleStep(
|
||
slotStatuses: Partial<
|
||
Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
|
||
>,
|
||
) {
|
||
return (
|
||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'generating') ??
|
||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'failed') ??
|
||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] !== 'ready') ??
|
||
GENERATION_STEPS[GENERATION_STEPS.length - 1]
|
||
);
|
||
}
|
||
|
||
export function BarkBattleGeneratingView({
|
||
draft,
|
||
isBusy = false,
|
||
error = null,
|
||
onBack,
|
||
onComplete,
|
||
onError,
|
||
}: BarkBattleGeneratingViewProps) {
|
||
const startedDraftIdRef = useRef<string | null>(null);
|
||
const [slotFailures, setSlotFailures] =
|
||
useState<BarkBattleImageGenerationFailures>({});
|
||
const [previewDraft, setPreviewDraft] = useState(draft);
|
||
const [slotStatuses, setSlotStatuses] = useState<
|
||
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||
>({});
|
||
const [elapsedMs, setElapsedMs] = useState(0);
|
||
const primaryFailureMessage = useMemo(
|
||
() => resolvePrimaryFailureMessage(slotFailures),
|
||
[slotFailures],
|
||
);
|
||
const progressValue = resolveBarkBattleProgressValue(slotStatuses);
|
||
const currentStep = resolveCurrentBarkBattleStep(slotStatuses);
|
||
const currentStepStatus = currentStep
|
||
? (slotStatuses[currentStep.slot] ??
|
||
(hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating'))
|
||
: 'generating';
|
||
const currentStepProgress =
|
||
currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36;
|
||
const currentStepLabel = currentStep?.label ?? '竞技素材';
|
||
const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus);
|
||
|
||
useEffect(() => {
|
||
const startedAtMs = Date.now();
|
||
const timerId = window.setInterval(() => {
|
||
setElapsedMs(Date.now() - startedAtMs);
|
||
}, 1000);
|
||
setElapsedMs(0);
|
||
|
||
return () => {
|
||
window.clearInterval(timerId);
|
||
};
|
||
}, [draft.draftId]);
|
||
|
||
useEffect(() => {
|
||
setPreviewDraft(draft);
|
||
setSlotStatuses(
|
||
GENERATION_STEPS.reduce<
|
||
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||
>((statuses, step) => {
|
||
statuses[step.slot] = hasSlotAsset(draft, step.slot)
|
||
? 'ready'
|
||
: 'generating';
|
||
return statuses;
|
||
}, {}),
|
||
);
|
||
}, [draft]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!draft.draftId ||
|
||
(() => {
|
||
const draftGenerationKey = buildDraftGenerationKey(draft);
|
||
return startedDraftIdRef.current === draftGenerationKey;
|
||
})()
|
||
) {
|
||
return;
|
||
}
|
||
const startedDraftKey = buildDraftGenerationKey(draft);
|
||
startedDraftIdRef.current = startedDraftKey;
|
||
let cancelled = false;
|
||
const generationTask = generateAllBarkBattleImageAssets({
|
||
config: draft,
|
||
draftId: draft.draftId,
|
||
onSlotComplete: (slot, result) => {
|
||
if (cancelled || startedDraftIdRef.current !== startedDraftKey) {
|
||
return;
|
||
}
|
||
if (result.status === 'fulfilled') {
|
||
setPreviewDraft((currentDraft) =>
|
||
mergeSlotAsset(currentDraft, slot, result.asset.imageSrc),
|
||
);
|
||
setSlotStatuses((current) => ({ ...current, [slot]: 'ready' }));
|
||
setSlotFailures((current) => {
|
||
const next = { ...current };
|
||
delete next[slot];
|
||
return next;
|
||
});
|
||
return;
|
||
}
|
||
setSlotStatuses((current) => ({ ...current, [slot]: 'failed' }));
|
||
setSlotFailures((current) => ({ ...current, [slot]: result.message }));
|
||
},
|
||
});
|
||
activeBarkBattleGenerationTasks.set(startedDraftKey, generationTask);
|
||
|
||
onError(null);
|
||
setSlotFailures({});
|
||
setPreviewDraft(draft);
|
||
setSlotStatuses(
|
||
GENERATION_STEPS.reduce<
|
||
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||
>((statuses, step) => {
|
||
statuses[step.slot] = hasSlotAsset(draft, step.slot)
|
||
? 'ready'
|
||
: 'generating';
|
||
return statuses;
|
||
}, {}),
|
||
);
|
||
void generationTask
|
||
.then(async ({ assets, failures }) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
setSlotFailures(failures);
|
||
const primaryMessage = resolvePrimaryFailureMessage(failures);
|
||
if (primaryMessage) {
|
||
onError(primaryMessage);
|
||
}
|
||
const generatedDraft = applyGeneratedAssets(draft, assets);
|
||
const partialFailed = GENERATION_STEPS.some(
|
||
(step) => !hasSlotAsset(generatedDraft, step.slot),
|
||
);
|
||
if (!isDraftPersistable(generatedDraft)) {
|
||
onComplete(generatedDraft, partialFailed);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const persistedDraft = await updateBarkBattleDraftConfig({
|
||
draftId: generatedDraft.draftId,
|
||
workId: generatedDraft.workId,
|
||
configVersion: generatedDraft.configVersion,
|
||
rulesetVersion: generatedDraft.rulesetVersion,
|
||
title: generatedDraft.title,
|
||
description: generatedDraft.description,
|
||
themeDescription: generatedDraft.themeDescription,
|
||
playerImageDescription: generatedDraft.playerImageDescription,
|
||
opponentImageDescription: generatedDraft.opponentImageDescription,
|
||
onomatopoeia: generatedDraft.onomatopoeia,
|
||
playerCharacterImageSrc: generatedDraft.playerCharacterImageSrc,
|
||
opponentCharacterImageSrc: generatedDraft.opponentCharacterImageSrc,
|
||
uiBackgroundImageSrc: generatedDraft.uiBackgroundImageSrc,
|
||
difficultyPreset: generatedDraft.difficultyPreset,
|
||
});
|
||
const updatedDraft = applyGeneratedAssets(persistedDraft, assets);
|
||
if (!cancelled) {
|
||
onComplete(updatedDraft, partialFailed);
|
||
}
|
||
} catch (persistError) {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
onError(
|
||
persistError instanceof Error
|
||
? persistError.message
|
||
: '汪汪声浪素材保存失败。',
|
||
);
|
||
onComplete(generatedDraft, true);
|
||
}
|
||
})
|
||
.catch((generationError) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
onError(
|
||
generationError instanceof Error
|
||
? generationError.message
|
||
: '汪汪声浪素材生成失败。',
|
||
);
|
||
onComplete(draft, true);
|
||
})
|
||
.finally(() => {
|
||
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
|
||
activeBarkBattleGenerationTasks.delete(startedDraftKey);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
// 中文注释:离开生成页后不再全局复用同一 Promise,避免悬挂生成任务导致再次进入时一直转圈。
|
||
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
|
||
activeBarkBattleGenerationTasks.delete(startedDraftKey);
|
||
}
|
||
if (startedDraftIdRef.current === startedDraftKey) {
|
||
startedDraftIdRef.current = null;
|
||
}
|
||
};
|
||
}, [draft, onComplete, onError]);
|
||
|
||
return (
|
||
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
|
||
<GenerationPageBackdrop />
|
||
<div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5">
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
disabled={isBusy}
|
||
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
|
||
>
|
||
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
|
||
<span className="break-keep">返回编辑</span>
|
||
</button>
|
||
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
|
||
生成中
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
className="relative z-10 mx-auto flex min-h-0 w-full max-w-[48rem] flex-1 flex-col overflow-y-auto overscroll-y-contain"
|
||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||
>
|
||
<section className="grid content-start gap-3 overflow-hidden px-0 pb-2 pt-0">
|
||
<GenerationProgressHero
|
||
title="汪汪声浪素材生成进度"
|
||
phaseLabel={draft.title || '未命名声浪竞技场'}
|
||
progressValue={progressValue}
|
||
estimatedWaitText="3 分钟"
|
||
elapsedText={formatGenerationDuration(elapsedMs)}
|
||
/>
|
||
|
||
<div className="mt-5 sm:mt-[-0.15rem]">
|
||
<GenerationCurrentStepCard
|
||
label={currentStepLabel}
|
||
statusLabel={currentStepStatusLabel}
|
||
progressValue={currentStepProgress}
|
||
/>
|
||
</div>
|
||
|
||
{error || primaryFailureMessage ? (
|
||
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
|
||
{error ?? primaryFailureMessage}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default BarkBattleGeneratingView;
|