Switch to VectorEngine gpt-image-2 and edits

Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
2026-05-22 03:06:41 +08:00
parent 321e1ea33a
commit ae014ac881
90 changed files with 7078 additions and 3389 deletions

View File

@@ -13,7 +13,7 @@ import {
const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_LOCAL_DURATION_MS = 600_000;
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
const MATCH3D_MAX_ITEM_TYPE_COUNT = 20;
const MATCH3D_ITEMS_PER_CLEAR = 3;
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
const MATCH3D_LOCAL_BOARD_CENTER = 0.5;
@@ -50,7 +50,7 @@ const MATCH3D_SIZE_TIER_RULES: Array<{
];
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
// 中文注释:默认 25使用参考图中的积木件,形状、尺寸和颜色都要能区分
// 中文注释:默认 20对齐 10*10 物品 Sprite可由生成素材替换显示
{
itemTypeId: 'block-red-2x4',
visualKey: 'block-red-2x4',
@@ -99,12 +99,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-amber-100 to-yellow-600',
label: '米色二乘三',
},
{
itemTypeId: 'block-lime-1x2',
visualKey: 'block-lime-1x2',
colorClassName: 'from-lime-300 to-lime-700',
label: '青柠一乘二',
},
{
itemTypeId: 'block-darkred-2x2',
visualKey: 'block-darkred-2x2',
@@ -141,18 +135,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-teal-300 to-teal-700',
label: '青色长光板',
},
{
itemTypeId: 'block-mint-tile-1x4',
visualKey: 'block-mint-tile-1x4',
colorClassName: 'from-emerald-100 to-emerald-400',
label: '薄荷长光板',
},
{
itemTypeId: 'block-magenta-tile-2x2',
visualKey: 'block-magenta-tile-2x2',
colorClassName: 'from-fuchsia-500 to-pink-800',
label: '洋红光板',
},
{
itemTypeId: 'block-orange-tile-2x2-stud',
visualKey: 'block-orange-tile-2x2-stud',
@@ -165,18 +147,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-violet-400 to-violet-900',
label: '紫色斜坡',
},
{
itemTypeId: 'block-brown-slope-1x2',
visualKey: 'block-brown-slope-1x2',
colorClassName: 'from-orange-900 to-stone-700',
label: '棕色斜坡',
},
{
itemTypeId: 'block-sky-slope-2x2',
visualKey: 'block-sky-slope-2x2',
colorClassName: 'from-sky-300 to-sky-600',
label: '天蓝斜坡',
},
{
itemTypeId: 'block-green-cylinder',
visualKey: 'block-green-cylinder',
@@ -236,13 +206,14 @@ export function resolveLocalMatch3DItemTypeCount(clearCount: number) {
if (normalizedClearCount === 8) return 3;
if (normalizedClearCount === 12) return 9;
if (normalizedClearCount === 16) return 15;
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 21;
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 20;
return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount);
}
export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) {
const normalizedClearCount = Math.max(1, Math.round(clearCount));
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩按新硬核 21 组三消执行。
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩保留硬核 21 组三消节奏,
// 但物品类型池最多加载 20 种,避免超过 10*10 Sprite 解析素材上限。
return normalizedClearCount === 20 ? 21 : normalizedClearCount;
}

View File

@@ -270,7 +270,7 @@ describe('match3dGeneratedModelCache', () => {
);
});
test('运行态预加载同时解析背景和容器 UI 资产', async () => {
test('运行态预加载同时解析背景和spritesheet资产', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
@@ -309,6 +309,14 @@ describe('match3dGeneratedModelCache', () => {
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/task/background.png',
uiSpritesheetPrompt: '果园 UI',
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
itemSpritesheetPrompt: '果园物品',
itemSpritesheetImageSrc: null,
itemSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
containerPrompt: '果园浅盘',
containerImageSrc: null,
containerImageObjectKey:
@@ -321,13 +329,15 @@ describe('match3dGeneratedModelCache', () => {
expect(getMatch3DGeneratedRuntimeUiAssetSources(assets)).toEqual([
'generated-match3d-assets/session/profile/background/task/background.png',
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
'generated-match3d-assets/session/profile/ui-container/task/container.png',
]);
await preloadMatch3DGeneratedRuntimeAssets(assets, null, {
expireSeconds: 300,
});
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
expect(globalThis.fetch).toHaveBeenCalledTimes(5);
expect(
vi
.mocked(globalThis.fetch)
@@ -336,6 +346,8 @@ describe('match3dGeneratedModelCache', () => {
expect.arrayContaining([
expect.stringContaining('/items/item-1/views/view-01.png'),
expect.stringContaining('/background/task/background.png'),
expect.stringContaining('/ui-spritesheet/task/ui.png'),
expect.stringContaining('/item-spritesheet/task/items.png'),
expect.stringContaining('/ui-container/task/container.png'),
]),
);

View File

@@ -129,11 +129,19 @@ export function getMatch3DGeneratedRuntimeUiAssetSources(
[
backgroundAsset?.imageObjectKey,
backgroundAsset?.imageSrc,
backgroundAsset?.uiSpritesheetImageObjectKey,
backgroundAsset?.uiSpritesheetImageSrc,
backgroundAsset?.itemSpritesheetImageObjectKey,
backgroundAsset?.itemSpritesheetImageSrc,
backgroundAsset?.containerImageObjectKey,
backgroundAsset?.containerImageSrc,
...assets.flatMap((asset) => [
asset.backgroundAsset?.imageObjectKey,
asset.backgroundAsset?.imageSrc,
asset.backgroundAsset?.uiSpritesheetImageObjectKey,
asset.backgroundAsset?.uiSpritesheetImageSrc,
asset.backgroundAsset?.itemSpritesheetImageObjectKey,
asset.backgroundAsset?.itemSpritesheetImageSrc,
asset.backgroundAsset?.containerImageObjectKey,
asset.backgroundAsset?.containerImageSrc,
]),

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from 'vitest';
import {
buildMatch3DItemSpritesheetViewRegions,
detectMatch3DSpritesheetRegions,
} from './match3dSpritesheetParser';
describe('match3dSpritesheetParser', () => {
test('按透明像素连通域检测素材并按从上到下从左到右排序', () => {
const width = 12;
const height = 10;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(8, 1, 10, 3);
paint(1, 1, 3, 2);
paint(5, 6, 7, 8);
const regions = detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels: ['返回', '设置', '移出'],
});
expect(regions).toEqual([
{ height: 2, label: '返回', width: 3, x: 1, y: 1 },
{ height: 3, label: '设置', width: 3, x: 8, y: 1 },
{ height: 3, label: '移出', width: 3, x: 5, y: 6 },
]);
});
test('忽略小噪点,只返回可用矩形素材', () => {
const width = 8;
const height = 8;
const alpha = new Uint8ClampedArray(width * height);
alpha[0] = 255;
for (let y = 2; y <= 5; y += 1) {
for (let x = 2; x <= 5; x += 1) {
alpha[y * width + x] = 255;
}
}
const regions = detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels: ['方格'],
minArea: 4,
});
expect(regions).toEqual([
{ height: 4, label: '方格', width: 4, x: 2, y: 2 },
]);
});
test('按10行10列图集顺序把每行两种物品拆成五视角', () => {
const regions = Array.from({ length: 100 }, (_, index) => ({
label: `素材${index + 1}`,
x: (index % 10) * 10,
y: Math.floor(index / 10) * 10,
width: 8,
height: 8,
}));
const grouped = buildMatch3DItemSpritesheetViewRegions(regions, [
'草莓',
'苹果',
'毛肚',
]);
expect(grouped).toHaveLength(20);
expect(grouped[0]).toEqual({
itemIndex: 0,
itemName: '草莓',
regions: regions.slice(0, 5).map((region, index) => ({
...region,
label: `草莓-形态${index + 1}`,
})),
});
expect(grouped[1]).toEqual({
itemIndex: 1,
itemName: '苹果',
regions: regions.slice(5, 10).map((region, index) => ({
...region,
label: `苹果-形态${index + 1}`,
})),
});
expect(grouped[2]?.itemName).toBe('毛肚');
expect(grouped[19]?.regions).toHaveLength(5);
});
});

View File

@@ -0,0 +1,352 @@
import { readAssetBytes } from './assetReadUrlService';
export type Match3DSpritesheetRegion = {
label: string;
x: number;
y: number;
width: number;
height: number;
};
export type Match3DItemSpritesheetViewRegion = Match3DSpritesheetRegion & {
itemIndex: number;
itemName: string;
viewIndex: number;
};
export type DetectMatch3DSpritesheetRegionsInput = {
alpha: ArrayLike<number>;
width: number;
height: number;
labels?: readonly string[];
minArea?: number;
alphaThreshold?: number;
};
export type Match3DDecodedSpritesheetRegion = Match3DSpritesheetRegion & {
imageSrc: string;
sheetWidth: number;
sheetHeight: number;
};
export type LoadMatch3DSpritesheetAssetRegionsInput = {
source: string;
labels?: readonly string[];
minArea?: number;
alphaThreshold?: number;
maxRegions?: number;
signal?: AbortSignal;
};
type Match3DDetectedComponent = Omit<Match3DSpritesheetRegion, 'label'> & {
area: number;
};
/**
* 中文注释AI spritesheet 只保证透明背景,不保证固定坐标;运行态和编辑器统一按 alpha 连通域识别独立素材矩形。
*/
export function detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels = [],
minArea = 1,
alphaThreshold = 0,
}: DetectMatch3DSpritesheetRegionsInput): Match3DSpritesheetRegion[] {
const pixelCount = width * height;
if (width <= 0 || height <= 0 || alpha.length < pixelCount) {
return [];
}
const visited = new Uint8Array(pixelCount);
const components: Match3DDetectedComponent[] = [];
for (let start = 0; start < pixelCount; start += 1) {
if (visited[start] || (alpha[start] ?? 0) <= alphaThreshold) {
continue;
}
const component = floodFillMatch3DSpritesheetComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
});
if (component.area >= minArea) {
components.push(component);
}
}
return components
.sort((left, right) => left.y - right.y || left.x - right.x)
.map((component, index) => ({
label: labels[index] ?? `素材${index + 1}`,
x: component.x,
y: component.y,
width: component.width,
height: component.height,
}));
}
export function buildMatch3DItemSpritesheetViewRegions<
Region extends Match3DSpritesheetRegion,
>(
regions: readonly Region[],
itemNames: readonly string[],
): Array<{
itemIndex: number;
itemName: string;
regions: Region[];
}> {
if (regions.length <= 0 || itemNames.length <= 0) {
return [];
}
const itemCount = Math.min(20, Math.floor(regions.length / 5));
return Array.from({ length: itemCount }, (_, itemIndex) => {
const itemName = itemNames[itemIndex]?.trim() || `物品${itemIndex + 1}`;
return {
itemIndex,
itemName,
regions: regions.slice(itemIndex * 5, itemIndex * 5 + 5).map(
(region, viewIndex): Region => ({
...region,
label: `${itemName}-形态${viewIndex + 1}`,
}) as Region,
),
};
});
}
export async function loadMatch3DSpritesheetAssetRegions({
source,
labels = [],
minArea = 16,
alphaThreshold = 8,
maxRegions,
signal,
}: LoadMatch3DSpritesheetAssetRegionsInput): Promise<
Match3DDecodedSpritesheetRegion[]
> {
const decoded = await decodeMatch3DSpritesheetImage(source, signal);
if (signal?.aborted) {
throw new DOMException('spritesheet 读取已取消', 'AbortError');
}
const regions = detectMatch3DSpritesheetRegions({
alpha: decoded.alpha,
width: decoded.width,
height: decoded.height,
labels,
minArea,
alphaThreshold,
}).slice(0, maxRegions ?? Number.POSITIVE_INFINITY);
return regions.map((region) => ({
...region,
imageSrc: cropMatch3DSpritesheetRegionToDataUrl(decoded.image, region),
sheetWidth: decoded.width,
sheetHeight: decoded.height,
}));
}
function loadMatch3DSpritesheetImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
image.src = source;
});
}
async function readMatch3DSpritesheetImageSource(
source: string,
signal?: AbortSignal,
) {
const response = await readAssetBytes(source, {
signal,
expireSeconds: 300,
});
const blob = await response.blob();
const canCreateObjectUrl =
typeof URL.createObjectURL === 'function' &&
typeof URL.revokeObjectURL === 'function';
if (canCreateObjectUrl) {
return {
imageSource: URL.createObjectURL(blob),
revoke: (imageSource: string) => URL.revokeObjectURL(imageSource),
};
}
return {
imageSource: await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
reader.readAsDataURL(blob);
}),
revoke: () => {},
};
}
async function decodeMatch3DSpritesheetImage(
source: string,
signal?: AbortSignal,
) {
const { imageSource, revoke } = await readMatch3DSpritesheetImageSource(
source,
signal,
);
try {
const image = await loadMatch3DSpritesheetImage(imageSource);
if (signal?.aborted) {
throw new DOMException('spritesheet 读取已取消', 'AbortError');
}
const width = Math.max(1, image.naturalWidth || image.width || 1);
const height = Math.max(1, image.naturalHeight || image.height || 1);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', {
willReadFrequently: true,
});
if (!context) {
throw new Error('浏览器不支持解析抓大鹅 spritesheet');
}
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
const alpha = new Uint8ClampedArray(width * height);
for (let index = 0; index < alpha.length; index += 1) {
alpha[index] = pixels[index * 4 + 3] ?? 0;
}
return {
alpha,
height,
image,
width,
};
} finally {
revoke(imageSource);
}
}
function cropMatch3DSpritesheetRegionToDataUrl(
image: HTMLImageElement,
region: Match3DSpritesheetRegion,
) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(region.width));
canvas.height = Math.max(1, Math.round(region.height));
const context = canvas.getContext('2d');
if (!context) {
throw new Error('浏览器不支持裁切抓大鹅 spritesheet');
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(
image,
region.x,
region.y,
region.width,
region.height,
0,
0,
canvas.width,
canvas.height,
);
return canvas.toDataURL('image/png');
}
function floodFillMatch3DSpritesheetComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
}: {
alpha: ArrayLike<number>;
visited: Uint8Array;
width: number;
height: number;
start: number;
alphaThreshold: number;
}): Match3DDetectedComponent {
const stack = [start];
visited[start] = 1;
let minX = start % width;
let maxX = minX;
let minY = Math.floor(start / width);
let maxY = minY;
let area = 0;
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % width;
const y = Math.floor(index / width);
area += 1;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
visitMatch3DSpritesheetNeighbor(
index - 1,
x > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index + 1,
x + 1 < width,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index - width,
y > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitMatch3DSpritesheetNeighbor(
index + width,
y + 1 < height,
alpha,
visited,
stack,
alphaThreshold,
);
}
return {
area,
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
}
function visitMatch3DSpritesheetNeighbor(
index: number,
inBounds: boolean,
alpha: ArrayLike<number>,
visited: Uint8Array,
stack: number[],
alphaThreshold: number,
) {
if (!inBounds || visited[index] || (alpha[index] ?? 0) <= alphaThreshold) {
return;
}
visited[index] = 1;
stack.push(index);
}

View File

@@ -26,15 +26,16 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'并行生成素材',
'校验背景资源',
'生成拼图首图',
'生成关卡画面',
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'读取画面描述,建立可编辑草稿与首关结构。',
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.estimatedRemainingMs).toBe(298_500);
expect(progress?.estimatedRemainingMs).toBe(296_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
@@ -50,22 +51,52 @@ describe('miniGameDraftGenerationProgress', () => {
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 282_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(275_000);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.estimatedRemainingMs).toBe(273_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[3]?.status).toBe('completed');
expect(writeBackProgress?.steps[4]?.status).toBe('active');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
});
test('puzzle direct upload generation skips the first image generation step', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: false,
},
};
const progress = buildMiniGameDraftGenerationProgress(state, 20_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成关卡画面',
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseId).toBe('puzzle-level-scene');
expect(progress?.steps[2]?.detail).toContain('直接使用上传图作为参考');
expect(progress?.estimatedRemainingMs).toBe(189_000);
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
@@ -78,12 +109,12 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 360_000);
const progress = buildMiniGameDraftGenerationProgress(state, 480_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[4]?.completed).toBe(1);
expect(progress?.steps[5]?.completed).toBe(1);
});
test('puzzle ready copy points to result page work info completion', () => {
@@ -111,7 +142,7 @@ describe('miniGameDraftGenerationProgress', () => {
finishedAtMs: 151_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: 'VectorEngine 图片编辑请求超时',
error: 'VectorEngine 图片生成请求超时',
};
const progress = buildMiniGameDraftGenerationProgress(state, 500_000);
@@ -177,7 +208,7 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('match3d draft generation exposes item sheet and image asset steps', () => {
test('match3d draft generation exposes level scene derived asset steps', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
@@ -188,17 +219,14 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps.map((step) => step.id)).toEqual([
'match3d-work-title',
'match3d-item-names',
'match3d-background-prompt',
'match3d-material-sheet',
'match3d-slice-images',
'match3d-upload-images',
'match3d-generate-views',
'match3d-background-image',
'match3d-level-scene',
'match3d-derived-assets',
'match3d-parse-spritesheet',
'match3d-write-draft',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('分批生成素材图');
expect(progress?.estimatedRemainingMs).toBe(480_000);
expect(progress?.phaseId).toBe('match3d-level-scene');
expect(progress?.phaseLabel).toBe('生成关卡整图');
expect(progress?.estimatedRemainingMs).toBe(430_000);
});
test('match3d draft generation starts from title generation', () => {
@@ -229,26 +257,26 @@ describe('miniGameDraftGenerationProgress', () => {
state.startedAtMs + 20_000,
);
expect(progress?.phaseId).toBe('match3d-generate-views');
expect(progress?.steps[6]?.detail).toContain('五视角图片');
expect(progress?.steps[6]?.completed).toBe(1);
expect(progress?.steps[6]?.total).toBe(3);
expect(progress?.phaseId).toBe('match3d-parse-spritesheet');
expect(progress?.steps[4]?.detail).toContain('解析 20 个物品');
expect(progress?.steps[4]?.completed).toBe(1);
expect(progress?.steps[4]?.total).toBe(3);
});
test('match3d draft generation reaches background image and writeback phases', () => {
const state = createMiniGameDraftGenerationState('match3d');
const backgroundProgress = buildMiniGameDraftGenerationProgress(
const derivedProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 400_000,
state.startedAtMs + 150_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 500_000,
state.startedAtMs + 460_000,
);
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
expect(derivedProgress?.phaseId).toBe('match3d-derived-assets');
expect(derivedProgress?.phaseLabel).toBe('生成三张派生图');
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
});
@@ -269,8 +297,8 @@ describe('miniGameDraftGenerationProgress', () => {
},
{
id: 'match3d-items',
label: '物品数量',
value: '25 件',
label: '素材数量',
value: '20 种素材',
},
]);
});

View File

@@ -49,6 +49,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'
@@ -59,6 +62,9 @@ export type MiniGameDraftGenerationPhase =
| 'jump-hop-tile-atlas'
| 'jump-hop-slice-tiles'
| 'jump-hop-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-images'
| 'puzzle-ui-background'
| 'puzzle-select-image'
@@ -73,6 +79,9 @@ export type MiniGameDraftGenerationState = {
completedAssetCount: number;
totalAssetCount: number;
error: string | null;
metadata?: {
puzzleAiRedraw?: boolean;
};
};
type MiniGameStepDefinition = {
@@ -82,65 +91,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 = [
{
@@ -198,65 +276,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',
@@ -319,7 +401,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;
@@ -429,26 +511,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(
@@ -481,10 +558,13 @@ function resolveJumpHopPhaseByElapsedMs(
return 'jump-hop-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) {
@@ -523,7 +603,7 @@ export function buildMiniGameDraftGenerationProgress(
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
: null;
const normalizedState =
puzzleTimeline != null
@@ -568,7 +648,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(
@@ -641,7 +724,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'
@@ -812,9 +895,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',
@@ -827,8 +907,8 @@ export function buildMatch3DGenerationAnchorEntries(
},
{
key: 'match3d-items',
label: '物品数量',
value: `${itemCount} `,
label: '素材数量',
value: `${resolveMatch3DGeneratedItemCount()} 种素材`,
},
];
@@ -843,22 +923,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(

View File

@@ -0,0 +1,33 @@
import { beforeEach, expect, test, vi } from 'vitest';
const { createCreationAgentClientMock, executeActionMock } = vi.hoisted(() => ({
createCreationAgentClientMock: vi.fn(),
executeActionMock: vi.fn(),
}));
vi.mock('../creation-agent', () => ({
createCreationAgentClient: createCreationAgentClientMock,
}));
beforeEach(() => {
vi.resetModules();
executeActionMock.mockReset();
createCreationAgentClientMock.mockReset();
createCreationAgentClientMock.mockReturnValue({
createSession: vi.fn(),
getSession: vi.fn(),
sendMessage: vi.fn(),
streamMessage: vi.fn(),
executeAction: executeActionMock,
});
});
test('puzzle compile action keeps the draft generation request alive for 30 minutes', async () => {
await import('./puzzleAgentClient');
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
expect.objectContaining({
executeActionTimeoutMs: 30 * 60 * 1000,
}),
);
});

View File

@@ -12,6 +12,8 @@ import type { TextStreamOptions } from '../aiTypes';
import { createCreationAgentClient } from '../creation-agent';
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
// 拼图草稿生成会串起多段图片生成,请求层保持 30 分钟等待窗口。
const PUZZLE_DRAFT_ACTION_TIMEOUT_MS = 30 * 60 * 1000;
const puzzleAgentHttpClient = createCreationAgentClient<
CreatePuzzleAgentSessionRequest,
CreatePuzzleAgentSessionResponse,
@@ -30,6 +32,7 @@ const puzzleAgentHttpClient = createCreationAgentClient<
streamIncomplete: '拼图共创消息流式结果不完整',
executeAction: '执行拼图共创操作失败',
},
executeActionTimeoutMs: PUZZLE_DRAFT_ACTION_TIMEOUT_MS,
});
/**

View File

@@ -4,14 +4,14 @@ import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/pu
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
applyLocalPuzzleFreezeTime,
advanceLocalPuzzleLevel,
applyLocalPuzzleFreezeTime,
dragLocalPuzzlePiece,
extendLocalPuzzleTime,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
restartLocalPuzzleLevel,
resolvePuzzleRestartLevelId,
restartLocalPuzzleLevel,
setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
@@ -575,6 +575,43 @@ describe('puzzleLocalRuntime', () => {
);
});
test('本地试玩优先把关卡背景和 UI spritesheet 带入运行态', () => {
const workWithRuntimeAssets: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/legacy-background.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background/background.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
backgroundMusic: null,
generationStatus: 'ready',
} as PuzzleDraftLevel,
],
};
const run = startLocalPuzzleRun(workWithRuntimeAssets);
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/level-background/background.png',
);
expect(run.currentLevel?.levelBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/level-background/background.png',
);
expect(run.currentLevel?.uiSpritesheetImageSrc).toBe(
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
);
});
test('本地试玩在只有 UI 背景 objectKey 时也能继承生成图', () => {
const workWithRuntimeAssets: PuzzleWorkSummary = {
...baseWork,
@@ -641,6 +678,10 @@ describe('puzzleLocalRuntime', () => {
const run = startLocalPuzzleRun(workWithLevels, 'puzzle-level-2');
expect(run.currentLevel?.levelId).toBe('puzzle-level-2');
expect(run.currentLevelIndex).toBe(2);
expect(run.currentLevel?.levelIndex).toBe(2);
expect(run.currentLevel?.gridSize).toBe(4);
expect(run.currentGridSize).toBe(4);
expect(run.currentLevel?.coverImageSrc).toBe('/level-2.png');
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background.png',

View File

@@ -1,3 +1,4 @@
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
@@ -10,7 +11,6 @@ import type {
PuzzleRuntimeLevelSnapshot,
SwapPuzzlePiecesRequest,
} 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 {
resolvePuzzleUiBackgroundFields,
@@ -773,6 +773,51 @@ function resolvePuzzleWorkUiBackgroundCarrier(
);
}
function resolvePuzzleWorkUiSpritesheetCarrier(
work: PuzzleWorkSummary | null | undefined,
) {
return (
work?.levels?.find(
(level) =>
level.uiSpritesheetImageSrc?.trim() ||
level.uiSpritesheetImageObjectKey?.trim(),
) ?? null
);
}
function resolvePuzzleUiSpritesheetFields(
...sources: Array<
| Pick<
PuzzleDraftLevel,
'uiSpritesheetImageSrc' | 'uiSpritesheetImageObjectKey'
>
| Pick<
PuzzleRuntimeLevelSnapshot,
'uiSpritesheetImageSrc' | 'uiSpritesheetImageObjectKey'
>
| null
| undefined
>
) {
for (const source of sources) {
const imageSrc = source?.uiSpritesheetImageSrc?.trim();
const objectKey = source?.uiSpritesheetImageObjectKey
?.trim()
.replace(/^\/+/u, '');
if (imageSrc || objectKey) {
return {
uiSpritesheetImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
uiSpritesheetImageObjectKey: objectKey || null,
};
}
}
return {
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
};
}
function applyLocalNextLevelHandoff(
run: PuzzleRunSnapshot,
work: PuzzleWorkSummary | null | undefined,
@@ -820,6 +865,11 @@ function buildFallbackLocalLevel(
resolvePuzzleWorkUiBackgroundCarrier(work),
currentLevel,
);
const nextUiSpritesheet = resolvePuzzleUiSpritesheetFields(
nextLevel,
resolvePuzzleWorkUiSpritesheetCarrier(work),
currentLevel,
);
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
@@ -852,6 +902,12 @@ function buildFallbackLocalLevel(
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackground.uiBackgroundImageObjectKey,
levelBackgroundImageSrc: nextUiBackground.levelBackgroundImageSrc,
levelBackgroundImageObjectKey:
nextUiBackground.levelBackgroundImageObjectKey,
uiSpritesheetImageSrc: nextUiSpritesheet.uiSpritesheetImageSrc,
uiSpritesheetImageObjectKey:
nextUiSpritesheet.uiSpritesheetImageObjectKey,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
@@ -869,11 +925,12 @@ export function startLocalPuzzleRun(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRunSnapshot {
const gridSize = resolvePuzzleLevelConfig(1).gridSize;
const runId = buildLocalPuzzleRunId(item.profileId);
const startedAtMs = Date.now();
const requestedLevelIndex = resolveWorkLevelIndexById(item.levels, levelId);
const currentLevelIndex = requestedLevelIndex >= 0 ? requestedLevelIndex : 0;
const currentLevelNumber = currentLevelIndex + 1;
const { gridSize } = resolvePuzzleLevelConfig(currentLevelNumber);
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
@@ -881,19 +938,23 @@ export function startLocalPuzzleRun(
firstLevel,
resolvePuzzleWorkUiBackgroundCarrier(item),
);
const firstUiSpritesheet = resolvePuzzleUiSpritesheetFields(
firstLevel,
resolvePuzzleWorkUiSpritesheetCarrier(item),
);
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
runId,
entryProfileId: item.profileId,
clearedLevelCount: 0,
currentLevelIndex: 1,
clearedLevelCount: Math.max(0, currentLevelIndex),
currentLevelIndex: currentLevelNumber,
currentGridSize: gridSize,
playedProfileIds: [item.profileId],
previousLevelTags: item.themeTags,
currentLevel: {
runId,
levelIndex: 1,
levelIndex: currentLevelNumber,
levelId: firstLevel?.levelId ?? null,
gridSize,
profileId: item.profileId,
@@ -903,13 +964,19 @@ export function startLocalPuzzleRun(
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackground.uiBackgroundImageObjectKey,
levelBackgroundImageSrc: firstUiBackground.levelBackgroundImageSrc,
levelBackgroundImageObjectKey:
firstUiBackground.levelBackgroundImageObjectKey,
uiSpritesheetImageSrc: firstUiSpritesheet.uiSpritesheetImageSrc,
uiSpritesheetImageObjectKey:
firstUiSpritesheet.uiSpritesheetImageObjectKey,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
board: buildInitialBoard(gridSize, runId, item.profileId, currentLevelNumber),
status: 'playing',
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(1),
...buildLevelTimerFields(currentLevelNumber),
leaderboardEntries: [],
},
recommendedNextProfileId: nextSameWorkLevel ? item.profileId : null,

View File

@@ -1,6 +1,8 @@
type PuzzleUiBackgroundFields = {
uiBackgroundImageSrc?: string | null;
uiBackgroundImageObjectKey?: string | null;
levelBackgroundImageSrc?: string | null;
levelBackgroundImageObjectKey?: string | null;
};
export function resolvePuzzleUiBackgroundSource(
@@ -13,14 +15,21 @@ export function resolvePuzzleUiBackgroundFields(
...sources: Array<PuzzleUiBackgroundFields | null | undefined>
) {
for (const source of sources) {
const imageSrc = source?.uiBackgroundImageSrc?.trim();
const objectKey = source?.uiBackgroundImageObjectKey
?.trim()
.replace(/^\/+/u, '');
const imageSrc =
source?.levelBackgroundImageSrc?.trim() ||
source?.uiBackgroundImageSrc?.trim();
const objectKey = (
source?.levelBackgroundImageObjectKey?.trim() ||
source?.uiBackgroundImageObjectKey?.trim() ||
''
).replace(/^\/+/u, '');
if (imageSrc || objectKey) {
return {
uiBackgroundImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
uiBackgroundImageObjectKey: objectKey || null,
levelBackgroundImageSrc:
imageSrc || (objectKey ? `/${objectKey}` : null),
levelBackgroundImageObjectKey: objectKey || null,
};
}
}
@@ -28,5 +37,7 @@ export function resolvePuzzleUiBackgroundFields(
return {
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
levelBackgroundImageSrc: null,
levelBackgroundImageObjectKey: null,
};
}

View File

@@ -0,0 +1,166 @@
import { describe, expect, test } from 'vitest';
import {
buildPuzzleUiSpriteBackgroundStyle,
buildPuzzleUiSpriteHitZoneStyle,
detectPuzzleUiSpritesheetLayout,
} from './puzzleUiSpritesheetParser';
describe('puzzleUiSpritesheetParser', () => {
test('按透明像素边界检测 UI 按钮矩形并按从左到右从上到下映射', () => {
const width = 32;
const height = 24;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(3, 2, 6, 5);
paint(24, 3, 28, 7);
paint(11, 11, 20, 15);
paint(2, 20, 6, 22);
paint(13, 19, 18, 22);
paint(25, 20, 29, 23);
alpha[0] = 255;
const layout = detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea: 4,
});
expect(layout).toEqual({
width,
height,
regions: {
back: { x: 3, y: 2, width: 4, height: 4 },
settings: { x: 24, y: 3, width: 5, height: 5 },
next: { x: 11, y: 11, width: 10, height: 5 },
hint: { x: 2, y: 20, width: 5, height: 3 },
reference: { x: 13, y: 19, width: 6, height: 4 },
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
},
hitRegions: {
back: { x: 3, y: 2, width: 4, height: 4 },
settings: { x: 24, y: 3, width: 5, height: 5 },
next: { x: 11, y: 11, width: 10, height: 5 },
hint: { x: 2, y: 20, width: 5, height: 3 },
reference: { x: 13, y: 19, width: 6, height: 4 },
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
},
});
});
test('检测不到完整六个按钮矩形时返回 null 交给固定六宫格兜底', () => {
const width = 20;
const height = 12;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(1, 1, 4, 4);
paint(8, 1, 11, 4);
paint(14, 1, 17, 4);
expect(
detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea: 4,
}),
).toBeNull();
});
test('按检测到的原图矩形生成 background 裁切样式', () => {
const style = buildPuzzleUiSpriteBackgroundStyle({
src: '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
kind: 'reference',
layout: {
width: 32,
height: 24,
regions: {
reference: { x: 13, y: 19, width: 6, height: 4 },
},
},
});
expect(style).toEqual({
backgroundImage:
'url("/generated-puzzle-assets/session/ui-spritesheet/sheet.png")',
backgroundSize: '533.3333333333333% 600%',
backgroundPosition: '50% 95%',
aspectRatio: '6 / 4',
});
});
test('点击热区优先使用高 alpha 像素的紧致矩形,减少透明边缘误触', () => {
const width = 48;
const height = 32;
const alpha = new Uint8ClampedArray(width * height);
const paint = (
x0: number,
y0: number,
x1: number,
y1: number,
value: number,
) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = value;
}
}
};
paint(2, 2, 12, 12, 48);
paint(5, 5, 9, 9, 255);
paint(20, 2, 26, 8, 255);
paint(34, 2, 42, 8, 255);
paint(2, 20, 8, 26, 255);
paint(19, 20, 27, 26, 255);
paint(34, 20, 42, 26, 255);
const layout = detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea: 4,
alphaThreshold: 16,
hitAlphaThreshold: 192,
});
expect(layout?.regions.back).toEqual({
x: 2,
y: 2,
width: 11,
height: 11,
});
expect(layout?.hitRegions?.back).toEqual({
x: 5,
y: 5,
width: 5,
height: 5,
});
expect(
buildPuzzleUiSpriteHitZoneStyle({
kind: 'back',
layout,
}),
).toEqual({
left: '27.27272727272727%',
top: '27.27272727272727%',
width: '45.45454545454545%',
height: '45.45454545454545%',
});
});
});

View File

@@ -0,0 +1,471 @@
import type { CSSProperties } from 'react';
import { readAssetBytes } from '../assetReadUrlService';
export type PuzzleUiSpriteKind =
| 'back'
| 'settings'
| 'next'
| 'hint'
| 'reference'
| 'freezeTime';
export type PuzzleUiSpriteRegion = {
x: number;
y: number;
width: number;
height: number;
};
export type PuzzleUiSpritesheetLayout = {
width: number;
height: number;
regions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>>;
hitRegions?: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>>;
};
export type DetectPuzzleUiSpritesheetLayoutInput = {
alpha: ArrayLike<number>;
width: number;
height: number;
minArea?: number;
alphaThreshold?: number;
hitAlphaThreshold?: number;
};
export type BuildPuzzleUiSpriteBackgroundStyleInput = {
src: string;
kind: PuzzleUiSpriteKind;
layout: PuzzleUiSpritesheetLayout | null;
};
export type BuildPuzzleUiSpriteHitZoneStyleInput = {
kind: PuzzleUiSpriteKind;
layout: PuzzleUiSpritesheetLayout | null;
};
export type LoadPuzzleUiSpritesheetLayoutOptions = {
signal?: AbortSignal;
expireSeconds?: number;
minArea?: number;
alphaThreshold?: number;
hitAlphaThreshold?: number;
};
type PuzzleUiDetectedComponent = PuzzleUiSpriteRegion & {
area: number;
hitRegion?: PuzzleUiSpriteRegion;
};
const PUZZLE_UI_SPRITE_ORDER = [
'back',
'settings',
'next',
'hint',
'reference',
'freezeTime',
] as const satisfies readonly PuzzleUiSpriteKind[];
const PUZZLE_UI_FIXED_GRID_INDEX: Record<PuzzleUiSpriteKind, number> = {
back: 0,
settings: 1,
next: 2,
hint: 3,
reference: 4,
freezeTime: 5,
};
/**
* 中文注释AI 生成的拼图 UI spritesheet 不稳定落在固定六宫格内,
* 因此这里以 alpha 连通域检测真实按钮矩形,再按原图位置映射到按钮语义。
*/
export function detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea = 1,
alphaThreshold = 0,
hitAlphaThreshold = Math.max(192, alphaThreshold),
}: DetectPuzzleUiSpritesheetLayoutInput): PuzzleUiSpritesheetLayout | null {
const pixelCount = width * height;
if (width <= 0 || height <= 0 || alpha.length < pixelCount) {
return null;
}
const visited = new Uint8Array(pixelCount);
const components: PuzzleUiDetectedComponent[] = [];
for (let start = 0; start < pixelCount; start += 1) {
const alphaValue = alpha[start];
if (
visited[start] ||
alphaValue === undefined ||
alphaValue <= alphaThreshold
) {
continue;
}
const component = floodFillPuzzleUiSpriteComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
hitAlphaThreshold,
});
if (component.area >= minArea) {
components.push(component);
}
}
if (components.length < PUZZLE_UI_SPRITE_ORDER.length) {
return null;
}
const sortedComponents = sortPuzzleUiSpriteComponentsByOriginalPosition(
components,
).slice(0, PUZZLE_UI_SPRITE_ORDER.length);
const regions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>> = {};
const hitRegions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>> =
{};
sortedComponents.forEach((component, index) => {
const kind = PUZZLE_UI_SPRITE_ORDER[index];
if (!kind) {
return;
}
const region = {
x: component.x,
y: component.y,
width: component.width,
height: component.height,
};
regions[kind] = region;
if (component.hitRegion) {
hitRegions[kind] = component.hitRegion;
}
});
return {
width,
height,
regions,
hitRegions,
};
}
export function buildPuzzleUiSpriteBackgroundStyle({
src,
kind,
layout,
}: BuildPuzzleUiSpriteBackgroundStyleInput): CSSProperties {
const region = layout?.regions[kind];
if (!layout || !region) {
const index = PUZZLE_UI_FIXED_GRID_INDEX[kind];
return {
backgroundImage: `url("${src}")`,
backgroundSize: '200% 300%',
backgroundPosition: `${(index % 2) * 100}% ${
Math.floor(index / 2) * 50
}%`,
};
}
return {
backgroundImage: `url("${src}")`,
backgroundSize: `${(layout.width / region.width) * 100}% ${
(layout.height / region.height) * 100
}%`,
backgroundPosition: `${resolvePuzzleUiSpriteBackgroundAxisPosition(
region.x,
layout.width,
region.width,
)}% ${resolvePuzzleUiSpriteBackgroundAxisPosition(
region.y,
layout.height,
region.height,
)}%`,
aspectRatio: `${region.width} / ${region.height}`,
};
}
export function buildPuzzleUiSpriteHitZoneStyle({
kind,
layout,
}: BuildPuzzleUiSpriteHitZoneStyleInput): CSSProperties {
const region = layout?.regions[kind];
const hitRegion = layout?.hitRegions?.[kind];
if (!region || !hitRegion) {
return {
inset: 0,
};
}
return {
left: `${resolvePuzzleUiSpriteHitZoneOffset(
hitRegion.x,
region.x,
region.width,
)}%`,
top: `${resolvePuzzleUiSpriteHitZoneOffset(
hitRegion.y,
region.y,
region.height,
)}%`,
width: `${resolvePuzzleUiSpriteHitZoneSize(
hitRegion.width,
region.width,
)}%`,
height: `${resolvePuzzleUiSpriteHitZoneSize(
hitRegion.height,
region.height,
)}%`,
};
}
export async function loadPuzzleUiSpritesheetLayout(
source: string,
options: LoadPuzzleUiSpritesheetLayoutOptions = {},
) {
const response = await readAssetBytes(source, {
signal: options.signal,
expireSeconds: options.expireSeconds,
});
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
try {
const image = await loadPuzzleUiSpritesheetImage(objectUrl);
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (width <= 0 || height <= 0) {
return null;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
const alpha = new Uint8ClampedArray(width * height);
for (let index = 0; index < alpha.length; index += 1) {
alpha[index] = imageData.data[index * 4 + 3] ?? 0;
}
return detectPuzzleUiSpritesheetLayout({
alpha,
width,
height,
minArea:
options.minArea ?? Math.max(16, Math.floor(width * height * 0.0002)),
alphaThreshold: options.alphaThreshold ?? 16,
hitAlphaThreshold: options.hitAlphaThreshold ?? 192,
});
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function sortPuzzleUiSpriteComponentsByOriginalPosition(
components: PuzzleUiDetectedComponent[],
) {
const averageHeight =
components.reduce((total, component) => total + component.height, 0) /
components.length;
const rowTolerance = Math.max(2, averageHeight * 0.65);
const rows: PuzzleUiDetectedComponent[][] = [];
for (const component of components
.slice()
.sort((left, right) => left.y - right.y)) {
const centerY = component.y + component.height / 2;
const row = rows.find((items) => {
const rowCenter =
items.reduce((total, item) => total + item.y + item.height / 2, 0) /
items.length;
return Math.abs(rowCenter - centerY) <= rowTolerance;
});
if (row) {
row.push(component);
} else {
rows.push([component]);
}
}
return rows.flatMap((row) => row.sort((left, right) => left.x - right.x));
}
function resolvePuzzleUiSpriteBackgroundAxisPosition(
offset: number,
imageSize: number,
regionSize: number,
) {
const movableSize = imageSize - regionSize;
if (movableSize <= 0) {
return 0;
}
return (offset / movableSize) * 100;
}
function resolvePuzzleUiSpriteHitZoneOffset(
hitOffset: number,
regionOffset: number,
regionSize: number,
) {
if (regionSize <= 0) {
return 0;
}
return clampPuzzleUiSpritePercent(
((hitOffset - regionOffset) / regionSize) * 100,
);
}
function resolvePuzzleUiSpriteHitZoneSize(
hitSize: number,
regionSize: number,
) {
if (regionSize <= 0) {
return 100;
}
return clampPuzzleUiSpritePercent((hitSize / regionSize) * 100);
}
function clampPuzzleUiSpritePercent(value: number) {
return Math.min(100, Math.max(0, value));
}
function loadPuzzleUiSpritesheetImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('拼图 UI spritesheet 图片解码失败'));
image.src = src;
});
}
function floodFillPuzzleUiSpriteComponent({
alpha,
visited,
width,
height,
start,
alphaThreshold,
hitAlphaThreshold,
}: {
alpha: ArrayLike<number>;
visited: Uint8Array;
width: number;
height: number;
start: number;
alphaThreshold: number;
hitAlphaThreshold: number;
}): PuzzleUiDetectedComponent {
const stack = [start];
visited[start] = 1;
let minX = start % width;
let maxX = minX;
let minY = Math.floor(start / width);
let maxY = minY;
let area = 0;
let hitMinX = Number.POSITIVE_INFINITY;
let hitMaxX = Number.NEGATIVE_INFINITY;
let hitMinY = Number.POSITIVE_INFINITY;
let hitMaxY = Number.NEGATIVE_INFINITY;
let hitArea = 0;
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % width;
const y = Math.floor(index / width);
const alphaValue = alpha[index] ?? 0;
area += 1;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
if (alphaValue > hitAlphaThreshold) {
hitArea += 1;
hitMinX = Math.min(hitMinX, x);
hitMaxX = Math.max(hitMaxX, x);
hitMinY = Math.min(hitMinY, y);
hitMaxY = Math.max(hitMaxY, y);
}
visitPuzzleUiSpriteNeighbor(
index - 1,
x > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitPuzzleUiSpriteNeighbor(
index + 1,
x + 1 < width,
alpha,
visited,
stack,
alphaThreshold,
);
visitPuzzleUiSpriteNeighbor(
index - width,
y > 0,
alpha,
visited,
stack,
alphaThreshold,
);
visitPuzzleUiSpriteNeighbor(
index + width,
y + 1 < height,
alpha,
visited,
stack,
alphaThreshold,
);
}
const component: PuzzleUiDetectedComponent = {
area,
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
if (hitArea > 0) {
component.hitRegion = {
x: hitMinX,
y: hitMinY,
width: hitMaxX - hitMinX + 1,
height: hitMaxY - hitMinY + 1,
};
}
return component;
}
function visitPuzzleUiSpriteNeighbor(
index: number,
inBounds: boolean,
alpha: ArrayLike<number>,
visited: Uint8Array,
stack: number[],
alphaThreshold: number,
) {
const alphaValue = alpha[index];
if (
!inBounds ||
visited[index] ||
alphaValue === undefined ||
alphaValue <= alphaThreshold
) {
return;
}
visited[index] = 1;
stack.push(index);
}