595 lines
16 KiB
TypeScript
595 lines
16 KiB
TypeScript
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||
import type {
|
||
CreatePuzzleAgentSessionRequest,
|
||
PuzzleAgentSessionSnapshot,
|
||
} from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||
import type {
|
||
CreateMatch3DSessionRequest,
|
||
Match3DAgentSessionSnapshot,
|
||
} from '../../packages/shared/src/contracts/match3dAgent';
|
||
import type {
|
||
CustomWorldGenerationProgress,
|
||
CustomWorldGenerationStep,
|
||
} from '../../packages/shared/src/contracts/runtime';
|
||
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
|
||
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
|
||
|
||
export type MiniGameDraftGenerationKind =
|
||
| 'puzzle'
|
||
| 'big-fish'
|
||
| 'square-hole'
|
||
| 'match3d';
|
||
|
||
export type MiniGameDraftGenerationPhase =
|
||
| 'idle'
|
||
| 'compile'
|
||
| 'big-fish-draft'
|
||
| 'big-fish-levels'
|
||
| 'big-fish-runtime'
|
||
| 'square-hole-draft'
|
||
| 'square-hole-cover'
|
||
| 'square-hole-shapes'
|
||
| 'square-hole-ready'
|
||
| 'match3d-item-names'
|
||
| 'match3d-material-sheet'
|
||
| 'match3d-slice-images'
|
||
| 'match3d-upload-images'
|
||
| 'match3d-ready'
|
||
| 'puzzle-images'
|
||
| 'puzzle-select-image'
|
||
| 'ready'
|
||
| 'failed';
|
||
|
||
export type MiniGameDraftGenerationState = {
|
||
kind: MiniGameDraftGenerationKind;
|
||
phase: MiniGameDraftGenerationPhase;
|
||
startedAtMs: number;
|
||
completedAssetCount: number;
|
||
totalAssetCount: number;
|
||
error: string | null;
|
||
};
|
||
|
||
type MiniGameStepDefinition = {
|
||
id: MiniGameDraftGenerationPhase;
|
||
label: string;
|
||
detail: string;
|
||
weight: number;
|
||
};
|
||
|
||
type MiniGameAnchorSource = {
|
||
key: string;
|
||
label: string;
|
||
value: string;
|
||
};
|
||
|
||
const PUZZLE_STEPS = [
|
||
{
|
||
id: 'compile',
|
||
label: '编译首关草稿',
|
||
detail: '理解画面描述,生成首关名称与可编辑草稿。',
|
||
weight: 20,
|
||
},
|
||
{
|
||
id: 'puzzle-images',
|
||
label: '生成首关画面',
|
||
detail: '调用图片模型生成适合切块的正方形首图。',
|
||
weight: 70,
|
||
},
|
||
{
|
||
id: 'puzzle-select-image',
|
||
label: '写入正式草稿',
|
||
detail: '确认首图并同步关卡数据,准备进入结果页。',
|
||
weight: 10,
|
||
},
|
||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||
|
||
const PUZZLE_ESTIMATED_WAIT_MS = 60_000;
|
||
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
|
||
const PUZZLE_PHASE_TIMELINE: Array<{
|
||
phase: Extract<
|
||
MiniGameDraftGenerationPhase,
|
||
'compile' | 'puzzle-images' | 'puzzle-select-image'
|
||
>;
|
||
durationMs: number;
|
||
}> = [
|
||
{ phase: 'compile', durationMs: 12_000 },
|
||
{ phase: 'puzzle-images', durationMs: 42_000 },
|
||
{ phase: 'puzzle-select-image', durationMs: 6_000 },
|
||
];
|
||
|
||
const BIG_FISH_STEPS = [
|
||
{
|
||
id: 'big-fish-draft',
|
||
label: '整理玩法骨架',
|
||
detail: '收拢玩法承诺、成长阶梯与风险节奏。',
|
||
weight: 30,
|
||
},
|
||
{
|
||
id: 'big-fish-levels',
|
||
label: '编译等级蓝图',
|
||
detail: '生成每级角色描述、形象描述与动作描述。',
|
||
weight: 45,
|
||
},
|
||
{
|
||
id: 'big-fish-runtime',
|
||
label: '校准场地与参数',
|
||
detail: '整理背景蓝图与运行参数,准备结果页。',
|
||
weight: 25,
|
||
},
|
||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||
|
||
const SQUARE_HOLE_STEPS = [
|
||
{
|
||
id: 'square-hole-draft',
|
||
label: '整理玩法草稿',
|
||
detail: '收拢题材、展示选项与洞口选项。',
|
||
weight: 28,
|
||
},
|
||
{
|
||
id: 'square-hole-cover',
|
||
label: '生成封面与背景',
|
||
detail: '生成作品封面和运行背景。',
|
||
weight: 32,
|
||
},
|
||
{
|
||
id: 'square-hole-shapes',
|
||
label: '生成选项贴图',
|
||
detail: '为展示选项与洞口选项生成贴图。',
|
||
weight: 40,
|
||
},
|
||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||
|
||
const MATCH3D_STEPS = [
|
||
{
|
||
id: 'match3d-item-names',
|
||
label: '生成物品名称',
|
||
detail: '根据题材生成本局的 3 个物品名称。',
|
||
weight: 16,
|
||
},
|
||
{
|
||
id: 'match3d-material-sheet',
|
||
label: '生成素材图',
|
||
detail: '生成一张 1:1 的网格素材图。',
|
||
weight: 30,
|
||
},
|
||
{
|
||
id: 'match3d-slice-images',
|
||
label: '切割独立图片',
|
||
detail: '把素材图切成独立物品参考图。',
|
||
weight: 14,
|
||
},
|
||
{
|
||
id: 'match3d-upload-images',
|
||
label: '上传图片资产',
|
||
detail: '写入切割图片并准备进入草稿页。',
|
||
weight: 40,
|
||
},
|
||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||
|
||
function clampProgress(value: number) {
|
||
return Math.max(0, Math.min(100, Math.round(value)));
|
||
}
|
||
|
||
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||
if (kind === 'puzzle') {
|
||
return PUZZLE_STEPS;
|
||
}
|
||
if (kind === 'square-hole') {
|
||
return SQUARE_HOLE_STEPS;
|
||
}
|
||
if (kind === 'match3d') {
|
||
return MATCH3D_STEPS;
|
||
}
|
||
return BIG_FISH_STEPS;
|
||
}
|
||
|
||
function getActiveStepIndex(
|
||
steps: ReadonlyArray<MiniGameStepDefinition>,
|
||
phase: MiniGameDraftGenerationPhase,
|
||
) {
|
||
if (phase === 'ready') {
|
||
return steps.length - 1;
|
||
}
|
||
const index = steps.findIndex((step) => step.id === phase);
|
||
return index >= 0 ? index : 0;
|
||
}
|
||
|
||
function buildMiniGameProgressSteps(
|
||
steps: ReadonlyArray<MiniGameStepDefinition>,
|
||
activeStepIndex: number,
|
||
state: MiniGameDraftGenerationState,
|
||
activeStepProgressRatio: number,
|
||
) {
|
||
return steps.map((step, index) => {
|
||
const isCompleted = state.phase === 'ready' || index < activeStepIndex;
|
||
const isActive =
|
||
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
|
||
|
||
return {
|
||
id: step.id,
|
||
label: step.label,
|
||
detail: step.detail,
|
||
completed: isCompleted
|
||
? 1
|
||
: isAssetStep
|
||
? state.completedAssetCount
|
||
: isActive
|
||
? activeStepProgressRatio
|
||
: 0,
|
||
total: isAssetStep ? state.totalAssetCount : 1,
|
||
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
|
||
} satisfies CustomWorldGenerationStep;
|
||
});
|
||
}
|
||
|
||
export function createMiniGameDraftGenerationState(
|
||
kind: MiniGameDraftGenerationKind,
|
||
): MiniGameDraftGenerationState {
|
||
return {
|
||
kind,
|
||
phase:
|
||
kind === 'big-fish'
|
||
? 'big-fish-draft'
|
||
: kind === 'square-hole'
|
||
? 'square-hole-draft'
|
||
: kind === 'match3d'
|
||
? 'match3d-item-names'
|
||
: 'compile',
|
||
startedAtMs: Date.now(),
|
||
completedAssetCount: 0,
|
||
totalAssetCount: 0,
|
||
error: null,
|
||
};
|
||
}
|
||
|
||
function resolveBigFishPhaseByElapsedMs(
|
||
elapsedMs: number,
|
||
): MiniGameDraftGenerationPhase {
|
||
if (elapsedMs >= 4_500) {
|
||
return 'big-fish-runtime';
|
||
}
|
||
if (elapsedMs >= 1_800) {
|
||
return 'big-fish-levels';
|
||
}
|
||
return 'big-fish-draft';
|
||
}
|
||
|
||
function resolveSquareHolePhaseByElapsedMs(
|
||
elapsedMs: number,
|
||
): MiniGameDraftGenerationPhase {
|
||
if (elapsedMs >= 6_500) {
|
||
return 'square-hole-shapes';
|
||
}
|
||
if (elapsedMs >= 2_400) {
|
||
return 'square-hole-cover';
|
||
}
|
||
return 'square-hole-draft';
|
||
}
|
||
|
||
function resolveMatch3DPhaseByElapsedMs(
|
||
elapsedMs: number,
|
||
): MiniGameDraftGenerationPhase {
|
||
if (elapsedMs >= 72_000) {
|
||
return 'match3d-upload-images';
|
||
}
|
||
if (elapsedMs >= 58_000) {
|
||
return 'match3d-slice-images';
|
||
}
|
||
if (elapsedMs >= 16_000) {
|
||
return 'match3d-material-sheet';
|
||
}
|
||
return 'match3d-item-names';
|
||
}
|
||
|
||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||
let elapsedBeforePhase = 0;
|
||
|
||
for (const item of PUZZLE_PHASE_TIMELINE) {
|
||
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;
|
||
}
|
||
|
||
return {
|
||
phase: 'puzzle-select-image' as const,
|
||
activeStepProgressRatio: 1,
|
||
};
|
||
}
|
||
|
||
export function buildMiniGameDraftGenerationProgress(
|
||
state: MiniGameDraftGenerationState | null,
|
||
nowMs = Date.now(),
|
||
): CustomWorldGenerationProgress | null {
|
||
if (!state) {
|
||
return null;
|
||
}
|
||
|
||
const elapsedMs = Math.max(0, nowMs - state.startedAtMs);
|
||
const puzzleTimeline =
|
||
state.kind === 'puzzle' &&
|
||
state.phase !== 'failed' &&
|
||
state.phase !== 'ready'
|
||
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
|
||
: null;
|
||
const normalizedState =
|
||
puzzleTimeline != null
|
||
? {
|
||
...state,
|
||
phase: puzzleTimeline.phase,
|
||
}
|
||
: state.kind === 'big-fish' &&
|
||
state.phase !== 'failed' &&
|
||
state.phase !== 'ready'
|
||
? {
|
||
...state,
|
||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||
}
|
||
: state.kind === 'square-hole' &&
|
||
state.phase !== 'failed' &&
|
||
state.phase !== 'ready'
|
||
? {
|
||
...state,
|
||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||
}
|
||
: state.kind === 'match3d' &&
|
||
state.phase !== 'failed' &&
|
||
state.phase !== 'ready'
|
||
? {
|
||
...state,
|
||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs),
|
||
}
|
||
: state;
|
||
|
||
const steps = 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'
|
||
? 1
|
||
: normalizedState.kind === 'puzzle'
|
||
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
|
||
: normalizedState.kind === 'big-fish'
|
||
? 0.55
|
||
: normalizedState.kind === 'square-hole'
|
||
? 0.42
|
||
: normalizedState.kind === 'match3d'
|
||
? 0.5
|
||
: 0;
|
||
const overallProgress =
|
||
normalizedState.phase === 'failed'
|
||
? Math.max(1, completedWeight)
|
||
: normalizedState.phase === 'ready'
|
||
? 100
|
||
: completedWeight + activeStep.weight * assetRatio;
|
||
const cappedOverallProgress =
|
||
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
|
||
? overallProgress
|
||
: normalizedState.kind === 'puzzle'
|
||
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
|
||
: overallProgress;
|
||
|
||
return {
|
||
phaseId: normalizedState.phase,
|
||
phaseLabel:
|
||
normalizedState.phase === 'failed'
|
||
? '生成失败'
|
||
: normalizedState.phase === 'ready'
|
||
? '生成完成'
|
||
: activeStep.label,
|
||
phaseDetail:
|
||
normalizedState.error ??
|
||
(normalizedState.phase === 'ready'
|
||
? normalizedState.kind === 'big-fish'
|
||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||
: normalizedState.kind === 'match3d'
|
||
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
|
||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||
: activeStep.detail),
|
||
batchLabel: activeStep.label,
|
||
overallProgress: clampProgress(cappedOverallProgress),
|
||
completedWeight: clampProgress(cappedOverallProgress),
|
||
totalWeight: 100,
|
||
elapsedMs,
|
||
estimatedRemainingMs:
|
||
normalizedState.phase === 'ready'
|
||
? 0
|
||
: normalizedState.kind === 'puzzle'
|
||
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
|
||
: normalizedState.kind === 'big-fish'
|
||
? Math.max(0, 7_000 - elapsedMs)
|
||
: normalizedState.kind === 'square-hole'
|
||
? Math.max(0, 12_000 - elapsedMs)
|
||
: normalizedState.kind === 'match3d'
|
||
? Math.max(0, 120_000 - elapsedMs)
|
||
: null,
|
||
activeStepIndex,
|
||
steps: buildMiniGameProgressSteps(
|
||
steps,
|
||
activeStepIndex,
|
||
normalizedState,
|
||
assetRatio,
|
||
),
|
||
};
|
||
}
|
||
|
||
export function buildPuzzleGenerationAnchorEntries(
|
||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
|
||
): CustomWorldStructuredAnchorEntry[] {
|
||
if (!session) {
|
||
return [];
|
||
}
|
||
|
||
const entries: Array<MiniGameAnchorSource | null> = [
|
||
{
|
||
key: 'picture-description',
|
||
label: '画面描述',
|
||
value:
|
||
formPayload?.pictureDescription?.trim() ||
|
||
formPayload?.seedText?.trim() ||
|
||
session.draft?.levels?.[0]?.pictureDescription ||
|
||
session.anchorPack.visualSubject.value,
|
||
},
|
||
];
|
||
|
||
return entries
|
||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||
.map((entry) => ({
|
||
id: entry.key,
|
||
label: entry.label,
|
||
value: entry.value,
|
||
}))
|
||
.filter((entry) => entry.value.trim());
|
||
}
|
||
|
||
export function buildBigFishGenerationAnchorEntries(
|
||
session: BigFishSessionSnapshotResponse | null | undefined,
|
||
): CustomWorldStructuredAnchorEntry[] {
|
||
if (!session) {
|
||
return [];
|
||
}
|
||
|
||
const draft = session.draft;
|
||
const assetReadyCount = session.assetSlots.filter(
|
||
(slot) => slot.status === 'ready',
|
||
).length;
|
||
|
||
const entries: Array<MiniGameAnchorSource | null> = [
|
||
session.anchorPack.gameplayPromise,
|
||
session.anchorPack.ecologyVisualTheme,
|
||
session.anchorPack.growthLadder,
|
||
session.anchorPack.riskTempo,
|
||
draft
|
||
? {
|
||
key: 'level-characters',
|
||
label: '角色描述',
|
||
value: draft.levels
|
||
.map(
|
||
(level) =>
|
||
`Lv.${level.level} ${level.name}:${level.oneLineFantasy}`,
|
||
)
|
||
.join('\n'),
|
||
}
|
||
: null,
|
||
draft
|
||
? {
|
||
key: 'asset-coverage',
|
||
label: '图片与动作',
|
||
value: `已生成 ${assetReadyCount}/${session.assetSlots.length} 个资产`,
|
||
}
|
||
: null,
|
||
];
|
||
|
||
return entries
|
||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||
.map((entry) => ({
|
||
id: entry.key,
|
||
label: entry.label,
|
||
value: entry.value,
|
||
}))
|
||
.filter((entry) => entry.value.trim());
|
||
}
|
||
|
||
export function buildMatch3DGenerationAnchorEntries(
|
||
session: Match3DAgentSessionSnapshot | null | undefined,
|
||
formPayload: CreateMatch3DSessionRequest | null | undefined = null,
|
||
): CustomWorldStructuredAnchorEntry[] {
|
||
if (!session && !formPayload) {
|
||
return [];
|
||
}
|
||
|
||
const config = session?.config;
|
||
const itemCount = 3;
|
||
const entries: Array<MiniGameAnchorSource | null> = [
|
||
{
|
||
key: 'match3d-theme',
|
||
label: '题材',
|
||
value:
|
||
formPayload?.themeText?.trim() ||
|
||
config?.themeText?.trim() ||
|
||
session?.anchorPack.theme.value ||
|
||
'',
|
||
},
|
||
{
|
||
key: 'match3d-items',
|
||
label: '物品数量',
|
||
value: `${itemCount} 件`,
|
||
},
|
||
];
|
||
|
||
return entries
|
||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||
.map((entry) => ({
|
||
id: entry.key,
|
||
label: entry.label,
|
||
value: entry.value,
|
||
}))
|
||
.filter((entry) => entry.value.trim());
|
||
}
|
||
|
||
export function buildSquareHoleGenerationAnchorEntries(
|
||
session: SquareHoleSessionSnapshot | null | undefined,
|
||
): CustomWorldStructuredAnchorEntry[] {
|
||
if (!session) {
|
||
return [];
|
||
}
|
||
|
||
const draft = session.draft;
|
||
const shapeCount =
|
||
draft?.shapeOptions.filter((option) => option.imageSrc?.trim()).length ??
|
||
session.config.shapeOptions.filter((option) => option.imageSrc?.trim())
|
||
.length;
|
||
const totalShapeCount =
|
||
draft?.shapeOptions.length || session.config.shapeOptions.length;
|
||
|
||
const entries: Array<MiniGameAnchorSource | null> = [
|
||
{
|
||
key: 'square-hole-title',
|
||
label: '作品名称',
|
||
value: draft?.gameName || `${session.config.themeText}方洞挑战`,
|
||
},
|
||
{
|
||
key: 'square-hole-theme',
|
||
label: '题材与规则',
|
||
value: `${session.config.themeText}|${session.config.twistRule}`,
|
||
},
|
||
{
|
||
key: 'square-hole-options',
|
||
label: '选项资产',
|
||
value:
|
||
totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '',
|
||
},
|
||
];
|
||
|
||
return entries
|
||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||
.map((entry) => ({
|
||
id: entry.key,
|
||
label: entry.label,
|
||
value: entry.value,
|
||
}))
|
||
.filter((entry) => entry.value.trim());
|
||
}
|