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

@@ -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);
}