Refine creation tab UX, generation flow, and bindings
Large changes across frontend, backend and docs to align creation-tab and generation-page behavior with new product UI/UX and Spacetime bindings. Updated hermes decision-log and pitfalls with concrete rules (banner carousel, font sizing, unread-dot tokens, template-card layout, direct card->entry routing, separation of account balance vs prize pools, removal of global page card shell, generation progress milestones and unified circular progress, and background video handling). Added GenerationProgressHero component and media assets, plus generation-related UI/tests updates (CustomWorldGenerationView, BarkBattleGeneratingView, creation hub/cards, platform entry routing, index tests). Backend and contract updates include new category fields in admin API types and admin UI form/list, spacetime-client/module/migration changes and generated bindings script. Misc: many tests adjusted, new docs and plan files added, and several server-rs crate changes to support the updated creation/ generation workflows.
This commit is contained in:
@@ -91,6 +91,9 @@ export type MiniGameDraftGenerationState = {
|
||||
error: string | null;
|
||||
metadata?: {
|
||||
puzzleAiRedraw?: boolean;
|
||||
puzzleActivePhaseId?: MiniGameDraftGenerationPhase;
|
||||
puzzleActiveStepStartedAtMs?: number;
|
||||
puzzleProgressPercent?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,10 +114,14 @@ type MiniGameAnchorSource = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
const PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS = 240_000;
|
||||
const PUZZLE_IMAGE_GENERATION_EXPECTED_MS = 90_000;
|
||||
const PUZZLE_COMPILE_EXPECTED_MS = 8_000;
|
||||
const PUZZLE_LEVEL_NAME_EXPECTED_MS = 10_000;
|
||||
const PUZZLE_WRITE_DRAFT_EXPECTED_MS = 10_000;
|
||||
const PUZZLE_COMPILE_MILESTONE_PROGRESS = 88;
|
||||
const PUZZLE_IMAGE_MILESTONE_PROGRESS = 94;
|
||||
const PUZZLE_UI_MILESTONE_PROGRESS = 96;
|
||||
|
||||
function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) {
|
||||
return state.metadata?.puzzleAiRedraw === false;
|
||||
@@ -160,8 +167,8 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
|
||||
steps.push({
|
||||
id: 'puzzle-cover-image',
|
||||
label: '生成拼图首图',
|
||||
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
|
||||
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,30 +211,40 @@ function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
|
||||
|
||||
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
|
||||
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
|
||||
function buildPuzzlePhaseTimeline(state: MiniGameDraftGenerationState): Array<{
|
||||
phase: Extract<
|
||||
MiniGameDraftGenerationPhase,
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'puzzle-cover-image'
|
||||
| 'puzzle-level-scene'
|
||||
| 'puzzle-ui-assets'
|
||||
| 'puzzle-select-image'
|
||||
>;
|
||||
durationMs: number;
|
||||
}> {
|
||||
return buildPuzzleTimedSteps(state).map((step) => ({
|
||||
phase: step.id as Extract<
|
||||
MiniGameDraftGenerationPhase,
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'puzzle-cover-image'
|
||||
| 'puzzle-level-scene'
|
||||
| 'puzzle-ui-assets'
|
||||
| 'puzzle-select-image'
|
||||
>,
|
||||
durationMs: step.durationMs,
|
||||
}));
|
||||
|
||||
function resolvePuzzleBackendProgressPercent(
|
||||
state: MiniGameDraftGenerationState,
|
||||
) {
|
||||
const progressPercent = state.metadata?.puzzleProgressPercent;
|
||||
if (typeof progressPercent !== 'number' || !Number.isFinite(progressPercent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progressPercent)));
|
||||
}
|
||||
|
||||
function resolvePuzzlePhaseByBackendProgress(
|
||||
state: MiniGameDraftGenerationState,
|
||||
): MiniGameDraftGenerationPhase | null {
|
||||
const progressPercent = resolvePuzzleBackendProgressPercent(state);
|
||||
if (progressPercent == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 中文注释:拼图生成页的跨步骤只跟随后端会话真实里程碑;
|
||||
// 每步内部的等待反馈仍由本地假进度补足。
|
||||
if (progressPercent >= 96) {
|
||||
return 'puzzle-select-image';
|
||||
}
|
||||
if (progressPercent >= 94) {
|
||||
return 'puzzle-ui-assets';
|
||||
}
|
||||
if (progressPercent >= 88) {
|
||||
return shouldSkipPuzzleCoverGeneration(state)
|
||||
? 'puzzle-level-scene'
|
||||
: 'puzzle-cover-image';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const BIG_FISH_STEPS = [
|
||||
@@ -484,17 +501,9 @@ function buildMiniGameProgressSteps(
|
||||
activeStepProgressRatio: number,
|
||||
) {
|
||||
return steps.map((step, index) => {
|
||||
// 中文注释:拼图草稿编译的 action 回包才代表可进入结果页;
|
||||
// 但预计写入时长已耗尽时,最后一步自身应呈现已完成,避免出现“进行中 100%”。
|
||||
const isPuzzleWriteStepCompleted =
|
||||
state.kind === 'puzzle' &&
|
||||
state.phase !== 'failed' &&
|
||||
step.id === 'puzzle-select-image' &&
|
||||
clampProgress(activeStepProgressRatio * 100) >= 100;
|
||||
const isCompleted =
|
||||
state.phase === 'ready' ||
|
||||
index < activeStepIndex ||
|
||||
isPuzzleWriteStepCompleted;
|
||||
index < activeStepIndex;
|
||||
const isActive =
|
||||
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
|
||||
@@ -636,32 +645,141 @@ function resolveWoodenFishPhaseByElapsedMs(
|
||||
return 'wooden-fish-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(
|
||||
function resolvePuzzleActiveStepProgressRatio(
|
||||
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
|
||||
activeStepIndex: number,
|
||||
elapsedMs: number,
|
||||
state: MiniGameDraftGenerationState,
|
||||
) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
for (const item of buildPuzzlePhaseTimeline(state)) {
|
||||
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
|
||||
|
||||
if (elapsedInPhase < item.durationMs) {
|
||||
return {
|
||||
phase: item.phase,
|
||||
activeStepProgressRatio: Math.max(
|
||||
0,
|
||||
Math.min(1, elapsedInPhase / item.durationMs),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
elapsedBeforePhase += item.durationMs;
|
||||
const activeStep = steps[activeStepIndex];
|
||||
if (!activeStep) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'puzzle-select-image' as const,
|
||||
activeStepProgressRatio: 1,
|
||||
};
|
||||
const elapsedBeforeActiveStep = steps
|
||||
.slice(0, activeStepIndex)
|
||||
.reduce((sum, step) => sum + step.durationMs, 0);
|
||||
const elapsedInActiveStep = Math.max(0, elapsedMs - elapsedBeforeActiveStep);
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(0.98, elapsedInActiveStep / Math.max(1, activeStep.durationMs)),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleActiveStepElapsedProgressRatio(
|
||||
state: MiniGameDraftGenerationState,
|
||||
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
|
||||
activeStepIndex: number,
|
||||
elapsedMs: number,
|
||||
effectiveNowMs: number,
|
||||
) {
|
||||
if (resolvePuzzleBackendProgressPercent(state) != null) {
|
||||
const stepStartedAtMs = state.metadata?.puzzleActiveStepStartedAtMs;
|
||||
if (
|
||||
state.metadata?.puzzleActivePhaseId === state.phase &&
|
||||
typeof stepStartedAtMs === 'number' &&
|
||||
Number.isFinite(stepStartedAtMs)
|
||||
) {
|
||||
const activeStep = steps[activeStepIndex];
|
||||
if (!activeStep) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
0.98,
|
||||
(effectiveNowMs - stepStartedAtMs) /
|
||||
Math.max(1, activeStep.durationMs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return resolvePuzzleActiveStepProgressRatio(
|
||||
steps,
|
||||
activeStepIndex,
|
||||
elapsedMs,
|
||||
);
|
||||
}
|
||||
|
||||
const activeStep = steps[activeStepIndex];
|
||||
if (!activeStep) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 中文注释:未收到后端真实里程碑时,跨步骤必须卡住;
|
||||
// 但当前步骤内的假进度要按整段等待时间继续向前走,避免短步骤几秒后停死。
|
||||
const fallbackDurationMs = Math.max(1, resolvePuzzleEstimatedWaitMs(state));
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(0.98, elapsedMs / fallbackDurationMs),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveElapsedActiveStepProgressRatio(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
elapsedMs: number,
|
||||
) {
|
||||
const estimatedWaitMs =
|
||||
kind === 'big-fish'
|
||||
? 7_000
|
||||
: kind === 'square-hole'
|
||||
? 12_000
|
||||
: kind === 'match3d'
|
||||
? MATCH3D_ESTIMATED_WAIT_MS
|
||||
: kind === 'baby-object-match'
|
||||
? BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS
|
||||
: kind === 'jump-hop'
|
||||
? JUMP_HOP_ESTIMATED_WAIT_MS
|
||||
: kind === 'wooden-fish'
|
||||
? WOODEN_FISH_ESTIMATED_WAIT_MS
|
||||
: 1;
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(0.98, elapsedMs / Math.max(1, estimatedWaitMs)),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleOverallProgress(
|
||||
state: MiniGameDraftGenerationState,
|
||||
activeStepProgressRatio: number,
|
||||
) {
|
||||
const backendProgressPercent = resolvePuzzleBackendProgressPercent(state);
|
||||
// 中文注释:88 以下的后端进度只保留为会话事实,不参与首帧总进度抬升。
|
||||
// 生成页恢复时必须先从 0% 起步,再由当前步骤内的假进度平滑推进。
|
||||
const backendProgressFloor =
|
||||
backendProgressPercent != null &&
|
||||
backendProgressPercent >= PUZZLE_COMPILE_MILESTONE_PROGRESS
|
||||
? backendProgressPercent
|
||||
: 0;
|
||||
const range =
|
||||
state.phase === 'puzzle-select-image'
|
||||
? {
|
||||
start: PUZZLE_UI_MILESTONE_PROGRESS,
|
||||
end: PUZZLE_NON_READY_MAX_PROGRESS,
|
||||
}
|
||||
: state.phase === 'puzzle-ui-assets'
|
||||
? {
|
||||
start: PUZZLE_IMAGE_MILESTONE_PROGRESS,
|
||||
end: PUZZLE_UI_MILESTONE_PROGRESS,
|
||||
}
|
||||
: state.phase === 'puzzle-cover-image' ||
|
||||
state.phase === 'puzzle-level-scene'
|
||||
? {
|
||||
start: PUZZLE_COMPILE_MILESTONE_PROGRESS,
|
||||
end: PUZZLE_IMAGE_MILESTONE_PROGRESS,
|
||||
}
|
||||
: {
|
||||
start: 0,
|
||||
end: PUZZLE_COMPILE_MILESTONE_PROGRESS,
|
||||
};
|
||||
const fakeProgress =
|
||||
range.start + (range.end - range.start) * activeStepProgressRatio;
|
||||
const nextProgress = Math.min(
|
||||
PUZZLE_NON_READY_MAX_PROGRESS,
|
||||
Math.max(range.start, backendProgressFloor, fakeProgress),
|
||||
);
|
||||
|
||||
return nextProgress;
|
||||
}
|
||||
|
||||
export function buildMiniGameDraftGenerationProgress(
|
||||
@@ -677,17 +795,17 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? state.finishedAtMs
|
||||
: nowMs;
|
||||
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
|
||||
const puzzleTimeline =
|
||||
const puzzleBackendPhase =
|
||||
state.kind === 'puzzle' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
|
||||
? resolvePuzzlePhaseByBackendProgress(state)
|
||||
: null;
|
||||
const normalizedState =
|
||||
puzzleTimeline != null
|
||||
puzzleBackendPhase != null
|
||||
? {
|
||||
...state,
|
||||
phase: puzzleTimeline.phase,
|
||||
phase: puzzleBackendPhase,
|
||||
}
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
@@ -733,47 +851,94 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
}
|
||||
: state;
|
||||
|
||||
const puzzleTimedSteps =
|
||||
normalizedState.kind === 'puzzle'
|
||||
? buildPuzzleTimedSteps(normalizedState)
|
||||
: null;
|
||||
const steps =
|
||||
normalizedState.kind === 'puzzle'
|
||||
? buildPuzzleSteps(normalizedState)
|
||||
? buildWeightedPuzzleSteps(
|
||||
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
|
||||
)
|
||||
: getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
const completedWeight = steps
|
||||
.slice(
|
||||
0,
|
||||
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
|
||||
)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const activeStep = steps[activeStepIndex] ?? steps[0];
|
||||
const assetRatio =
|
||||
normalizedState.totalAssetCount > 0
|
||||
? Math.min(
|
||||
1,
|
||||
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
|
||||
)
|
||||
: normalizedState.phase === 'ready'
|
||||
const activeStepProgressRatio =
|
||||
normalizedState.kind === 'puzzle'
|
||||
? normalizedState.phase === 'ready'
|
||||
? 1
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
|
||||
: normalizedState.phase === 'failed'
|
||||
? 0
|
||||
: resolvePuzzleActiveStepElapsedProgressRatio(
|
||||
normalizedState,
|
||||
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
|
||||
activeStepIndex,
|
||||
elapsedMs,
|
||||
effectiveNowMs,
|
||||
)
|
||||
: normalizedState.totalAssetCount > 0
|
||||
? Math.min(
|
||||
1,
|
||||
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
|
||||
)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 1
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? 0.5
|
||||
: 0;
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: 0;
|
||||
const completedWeight =
|
||||
normalizedState.kind === 'puzzle'
|
||||
? steps
|
||||
.slice(
|
||||
0,
|
||||
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
|
||||
)
|
||||
.reduce((sum, step) => sum + step.weight, 0)
|
||||
: steps
|
||||
.slice(
|
||||
0,
|
||||
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
|
||||
)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 100
|
||||
: completedWeight + activeStep.weight * assetRatio;
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? resolvePuzzleOverallProgress(
|
||||
normalizedState,
|
||||
activeStepProgressRatio,
|
||||
)
|
||||
: completedWeight + activeStep.weight * activeStepProgressRatio;
|
||||
const cappedOverallProgress =
|
||||
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
|
||||
? overallProgress
|
||||
@@ -835,7 +1000,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
steps,
|
||||
activeStepIndex,
|
||||
normalizedState,
|
||||
assetRatio,
|
||||
activeStepProgressRatio,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user