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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
98
src/services/match3dSpritesheetParser.test.ts
Normal file
98
src/services/match3dSpritesheetParser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
352
src/services/match3dSpritesheetParser.ts
Normal file
352
src/services/match3dSpritesheetParser.ts
Normal 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);
|
||||
}
|
||||
@@ -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 种素材',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
33
src/services/puzzle-agent/puzzleAgentClient.test.ts
Normal file
33
src/services/puzzle-agent/puzzleAgentClient.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
166
src/services/puzzle-runtime/puzzleUiSpritesheetParser.test.ts
Normal file
166
src/services/puzzle-runtime/puzzleUiSpritesheetParser.test.ts
Normal 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%',
|
||||
});
|
||||
});
|
||||
});
|
||||
471
src/services/puzzle-runtime/puzzleUiSpritesheetParser.ts
Normal file
471
src/services/puzzle-runtime/puzzleUiSpritesheetParser.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user