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:
@@ -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