Update Match3D/image-generation docs & code

Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
2026-05-14 20:34:45 +08:00
parent d33c937ebc
commit 548db78ca7
103 changed files with 6687 additions and 3270 deletions

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
@@ -62,7 +63,7 @@ function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
let nextRun = run;
for (let index = 0; index < 12; index += 1) {
for (let index = 0; index < 24; index += 1) {
const currentLevel = nextRun.currentLevel;
if (!currentLevel || currentLevel.status === 'cleared') {
return nextRun;
@@ -574,6 +575,34 @@ describe('puzzleLocalRuntime', () => {
);
});
test('本地试玩在只有 UI 背景 objectKey 时也能继承生成图', () => {
const workWithRuntimeAssets: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background-object-key.png',
backgroundMusic: null,
generationStatus: 'ready',
} as PuzzleDraftLevel,
],
};
const run = startLocalPuzzleRun(workWithRuntimeAssets);
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background-object-key.png',
);
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(

View File

@@ -12,6 +12,7 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { resolvePuzzleUiBackgroundSource } from './puzzleUiBackgroundSource';
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
@@ -803,7 +804,10 @@ function buildFallbackLocalLevel(
const nextCoverImageSrc =
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
const nextUiBackgroundImageSrc =
nextLevel?.uiBackgroundImageSrc ?? currentLevel.uiBackgroundImageSrc;
resolvePuzzleUiBackgroundSource(nextLevel) ?? currentLevel.uiBackgroundImageSrc;
const nextUiBackgroundImageObjectKey = resolvePuzzleUiBackgroundSource(nextLevel)
? nextLevel?.uiBackgroundImageObjectKey?.trim() || null
: currentLevel.uiBackgroundImageObjectKey ?? null;
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
@@ -835,6 +839,7 @@ function buildFallbackLocalLevel(
elapsedMs: null,
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackgroundImageObjectKey,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
@@ -860,7 +865,9 @@ export function startLocalPuzzleRun(
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
const firstUiBackgroundImageSrc = firstLevel?.uiBackgroundImageSrc ?? null;
const firstUiBackgroundImageSrc = resolvePuzzleUiBackgroundSource(firstLevel);
const firstUiBackgroundImageObjectKey =
firstLevel?.uiBackgroundImageObjectKey?.trim() || null;
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
@@ -882,6 +889,7 @@ export function startLocalPuzzleRun(
themeTags: item.themeTags,
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackgroundImageObjectKey,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',

View File

@@ -0,0 +1,20 @@
type PuzzleUiBackgroundFields = {
uiBackgroundImageSrc?: string | null;
uiBackgroundImageObjectKey?: string | null;
};
export function resolvePuzzleUiBackgroundSource(
level: PuzzleUiBackgroundFields | null | undefined,
) {
const imageSrc = level?.uiBackgroundImageSrc?.trim();
if (imageSrc) {
return imageSrc;
}
const objectKey = level?.uiBackgroundImageObjectKey?.trim().replace(/^\/+/u, '');
if (!objectKey) {
return null;
}
return `/${objectKey}`;
}