Add generationStatus and match3d/runtime fixes
Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
@@ -86,6 +86,58 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
|
||||
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
{
|
||||
workId: 'puzzle:generating',
|
||||
profileId: 'puzzle-profile-generating',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-generating',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '生成中拼图',
|
||||
summary: '退出产品后仍应显示生成中。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
match3dItems: [
|
||||
{
|
||||
workId: 'match3d:generating',
|
||||
profileId: 'match3d-profile-generating',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-generating',
|
||||
gameName: '生成中抓鹅',
|
||||
themeText: '糖果厨房',
|
||||
summary: '退出产品后仍应显示生成中。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 18,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
|
||||
true,
|
||||
);
|
||||
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
|
||||
const onOpenBabyObjectMatchDetail = vi.fn();
|
||||
const onDeleteBabyObjectMatch = vi.fn();
|
||||
|
||||
@@ -238,13 +238,19 @@ export function buildCreationWorkShelfItems(params: {
|
||||
]
|
||||
.map((item) => {
|
||||
const state = getItemState?.(item);
|
||||
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
|
||||
return state
|
||||
? {
|
||||
...item,
|
||||
isGenerating: state.isGenerating,
|
||||
isGenerating: Boolean(state.isGenerating || persistedIsGenerating),
|
||||
hasUnreadUpdate: state.hasUnreadUpdate,
|
||||
}
|
||||
: item;
|
||||
: persistedIsGenerating
|
||||
? {
|
||||
...item,
|
||||
isGenerating: true,
|
||||
}
|
||||
: item;
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
@@ -793,6 +799,17 @@ function buildPuzzleWorkShelfActions(
|
||||
};
|
||||
}
|
||||
|
||||
function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'match3d':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRpgWorkShelfActions(
|
||||
item: CustomWorldWorkSummary,
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
|
||||
@@ -1359,7 +1359,7 @@ describe('Match3DResultView', () => {
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
);
|
||||
expect(containerImage).toBeTruthy();
|
||||
expect(containerImage?.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage?.className).toContain('w-[min(108vw,38rem)]');
|
||||
expect(containerImage?.className).toContain('-translate-x-1/2');
|
||||
expect(
|
||||
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
|
||||
|
||||
@@ -52,6 +52,19 @@ import {
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
|
||||
const runtimeAudioFeedback = vi.hoisted(() => ({
|
||||
playRuntimeMergeSound: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/runtimeAudioFeedback')>();
|
||||
return {
|
||||
...actual,
|
||||
playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
@@ -82,6 +95,7 @@ afterEach(() => {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -519,6 +533,318 @@ test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () =>
|
||||
).toBe('scale(0.58)');
|
||||
});
|
||||
|
||||
test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => {
|
||||
const baseRun = startLocalMatch3DRun(3);
|
||||
const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3);
|
||||
expect(appleBoard && pearTray && appleTray).toBeTruthy();
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
items: [
|
||||
{
|
||||
...appleBoard!,
|
||||
itemInstanceId: 'apple-3',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: true,
|
||||
state: 'InBoard',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
layer: 10,
|
||||
traySlotIndex: null,
|
||||
},
|
||||
{
|
||||
...pearTray!,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 0,
|
||||
},
|
||||
{
|
||||
...appleTray!,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 1,
|
||||
},
|
||||
{
|
||||
...baseRun.items[3]!,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 2,
|
||||
},
|
||||
...baseRun.items.slice(4).map((item) => ({
|
||||
...item,
|
||||
clickable: false,
|
||||
state: 'InBoard' as const,
|
||||
})),
|
||||
],
|
||||
traySlots: baseRun.traySlots.map((slot) => {
|
||||
if (slot.slotIndex === 0) {
|
||||
return {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 1) {
|
||||
return {
|
||||
slotIndex: 1,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 2) {
|
||||
return {
|
||||
slotIndex: 2,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
};
|
||||
}
|
||||
return { slotIndex: slot.slotIndex };
|
||||
}),
|
||||
};
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
|
||||
status: 'Accepted' as const,
|
||||
acceptedItemInstanceId: payload.itemInstanceId,
|
||||
clearedItemInstanceIds: [],
|
||||
run: {
|
||||
...run,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
},
|
||||
}));
|
||||
const onOptimisticRunChange = vi.fn();
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 530,
|
||||
height: 56,
|
||||
left: 220,
|
||||
right: 276,
|
||||
top: 474,
|
||||
width: 56,
|
||||
x: 220,
|
||||
y: 474,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
const point = toMatch3DBoardClientPoint(run.items[0]!);
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 14);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 14);
|
||||
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled());
|
||||
const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as
|
||||
| Match3DRunSnapshot
|
||||
| undefined;
|
||||
expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
|
||||
'apple-1',
|
||||
'apple-2',
|
||||
'apple-3',
|
||||
'pear-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(
|
||||
optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3')
|
||||
?.traySlotIndex,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => {
|
||||
const baseRun = startLocalMatch3DRun(1);
|
||||
const [first, second, third, fourth] = baseRun.items.slice(0, 4);
|
||||
expect(first && second && third).toBeTruthy();
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
totalItemCount: 4,
|
||||
items: [
|
||||
{
|
||||
...first!,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
},
|
||||
{
|
||||
...second!,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 1,
|
||||
},
|
||||
{
|
||||
...third!,
|
||||
itemInstanceId: 'apple-3',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InBoard',
|
||||
clickable: true,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
layer: 10,
|
||||
traySlotIndex: null,
|
||||
},
|
||||
{
|
||||
...(fourth ?? third!),
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 2,
|
||||
},
|
||||
],
|
||||
traySlots: baseRun.traySlots.map((slot) => {
|
||||
if (slot.slotIndex === 0) {
|
||||
return {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 1) {
|
||||
return {
|
||||
slotIndex: 1,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 2) {
|
||||
return {
|
||||
slotIndex: 2,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
};
|
||||
}
|
||||
return { slotIndex: slot.slotIndex };
|
||||
}),
|
||||
};
|
||||
const acceptedRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
clearedItemCount: 3,
|
||||
items: run.items.map((item) =>
|
||||
item.itemTypeId === 'apple'
|
||||
? {
|
||||
...item,
|
||||
state: 'Cleared' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: null,
|
||||
}
|
||||
: { ...item, traySlotIndex: 0 },
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
}
|
||||
: { slotIndex: slot.slotIndex },
|
||||
),
|
||||
};
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
|
||||
status: 'Accepted' as const,
|
||||
acceptedItemInstanceId: payload.itemInstanceId,
|
||||
clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'],
|
||||
run: acceptedRun,
|
||||
}));
|
||||
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={nextRun}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => {
|
||||
Object.defineProperty(slot, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 530,
|
||||
height: 56,
|
||||
left: 52 + index * 58,
|
||||
right: 108 + index * 58,
|
||||
top: 474,
|
||||
width: 56,
|
||||
x: 52 + index * 58,
|
||||
y: 474,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const point = toMatch3DBoardClientPoint(run.items[2]!);
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 15);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 15);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3);
|
||||
expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy();
|
||||
expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull();
|
||||
expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1);
|
||||
const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as
|
||||
| Match3DRunSnapshot
|
||||
| undefined;
|
||||
expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
|
||||
'pear-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const clickableItem = run.items.find((item) => item.clickable)!;
|
||||
@@ -1025,9 +1351,10 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
|
||||
const containerImage = screen.getByTestId(
|
||||
'match3d-container-image',
|
||||
) as HTMLImageElement;
|
||||
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage.className).toContain('w-[min(116vw,42rem)]');
|
||||
expect(containerImage.className).toContain('h-auto');
|
||||
expect(containerImage.className).toContain('left-1/2');
|
||||
expect(containerImage.className).toContain('top-[54%]');
|
||||
expect(containerImage.className).toContain('-translate-x-1/2');
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Clock3,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
type PointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -30,20 +30,35 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import {
|
||||
isGeneratedLegacyPath,
|
||||
readAssetBytes,
|
||||
resolveAssetReadUrl,
|
||||
} from '../../services/assetReadUrlService';
|
||||
import {
|
||||
getMatch3DGeneratedImageViewSources,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
buildMatch3DTrayInsertionPlan,
|
||||
resolveMatch3DTrayItemIdToSlotIndexMap,
|
||||
syncMatch3DItemTraySlotIndexes,
|
||||
} from '../../services/match3d-runtime/match3dTrayLayout';
|
||||
import {
|
||||
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
||||
playRuntimeClickSound,
|
||||
playRuntimeCountdownSound,
|
||||
playRuntimeLevelClearSound,
|
||||
playRuntimeMergeSound,
|
||||
resolveRuntimeCountdownSecondBucket,
|
||||
} from '../../services/runtimeAudioFeedback';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
findMatch3DHitItem,
|
||||
type Match3DAlphaHitMask,
|
||||
type Match3DGeneratedItemRelativeSize,
|
||||
type Match3DResolvedImageSourceEntry,
|
||||
resolveMatch3DImageSourceEntryForItem,
|
||||
resolveMatch3DItemSizeScale,
|
||||
} from './match3dHotspot';
|
||||
import {
|
||||
isItemState,
|
||||
isRunState,
|
||||
@@ -103,6 +118,19 @@ type Match3DBoardPoint = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
type Match3DTraySlotLayout = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Match3DTrayMovingItemAnimation = {
|
||||
itemInstanceId: string;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
type Match3DFlyingTrayAnimation = {
|
||||
id: string;
|
||||
item: Match3DItemSnapshot;
|
||||
@@ -116,7 +144,24 @@ type Match3DFlyingTrayAnimation = {
|
||||
toSize: number;
|
||||
};
|
||||
|
||||
type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
|
||||
type Match3DTrayClearAnimation = {
|
||||
id: string;
|
||||
items: Array<{
|
||||
itemInstanceId: string;
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
imageSrc: string;
|
||||
itemSize: Match3DGeneratedItemRelativeSize;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
};
|
||||
|
||||
function resolveTrayPreviewItem(
|
||||
run: Match3DRunSnapshot,
|
||||
@@ -168,26 +213,6 @@ function buildClientEventId(itemInstanceId: string) {
|
||||
)}`;
|
||||
}
|
||||
|
||||
function isPointInsideCircle(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
|
||||
}
|
||||
|
||||
function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
|
||||
return run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
isItemState(item.state, 'in_board') &&
|
||||
item.clickable &&
|
||||
isPointInsideCircle(pointX, pointY, item),
|
||||
)
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
}
|
||||
|
||||
function resolveBoardPointFromPointerEvent(
|
||||
event: Pick<PointerEvent<HTMLDivElement>, 'clientX' | 'clientY'>,
|
||||
stage: HTMLElement | null,
|
||||
@@ -284,26 +309,47 @@ function resolveStaticMatch3DReadUrlMap(sources: readonly string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildResolvedMatch3DImageSourcesByType(
|
||||
function buildResolvedMatch3DImageSourceEntriesByType(
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
resolvedImageSources: ReadonlyMap<string, string>,
|
||||
) {
|
||||
return new Map(
|
||||
[...imageSourcesByType.entries()].map(([typeId, sources]) => [
|
||||
typeId,
|
||||
sources
|
||||
.map((source) => {
|
||||
const resolvedSource = resolvedImageSources.get(source);
|
||||
if (resolvedSource) {
|
||||
return resolvedSource;
|
||||
}
|
||||
return isGeneratedLegacyPath(source) ? '' : source;
|
||||
})
|
||||
.filter(Boolean),
|
||||
sources.flatMap((rawSource) => {
|
||||
const source = rawSource.trim();
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
const resolvedSource = resolvedImageSources.get(source);
|
||||
if (resolvedSource) {
|
||||
return [{ source, resolvedSource }];
|
||||
}
|
||||
return isGeneratedLegacyPath(source)
|
||||
? []
|
||||
: [{ source, resolvedSource: source }];
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DAlphaHitMaskCacheKey(
|
||||
imageSourceEntriesByType: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>,
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
[...imageSourceEntriesByType.values()].flatMap((entries) =>
|
||||
entries.map((entry) => entry.source.trim()).filter(Boolean),
|
||||
),
|
||||
),
|
||||
]
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function normalizeMatch3DGeneratedItemSize(
|
||||
itemSize: Match3DGeneratedItemAsset['itemSize'] | null | undefined,
|
||||
): Match3DGeneratedItemRelativeSize {
|
||||
@@ -342,35 +388,17 @@ function buildMatch3DItemSizeByType(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DItemSizeScale(
|
||||
itemSize: Match3DGeneratedItemRelativeSize | undefined,
|
||||
) {
|
||||
if (itemSize === '小') {
|
||||
return 0.58;
|
||||
}
|
||||
if (itemSize === '中') {
|
||||
return 0.78;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function hashMatch3DString(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function resolveMatch3DImageForItem(
|
||||
function resolveMatch3DResolvedImageForItem(
|
||||
item: Match3DItemSnapshot,
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
imageSourceEntriesByType: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>,
|
||||
) {
|
||||
const sources = imageSourcesByType.get(item.itemTypeId);
|
||||
if (!sources || sources.length <= 0) {
|
||||
return '';
|
||||
}
|
||||
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? '';
|
||||
return (
|
||||
resolveMatch3DImageSourceEntryForItem(item, imageSourceEntriesByType)
|
||||
?.resolvedSource ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function hasPendingMatch3DGeneratedImageForItem(
|
||||
@@ -391,13 +419,6 @@ function hasPendingMatch3DGeneratedImageForItem(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DFirstImageForItem(
|
||||
item: Match3DItemSnapshot,
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
return imageSourcesByType.get(item.itemTypeId)?.[0] ?? '';
|
||||
}
|
||||
|
||||
function resolveMatch3DItemSizeForType(
|
||||
item: Pick<Match3DItemSnapshot, 'itemTypeId'>,
|
||||
itemSizeByType: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>,
|
||||
@@ -405,38 +426,106 @@ function resolveMatch3DItemSizeForType(
|
||||
return itemSizeByType.get(item.itemTypeId) ?? '大';
|
||||
}
|
||||
|
||||
function resolveMatch3DSlotLayout(
|
||||
element: HTMLElement | null,
|
||||
): Match3DTraySlotLayout | null {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptimisticRun(
|
||||
run: Match3DRunSnapshot,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
|
||||
if (!nextSlot) {
|
||||
const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, item);
|
||||
if (!insertion) {
|
||||
return run;
|
||||
}
|
||||
const nextItems = run.items.map((entry) =>
|
||||
entry.itemInstanceId === item.itemInstanceId
|
||||
? {
|
||||
...entry,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: insertion.slotIndex,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
return {
|
||||
...run,
|
||||
items: run.items.map((entry) =>
|
||||
entry.itemInstanceId === item.itemInstanceId
|
||||
? {
|
||||
...entry,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextSlot.slotIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
itemTypeId: item.itemTypeId,
|
||||
visualKey: item.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots),
|
||||
traySlots: insertion.traySlots,
|
||||
};
|
||||
}
|
||||
|
||||
function loadMatch3DAlphaHitMaskImage(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('读取抓大鹅物品热区图片失败'));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMatch3DAlphaHitMask(
|
||||
source: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Match3DAlphaHitMask> {
|
||||
const response = await readAssetBytes(source, {
|
||||
signal,
|
||||
expireSeconds: 300,
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const canCreateObjectUrl =
|
||||
typeof URL.createObjectURL === 'function' &&
|
||||
typeof URL.revokeObjectURL === 'function';
|
||||
const imageSource = canCreateObjectUrl
|
||||
? URL.createObjectURL(blob)
|
||||
: await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(new Error('读取抓大鹅热区图片失败'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
try {
|
||||
const image = await loadMatch3DAlphaHitMaskImage(imageSource);
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('热区图片读取已取消', '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('浏览器不支持读取物品热区图片');
|
||||
}
|
||||
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 { width, height, alpha };
|
||||
} finally {
|
||||
if (canCreateObjectUrl) {
|
||||
URL.revokeObjectURL(imageSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Match3DToken({
|
||||
item,
|
||||
imageSrc,
|
||||
@@ -514,11 +603,15 @@ function Match3DTrayToken({
|
||||
imageSrc,
|
||||
itemSize,
|
||||
isArriving = false,
|
||||
isClearing = false,
|
||||
moveAnimation = null,
|
||||
}: {
|
||||
slot: Match3DTraySlot;
|
||||
imageSrc?: string;
|
||||
itemSize?: Match3DGeneratedItemRelativeSize;
|
||||
isArriving?: boolean;
|
||||
isClearing?: boolean;
|
||||
moveAnimation?: Match3DTrayMovingItemAnimation | null;
|
||||
}) {
|
||||
if (!slot.visualKey) {
|
||||
return (
|
||||
@@ -526,11 +619,20 @@ function Match3DTrayToken({
|
||||
);
|
||||
}
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
const style = moveAnimation
|
||||
? ({
|
||||
'--match3d-tray-shift-x': `${moveAnimation.offsetX}px`,
|
||||
'--match3d-tray-shift-y': `${moveAnimation.offsetY}px`,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
return (
|
||||
<span
|
||||
className={`flex h-full w-full items-center justify-center p-1 transition-opacity duration-150 ${
|
||||
isArriving ? 'opacity-0' : 'opacity-100'
|
||||
moveAnimation ? 'match3d-tray-token-shift' : ''
|
||||
} ${isArriving || isClearing ? 'opacity-0' : 'opacity-100'} ${
|
||||
isClearing ? 'pointer-events-none' : ''
|
||||
}`}
|
||||
style={style}
|
||||
aria-label={visualSeed.label}
|
||||
>
|
||||
{imageSrc ? (
|
||||
@@ -603,6 +705,65 @@ function Match3DFlyingTrayToken({
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayClearToken({
|
||||
animation,
|
||||
onDone,
|
||||
}: {
|
||||
animation: Match3DTrayClearAnimation;
|
||||
onDone: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div aria-hidden="true" data-testid="match3d-tray-clear-animation">
|
||||
{animation.items.map((item, index) => {
|
||||
const visualSeed = resolveVisualSeed(item.visualKey);
|
||||
const style = {
|
||||
'--match3d-tray-clear-dx': `${item.toX - item.fromX}px`,
|
||||
'--match3d-tray-clear-dy': `${item.toY - item.fromY}px`,
|
||||
height: `${item.height}px`,
|
||||
left: `${item.fromX}px`,
|
||||
top: `${item.fromY}px`,
|
||||
width: `${item.width}px`,
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<div
|
||||
key={item.itemInstanceId}
|
||||
className="match3d-tray-token-clear pointer-events-none fixed z-[96] flex items-center justify-center p-1"
|
||||
data-testid="match3d-tray-clear-token"
|
||||
style={style}
|
||||
onAnimationEnd={
|
||||
index === animation.items.length - 1
|
||||
? () => onDone(animation.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.imageSrc ? (
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-contain drop-shadow-[0_7px_11px_rgba(15,23,42,0.28)]"
|
||||
style={{
|
||||
transform: `scale(${resolveMatch3DItemSizeScale(item.itemSize)})`,
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Match3DVisualIcon visualKey={item.visualKey} />
|
||||
)}
|
||||
<span className="sr-only">{visualSeed.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="match3d-tray-clear-flash pointer-events-none fixed z-[97]"
|
||||
style={{
|
||||
left: `${animation.centerX}px`,
|
||||
top: `${animation.centerY}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DSettlement({
|
||||
run,
|
||||
hideBackButton,
|
||||
@@ -692,6 +853,7 @@ export function Match3DRuntimeShell({
|
||||
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const clearSoundKeyRef = useRef<string | null>(null);
|
||||
const countdownSoundKeyRef = useRef<string | null>(null);
|
||||
const mergeSoundKeyRef = useRef<string | null>(null);
|
||||
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
|
||||
const [feedbackEvent, setFeedbackEvent] =
|
||||
useState<Match3DFeedbackEvent | null>(null);
|
||||
@@ -702,6 +864,15 @@ export function Match3DRuntimeShell({
|
||||
const pendingClickLockRef = useRef(false);
|
||||
const [flyingTrayAnimation, setFlyingTrayAnimation] =
|
||||
useState<Match3DFlyingTrayAnimation | null>(null);
|
||||
const [trayClearAnimation, setTrayClearAnimation] =
|
||||
useState<Match3DTrayClearAnimation | null>(null);
|
||||
const [trayMovingItemAnimations, setTrayMovingItemAnimations] = useState<
|
||||
Match3DTrayMovingItemAnimation[]
|
||||
>([]);
|
||||
const previousTrayItemSlotIndexMapRef = useRef<Map<string, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const trayMovingTimeoutRef = useRef<number | null>(null);
|
||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
|
||||
useState('');
|
||||
@@ -753,6 +924,80 @@ export function Match3DRuntimeShell({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [flyingTrayAnimation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trayClearAnimation) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setTrayClearAnimation((current) =>
|
||||
current?.id === trayClearAnimation.id ? null : current,
|
||||
);
|
||||
}, 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [trayClearAnimation]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!run) {
|
||||
previousTrayItemSlotIndexMapRef.current = new Map();
|
||||
setTrayMovingItemAnimations((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousMap = previousTrayItemSlotIndexMapRef.current;
|
||||
const nextMap = resolveMatch3DTrayItemIdToSlotIndexMap(run.traySlots);
|
||||
const movingAnimations = [...nextMap.entries()].flatMap(
|
||||
([itemInstanceId, nextSlotIndex]) => {
|
||||
const previousSlotIndex = previousMap.get(itemInstanceId);
|
||||
if (
|
||||
previousSlotIndex === undefined ||
|
||||
previousSlotIndex === nextSlotIndex
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const previousLayout = resolveMatch3DSlotLayout(
|
||||
traySlotRefs.current[previousSlotIndex] ?? null,
|
||||
);
|
||||
const nextLayout = resolveMatch3DSlotLayout(
|
||||
traySlotRefs.current[nextSlotIndex] ?? null,
|
||||
);
|
||||
if (!previousLayout || !nextLayout) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
itemInstanceId,
|
||||
offsetX: previousLayout.left - nextLayout.left,
|
||||
offsetY: previousLayout.top - nextLayout.top,
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
previousTrayItemSlotIndexMapRef.current = nextMap;
|
||||
if (movingAnimations.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTrayMovingItemAnimations(movingAnimations);
|
||||
if (trayMovingTimeoutRef.current !== null) {
|
||||
window.clearTimeout(trayMovingTimeoutRef.current);
|
||||
}
|
||||
trayMovingTimeoutRef.current = window.setTimeout(() => {
|
||||
setTrayMovingItemAnimations([]);
|
||||
trayMovingTimeoutRef.current = null;
|
||||
}, 260);
|
||||
}, [run]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (trayMovingTimeoutRef.current !== null) {
|
||||
window.clearTimeout(trayMovingTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run) {
|
||||
clearSoundKeyRef.current = null;
|
||||
@@ -845,14 +1090,24 @@ export function Match3DRuntimeShell({
|
||||
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const resolvedImageSourcesByType = useMemo(
|
||||
const resolvedImageSourceEntriesByType = useMemo(
|
||||
() =>
|
||||
buildResolvedMatch3DImageSourcesByType(
|
||||
buildResolvedMatch3DImageSourceEntriesByType(
|
||||
imageSourcesByType,
|
||||
resolvedImageSources,
|
||||
),
|
||||
[imageSourcesByType, resolvedImageSources],
|
||||
);
|
||||
const alphaHitMaskCacheKey = useMemo(
|
||||
() => resolveMatch3DAlphaHitMaskCacheKey(resolvedImageSourceEntriesByType),
|
||||
[resolvedImageSourceEntriesByType],
|
||||
);
|
||||
const [alphaHitMasks, setAlphaHitMasks] = useState<
|
||||
Map<string, Match3DAlphaHitMask>
|
||||
>(() => new Map());
|
||||
const [failedAlphaHitMaskSources, setFailedAlphaHitMaskSources] = useState<
|
||||
Set<string>
|
||||
>(() => new Set());
|
||||
const backgroundMusicSrc =
|
||||
runtimeGeneratedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
|
||||
?.backgroundMusic?.audioSrc ?? null;
|
||||
@@ -1086,12 +1341,84 @@ export function Match3DRuntimeShell({
|
||||
};
|
||||
}, [imageReadUrlCacheKey, imageSourcesByType]);
|
||||
|
||||
useEffect(() => {
|
||||
const rawSources = alphaHitMaskCacheKey
|
||||
? alphaHitMaskCacheKey.split('|').filter(Boolean)
|
||||
: [];
|
||||
if (rawSources.length <= 0) {
|
||||
setAlphaHitMasks((current) => (current.size > 0 ? new Map() : current));
|
||||
setFailedAlphaHitMaskSources((current) =>
|
||||
current.size > 0 ? new Set() : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const sourceSet = new Set(rawSources);
|
||||
const nextMasks = new Map<string, Match3DAlphaHitMask>();
|
||||
const failedSources = new Set<string>();
|
||||
setAlphaHitMasks((current) => {
|
||||
const retained = new Map(
|
||||
[...current.entries()].filter(([source]) => sourceSet.has(source)),
|
||||
);
|
||||
retained.forEach((mask, source) => nextMasks.set(source, mask));
|
||||
return retained.size === current.size ? current : retained;
|
||||
});
|
||||
setFailedAlphaHitMaskSources(new Set());
|
||||
|
||||
void Promise.all(
|
||||
rawSources.map(async (source) => {
|
||||
if (nextMasks.has(source)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
nextMasks.set(
|
||||
source,
|
||||
await loadMatch3DAlphaHitMask(source, controller.signal),
|
||||
);
|
||||
} catch {
|
||||
// 中文注释:读不到 alpha 时保留旧圆形粗筛,避免个别跨域旧图导致物品完全不可点。
|
||||
failedSources.add(source);
|
||||
}
|
||||
}),
|
||||
).then(() => {
|
||||
if (!cancelled) {
|
||||
setAlphaHitMasks(new Map(nextMasks));
|
||||
setFailedAlphaHitMaskSources(failedSources);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [alphaHitMaskCacheKey]);
|
||||
|
||||
const trayPreviewItems = useMemo(() => {
|
||||
if (!run) {
|
||||
return [];
|
||||
}
|
||||
return run.traySlots.map((slot) => resolveTrayPreviewItem(run, slot));
|
||||
}, [run]);
|
||||
const trayMovingItemAnimationById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
trayMovingItemAnimations.map((animation) => [
|
||||
animation.itemInstanceId,
|
||||
animation,
|
||||
]),
|
||||
),
|
||||
[trayMovingItemAnimations],
|
||||
);
|
||||
|
||||
const resolveFirstResolvedImageForItem = useCallback(
|
||||
(item: Match3DItemSnapshot) => {
|
||||
return resolvedImageSourceEntriesByType.get(item.itemTypeId)?.[0]
|
||||
?.resolvedSource ?? '';
|
||||
},
|
||||
[resolvedImageSourceEntriesByType],
|
||||
);
|
||||
|
||||
const startFlyingTrayAnimation = useCallback(
|
||||
(
|
||||
@@ -1120,10 +1447,7 @@ export function Match3DRuntimeShell({
|
||||
setFlyingTrayAnimation({
|
||||
id: animationId,
|
||||
item,
|
||||
imageSrc: resolveMatch3DFirstImageForItem(
|
||||
item,
|
||||
resolvedImageSourcesByType,
|
||||
),
|
||||
imageSrc: resolveFirstResolvedImageForItem(item),
|
||||
itemSize: resolveMatch3DItemSizeForType(item, itemSizeByType),
|
||||
fromSize,
|
||||
fromX: boardRect.left + frame.x * boardRect.width,
|
||||
@@ -1133,7 +1457,88 @@ export function Match3DRuntimeShell({
|
||||
toY: slotRect.top + slotRect.height / 2,
|
||||
});
|
||||
},
|
||||
[itemSizeByType, resolvedImageSourcesByType],
|
||||
[itemSizeByType, resolveFirstResolvedImageForItem],
|
||||
);
|
||||
|
||||
const startTrayClearAnimation = useCallback(
|
||||
(
|
||||
animationId: string,
|
||||
clearedItemInstanceIds: readonly string[],
|
||||
sourceRun: Match3DRunSnapshot,
|
||||
) => {
|
||||
if (clearedItemInstanceIds.length <= 0) {
|
||||
return;
|
||||
}
|
||||
const slots = clearedItemInstanceIds
|
||||
.map((itemInstanceId) =>
|
||||
sourceRun.traySlots.find(
|
||||
(slot) => slot.itemInstanceId === itemInstanceId,
|
||||
),
|
||||
)
|
||||
.filter((slot): slot is Match3DTraySlot => Boolean(slot));
|
||||
const layouts = slots
|
||||
.map((slot) => ({
|
||||
slot,
|
||||
layout: resolveMatch3DSlotLayout(
|
||||
traySlotRefs.current[slot.slotIndex] ?? null,
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(entry): entry is { slot: Match3DTraySlot; layout: Match3DTraySlotLayout } =>
|
||||
Boolean(entry.layout),
|
||||
);
|
||||
if (layouts.length <= 0) {
|
||||
return;
|
||||
}
|
||||
const centerX =
|
||||
layouts.reduce(
|
||||
(sum, entry) => sum + entry.layout.left + entry.layout.width / 2,
|
||||
0,
|
||||
) / layouts.length;
|
||||
const centerY =
|
||||
layouts.reduce(
|
||||
(sum, entry) => sum + entry.layout.top + entry.layout.height / 2,
|
||||
0,
|
||||
) / layouts.length;
|
||||
|
||||
setTrayClearAnimation({
|
||||
id: animationId,
|
||||
centerX,
|
||||
centerY,
|
||||
items: layouts.map(({ slot, layout }) => {
|
||||
const item = sourceRun.items.find(
|
||||
(entry) => entry.itemInstanceId === slot.itemInstanceId,
|
||||
);
|
||||
const itemTypeId = slot.itemTypeId ?? item?.itemTypeId ?? '';
|
||||
const visualKey = slot.visualKey ?? item?.visualKey ?? '';
|
||||
return {
|
||||
itemInstanceId: slot.itemInstanceId ?? '',
|
||||
itemTypeId,
|
||||
visualKey,
|
||||
imageSrc: item
|
||||
? resolveFirstResolvedImageForItem(item)
|
||||
: itemTypeId
|
||||
? (resolvedImageSourceEntriesByType.get(itemTypeId)?.[0]
|
||||
?.resolvedSource ?? '')
|
||||
: '',
|
||||
itemSize:
|
||||
itemSizeByType.get(itemTypeId) ??
|
||||
(item ? resolveMatch3DItemSizeForType(item, itemSizeByType) : '大'),
|
||||
fromX: layout.left + layout.width / 2,
|
||||
fromY: layout.top + layout.height / 2,
|
||||
toX: centerX,
|
||||
toY: centerY,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
};
|
||||
}),
|
||||
});
|
||||
},
|
||||
[
|
||||
itemSizeByType,
|
||||
resolvedImageSourceEntriesByType,
|
||||
resolveFirstResolvedImageForItem,
|
||||
],
|
||||
);
|
||||
|
||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||
@@ -1148,12 +1553,15 @@ export function Match3DRuntimeShell({
|
||||
pendingClickLockRef.current = true;
|
||||
const optimisticRun = buildOptimisticRun(run, item);
|
||||
const clientEventId = buildClientEventId(item.itemInstanceId);
|
||||
const targetSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
|
||||
const targetSlotIndex =
|
||||
optimisticRun.items.find(
|
||||
(entry) => entry.itemInstanceId === item.itemInstanceId,
|
||||
)?.traySlotIndex ?? null;
|
||||
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
|
||||
tryPlayBackgroundMusic();
|
||||
playClickSound(item);
|
||||
if (targetSlot) {
|
||||
startFlyingTrayAnimation(item, targetSlot.slotIndex, clientEventId);
|
||||
if (targetSlotIndex !== null && targetSlotIndex !== undefined) {
|
||||
startFlyingTrayAnimation(item, targetSlotIndex, clientEventId);
|
||||
}
|
||||
setPendingClick({
|
||||
clientEventId,
|
||||
@@ -1172,6 +1580,19 @@ export function Match3DRuntimeShell({
|
||||
});
|
||||
if (result.status === 'Accepted') {
|
||||
if (result.clearedItemInstanceIds.length > 0) {
|
||||
startTrayClearAnimation(
|
||||
clientEventId,
|
||||
result.clearedItemInstanceIds,
|
||||
optimisticRun,
|
||||
);
|
||||
// 中文注释:普通三消播放合成音效;最终胜利局由结算音效接管,避免同一手势连播。
|
||||
if (!isRunState(result.run.status, 'won')) {
|
||||
const soundKey = `${result.run.runId}:${result.run.snapshotVersion}:merge`;
|
||||
if (mergeSoundKeyRef.current !== soundKey) {
|
||||
mergeSoundKeyRef.current = soundKey;
|
||||
playRuntimeMergeSound(musicVolume);
|
||||
}
|
||||
}
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'cleared',
|
||||
@@ -1198,7 +1619,14 @@ export function Match3DRuntimeShell({
|
||||
return null;
|
||||
}
|
||||
const point = resolveBoardPointFromPointerEvent(event, stageRef.current);
|
||||
return point ? (findHitItem(run, point.x, point.y) ?? null) : null;
|
||||
return point
|
||||
? (findMatch3DHitItem(run, point.x, point.y, {
|
||||
alphaHitMasks,
|
||||
failedAlphaHitMaskSources,
|
||||
imageSourceEntriesByType: resolvedImageSourceEntriesByType,
|
||||
itemSizeByType,
|
||||
}) ?? null)
|
||||
: null;
|
||||
};
|
||||
|
||||
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
@@ -1377,9 +1805,9 @@ export function Match3DRuntimeShell({
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
item={item}
|
||||
imageSrc={resolveMatch3DImageForItem(
|
||||
imageSrc={resolveMatch3DResolvedImageForItem(
|
||||
item,
|
||||
resolvedImageSourcesByType,
|
||||
resolvedImageSourceEntriesByType,
|
||||
)}
|
||||
itemSize={resolveMatch3DItemSizeForType(item, itemSizeByType)}
|
||||
disabled={Boolean(pendingClick)}
|
||||
@@ -1389,9 +1817,10 @@ export function Match3DRuntimeShell({
|
||||
)}
|
||||
{feedbackEvent?.kind === 'cleared' ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
|
||||
<Sparkles size={42} />
|
||||
</div>
|
||||
<div
|
||||
className="match3d-merge-feedback-pulse h-24 w-24 rounded-full bg-white/20 shadow-[0_0_42px_rgba(255,255,255,0.62)] backdrop-blur-sm"
|
||||
data-testid="match3d-merge-feedback"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1415,6 +1844,7 @@ export function Match3DRuntimeShell({
|
||||
key={slot.slotIndex}
|
||||
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
|
||||
data-testid="match3d-tray-slot"
|
||||
data-slot-index={slot.slotIndex}
|
||||
ref={(element) => {
|
||||
traySlotRefs.current[slot.slotIndex] = element;
|
||||
}}
|
||||
@@ -1425,12 +1855,24 @@ export function Match3DRuntimeShell({
|
||||
flyingTrayAnimation?.item.itemInstanceId ===
|
||||
slot.itemInstanceId
|
||||
}
|
||||
isClearing={
|
||||
Boolean(slot.itemInstanceId) &&
|
||||
(trayClearAnimation?.items.some(
|
||||
(clearItem) =>
|
||||
clearItem.itemInstanceId === slot.itemInstanceId,
|
||||
) ??
|
||||
false)
|
||||
}
|
||||
moveAnimation={
|
||||
slot.itemInstanceId
|
||||
? (trayMovingItemAnimationById.get(
|
||||
slot.itemInstanceId,
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
imageSrc={
|
||||
trayItem
|
||||
? resolveMatch3DFirstImageForItem(
|
||||
trayItem,
|
||||
resolvedImageSourcesByType,
|
||||
)
|
||||
? resolveFirstResolvedImageForItem(trayItem)
|
||||
: ''
|
||||
}
|
||||
itemSize={
|
||||
@@ -1466,6 +1908,17 @@ export function Match3DRuntimeShell({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{trayClearAnimation ? (
|
||||
<Match3DTrayClearToken
|
||||
animation={trayClearAnimation}
|
||||
onDone={(id) =>
|
||||
setTrayClearAnimation((current) =>
|
||||
current?.id === id ? null : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Match3DSettlement
|
||||
run={run}
|
||||
hideBackButton={hideBackButton}
|
||||
|
||||
124
src/components/match3d-runtime/match3dHotspot.test.ts
Normal file
124
src/components/match3d-runtime/match3dHotspot.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
findMatch3DHitItem,
|
||||
isPointInsideMatch3DItemAlphaHotspot,
|
||||
resolveMatch3DItemSizeScale,
|
||||
type Match3DResolvedImageSourceEntry,
|
||||
} from './match3dHotspot';
|
||||
import { resolveRenderableItemFrame } from './match3dRuntimePresentation';
|
||||
|
||||
function buildMatch3DHotspotRun(): Match3DRunSnapshot {
|
||||
return {
|
||||
runId: 'hotspot-run',
|
||||
profileId: 'hotspot-profile',
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1,
|
||||
durationLimitMs: 600_000,
|
||||
remainingMs: 600_000,
|
||||
clearCount: 1,
|
||||
totalItemCount: 1,
|
||||
clearedItemCount: 0,
|
||||
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
|
||||
items: [
|
||||
{
|
||||
clickable: true,
|
||||
itemInstanceId: 'alpha-hotspot-item',
|
||||
itemTypeId: 'match3d-type-01',
|
||||
visualKey: 'block-red-2x2',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
radius: 0.1,
|
||||
layer: 1,
|
||||
state: 'InBoard',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('透明像素不作为抓大鹅物品点击热区', () => {
|
||||
const run = buildMatch3DHotspotRun();
|
||||
const item = run.items[0]!;
|
||||
const mask = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
alpha: new Uint8ClampedArray([
|
||||
0, 0, 0, 0,
|
||||
0, 255, 255, 0,
|
||||
0, 255, 255, 0,
|
||||
0, 0, 0, 0,
|
||||
]),
|
||||
};
|
||||
const imageSourceEntriesByType = new Map<string, Match3DResolvedImageSourceEntry[]>([
|
||||
[
|
||||
'match3d-type-01',
|
||||
[
|
||||
{
|
||||
source: '/generated-match3d-assets/item-01.png',
|
||||
resolvedSource: 'https://oss.example.com/item-01.png',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const alphaHitMasks = new Map([
|
||||
['/generated-match3d-assets/item-01.png', mask],
|
||||
]);
|
||||
const itemSizeByType = new Map([['match3d-type-01', '大' as const]]);
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
|
||||
expect(
|
||||
findMatch3DHitItem(run, frame.x - frame.radius * 0.6, 0.5, {
|
||||
alphaHitMasks,
|
||||
imageSourceEntriesByType,
|
||||
itemSizeByType,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findMatch3DHitItem(run, 0.5, 0.5, {
|
||||
alphaHitMasks,
|
||||
imageSourceEntriesByType,
|
||||
itemSizeByType,
|
||||
})?.itemInstanceId,
|
||||
).toBe('alpha-hotspot-item');
|
||||
});
|
||||
|
||||
test('小尺寸物品只在缩放后的非透明主体内命中', () => {
|
||||
const item = buildMatch3DHotspotRun().items[0]!;
|
||||
const mask = {
|
||||
width: 2,
|
||||
height: 2,
|
||||
alpha: new Uint8ClampedArray([255, 255, 255, 255]),
|
||||
};
|
||||
|
||||
expect(
|
||||
isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX: 0.5,
|
||||
pointY: 0.5,
|
||||
mask,
|
||||
itemSize: '小',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX: 0.38,
|
||||
pointY: 0.5,
|
||||
mask,
|
||||
itemSize: '小',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('抓大鹅生成物品大和中也会做一定程度缩小', () => {
|
||||
expect(resolveMatch3DItemSizeScale('大')).toBeLessThan(1);
|
||||
expect(resolveMatch3DItemSizeScale('中')).toBeLessThan(0.78);
|
||||
expect(resolveMatch3DItemSizeScale('大')).toBeGreaterThan(
|
||||
resolveMatch3DItemSizeScale('中'),
|
||||
);
|
||||
expect(resolveMatch3DItemSizeScale('中')).toBeGreaterThan(
|
||||
resolveMatch3DItemSizeScale('小'),
|
||||
);
|
||||
});
|
||||
194
src/components/match3d-runtime/match3dHotspot.ts
Normal file
194
src/components/match3d-runtime/match3dHotspot.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
|
||||
export type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
|
||||
|
||||
export type Match3DAlphaHitMask = {
|
||||
width: number;
|
||||
height: number;
|
||||
alpha: Uint8ClampedArray;
|
||||
};
|
||||
|
||||
export type Match3DResolvedImageSourceEntry = {
|
||||
source: string;
|
||||
resolvedSource: string;
|
||||
};
|
||||
|
||||
const MATCH3D_HIT_ALPHA_THRESHOLD = 8;
|
||||
|
||||
function isPointInsideCircle(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
|
||||
}
|
||||
|
||||
function clampMatch3DHitPixelIndex(value: number, size: number) {
|
||||
return Math.min(size - 1, Math.max(0, Math.floor(value * size)));
|
||||
}
|
||||
|
||||
export function resolveMatch3DItemSizeScale(
|
||||
itemSize: Match3DGeneratedItemRelativeSize | undefined,
|
||||
) {
|
||||
if (itemSize === '小') {
|
||||
return 0.58;
|
||||
}
|
||||
if (itemSize === '中') {
|
||||
return 0.68;
|
||||
}
|
||||
return 0.88;
|
||||
}
|
||||
|
||||
function isPointInsideAlphaHitMask(
|
||||
localX: number,
|
||||
localY: number,
|
||||
mask: Match3DAlphaHitMask,
|
||||
itemSize: Match3DGeneratedItemRelativeSize,
|
||||
) {
|
||||
if (
|
||||
mask.width <= 0 ||
|
||||
mask.height <= 0 ||
|
||||
mask.alpha.length < mask.width * mask.height ||
|
||||
localX < 0 ||
|
||||
localX > 1 ||
|
||||
localY < 0 ||
|
||||
localY > 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aspectRatio = mask.width / mask.height;
|
||||
const containWidth = aspectRatio >= 1 ? 1 : aspectRatio;
|
||||
const containHeight = aspectRatio >= 1 ? 1 / aspectRatio : 1;
|
||||
const imageScale = resolveMatch3DItemSizeScale(itemSize);
|
||||
const renderedWidth = containWidth * imageScale;
|
||||
const renderedHeight = containHeight * imageScale;
|
||||
const imageLeft = (1 - renderedWidth) / 2;
|
||||
const imageTop = (1 - renderedHeight) / 2;
|
||||
|
||||
if (
|
||||
localX < imageLeft ||
|
||||
localX > imageLeft + renderedWidth ||
|
||||
localY < imageTop ||
|
||||
localY > imageTop + renderedHeight
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageX = (localX - imageLeft) / renderedWidth;
|
||||
const imageY = (localY - imageTop) / renderedHeight;
|
||||
const pixelX = clampMatch3DHitPixelIndex(imageX, mask.width);
|
||||
const pixelY = clampMatch3DHitPixelIndex(imageY, mask.height);
|
||||
return (
|
||||
(mask.alpha[pixelY * mask.width + pixelX] ?? 0) >
|
||||
MATCH3D_HIT_ALPHA_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
export function isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX,
|
||||
pointY,
|
||||
mask,
|
||||
itemSize,
|
||||
}: {
|
||||
item: Match3DItemSnapshot;
|
||||
pointX: number;
|
||||
pointY: number;
|
||||
mask: Match3DAlphaHitMask;
|
||||
itemSize: Match3DGeneratedItemRelativeSize;
|
||||
}) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
const diameter = frame.radius * 2;
|
||||
if (diameter <= 0) {
|
||||
return false;
|
||||
}
|
||||
return isPointInsideAlphaHitMask(
|
||||
(pointX - (frame.x - frame.radius)) / diameter,
|
||||
(pointY - (frame.y - frame.radius)) / diameter,
|
||||
mask,
|
||||
itemSize,
|
||||
);
|
||||
}
|
||||
|
||||
export function hashMatch3DString(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function resolveMatch3DImageSourceEntryForItem(
|
||||
item: Match3DItemSnapshot,
|
||||
imageSourceEntriesByType: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>,
|
||||
) {
|
||||
const sources = imageSourceEntriesByType.get(item.itemTypeId);
|
||||
if (!sources || sources.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? null;
|
||||
}
|
||||
|
||||
export function findMatch3DHitItem(
|
||||
run: Match3DRunSnapshot,
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
options: {
|
||||
imageSourceEntriesByType?: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>;
|
||||
alphaHitMasks?: ReadonlyMap<string, Match3DAlphaHitMask>;
|
||||
failedAlphaHitMaskSources?: ReadonlySet<string>;
|
||||
itemSizeByType?: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>;
|
||||
} = {},
|
||||
) {
|
||||
return run.items
|
||||
.filter((item) => {
|
||||
if (
|
||||
!isItemState(item.state, 'in_board') ||
|
||||
!item.clickable ||
|
||||
!isPointInsideCircle(pointX, pointY, item)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageSourceEntry = resolveMatch3DImageSourceEntryForItem(
|
||||
item,
|
||||
options.imageSourceEntriesByType ?? new Map(),
|
||||
);
|
||||
const mask = imageSourceEntry
|
||||
? options.alphaHitMasks?.get(imageSourceEntry.source)
|
||||
: null;
|
||||
if (!mask) {
|
||||
return (
|
||||
!imageSourceEntry ||
|
||||
!isGeneratedLegacyPath(imageSourceEntry.source) ||
|
||||
options.failedAlphaHitMaskSources?.has(imageSourceEntry.source) ===
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX,
|
||||
pointY,
|
||||
mask,
|
||||
itemSize: options.itemSizeByType?.get(item.itemTypeId) ?? '大',
|
||||
});
|
||||
})
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
|
||||
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';
|
||||
|
||||
export const MATCH3D_RUNTIME_STAGE_CLASS =
|
||||
'relative mt-3 flex min-h-0 flex-1 items-center justify-center';
|
||||
'relative mt-5 flex min-h-0 flex-1 items-center justify-center';
|
||||
|
||||
export const MATCH3D_RUNTIME_BOARD_BASE_CLASS =
|
||||
'relative aspect-square max-w-full';
|
||||
@@ -41,7 +41,7 @@ export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS =
|
||||
'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
|
||||
export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS =
|
||||
'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
'pointer-events-none absolute left-1/2 top-[54%] z-0 h-auto w-[min(116vw,42rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
|
||||
export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS =
|
||||
'pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]';
|
||||
|
||||
@@ -188,6 +188,7 @@ import {
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
buildSquareHoleGenerationAnchorEntries,
|
||||
createMiniGameDraftGenerationState,
|
||||
type MiniGameDraftGenerationKind,
|
||||
type MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
@@ -629,7 +630,9 @@ function resolveVisiblePuzzleDetailCoverCount(
|
||||
function mapMatch3DWorkToPublicWorkDetail(
|
||||
item: Match3DWorkSummary,
|
||||
): PlatformPublicGalleryCard {
|
||||
return mapMatch3DWorkToPlatformGalleryCard(item);
|
||||
return mapMatch3DWorkToPlatformGalleryCard(
|
||||
normalizeMatch3DWorkForRuntimeUi(item),
|
||||
);
|
||||
}
|
||||
|
||||
function mapSquareHoleWorkToPublicWorkDetail(
|
||||
@@ -753,6 +756,23 @@ function promoteMatch3DGeneratedBackgroundAsset<
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMatch3DWorkForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profile: T,
|
||||
): T {
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
...profile,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
profile.generatedItemAssets,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function mapMatch3DWorksForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profiles: readonly T[],
|
||||
): T[] {
|
||||
return profiles.map(normalizeMatch3DWorkForRuntimeUi);
|
||||
}
|
||||
|
||||
function buildMatch3DProfileFromSession(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): Match3DWorkProfile | null {
|
||||
@@ -1648,14 +1668,121 @@ function normalizeDraftNoticeId(id: string | null | undefined) {
|
||||
function createPendingDraftShelfState(
|
||||
status: DraftGenerationNoticeStatus,
|
||||
seen = false,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): PendingDraftShelfState {
|
||||
return {
|
||||
status,
|
||||
seen,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDraftGenerationStartedAtMs(value: string | null | undefined) {
|
||||
const parsedMs = value ? Date.parse(value) : Number.NaN;
|
||||
return Number.isFinite(parsedMs) ? parsedMs : Date.now();
|
||||
}
|
||||
|
||||
function createMiniGameDraftGenerationStateFromStartedAt(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
startedAtMs: number,
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(kind),
|
||||
startedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFinishedMiniGameDraftGenerationState(
|
||||
state: MiniGameDraftGenerationState,
|
||||
phase: 'ready' | 'failed',
|
||||
options: {
|
||||
error?: string | null;
|
||||
completedAssetCount?: number;
|
||||
totalAssetCount?: number;
|
||||
} = {},
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...state,
|
||||
phase,
|
||||
finishedAtMs: Date.now(),
|
||||
error: options.error ?? state.error,
|
||||
completedAssetCount:
|
||||
options.completedAssetCount ?? state.completedAssetCount,
|
||||
totalAssetCount: options.totalAssetCount ?? state.totalAssetCount,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecoveredPuzzleDraftSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const primaryLevel = draft.levels?.[0];
|
||||
const selectedCandidate =
|
||||
primaryLevel?.candidates.find((candidate) => candidate.selected) ??
|
||||
primaryLevel?.candidates[0] ??
|
||||
draft.candidates.find((candidate) => candidate.selected) ??
|
||||
draft.candidates[0] ??
|
||||
null;
|
||||
const coverImageSrc =
|
||||
draft.coverImageSrc?.trim() ||
|
||||
primaryLevel?.coverImageSrc?.trim() ||
|
||||
selectedCandidate?.imageSrc.trim() ||
|
||||
null;
|
||||
const coverAssetId =
|
||||
draft.coverAssetId?.trim() ||
|
||||
primaryLevel?.coverAssetId?.trim() ||
|
||||
selectedCandidate?.assetId.trim() ||
|
||||
null;
|
||||
const selectedCandidateId =
|
||||
draft.selectedCandidateId ??
|
||||
primaryLevel?.selectedCandidateId ??
|
||||
selectedCandidate?.candidateId ??
|
||||
null;
|
||||
|
||||
return {
|
||||
...session,
|
||||
draft: {
|
||||
...draft,
|
||||
coverImageSrc,
|
||||
coverAssetId,
|
||||
selectedCandidateId,
|
||||
generationStatus: 'ready',
|
||||
levels: draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
coverImageSrc: level.coverImageSrc ?? coverImageSrc,
|
||||
coverAssetId: level.coverAssetId ?? coverAssetId,
|
||||
selectedCandidateId:
|
||||
level.selectedCandidateId ?? selectedCandidateId,
|
||||
generationStatus: 'ready',
|
||||
}
|
||||
: level,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasRecoverableGeneratedPuzzleDraft(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstLevel = draft.levels?.[0];
|
||||
return Boolean(
|
||||
draft.coverImageSrc?.trim() ||
|
||||
firstLevel?.coverImageSrc?.trim() ||
|
||||
firstLevel?.candidates.some((candidate) => candidate.imageSrc.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'rpg':
|
||||
@@ -1790,6 +1917,7 @@ function buildPendingMatch3DWorks(
|
||||
updatedAt: state.updatedAt,
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: state.status === 'generating' ? 'generating' : 'ready',
|
||||
generatedItemAssets: [],
|
||||
}));
|
||||
}
|
||||
@@ -1867,6 +1995,7 @@ function buildPendingPuzzleWorks(
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
generationStatus: state.status === 'generating' ? 'generating' : 'ready',
|
||||
levels: [],
|
||||
};
|
||||
});
|
||||
@@ -2885,7 +3014,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
const worksResponse = await listMatch3DWorks();
|
||||
setMatch3DWorks(worksResponse.items);
|
||||
setMatch3DWorks(mapMatch3DWorksForRuntimeUi(worksResponse.items));
|
||||
match3DErrorSetterRef.current(null);
|
||||
} catch (error) {
|
||||
match3DErrorSetterRef.current(
|
||||
@@ -2899,8 +3028,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const refreshMatch3DGallery = useCallback(async () => {
|
||||
try {
|
||||
const galleryResponse = await listMatch3DGallery();
|
||||
setMatch3DGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
const items = mapMatch3DWorksForRuntimeUi(galleryResponse.items);
|
||||
setMatch3DGalleryEntries(items);
|
||||
return items;
|
||||
} catch {
|
||||
// 中文注释:公开广场是首页展示数据,失败时只降级为空列表;
|
||||
// 不写入创作错误态,避免挡住抓大鹅共创入口。
|
||||
@@ -3247,7 +3377,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
.map(mapBabyObjectMatchDraftToPlatformGalleryCard)
|
||||
: [];
|
||||
const match3dPublicEntries = match3dGalleryEntries.map(
|
||||
mapMatch3DWorkToPlatformGalleryCard,
|
||||
mapMatch3DWorkToPublicWorkDetail,
|
||||
);
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
@@ -3289,7 +3419,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
...(isBigFishCreationVisible
|
||||
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: []),
|
||||
...match3dGalleryEntries.map(mapMatch3DWorkToPlatformGalleryCard),
|
||||
...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail),
|
||||
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
...squareHoleGalleryEntries.map(
|
||||
mapSquareHoleWorkToPlatformGalleryCard,
|
||||
@@ -3395,7 +3525,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
getGenerationNoticeShelfKeys(item),
|
||||
);
|
||||
return {
|
||||
isGenerating: notice?.status === 'generating',
|
||||
isGenerating:
|
||||
notice?.status === 'generating' || item.isGenerating === true,
|
||||
hasUnreadUpdate: notice?.status === 'ready' && !notice.seen,
|
||||
};
|
||||
},
|
||||
@@ -3769,14 +3900,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
setBigFishGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'ready',
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
||||
completedAssetCount: response.session.assetSlots.filter(
|
||||
(slot) => slot.status === 'ready',
|
||||
).length,
|
||||
totalAssetCount: response.session.assetSlots.length,
|
||||
}
|
||||
})
|
||||
: current,
|
||||
);
|
||||
const openResult = selectionStageRef.current === 'big-fish-generating';
|
||||
@@ -3808,11 +3937,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
setBigFishGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'failed',
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'failed', {
|
||||
error: errorMessage,
|
||||
}
|
||||
})
|
||||
: current,
|
||||
);
|
||||
},
|
||||
@@ -3864,14 +3991,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
const openResult = selectionStageRef.current === 'match3d-generating';
|
||||
setMatch3DGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'ready',
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
||||
completedAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 5,
|
||||
totalAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 5,
|
||||
}
|
||||
})
|
||||
: current,
|
||||
);
|
||||
|
||||
@@ -3885,7 +4010,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
let runtimeProfile: Match3DWorkProfile | null = null;
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
|
||||
runtimeProfile = normalizeMatch3DWorkForRuntimeUi({
|
||||
...item,
|
||||
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
response.session.draft?.generatedItemAssets,
|
||||
@@ -3949,11 +4074,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
setMatch3DGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'failed',
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'failed', {
|
||||
error: errorMessage,
|
||||
}
|
||||
})
|
||||
: current,
|
||||
);
|
||||
try {
|
||||
@@ -3964,7 +4087,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
latestSession.draft?.profileId ?? latestSession.publishedProfileId;
|
||||
if (profileId) {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
setMatch3DProfile(item);
|
||||
setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item));
|
||||
}
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
} catch {
|
||||
@@ -4101,15 +4224,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { item } = await getSquareHoleWorkDetail(assetProfileId);
|
||||
const shouldOpenResult = shouldOpenSquareHoleResult();
|
||||
setSquareHoleProfile(item);
|
||||
setSquareHoleGenerationState((current) => ({
|
||||
...(current ?? createMiniGameDraftGenerationState('square-hole')),
|
||||
phase: 'ready',
|
||||
completedAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
totalAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
error: null,
|
||||
}));
|
||||
setSquareHoleGenerationState((current) =>
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
current ?? createMiniGameDraftGenerationState('square-hole'),
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
totalAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
error: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
await refreshSquareHoleShelf().catch(() => undefined);
|
||||
markPendingDraftReady(
|
||||
'square-hole',
|
||||
@@ -4131,11 +4258,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
'生成方洞挑战图片失败。',
|
||||
);
|
||||
setSquareHoleError(errorMessage);
|
||||
setSquareHoleGenerationState((current) => ({
|
||||
...(current ?? createMiniGameDraftGenerationState('square-hole')),
|
||||
phase: 'failed',
|
||||
error: errorMessage,
|
||||
}));
|
||||
setSquareHoleGenerationState((current) =>
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
current ?? createMiniGameDraftGenerationState('square-hole'),
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
),
|
||||
);
|
||||
setSquareHoleProfile(
|
||||
buildSquareHoleProfileFromSession(response.session),
|
||||
);
|
||||
@@ -4150,15 +4279,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { item } = await getSquareHoleWorkDetail(profileId);
|
||||
const shouldOpenResult = shouldOpenSquareHoleResult();
|
||||
setSquareHoleProfile(item);
|
||||
setSquareHoleGenerationState((current) => ({
|
||||
...(current ?? createMiniGameDraftGenerationState('square-hole')),
|
||||
phase: 'ready',
|
||||
completedAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
totalAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
error: null,
|
||||
}));
|
||||
setSquareHoleGenerationState((current) =>
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
current ?? createMiniGameDraftGenerationState('square-hole'),
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
totalAssetCount:
|
||||
item.shapeOptions.length + item.holeOptions.length + 2,
|
||||
error: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
await refreshSquareHoleShelf().catch(() => undefined);
|
||||
markPendingDraftReady(
|
||||
'square-hole',
|
||||
@@ -4198,11 +4331,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
payload.action === 'square_hole_compile_draft' ||
|
||||
payload.action === 'square_hole_generate_visual_assets'
|
||||
) {
|
||||
setSquareHoleGenerationState((current) => ({
|
||||
...(current ?? createMiniGameDraftGenerationState('square-hole')),
|
||||
phase: 'failed',
|
||||
error: errorMessage,
|
||||
}));
|
||||
setSquareHoleGenerationState((current) =>
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
current ?? createMiniGameDraftGenerationState('square-hole'),
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
),
|
||||
);
|
||||
if (selectionStageRef.current === 'square-hole-generating') {
|
||||
setSelectionStage('square-hole-generating');
|
||||
}
|
||||
@@ -4275,12 +4410,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
const openResult = selectionStageRef.current === 'puzzle-generating';
|
||||
setPuzzleGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'ready',
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
}
|
||||
})
|
||||
: current,
|
||||
);
|
||||
const profileId =
|
||||
@@ -4385,19 +4518,56 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftGenerating('puzzle', session.sessionId);
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
setSelectionStage('puzzle-generating');
|
||||
setPuzzleGenerationState(createMiniGameDraftGenerationState('puzzle'));
|
||||
const nextGenerationState = createMiniGameDraftGenerationState('puzzle');
|
||||
setPuzzleGenerationState(nextGenerationState);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[session.sessionId]: {
|
||||
session,
|
||||
payload: formPayload ?? buildPuzzleFormPayloadFromSession(session),
|
||||
generationState: nextGenerationState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
},
|
||||
onActionError: ({ payload, errorMessage }) => {
|
||||
onActionError: async ({ payload, errorMessage, session, setSession }) => {
|
||||
if (payload.action !== 'compile_puzzle_draft') {
|
||||
return;
|
||||
}
|
||||
const generationState =
|
||||
puzzleBackgroundCompileTasks[session.sessionId]?.generationState ??
|
||||
puzzleGenerationState ??
|
||||
createMiniGameDraftGenerationState('puzzle');
|
||||
const formPayload =
|
||||
buildPuzzleFormPayloadFromAction(payload) ??
|
||||
puzzleBackgroundCompileTasks[session.sessionId]?.payload ??
|
||||
buildPuzzleFormPayloadFromSession(session);
|
||||
const recovered = await recoverCompletedPuzzleDraftGeneration({
|
||||
sessionId: session.sessionId,
|
||||
payload: formPayload,
|
||||
generationState,
|
||||
setSession,
|
||||
});
|
||||
if (recovered) {
|
||||
return;
|
||||
}
|
||||
const failedGenerationState = resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[session.sessionId]: {
|
||||
session,
|
||||
payload: formPayload,
|
||||
generationState: failedGenerationState,
|
||||
error: errorMessage,
|
||||
},
|
||||
}));
|
||||
setPuzzleGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'failed',
|
||||
error: errorMessage,
|
||||
}
|
||||
? failedGenerationState
|
||||
: current,
|
||||
);
|
||||
},
|
||||
@@ -4527,6 +4697,124 @@ export function PlatformEntryFlowShellImpl({
|
||||
setMatch3DError,
|
||||
);
|
||||
}, [ensureEnoughDraftGenerationPointsFromServer, setMatch3DError]);
|
||||
const recoverCompletedPuzzleDraftGeneration = useCallback(
|
||||
async ({
|
||||
sessionId,
|
||||
payload,
|
||||
generationState,
|
||||
setSession,
|
||||
}: {
|
||||
sessionId: string;
|
||||
payload: CreatePuzzleAgentSessionRequest;
|
||||
generationState: MiniGameDraftGenerationState;
|
||||
setSession?: (session: PuzzleAgentSessionSnapshot) => void;
|
||||
}) => {
|
||||
let latestSession: PuzzleAgentSessionSnapshot;
|
||||
try {
|
||||
const response = await getPuzzleAgentSession(sessionId);
|
||||
latestSession = normalizeRecoveredPuzzleDraftSession(response.session);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasRecoverableGeneratedPuzzleDraft(latestSession)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readyGenerationState = resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
const openResult = isViewingPuzzleGeneration(sessionId);
|
||||
const profileId =
|
||||
latestSession.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(latestSession.sessionId);
|
||||
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[sessionId]: {
|
||||
session: latestSession,
|
||||
payload,
|
||||
generationState: readyGenerationState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
|
||||
setSession?.(latestSession);
|
||||
setPuzzleFormDraftPayload(payload);
|
||||
setPuzzleOperation(null);
|
||||
puzzleErrorSetterRef.current(null);
|
||||
if (isViewingPuzzleGeneration(sessionId)) {
|
||||
setPuzzleGenerationState(readyGenerationState);
|
||||
}
|
||||
|
||||
markPendingDraftReady('puzzle', latestSession.sessionId, openResult);
|
||||
markDraftReady(
|
||||
'puzzle',
|
||||
[
|
||||
latestSession.sessionId,
|
||||
buildPuzzleResultWorkId(latestSession.sessionId),
|
||||
profileId,
|
||||
],
|
||||
openResult,
|
||||
);
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
|
||||
if (!openResult) {
|
||||
return { openResult };
|
||||
}
|
||||
|
||||
const draft = latestSession.draft;
|
||||
if (!draft?.coverImageSrc || !profileId) {
|
||||
puzzleErrorSetterRef.current(
|
||||
!draft?.coverImageSrc
|
||||
? '请先选择一张正式拼图图片。'
|
||||
: '这份拼图草稿缺少会话信息,请重新开始创作。',
|
||||
);
|
||||
setSelectionStage('puzzle-result');
|
||||
return { openResult: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const { item } = await updatePuzzleWork(profileId, {
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
levels: draft.levels ?? [],
|
||||
});
|
||||
const run = startLocalPuzzleRun(item);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
} catch (error) {
|
||||
puzzleErrorSetterRef.current(
|
||||
resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'),
|
||||
);
|
||||
setSelectionStage('puzzle-result');
|
||||
}
|
||||
|
||||
return { openResult: false };
|
||||
},
|
||||
[
|
||||
isViewingPuzzleGeneration,
|
||||
markDraftReady,
|
||||
markPendingDraftReady,
|
||||
refreshPuzzleShelf,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const activeMatch3DGenerationSessionId =
|
||||
selectionStage === 'match3d-generating'
|
||||
@@ -4612,11 +4900,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
setMatch3DProfile(item);
|
||||
const normalizedItem = normalizeMatch3DWorkForRuntimeUi(item);
|
||||
setMatch3DProfile(normalizedItem);
|
||||
setMatch3DGenerationState((current) =>
|
||||
resolveMatch3DGenerationStateFromAssets(
|
||||
current,
|
||||
item.generatedItemAssets,
|
||||
normalizedItem.generatedItemAssets,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
@@ -4780,12 +5069,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setPuzzleOperation(response.operation);
|
||||
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
|
||||
const readyGenerationState = {
|
||||
...generationState,
|
||||
phase: 'ready' as const,
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
};
|
||||
const readyGenerationState = resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
},
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[nextSession.sessionId]: {
|
||||
@@ -4859,11 +5150,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
error,
|
||||
'执行拼图操作失败。',
|
||||
);
|
||||
const failedGenerationState = {
|
||||
...generationState,
|
||||
phase: 'failed' as const,
|
||||
error: errorMessage,
|
||||
};
|
||||
const recovered = await recoverCompletedPuzzleDraftGeneration({
|
||||
sessionId: nextSession.sessionId,
|
||||
payload,
|
||||
generationState,
|
||||
setSession: puzzleFlow.setSession,
|
||||
});
|
||||
if (recovered) {
|
||||
return;
|
||||
}
|
||||
const failedGenerationState = resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[nextSession.sessionId]: {
|
||||
@@ -4888,6 +5188,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
preflightPuzzleDraftGeneration,
|
||||
puzzleFlow,
|
||||
refreshPuzzleShelf,
|
||||
recoverCompletedPuzzleDraftGeneration,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
setSelectionStage,
|
||||
@@ -4950,14 +5251,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
);
|
||||
const openResult = isViewingMatch3DGeneration(nextSession.sessionId);
|
||||
const readyGenerationState = {
|
||||
...generationState,
|
||||
phase: 'ready' as const,
|
||||
completedAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 5,
|
||||
totalAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 5,
|
||||
};
|
||||
const readyGenerationState = resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 5,
|
||||
totalAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 5,
|
||||
},
|
||||
);
|
||||
setMatch3DBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[nextSession.sessionId]: {
|
||||
@@ -4984,7 +5287,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
let runtimeProfile: Match3DWorkProfile | null = null;
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
|
||||
runtimeProfile = normalizeMatch3DWorkForRuntimeUi({
|
||||
...item,
|
||||
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
response.session.draft?.generatedItemAssets,
|
||||
@@ -5039,11 +5342,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
error,
|
||||
'执行抓大鹅操作失败。',
|
||||
);
|
||||
const failedGenerationState = {
|
||||
...generationState,
|
||||
phase: 'failed' as const,
|
||||
error: errorMessage,
|
||||
};
|
||||
const failedGenerationState = resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
);
|
||||
setMatch3DBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[nextSession.sessionId]: {
|
||||
@@ -5075,7 +5378,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
latestSession.draft?.profileId ?? latestSession.publishedProfileId;
|
||||
if (profileId) {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
setMatch3DProfile(item);
|
||||
setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item));
|
||||
}
|
||||
}
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
@@ -5203,12 +5506,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBabyObjectMatchGenerationPhase('ready');
|
||||
setBabyObjectMatchGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'ready',
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
||||
completedAssetCount: response.draft.itemAssets.length,
|
||||
totalAssetCount: response.draft.itemAssets.length,
|
||||
}
|
||||
})
|
||||
: current,
|
||||
);
|
||||
const openResult =
|
||||
@@ -5230,11 +5531,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBabyObjectMatchError(errorMessage);
|
||||
setBabyObjectMatchGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'failed',
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'failed', {
|
||||
error: errorMessage,
|
||||
}
|
||||
})
|
||||
: current,
|
||||
);
|
||||
} finally {
|
||||
@@ -6702,13 +7001,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
) {
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profile.profileId);
|
||||
runtimeProfile = {
|
||||
runtimeProfile = normalizeMatch3DWorkForRuntimeUi({
|
||||
...item,
|
||||
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
item.generatedItemAssets,
|
||||
profile.generatedItemAssets,
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
|
||||
}
|
||||
@@ -7777,7 +8076,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
void deleteMatch3DWork(work.profileId)
|
||||
.then((response) => {
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
setMatch3DWorks(response.items);
|
||||
setMatch3DWorks(mapMatch3DWorksForRuntimeUi(response.items));
|
||||
void refreshMatch3DGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -8632,7 +8931,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
puzzleFlow.setSession(latestSession);
|
||||
setPuzzleFormDraftPayload(buildPuzzleFormPayloadFromSession(latestSession));
|
||||
setPuzzleGenerationState(createMiniGameDraftGenerationState('puzzle'));
|
||||
setPuzzleGenerationState(
|
||||
createMiniGameDraftGenerationStateFromStartedAt(
|
||||
'puzzle',
|
||||
parseDraftGenerationStartedAtMs(item.updatedAt),
|
||||
),
|
||||
);
|
||||
enterCreateTab();
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
activePuzzleGenerationSessionIdRef.current = item.sourceSessionId;
|
||||
@@ -8734,13 +9038,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
setMatch3DFormDraftPayload(null);
|
||||
const profileId = latestSession.draft?.profileId ?? item.profileId;
|
||||
const { item: profile } = await getMatch3DWorkDetail(profileId);
|
||||
const normalizedProfile = normalizeMatch3DWorkForRuntimeUi(profile);
|
||||
match3dFlow.setIsBusy(false);
|
||||
const started = await startMatch3DRunFromProfile(
|
||||
profile,
|
||||
normalizedProfile,
|
||||
'match3d-result',
|
||||
);
|
||||
if (!started) {
|
||||
setMatch3DProfile(profile);
|
||||
setMatch3DProfile(normalizedProfile);
|
||||
enterCreateTab();
|
||||
setSelectionStage('match3d-result');
|
||||
}
|
||||
@@ -8823,7 +9128,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const profileId = restoredSession.draft?.profileId ?? item.profileId;
|
||||
const { item: profile } = await getMatch3DWorkDetail(profileId);
|
||||
setMatch3DProfile(profile);
|
||||
setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(profile));
|
||||
} catch (error) {
|
||||
setMatch3DProfile(buildMatch3DProfileFromSession(restoredSession));
|
||||
setMatch3DError(
|
||||
@@ -11538,29 +11843,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
returnToCreationCenterFromGeneration();
|
||||
}}
|
||||
onSaved={(profile) => {
|
||||
setMatch3DProfile(profile);
|
||||
setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(profile));
|
||||
}}
|
||||
onPublished={(profile) => {
|
||||
setMatch3DProfile(profile);
|
||||
const normalizedProfile =
|
||||
normalizeMatch3DWorkForRuntimeUi(profile);
|
||||
setMatch3DProfile(normalizedProfile);
|
||||
void Promise.allSettled([
|
||||
refreshMatch3DShelf(),
|
||||
refreshMatch3DGallery(),
|
||||
]);
|
||||
openPublicWorkDetail(
|
||||
mapMatch3DWorkToPublicWorkDetail(profile),
|
||||
mapMatch3DWorkToPublicWorkDetail(normalizedProfile),
|
||||
);
|
||||
openPublishShareModal({
|
||||
title: profile.gameName,
|
||||
title: normalizedProfile.gameName,
|
||||
publicWorkCode: buildMatch3DPublicWorkCode(
|
||||
profile.profileId,
|
||||
normalizedProfile.profileId,
|
||||
),
|
||||
stage: 'work-detail',
|
||||
});
|
||||
}}
|
||||
onStartTestRun={(profile, options) => {
|
||||
setMatch3DProfile(profile);
|
||||
const normalizedProfile =
|
||||
normalizeMatch3DWorkForRuntimeUi(profile);
|
||||
setMatch3DProfile(normalizedProfile);
|
||||
void startMatch3DRunFromProfile(
|
||||
profile,
|
||||
normalizedProfile,
|
||||
'match3d-result',
|
||||
false,
|
||||
options,
|
||||
|
||||
@@ -567,6 +567,96 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
const generatingSecondLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '第二关',
|
||||
pictureDescription: '第二关画面正在生成。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating' as const,
|
||||
};
|
||||
const localThirdLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-3',
|
||||
levelName: '第三关',
|
||||
pictureDescription: '第三关初稿。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle' as const,
|
||||
};
|
||||
const completedSecondLevel = {
|
||||
...generatingSecondLevel,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-level-2',
|
||||
imageSrc: '/puzzle/level-2.png',
|
||||
assetId: 'asset-level-2',
|
||||
prompt: '第二关画面',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated' as const,
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-level-2',
|
||||
coverImageSrc: '/puzzle/level-2.png',
|
||||
coverAssetId: 'asset-level-2',
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, generatingSecondLevel, localThirdLevel],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('第三关'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
target: { value: '正在编辑第三关的信息。' },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, completedSecondLevel],
|
||||
},
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const currentDialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(within(currentDialog).getByLabelText('关卡名称')).toHaveProperty(
|
||||
'value',
|
||||
'第三关',
|
||||
);
|
||||
expect(within(currentDialog).getByLabelText('画面描述')).toHaveProperty(
|
||||
'value',
|
||||
'正在编辑第三关的信息。',
|
||||
);
|
||||
expect(within(currentDialog).queryByDisplayValue('第二关')).toBeNull();
|
||||
});
|
||||
|
||||
test('publishes with work info and serialized levels', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
@@ -1833,13 +1833,21 @@ export function PuzzleResultView({
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
|
||||
const latestEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
const savedEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestEditStateRef.current = editState;
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
latestEditStateRef.current = null;
|
||||
setActiveLevelId(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
@@ -1847,17 +1855,16 @@ export function PuzzleResultView({
|
||||
return;
|
||||
}
|
||||
const nextState = createDraftEditState(draft);
|
||||
setEditState((currentState) => {
|
||||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||||
currentState,
|
||||
nextState,
|
||||
);
|
||||
savedEditStateRef.current = nextState;
|
||||
return mergedState;
|
||||
});
|
||||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||||
latestEditStateRef.current,
|
||||
nextState,
|
||||
);
|
||||
latestEditStateRef.current = mergedState;
|
||||
savedEditStateRef.current = nextState;
|
||||
setEditState(mergedState);
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
|
||||
nextState.levels.forEach((level) => {
|
||||
mergedState.levels.forEach((level) => {
|
||||
if (level.generationStatus === 'generating') {
|
||||
nextRuntimes[level.levelId] =
|
||||
current[level.levelId] ?? {
|
||||
@@ -1870,7 +1877,7 @@ export function PuzzleResultView({
|
||||
});
|
||||
setActiveLevelId((currentLevelId) =>
|
||||
currentLevelId &&
|
||||
nextState.levels.some((level) => level.levelId === currentLevelId)
|
||||
mergedState.levels.some((level) => level.levelId === currentLevelId)
|
||||
? currentLevelId
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -1526,6 +1526,65 @@ function buildMockPuzzleAgentSession(
|
||||
};
|
||||
}
|
||||
|
||||
function buildReadyPuzzleDraft(
|
||||
overrides: Partial<PuzzleResultDraft> = {},
|
||||
): PuzzleResultDraft {
|
||||
return {
|
||||
workTitle: '自动恢复拼图',
|
||||
workDescription: '前端断连后复读 session 恢复的拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '拼图'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/recovered-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/recovered-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/recovered-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/recovered-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏纯背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClearedPuzzleRun(params: {
|
||||
runId: string;
|
||||
entryProfileId: string;
|
||||
@@ -1591,6 +1650,20 @@ function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
const match3DGeneratedUiAsset = {
|
||||
prompt: '果园竖屏纯背景',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/background/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/background.png',
|
||||
containerPrompt: '果园浅盘容器',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
} satisfies NonNullable<Match3DWorkSummary['generatedBackgroundAsset']>;
|
||||
|
||||
function buildMockMatch3DAgentSession(
|
||||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||||
): Match3DAgentSessionSnapshot {
|
||||
@@ -3829,6 +3902,7 @@ test('match3d result trial passes generated models into first runtime mount', as
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const match3dDraftWork: Match3DWorkSummary = {
|
||||
@@ -4076,6 +4150,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const generatedSession = buildMockMatch3DAgentSession({
|
||||
@@ -4139,6 +4214,26 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
}),
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
@@ -4146,6 +4241,110 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('match3d result trial loads generated background and container assets', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
{
|
||||
itemId: 'match3d-trial-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png',
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const match3dDraftWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-trial-ui',
|
||||
profileId: 'match3d-profile-trial-ui',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-trial-ui',
|
||||
gameName: '手动试玩抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-14T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets,
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [match3dDraftWork],
|
||||
});
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: buildMockMatch3DAgentSession({
|
||||
sessionId: 'match3d-session-trial-ui',
|
||||
stage: 'draft_ready',
|
||||
draft: {
|
||||
profileId: 'match3d-profile-trial-ui',
|
||||
gameName: '手动试玩抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
generatedItemAssets,
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDraftWork,
|
||||
});
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dDraftWork.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: /继续创作《手动试玩抓大鹅》/u }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
}),
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
});
|
||||
|
||||
test('completed match3d draft notice first opens trial then reopens result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
@@ -4164,6 +4363,7 @@ test('completed match3d draft notice first opens trial then reopens result', asy
|
||||
subscriptionKey: 'sub-notice-strawberry',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
backgroundAsset: match3DGeneratedUiAsset,
|
||||
},
|
||||
];
|
||||
const runningSession = buildMockMatch3DAgentSession({
|
||||
@@ -4257,6 +4457,14 @@ test('completed match3d draft notice first opens trial then reopens result', asy
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
@@ -4383,51 +4591,14 @@ test('completed baby object match draft shows unread marker after leaving genera
|
||||
|
||||
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedDraft: PuzzleResultDraft = {
|
||||
const generatedDraft = buildReadyPuzzleDraft({
|
||||
workTitle: '自动试玩拼图',
|
||||
workDescription: '生成完成后直接试玩。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '拼图'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
...buildReadyPuzzleDraft().levels![0]!,
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
uiBackgroundPrompt: '水果乐园竖屏纯背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
@@ -4443,10 +4614,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
title: '水果乐园',
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
},
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const generatedSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-auto-1',
|
||||
seedText: '屋檐下的猫与暖灯街角。',
|
||||
@@ -4530,6 +4700,63 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('embedded puzzle form recovers when compile request times out after backend completion', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedDraft = buildReadyPuzzleDraft();
|
||||
const generatedSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-session-recovered',
|
||||
stage: 'ready_to_publish',
|
||||
progressPercent: 100,
|
||||
draft: generatedDraft,
|
||||
lastAssistantReply: '拼图草稿已经生成。',
|
||||
resultPreview: {
|
||||
draft: generatedDraft,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
|
||||
session: buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-session-recovered',
|
||||
}),
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error('请求超时:90000ms'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
);
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({
|
||||
session: generatedSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
|
||||
'puzzle-session-recovered',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-recovered',
|
||||
expect.objectContaining({
|
||||
levelName: '雨夜猫街',
|
||||
coverImageSrc: '/puzzle/recovered-candidate.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('执行拼图操作失败。')).toBeNull();
|
||||
expect(screen.queryByText('请求超时:90000ms')).toBeNull();
|
||||
expect(screen.queryByText('拼图草稿生成进度')).toBeNull();
|
||||
expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -5601,6 +5828,12 @@ test('home recommendation Match3D runtime keeps image, music and UI assets witho
|
||||
'textContent',
|
||||
'1',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime passes top-level UI background assets', async () => {
|
||||
@@ -5774,6 +6007,12 @@ test('home recommendation Match3D runtime reloads detail when card only has UI a
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-item-image-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
|
||||
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
|
||||
|
||||
Reference in New Issue
Block a user