Merge remote-tracking branch 'origin/master' into codex/bark-battle
This commit is contained in:
274
src/services/jump-hop/jumpHopClient.ts
Normal file
274
src/services/jump-hop/jumpHopClient.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
JumpHopActionRequest,
|
||||
JumpHopActionResponse,
|
||||
JumpHopDraftResponse,
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
|
||||
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
||||
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
|
||||
const JUMP_HOP_RUNTIME_API_BASE = '/api/runtime/jump-hop';
|
||||
const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
|
||||
export type {
|
||||
JumpHopActionRequest,
|
||||
JumpHopActionResponse,
|
||||
JumpHopDraftResponse,
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
};
|
||||
export type CreateJumpHopSessionRequest = {
|
||||
themeText: string;
|
||||
characterDescription: string;
|
||||
tileStyle: string;
|
||||
difficulty: string;
|
||||
rhythmPreference: string;
|
||||
};
|
||||
export type ExecuteJumpHopActionRequest = JumpHopActionRequest;
|
||||
export type JumpHopSessionSnapshot = JumpHopSessionSnapshotResponse;
|
||||
|
||||
const jumpHopCreationClient = createCreationAgentClient<
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
never,
|
||||
never,
|
||||
JumpHopActionRequest,
|
||||
JumpHopActionResponse
|
||||
>({
|
||||
apiBase: JUMP_HOP_API_BASE,
|
||||
messages: {
|
||||
createSession: '创建跳一跳共创会话失败',
|
||||
getSession: '读取跳一跳共创会话失败',
|
||||
sendMessage: '发送跳一跳共创消息失败',
|
||||
streamIncomplete: '跳一跳共创消息流式结果不完整',
|
||||
executeAction: '执行跳一跳共创操作失败',
|
||||
},
|
||||
});
|
||||
|
||||
type FlattenedJumpHopWorkProfileResponse = Omit<
|
||||
JumpHopWorkProfileResponse,
|
||||
'summary'
|
||||
> &
|
||||
JumpHopWorkSummaryResponse;
|
||||
|
||||
function normalizeJumpHopWorkProfile(
|
||||
work: JumpHopWorkProfileResponse | FlattenedJumpHopWorkProfileResponse,
|
||||
): JumpHopWorkProfileResponse {
|
||||
if ('summary' in work && work.summary) {
|
||||
return work;
|
||||
}
|
||||
|
||||
const flattened = work as FlattenedJumpHopWorkProfileResponse;
|
||||
const summary: JumpHopWorkProfileResponse['summary'] = {
|
||||
runtimeKind: flattened.runtimeKind,
|
||||
workId: flattened.workId,
|
||||
profileId: flattened.profileId,
|
||||
ownerUserId: flattened.ownerUserId,
|
||||
sourceSessionId: flattened.sourceSessionId ?? null,
|
||||
workTitle: flattened.workTitle,
|
||||
workDescription: flattened.workDescription,
|
||||
themeTags: flattened.themeTags,
|
||||
difficulty: flattened.difficulty,
|
||||
stylePreset: flattened.stylePreset,
|
||||
coverImageSrc: flattened.coverImageSrc ?? null,
|
||||
publicationStatus: flattened.publicationStatus,
|
||||
playCount: flattened.playCount,
|
||||
updatedAt: flattened.updatedAt,
|
||||
publishedAt: flattened.publishedAt ?? null,
|
||||
publishReady: flattened.publishReady,
|
||||
generationStatus: flattened.generationStatus,
|
||||
};
|
||||
|
||||
return {
|
||||
summary,
|
||||
draft: flattened.draft,
|
||||
path: flattened.path,
|
||||
characterAsset: flattened.characterAsset,
|
||||
tileAtlasAsset: flattened.tileAtlasAsset,
|
||||
tileAssets: flattened.tileAssets,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJumpHopActionResponse(
|
||||
response: JumpHopActionResponse,
|
||||
): JumpHopActionResponse {
|
||||
return {
|
||||
...response,
|
||||
work: response.work ? normalizeJumpHopWorkProfile(response.work) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJumpHopWorkDetailResponse(
|
||||
response: JumpHopWorkDetailResponse,
|
||||
): JumpHopWorkDetailResponse {
|
||||
return {
|
||||
...response,
|
||||
item: normalizeJumpHopWorkProfile(response.item),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJumpHopWorkMutationResponse(
|
||||
response: JumpHopWorkMutationResponse,
|
||||
): JumpHopWorkMutationResponse {
|
||||
return {
|
||||
...response,
|
||||
item: normalizeJumpHopWorkProfile(response.item),
|
||||
};
|
||||
}
|
||||
|
||||
export function createJumpHopCreationSession(
|
||||
payload: JumpHopWorkspaceCreateRequest,
|
||||
) {
|
||||
return jumpHopCreationClient.createSession(payload);
|
||||
}
|
||||
|
||||
export function getJumpHopCreationSession(sessionId: string) {
|
||||
return jumpHopCreationClient.getSession(sessionId);
|
||||
}
|
||||
|
||||
export function executeJumpHopCreationAction(
|
||||
sessionId: string,
|
||||
payload: JumpHopActionRequest,
|
||||
) {
|
||||
return jumpHopCreationClient
|
||||
.executeAction(sessionId, payload)
|
||||
.then(normalizeJumpHopActionResponse);
|
||||
}
|
||||
|
||||
export async function getJumpHopWorkDetail(profileId: string) {
|
||||
const response = await requestJson<JumpHopWorkDetailResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳作品详情失败',
|
||||
);
|
||||
return normalizeJumpHopWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function listJumpHopGallery() {
|
||||
return requestJson<JumpHopGalleryResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/gallery`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳广场失败',
|
||||
{
|
||||
retry: JUMP_HOP_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getJumpHopGalleryDetail(publicWorkCode: string) {
|
||||
const response = await requestJson<JumpHopGalleryDetailResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/gallery/${encodeURIComponent(publicWorkCode)}`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳广场详情失败',
|
||||
{
|
||||
retry: JUMP_HOP_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
return normalizeJumpHopWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function publishJumpHopWork(profileId: string) {
|
||||
const response = await requestJson<JumpHopWorkMutationResponse>(
|
||||
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布跳一跳作品失败',
|
||||
);
|
||||
return normalizeJumpHopWorkMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function startJumpHopRuntimeRun(profileId: string) {
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动跳一跳运行态失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitJumpHopJump(
|
||||
runId: string,
|
||||
payload: { chargeMs: number },
|
||||
) {
|
||||
const requestPayload = {
|
||||
chargeMs: payload.chargeMs,
|
||||
clientEventId: `jump-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/jump`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'提交跳一跳起跳失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function restartJumpHopRuntimeRun(runId: string) {
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientActionId: `restart-${runId}-${Date.now()}`,
|
||||
}),
|
||||
},
|
||||
'重新开始跳一跳失败',
|
||||
);
|
||||
}
|
||||
|
||||
export const jumpHopClient = {
|
||||
createSession: createJumpHopCreationSession,
|
||||
getSession: getJumpHopCreationSession,
|
||||
executeAction: executeJumpHopCreationAction,
|
||||
getGalleryDetail: getJumpHopGalleryDetail,
|
||||
getWorkDetail: getJumpHopWorkDetail,
|
||||
listGallery: listJumpHopGallery,
|
||||
publishWork: publishJumpHopWork,
|
||||
restartRun: restartJumpHopRuntimeRun,
|
||||
startRun: startJumpHopRuntimeRun,
|
||||
submitJump: submitJumpHopJump,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBabyObjectMatchGenerationAnchorEntries,
|
||||
buildJumpHopGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
@@ -25,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);
|
||||
});
|
||||
@@ -49,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', () => {
|
||||
@@ -77,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', () => {
|
||||
@@ -110,7 +142,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
finishedAtMs: 151_000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: 'VectorEngine 图片编辑请求超时',
|
||||
error: 'VectorEngine 图片生成请求超时',
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 500_000);
|
||||
@@ -176,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(
|
||||
@@ -187,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', () => {
|
||||
@@ -228,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('写入草稿页');
|
||||
});
|
||||
@@ -268,8 +297,8 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
},
|
||||
{
|
||||
id: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: '25 件',
|
||||
label: '素材数量',
|
||||
value: '20 种素材',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -306,6 +335,54 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('jump hop draft generation exposes character and tile atlas pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('jump-hop');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 35_000,
|
||||
);
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'jump-hop-draft',
|
||||
'jump-hop-character',
|
||||
'jump-hop-tile-atlas',
|
||||
'jump-hop-slice-tiles',
|
||||
'jump-hop-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('jump-hop-character');
|
||||
expect(progress?.phaseLabel).toBe('生成角色形象');
|
||||
expect(progress?.estimatedRemainingMs).toBe(265_000);
|
||||
});
|
||||
|
||||
test('jump hop generation anchors expose theme, character and tile style', () => {
|
||||
const entries = buildJumpHopGenerationAnchorEntries(null, {
|
||||
themeText: '云端糖果塔',
|
||||
characterDescription: '披着星星披风的小旅人',
|
||||
tileStyle: '纸模玩具',
|
||||
difficulty: '标准',
|
||||
rhythmPreference: '轻快',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'jump-hop-theme',
|
||||
label: '主题',
|
||||
value: '云端糖果塔',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value: '披着星星披风的小旅人',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
value: '纸模玩具',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
|
||||
@@ -17,13 +17,18 @@ import type {
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
|
||||
import type {
|
||||
CreateJumpHopSessionRequest,
|
||||
JumpHopSessionSnapshot,
|
||||
} from './jump-hop/jumpHopClient';
|
||||
|
||||
export type MiniGameDraftGenerationKind =
|
||||
| 'puzzle'
|
||||
| 'big-fish'
|
||||
| 'square-hole'
|
||||
| 'match3d'
|
||||
| 'baby-object-match';
|
||||
| 'baby-object-match'
|
||||
| 'jump-hop';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
@@ -44,11 +49,22 @@ 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'
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'jump-hop-draft'
|
||||
| 'jump-hop-character'
|
||||
| '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'
|
||||
@@ -63,6 +79,9 @@ export type MiniGameDraftGenerationState = {
|
||||
completedAssetCount: number;
|
||||
totalAssetCount: number;
|
||||
error: string | null;
|
||||
metadata?: {
|
||||
puzzleAiRedraw?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type MiniGameStepDefinition = {
|
||||
@@ -72,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 = [
|
||||
{
|
||||
@@ -188,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',
|
||||
@@ -268,13 +360,48 @@ const BABY_OBJECT_MATCH_STEPS = [
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const JUMP_HOP_STEPS = [
|
||||
{
|
||||
id: 'jump-hop-draft',
|
||||
label: '整理玩法草稿',
|
||||
detail: '建立主题、难度和路径基础数据。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '生成角色形象',
|
||||
detail: '生成可进入运行态的俯视角角色图。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-atlas',
|
||||
label: '生成地块图集',
|
||||
detail: '生成起点、普通、目标和终点地块图集。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-slice-tiles',
|
||||
label: '切分地块素材',
|
||||
detail: '切分透明地块 PNG 并校验落点半径。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-write-draft',
|
||||
label: '写入正式草稿',
|
||||
detail: '保存角色、地块、路径和封面合成结果。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'puzzle') {
|
||||
return PUZZLE_STEPS;
|
||||
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
|
||||
}
|
||||
if (kind === 'square-hole') {
|
||||
return SQUARE_HOLE_STEPS;
|
||||
@@ -285,6 +412,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'baby-object-match') {
|
||||
return BABY_OBJECT_MATCH_STEPS;
|
||||
}
|
||||
if (kind === 'jump-hop') {
|
||||
return JUMP_HOP_STEPS;
|
||||
}
|
||||
return BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
@@ -340,8 +470,10 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'square-hole-draft'
|
||||
: kind === 'match3d'
|
||||
? 'match3d-work-title'
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: kind === 'jump-hop'
|
||||
? 'jump-hop-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
@@ -379,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(
|
||||
@@ -413,10 +540,31 @@ function resolveBabyObjectMatchPhaseByElapsedMs(
|
||||
return 'baby-object-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
function resolveJumpHopPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 270_000) {
|
||||
return 'jump-hop-write-draft';
|
||||
}
|
||||
if (elapsedMs >= 220_000) {
|
||||
return 'jump-hop-slice-tiles';
|
||||
}
|
||||
if (elapsedMs >= 115_000) {
|
||||
return 'jump-hop-tile-atlas';
|
||||
}
|
||||
if (elapsedMs >= 12_000) {
|
||||
return 'jump-hop-character';
|
||||
}
|
||||
return 'jump-hop-draft';
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -455,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
|
||||
@@ -491,9 +639,19 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
: state.kind === 'jump-hop' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: 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(
|
||||
@@ -518,9 +676,11 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: 0;
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? 0.5
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
@@ -551,6 +711,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
@@ -562,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'
|
||||
@@ -574,6 +736,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
0,
|
||||
BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
@@ -585,6 +749,65 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopGenerationAnchorEntries(
|
||||
session: JumpHopSessionSnapshot | null | undefined,
|
||||
formPayload: CreateJumpHopSessionRequest | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
const sessionRecord = session as
|
||||
| {
|
||||
config?: Partial<CreateJumpHopSessionRequest>;
|
||||
draft?: {
|
||||
workTitle?: string;
|
||||
themeText?: string;
|
||||
characterPrompt?: string;
|
||||
stylePreset?: string;
|
||||
} | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const config = sessionRecord?.config;
|
||||
const draft = sessionRecord?.draft;
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'jump-hop-theme',
|
||||
label: '主题',
|
||||
value:
|
||||
formPayload?.themeText?.trim() ||
|
||||
config?.themeText?.trim() ||
|
||||
draft?.themeText?.trim() ||
|
||||
draft?.workTitle?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value:
|
||||
formPayload?.characterDescription?.trim() ||
|
||||
config?.characterDescription?.trim() ||
|
||||
draft?.characterPrompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
value:
|
||||
formPayload?.tileStyle?.trim() ||
|
||||
config?.tileStyle?.trim() ||
|
||||
draft?.stylePreset?.trim() ||
|
||||
'',
|
||||
},
|
||||
];
|
||||
|
||||
return entries
|
||||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildPuzzleGenerationAnchorEntries(
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||||
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
|
||||
@@ -672,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',
|
||||
@@ -687,8 +907,8 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
},
|
||||
{
|
||||
key: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: `${itemCount} 件`,
|
||||
label: '素材数量',
|
||||
value: `${resolveMatch3DGeneratedItemCount()} 种素材`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -703,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);
|
||||
}
|
||||
@@ -13,6 +13,153 @@ export type PuzzleHistoryAsset = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PuzzleReferenceAsset = PuzzleHistoryAsset & {
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: 'puzzle_cover_image';
|
||||
ownerUserId?: string | null;
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
function resolvePuzzleImageContentType(file: File) {
|
||||
if (file.type.trim()) {
|
||||
return file.type.trim();
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? '';
|
||||
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function validatePuzzleReferenceImageFile(file: File) {
|
||||
const contentType = resolvePuzzleImageContentType(file);
|
||||
if (file.size <= 0) {
|
||||
throw new Error('参考图文件为空,请重新选择。');
|
||||
}
|
||||
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
|
||||
throw new Error('参考图过大,请压缩后再上传。');
|
||||
}
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw new Error('参考图必须是图片文件。');
|
||||
}
|
||||
}
|
||||
|
||||
async function postDirectUploadFile(
|
||||
upload: DirectUploadTicketResponse['upload'],
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(upload.host, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传拼图参考图失败。');
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPuzzleReferenceImage(payload: {
|
||||
file: File;
|
||||
}): Promise<PuzzleReferenceAsset> {
|
||||
validatePuzzleReferenceImageFile(payload.file);
|
||||
const contentType = resolvePuzzleImageContentType(payload.file);
|
||||
const uploadedAt = Date.now();
|
||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-puzzle-assets',
|
||||
pathSegments: ['puzzle-reference', 'draft', `${uploadedAt}`],
|
||||
fileName: payload.file.name,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||
metadata: {
|
||||
asset_kind: 'puzzle_cover_image',
|
||||
puzzle_slot: 'reference_image',
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建拼图参考图上传凭证失败',
|
||||
);
|
||||
|
||||
await postDirectUploadFile(ticket.upload, payload.file);
|
||||
|
||||
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: payload.file.size,
|
||||
assetKind: 'puzzle_cover_image',
|
||||
accessPolicy: 'private',
|
||||
}),
|
||||
},
|
||||
'确认拼图参考图失败',
|
||||
);
|
||||
|
||||
return {
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
assetKind: confirmed.assetObject.assetKind,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
imageSrc: ticket.upload.legacyPublicPath,
|
||||
ownerUserId: confirmed.assetObject.ownerUserId,
|
||||
ownerLabel: confirmed.assetObject.ownerUserId
|
||||
? `账号 ${confirmed.assetObject.ownerUserId}`
|
||||
: '当前账号',
|
||||
profileId: confirmed.assetObject.profileId,
|
||||
entityId: confirmed.assetObject.entityId,
|
||||
createdAt: confirmed.assetObject.createdAt ?? '',
|
||||
updatedAt: confirmed.assetObject.updatedAt ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export const puzzleReferenceAssetTestUtils = {
|
||||
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||
validateFile: validatePuzzleReferenceImageFile,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
|
||||
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
|
||||
@@ -34,4 +181,5 @@ export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
|
||||
|
||||
export const puzzleAssetClient = {
|
||||
listHistoryAssets: listPuzzleHistoryAssets,
|
||||
uploadReferenceImage: uploadPuzzleReferenceImage,
|
||||
};
|
||||
|
||||
@@ -238,3 +238,18 @@ export async function cropPuzzleReferenceImageDataUrl({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function puzzleReferenceImageDataUrlToFile(
|
||||
dataUrl: string,
|
||||
fileName = 'puzzle-reference.jpg',
|
||||
) {
|
||||
const [metadata = '', encoded = ''] = dataUrl.split(',', 2);
|
||||
const mimeType =
|
||||
metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg';
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new File([bytes], fileName, { type: mimeType });
|
||||
}
|
||||
|
||||
@@ -79,6 +79,72 @@ describe('rpgEntryLibraryClient world library routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes detail profiles before runtime launch consumes them', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
publicWorkCode: 'CW-1',
|
||||
authorPublicUserCode: 'U-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
name: '旧数据世界',
|
||||
summary: '只有摘要字段的旧 profile。',
|
||||
},
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
authorDisplayName: '作者',
|
||||
worldName: '旧数据世界',
|
||||
subtitle: '旧数据',
|
||||
summaryText: '只有摘要字段的旧 profile。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||
|
||||
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
|
||||
expect(Array.isArray(entry.profile.storyNpcs)).toBe(true);
|
||||
expect(Array.isArray(entry.profile.landmarks)).toBe(true);
|
||||
expect(entry.profile.attributeSchema.schemaVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to entry summary when old detail profile cannot be normalized', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
publicWorkCode: 'CW-1',
|
||||
authorPublicUserCode: 'U-1',
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
summary: '缺少 name 的旧 profile。',
|
||||
},
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-21T00:00:00.000Z',
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
authorDisplayName: '作者',
|
||||
worldName: '摘要兜底世界',
|
||||
subtitle: '旧数据',
|
||||
summaryText: '缺少 name 的旧 profile。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||
|
||||
expect(entry.profile.id).toBe('profile-1');
|
||||
expect(entry.profile.name).toBe('摘要兜底世界');
|
||||
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads owned library detail from the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
|
||||
@@ -7,13 +7,62 @@ import {
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
type RpgEntryWorldEntry = CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
type RpgEntryWorldMutationResponse =
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
|
||||
function normalizeRpgEntryWorldProfile(entry: RpgEntryWorldEntry) {
|
||||
const rawProfile =
|
||||
entry.profile && typeof entry.profile === 'object' ? entry.profile : {};
|
||||
const fallbackProfile = {
|
||||
id: entry.profileId,
|
||||
name: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summary: entry.summaryText,
|
||||
settingText: entry.summaryText || entry.worldName,
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
};
|
||||
const normalizedProfile =
|
||||
normalizeCustomWorldProfileRecord({
|
||||
...fallbackProfile,
|
||||
...rawProfile,
|
||||
}) ?? normalizeCustomWorldProfileRecord(fallbackProfile);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
profile: normalizedProfile ?? entry.profile,
|
||||
} as RpgEntryWorldEntry;
|
||||
}
|
||||
|
||||
function normalizeRpgEntryWorldEntries(
|
||||
entries: RpgEntryWorldEntry[] | null | undefined,
|
||||
) {
|
||||
return Array.isArray(entries)
|
||||
? entries.map((entry) => normalizeRpgEntryWorldProfile(entry))
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeRpgEntryWorldMutationResponse(
|
||||
response: RpgEntryWorldMutationResponse,
|
||||
) {
|
||||
return {
|
||||
entry: normalizeRpgEntryWorldProfile(response.entry),
|
||||
entries: normalizeRpgEntryWorldEntries(response.entries),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 入口世界库 client 的真实实现。
|
||||
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
|
||||
@@ -33,7 +82,7 @@ export async function listRpgEntryWorldLibrary(
|
||||
},
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
return normalizeRpgEntryWorldEntries(response?.entries);
|
||||
}
|
||||
|
||||
export async function listRpgEntryWorldGallery(
|
||||
@@ -63,7 +112,7 @@ export async function getRpgEntryWorldGalleryDetail(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldGalleryDetailByCode(
|
||||
@@ -79,7 +128,7 @@ export async function getRpgEntryWorldGalleryDetailByCode(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function remixRpgEntryWorldGallery(
|
||||
@@ -96,10 +145,7 @@ export async function remixRpgEntryWorldGallery(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function recordRpgEntryWorldGalleryPlay(
|
||||
@@ -116,7 +162,7 @@ export async function recordRpgEntryWorldGalleryPlay(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function likeRpgEntryWorldGallery(
|
||||
@@ -133,7 +179,7 @@ export async function likeRpgEntryWorldGallery(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldLibraryDetail(
|
||||
@@ -149,7 +195,7 @@ export async function getRpgEntryWorldLibraryDetail(
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
return normalizeRpgEntryWorldProfile(response.entry);
|
||||
}
|
||||
|
||||
export async function upsertRpgEntryWorldProfile(
|
||||
@@ -171,10 +217,7 @@ export async function upsertRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function deleteRpgEntryWorldProfile(
|
||||
@@ -190,7 +233,7 @@ export async function deleteRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
return normalizeRpgEntryWorldEntries(response?.entries);
|
||||
}
|
||||
|
||||
export async function publishRpgEntryWorldProfile(
|
||||
@@ -206,10 +249,7 @@ export async function publishRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function unpublishRpgEntryWorldProfile(
|
||||
@@ -225,10 +265,7 @@ export async function unpublishRpgEntryWorldProfile(
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
return normalizeRpgEntryWorldMutationResponse(response);
|
||||
}
|
||||
|
||||
export const rpgEntryLibraryClient = {
|
||||
|
||||
Reference in New Issue
Block a user