Switch to VectorEngine gpt-image-2 and edits

Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
2026-05-22 03:06:41 +08:00
parent 321e1ea33a
commit ae014ac881
90 changed files with 7078 additions and 3389 deletions

View File

@@ -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']);

View File

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

View File

@@ -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' ? (