Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -173,7 +173,7 @@ const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
|
||||
const MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE = 1.9;
|
||||
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 25;
|
||||
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 20;
|
||||
const MATCH3D_SELECTED_MODEL_SCALE = 1.1;
|
||||
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
|
||||
new Set(['ring', 'arch']);
|
||||
|
||||
@@ -55,6 +55,9 @@ import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
const runtimeAudioFeedback = vi.hoisted(() => ({
|
||||
playRuntimeMergeSound: vi.fn(),
|
||||
}));
|
||||
const match3dSpritesheetParser = vi.hoisted(() => ({
|
||||
loadMatch3DSpritesheetAssetRegions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -65,6 +68,16 @@ vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
|
||||
return {
|
||||
...actual,
|
||||
loadMatch3DSpritesheetAssetRegions:
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
@@ -96,6 +109,7 @@ afterEach(() => {
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1493,6 +1507,194 @@ test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
|
||||
['返回', '设置', '方格', '移出', '凑齐', '打乱'].map((label, index) => ({
|
||||
label,
|
||||
x: index * 10,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
sheetWidth: 64,
|
||||
sheetHeight: 64,
|
||||
imageSrc: `data:image/png;base64,${label}`,
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={[]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: '果园UI',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,返回');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,设置');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,移出');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-collect').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,凑齐');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-shuffle').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,打乱');
|
||||
});
|
||||
expect(
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
|
||||
maxRegions: 6,
|
||||
source: '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态不把兼容写入的UI spritesheet当中心容器图', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={[]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: '果园UI',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
containerPrompt: '兼容UI素材',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
containerImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-container-image').getAttribute('src'),
|
||||
).toBe('/match3d-background-references/pot-fused-reference.png');
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态缺少imageViews时从物品spritesheet解析五视角图片', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const selectedItem = run.items[0]!;
|
||||
const nextRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
items: run.items.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: selectedItem.itemInstanceId,
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockImplementation(
|
||||
async ({ source }: { source: string }) => {
|
||||
if (source.includes('item-spritesheet')) {
|
||||
return Array.from({ length: 100 }, (_, index) => ({
|
||||
label: `素材${index + 1}`,
|
||||
x: index * 2,
|
||||
y: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
sheetWidth: 200,
|
||||
sheetHeight: 2,
|
||||
imageSrc: `data:image/png;base64,item-${index + 1}`,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={nextRun}
|
||||
generatedItemAssets={[
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
itemSpritesheetPrompt: '果园物品',
|
||||
itemSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
|
||||
itemSpritesheetImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByTestId('match3d-tray-image') as HTMLImageElement).src,
|
||||
).toBe('data:image/png;base64,item-1');
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
const playSpy = vi
|
||||
@@ -1570,10 +1772,10 @@ test('本地试玩按难度档位生成类型并兼容历史硬核消除数', ()
|
||||
expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3);
|
||||
expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9);
|
||||
expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15);
|
||||
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(21);
|
||||
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(21);
|
||||
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(20);
|
||||
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(20);
|
||||
expect(countTypes(smallRun)).toBe(9);
|
||||
expect(countTypes(hardRun)).toBe(21);
|
||||
expect(countTypes(hardRun)).toBe(20);
|
||||
expect(hardRun.clearCount).toBe(21);
|
||||
expect(hardRun.items).toHaveLength(63);
|
||||
});
|
||||
@@ -1593,9 +1795,9 @@ test('硬核档位生成不重复积木视觉签名', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(21);
|
||||
expect(visualKeys.size).toBe(21);
|
||||
expect(signatures.size).toBe(21);
|
||||
expect(firstItemByType.size).toBe(20);
|
||||
expect(visualKeys.size).toBe(20);
|
||||
expect(signatures.size).toBe(20);
|
||||
});
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
@@ -1690,7 +1892,7 @@ test('硬核档位按五档体积比例生成尺寸', () => {
|
||||
}
|
||||
|
||||
expect(tierCounts.get('XL')).toBe(4);
|
||||
expect(tierCounts.get('L')).toBe(7);
|
||||
expect(tierCounts.get('L')).toBe(6);
|
||||
expect(tierCounts.get('M')).toBe(6);
|
||||
expect(tierCounts.get('XS')).toBe(3);
|
||||
expect(tierCounts.get('S')).toBe(1);
|
||||
@@ -1706,7 +1908,7 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
radiiByVisualKey.set(item.visualKey, radii);
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(21);
|
||||
expect(radiiByVisualKey.size).toBe(20);
|
||||
expect(
|
||||
[...radiiByVisualKey.values()].every((radii) => radii.size === 1),
|
||||
).toBe(true);
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
getMatch3DGeneratedImageViewSources,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
buildMatch3DItemSpritesheetViewRegions,
|
||||
loadMatch3DSpritesheetAssetRegions,
|
||||
type Match3DDecodedSpritesheetRegion,
|
||||
} from '../../services/match3dSpritesheetParser';
|
||||
import {
|
||||
buildMatch3DTrayInsertionPlan,
|
||||
resolveMatch3DTrayItemIdToSlotIndexMap,
|
||||
@@ -163,6 +168,12 @@ type Match3DTrayClearAnimation = {
|
||||
centerY: number;
|
||||
};
|
||||
|
||||
type Match3DItemSpritesheetViewGroup = {
|
||||
itemIndex: number;
|
||||
itemName: string;
|
||||
regions: Match3DDecodedSpritesheetRegion[];
|
||||
};
|
||||
|
||||
function resolveTrayPreviewItem(
|
||||
run: Match3DRunSnapshot,
|
||||
slot: Match3DTraySlot,
|
||||
@@ -187,6 +198,19 @@ const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
|
||||
const EMPTY_MATCH3D_GENERATED_ITEM_ASSETS: Match3DGeneratedItemAsset[] = [];
|
||||
const MATCH3D_CONTAINER_REFERENCE_SRC =
|
||||
'/match3d-background-references/pot-fused-reference.png';
|
||||
const MATCH3D_UI_SPRITESHEET_LABELS = [
|
||||
'返回',
|
||||
'设置',
|
||||
'方格',
|
||||
'移出',
|
||||
'凑齐',
|
||||
'打乱',
|
||||
] as const;
|
||||
const MATCH3D_PROP_BUTTONS = [
|
||||
['移出', 'match3d-ui-sprite-prop-remove'],
|
||||
['凑齐', 'match3d-ui-sprite-prop-collect'],
|
||||
['打乱', 'match3d-ui-sprite-prop-shuffle'],
|
||||
] as const;
|
||||
|
||||
function formatTimer(value: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
||||
@@ -228,9 +252,9 @@ function resolveBoardPointFromPointerEvent(
|
||||
}
|
||||
|
||||
function compareMatch3DGeneratedTypeId(left: string, right: string) {
|
||||
const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) {
|
||||
const leftIndex = resolveMatch3DGeneratedItemIndex(left);
|
||||
const rightIndex = resolveMatch3DGeneratedItemIndex(right);
|
||||
if (leftIndex !== null && rightIndex !== null) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
@@ -243,13 +267,17 @@ function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
|
||||
const parsed = Number.parseInt(value?.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
const parsed = Number.parseInt(
|
||||
value?.match(/^match3d-(?:item|type)-0*(\d+)$/u)?.[1] ?? '',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
|
||||
}
|
||||
|
||||
function buildMatch3DImageSourcesByType(
|
||||
run: Match3DRunSnapshot | null,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
itemSpritesheetViewGroups: readonly Match3DItemSpritesheetViewGroup[] = [],
|
||||
) {
|
||||
if (!run) {
|
||||
return new Map<string, string[]>();
|
||||
@@ -267,6 +295,20 @@ function buildMatch3DImageSourcesByType(
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const parsedAssets = itemSpritesheetViewGroups.flatMap((group) => {
|
||||
const sources = group.regions
|
||||
.map((region) => region.imageSrc.trim())
|
||||
.filter(Boolean);
|
||||
return sources.length > 0
|
||||
? [
|
||||
{
|
||||
fallbackIndex: group.itemIndex,
|
||||
itemIndex: group.itemIndex,
|
||||
sources,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
return new Map(
|
||||
typeIds.flatMap((typeId, index) => {
|
||||
@@ -274,12 +316,99 @@ function buildMatch3DImageSourcesByType(
|
||||
const asset =
|
||||
readyAssets.find(
|
||||
(entry) => directIndex !== null && entry.itemIndex === directIndex,
|
||||
) ?? readyAssets.find((entry) => entry.fallbackIndex === index);
|
||||
) ??
|
||||
readyAssets.find((entry) => entry.fallbackIndex === index) ??
|
||||
parsedAssets.find(
|
||||
(entry) => directIndex !== null && entry.itemIndex === directIndex,
|
||||
) ??
|
||||
parsedAssets.find((entry) => entry.fallbackIndex === index);
|
||||
return asset ? [[typeId, asset.sources] as const] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DUiSpritesheetSource(
|
||||
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return (
|
||||
generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
generatedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DItemSpritesheetSource(
|
||||
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return (
|
||||
generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
generatedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedContainerSource(
|
||||
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
const normalize = (value: string | null | undefined) =>
|
||||
value?.trim().replace(/^\/+/u, '') ?? '';
|
||||
const resolveAssetContainerSource = (
|
||||
asset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
) => {
|
||||
const containerSrc = asset?.containerImageSrc?.trim() || '';
|
||||
const containerObjectKey = asset?.containerImageObjectKey?.trim() || '';
|
||||
const uiSrc = asset?.uiSpritesheetImageSrc?.trim() || '';
|
||||
const uiObjectKey = asset?.uiSpritesheetImageObjectKey?.trim() || '';
|
||||
|
||||
if (
|
||||
normalize(containerSrc) &&
|
||||
normalize(containerSrc) !== normalize(uiSrc)
|
||||
) {
|
||||
return containerSrc;
|
||||
}
|
||||
if (
|
||||
normalize(containerObjectKey) &&
|
||||
normalize(containerObjectKey) !== normalize(uiObjectKey)
|
||||
) {
|
||||
return containerObjectKey;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
resolveAssetContainerSource(generatedBackgroundAsset) ||
|
||||
generatedItemAssets
|
||||
.map((asset) => resolveAssetContainerSource(asset.backgroundAsset))
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function indexMatch3DUiSpritesheetRegions(
|
||||
regions: readonly Match3DDecodedSpritesheetRegion[],
|
||||
) {
|
||||
return new Map(regions.map((region) => [region.label, region]));
|
||||
}
|
||||
|
||||
function resolveMatch3DImageReadUrlCacheKey(
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
@@ -598,6 +727,31 @@ function Match3DToken({
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DSpriteImage({
|
||||
region,
|
||||
testId,
|
||||
className = 'h-full w-full object-contain',
|
||||
}: {
|
||||
region: Match3DDecodedSpritesheetRegion | null | undefined;
|
||||
testId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!region?.imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={region.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-testid={testId}
|
||||
className={className}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({
|
||||
slot,
|
||||
imageSrc,
|
||||
@@ -883,6 +1037,32 @@ export function Match3DRuntimeShell({
|
||||
() => normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
|
||||
[generatedItemAssets],
|
||||
);
|
||||
const uiSpritesheetSource = useMemo(
|
||||
() =>
|
||||
resolveMatch3DUiSpritesheetSource(
|
||||
generatedBackgroundAsset,
|
||||
runtimeGeneratedItemAssets,
|
||||
),
|
||||
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
|
||||
);
|
||||
const itemSpritesheetSource = useMemo(
|
||||
() =>
|
||||
resolveMatch3DItemSpritesheetSource(
|
||||
generatedBackgroundAsset,
|
||||
runtimeGeneratedItemAssets,
|
||||
),
|
||||
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
|
||||
);
|
||||
const [uiSpritesheetRegions, setUiSpritesheetRegions] = useState<
|
||||
Match3DDecodedSpritesheetRegion[]
|
||||
>([]);
|
||||
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
|
||||
Match3DItemSpritesheetViewGroup[]
|
||||
>([]);
|
||||
const uiSpritesheetRegionByLabel = useMemo(
|
||||
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
|
||||
[uiSpritesheetRegions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeftMs(run?.remainingMs ?? 0);
|
||||
@@ -904,6 +1084,80 @@ export function Match3DRuntimeShell({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [onTimeExpired, run]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiSpritesheetSource) {
|
||||
setUiSpritesheetRegions((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
void loadMatch3DSpritesheetAssetRegions({
|
||||
source: uiSpritesheetSource,
|
||||
labels: MATCH3D_UI_SPRITESHEET_LABELS,
|
||||
maxRegions: MATCH3D_UI_SPRITESHEET_LABELS.length,
|
||||
minArea: 16,
|
||||
alphaThreshold: 8,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((regions) => {
|
||||
if (!cancelled) {
|
||||
setUiSpritesheetRegions(regions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setUiSpritesheetRegions([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [uiSpritesheetSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemSpritesheetSource) {
|
||||
setItemSpritesheetViewGroups((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
void loadMatch3DSpritesheetAssetRegions({
|
||||
source: itemSpritesheetSource,
|
||||
maxRegions: 100,
|
||||
minArea: 16,
|
||||
alphaThreshold: 8,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((regions) => {
|
||||
if (!cancelled) {
|
||||
setItemSpritesheetViewGroups(
|
||||
buildMatch3DItemSpritesheetViewRegions(
|
||||
regions,
|
||||
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
|
||||
),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setItemSpritesheetViewGroups([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [itemSpritesheetSource, runtimeGeneratedItemAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedbackEvent) {
|
||||
return undefined;
|
||||
@@ -1058,23 +1312,20 @@ export function Match3DRuntimeShell({
|
||||
)
|
||||
.find(Boolean) ||
|
||||
'';
|
||||
const generatedContainerAssetSrc =
|
||||
generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
runtimeGeneratedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
'';
|
||||
const generatedContainerAssetSrc = resolveMatch3DGeneratedContainerSource(
|
||||
generatedBackgroundAsset,
|
||||
runtimeGeneratedItemAssets,
|
||||
);
|
||||
const containerAssetSrc =
|
||||
generatedContainerAssetSrc || MATCH3D_CONTAINER_REFERENCE_SRC;
|
||||
const imageSourcesByType = useMemo(
|
||||
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
|
||||
[runtimeGeneratedItemAssets, run],
|
||||
() =>
|
||||
buildMatch3DImageSourcesByType(
|
||||
run,
|
||||
runtimeGeneratedItemAssets,
|
||||
itemSpritesheetViewGroups,
|
||||
),
|
||||
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
|
||||
);
|
||||
const itemSizeByType = useMemo(
|
||||
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
|
||||
@@ -1731,7 +1982,14 @@ export function Match3DRuntimeShell({
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<Match3DSpriteImage
|
||||
region={uiSpritesheetRegionByLabel.get('返回')}
|
||||
testId="match3d-ui-sprite-back"
|
||||
className="h-7 w-7 object-contain"
|
||||
/>
|
||||
{!uiSpritesheetRegionByLabel.get('返回') ? (
|
||||
<ArrowLeft size={20} />
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
<div className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
|
||||
@@ -1752,7 +2010,14 @@ export function Match3DRuntimeShell({
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开抓大鹅设置"
|
||||
>
|
||||
<Settings size={18} />
|
||||
<Match3DSpriteImage
|
||||
region={uiSpritesheetRegionByLabel.get('设置')}
|
||||
testId="match3d-ui-sprite-settings"
|
||||
className="h-7 w-7 object-contain"
|
||||
/>
|
||||
{!uiSpritesheetRegionByLabel.get('设置') ? (
|
||||
<Settings size={18} />
|
||||
) : null}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -1849,6 +2114,11 @@ export function Match3DRuntimeShell({
|
||||
traySlotRefs.current[slot.slotIndex] = element;
|
||||
}}
|
||||
>
|
||||
<Match3DSpriteImage
|
||||
region={uiSpritesheetRegionByLabel.get('方格')}
|
||||
testId={`match3d-ui-sprite-grid-${slot.slotIndex}`}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
|
||||
/>
|
||||
<Match3DTrayToken
|
||||
slot={slot}
|
||||
isArriving={
|
||||
@@ -1889,6 +2159,30 @@ export function Match3DRuntimeShell({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="relative z-10 mt-2 grid grid-cols-3 gap-2"
|
||||
aria-label="抓大鹅道具"
|
||||
>
|
||||
{MATCH3D_PROP_BUTTONS.map(([label, testId]) => {
|
||||
const region = uiSpritesheetRegionByLabel.get(label);
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="flex min-h-12 items-center justify-center overflow-hidden rounded-[1rem] border border-white/58 bg-white/50 px-2 py-2 text-sm font-black text-slate-800 shadow-[0_10px_24px_rgba(15,23,42,0.14)] backdrop-blur-md"
|
||||
aria-label={label}
|
||||
>
|
||||
<Match3DSpriteImage
|
||||
region={region}
|
||||
testId={testId}
|
||||
className="h-10 w-full object-contain"
|
||||
/>
|
||||
{!region ? label : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{feedbackEvent?.kind === 'rejected' ? (
|
||||
|
||||
Reference in New Issue
Block a user