Merge remote-tracking branch 'origin/master' into codex/wooden-fish-template

This commit is contained in:
2026-05-22 04:00:52 +08:00
121 changed files with 10876 additions and 3477 deletions

View File

@@ -54,6 +54,9 @@ export type MiniGameDraftGenerationPhase =
| 'match3d-upload-images'
| 'match3d-generate-views'
| 'match3d-background-image'
| 'match3d-level-scene'
| 'match3d-derived-assets'
| 'match3d-parse-spritesheet'
| 'match3d-write-draft'
| 'match3d-ready'
| 'baby-object-draft'
@@ -69,6 +72,9 @@ export type MiniGameDraftGenerationPhase =
| 'wooden-fish-background'
| 'wooden-fish-hit-sound'
| 'wooden-fish-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-images'
| 'puzzle-ui-background'
| 'puzzle-select-image'
@@ -83,6 +89,9 @@ export type MiniGameDraftGenerationState = {
completedAssetCount: number;
totalAssetCount: number;
error: string | null;
metadata?: {
puzzleAiRedraw?: boolean;
};
};
type MiniGameStepDefinition = {
@@ -92,65 +101,134 @@ type MiniGameStepDefinition = {
weight: number;
};
type TimedMiniGameStepDefinition = Omit<MiniGameStepDefinition, 'weight'> & {
durationMs: number;
};
type MiniGameAnchorSource = {
key: string;
label: string;
value: string;
};
const PUZZLE_STEPS = [
{
id: 'compile',
label: '编译首关草稿',
detail: '读取画面描述,建立可编辑草稿与首关结构。',
weight: 10,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据画面描述和图像语义整理首关题目。',
weight: 8,
},
{
id: 'puzzle-images',
label: '并行生成素材',
detail: '同时生成首关画面与 9:16 纯背景。',
weight: 74,
},
{
id: 'puzzle-ui-background',
label: '校验背景资源',
detail: '确认首关图和 UI 背景都已写入资产库。',
weight: 0,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '写入首图、UI背景和首关数据。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
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;
function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) {
return state.metadata?.puzzleAiRedraw === false;
}
function buildWeightedPuzzleSteps(
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
) {
const totalDuration = steps.reduce((sum, step) => sum + step.durationMs, 0);
let usedWeight = 0;
return steps.map((step, index) => {
const weight =
index === steps.length - 1
? Math.max(1, 100 - usedWeight)
: Math.max(1, Math.round((step.durationMs / totalDuration) * 100));
usedWeight += weight;
return {
id: step.id,
label: step.label,
detail: step.detail,
weight,
} satisfies MiniGameStepDefinition;
});
}
function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
const steps: TimedMiniGameStepDefinition[] = [
{
id: 'compile',
label: '编译首关草稿',
detail: '建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
durationMs: PUZZLE_COMPILE_EXPECTED_MS,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据描述生成关卡名、作品描述和标签,约 10 秒。',
durationMs: PUZZLE_LEVEL_NAME_EXPECTED_MS,
},
];
if (!shouldSkipPuzzleCoverGeneration(state)) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
});
}
steps.push(
{
id: 'puzzle-level-scene',
label: '生成关卡画面',
detail: shouldSkipPuzzleCoverGeneration(state)
? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。'
: '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-ui-assets',
label: '生成UI与背景',
detail:
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '校验资产并写入正式首关、作品摘要和草稿投影,约 10 秒。',
durationMs: PUZZLE_WRITE_DRAFT_EXPECTED_MS,
},
);
return steps;
}
function buildPuzzleSteps(state: MiniGameDraftGenerationState) {
return buildWeightedPuzzleSteps(buildPuzzleTimedSteps(state));
}
function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
return buildPuzzleTimedSteps(state).reduce(
(sum, step) => sum + step.durationMs,
0,
);
}
const PUZZLE_ESTIMATED_WAIT_MS = 5 * 60_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
const PUZZLE_PHASE_TIMELINE: Array<{
function buildPuzzlePhaseTimeline(state: MiniGameDraftGenerationState): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-images'
| 'puzzle-ui-background'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>;
durationMs: number;
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 260_000 },
{ phase: 'puzzle-ui-background', durationMs: 10_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];
}> {
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,
}));
}
const BIG_FISH_STEPS = [
{
@@ -208,65 +286,69 @@ const MATCH3D_STEPS = [
weight: 10,
},
{
id: 'match3d-background-prompt',
label: '生成背景提示词',
detail: '整理纯背景图与容器 UI 图提示词。',
weight: 6,
id: 'match3d-level-scene',
label: '生成关卡整图',
detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。',
weight: 28,
},
{
id: 'match3d-material-sheet',
label: '分批生成素材图',
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
weight: 24,
id: 'match3d-derived-assets',
label: '生成三张派生图',
detail: '以关卡整图为参考,并发生成 UI、背景和 10x10 物品 Sprite。',
weight: 34,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成每个物品的五个视角。',
weight: 12,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '上传每个物品的 2D 五视角素材。',
weight: 14,
},
{
id: 'match3d-generate-views',
label: '校验素材结构',
detail: '确认物品顺序和五视角图片。',
weight: 8,
},
{
id: 'match3d-background-image',
label: '生成UI背景',
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
weight: 16,
id: 'match3d-parse-spritesheet',
label: '解析物品Sprite',
detail: '解析 20 个物品和每个物品的 5 个形态,并上传透明 PNG。',
weight: 18,
},
{
id: 'match3d-write-draft',
label: '写入草稿页',
detail: '保存素材、背景、容器和作品草稿。',
detail: '保存关卡整图、派生图集、20 种物品素材和作品草稿。',
weight: 2,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_ESTIMATED_WAIT_MS = 510_000;
const MATCH3D_ESTIMATED_WAIT_MS = 460_000;
const MATCH3D_PHASE_ORDER: Partial<
Record<MiniGameDraftGenerationPhase, number>
> = {
'match3d-work-title': 0,
'match3d-item-names': 1,
'match3d-background-prompt': 2,
'match3d-level-scene': 2,
'match3d-derived-assets': 3,
'match3d-parse-spritesheet': 4,
'match3d-write-draft': 5,
// 中文注释:旧生成页阶段在恢复生成中草稿时归并到新流程对应阶段。
'match3d-background-prompt': 1,
'match3d-material-sheet': 3,
'match3d-slice-images': 4,
'match3d-upload-images': 5,
'match3d-generate-views': 6,
'match3d-background-image': 7,
'match3d-write-draft': 8,
'match3d-upload-images': 4,
'match3d-generate-views': 4,
'match3d-background-image': 3,
};
function normalizeMatch3DGenerationPhase(
phase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
switch (phase) {
case 'match3d-background-prompt':
return 'match3d-item-names';
case 'match3d-material-sheet':
case 'match3d-background-image':
return 'match3d-derived-assets';
case 'match3d-slice-images':
case 'match3d-upload-images':
case 'match3d-generate-views':
return 'match3d-parse-spritesheet';
default:
return phase;
}
}
const BABY_OBJECT_MATCH_STEPS = [
{
id: 'baby-object-draft',
@@ -364,7 +446,7 @@ function clampProgress(value: number) {
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'puzzle') {
return PUZZLE_STEPS;
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
}
if (kind === 'square-hole') {
return SQUARE_HOLE_STEPS;
@@ -479,26 +561,21 @@ function resolveMatch3DPhaseByElapsedMs(
currentPhase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
const elapsedPhase =
elapsedMs >= 492_000
elapsedMs >= 450_000
? 'match3d-write-draft'
: elapsedMs >= 370_000
? 'match3d-background-image'
: elapsedMs >= 340_000
? 'match3d-generate-views'
: elapsedMs >= 260_000
? 'match3d-upload-images'
: elapsedMs >= 210_000
? 'match3d-slice-images'
: elapsedMs >= 28_000
? 'match3d-material-sheet'
: elapsedMs >= 12_000
? 'match3d-background-prompt'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
: elapsedMs >= 360_000
? 'match3d-parse-spritesheet'
: elapsedMs >= 118_000
? 'match3d-derived-assets'
: elapsedMs >= 28_000
? 'match3d-level-scene'
: elapsedMs >= 8_000
? 'match3d-item-names'
: 'match3d-work-title';
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
const normalizedCurrentPhase = normalizeMatch3DGenerationPhase(currentPhase);
const currentOrder = MATCH3D_PHASE_ORDER[normalizedCurrentPhase] ?? -1;
return currentOrder > elapsedOrder ? normalizedCurrentPhase : elapsedPhase;
}
function resolveBabyObjectMatchPhaseByElapsedMs(
@@ -549,10 +626,13 @@ function resolveWoodenFishPhaseByElapsedMs(
return 'wooden-fish-draft';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
function resolvePuzzleTimelineByElapsedMs(
elapsedMs: number,
state: MiniGameDraftGenerationState,
) {
let elapsedBeforePhase = 0;
for (const item of PUZZLE_PHASE_TIMELINE) {
for (const item of buildPuzzlePhaseTimeline(state)) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
@@ -591,7 +671,7 @@ export function buildMiniGameDraftGenerationProgress(
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
: null;
const normalizedState =
puzzleTimeline != null
@@ -643,7 +723,10 @@ export function buildMiniGameDraftGenerationProgress(
}
: state;
const steps = getStepDefinitions(normalizedState.kind);
const steps =
normalizedState.kind === 'puzzle'
? buildPuzzleSteps(normalizedState)
: getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const completedWeight = steps
.slice(
@@ -720,7 +803,7 @@ export function buildMiniGameDraftGenerationProgress(
normalizedState.phase === 'ready'
? 0
: normalizedState.kind === 'puzzle'
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
? Math.max(0, resolvePuzzleEstimatedWaitMs(normalizedState) - elapsedMs)
: normalizedState.kind === 'big-fish'
? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
@@ -944,9 +1027,6 @@ export function buildMatch3DGenerationAnchorEntries(
}
const config = session?.config;
const clearCount = formPayload?.clearCount ?? config?.clearCount ?? null;
const difficulty = formPayload?.difficulty ?? config?.difficulty ?? null;
const itemCount = resolveMatch3DGeneratedItemCount(clearCount, difficulty);
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'match3d-theme',
@@ -959,8 +1039,8 @@ export function buildMatch3DGenerationAnchorEntries(
},
{
key: 'match3d-items',
label: '物品数量',
value: `${itemCount} `,
label: '素材数量',
value: `${resolveMatch3DGeneratedItemCount()} 种素材`,
},
];
@@ -975,22 +1055,10 @@ export function buildMatch3DGenerationAnchorEntries(
}
function resolveMatch3DGeneratedItemCount(
clearCount: number | null | undefined,
difficulty: number | null | undefined,
_clearCount: number | null | undefined = null,
_difficulty: number | null | undefined = null,
) {
const roundToSheet = (count: number) => Math.ceil(count / 5) * 5;
if (clearCount === 8) return roundToSheet(3);
if (clearCount === 12) return roundToSheet(9);
if (clearCount === 16) return roundToSheet(15);
if (clearCount === 20 || clearCount === 21) return roundToSheet(21);
const normalizedDifficulty =
typeof difficulty === 'number' && Number.isFinite(difficulty)
? Math.max(1, Math.min(10, Math.round(difficulty)))
: 4;
if (normalizedDifficulty <= 2) return roundToSheet(3);
if (normalizedDifficulty <= 4) return roundToSheet(9);
if (normalizedDifficulty <= 6) return roundToSheet(15);
return roundToSheet(21);
return 20;
}
export function buildBabyObjectMatchGenerationAnchorEntries(