This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -358,6 +358,123 @@ test('运行态会先换签 generated 图片素材再渲染局内物品', async
);
});
test('运行态按 generated itemId 编号映射到后端 match3d-type 类型', async () => {
const baseRun = startLocalMatch3DRun(3);
const baseTypeIds = [...new Set(baseRun.items.map((item) => item.itemTypeId))];
const run: Match3DRunSnapshot = {
...baseRun,
items: baseRun.items.map((item) =>
item.itemTypeId === baseTypeIds[0]
? {...item, itemTypeId: 'match3d-type-01'}
: item.itemTypeId === baseTypeIds[1]
? {...item, itemTypeId: 'match3d-type-02'}
: item,
),
};
const typeOneItem = run.items.find(
(item) => item.itemTypeId === 'match3d-type-01',
);
expect(typeOneItem).toBeTruthy();
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '樱桃',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/cherry-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/apple-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
];
renderRuntime(run, generatedItemAssets);
const token = screen.getByTestId(
`match3d-item-${typeOneItem!.itemInstanceId}`,
);
await waitFor(() => {
expect(token.querySelector('img')?.getAttribute('src')).toContain(
'/match3d/cherry-view-01.png',
);
});
});
test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundAsset: {
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey: null,
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/task/container.png',
status: 'image_ready',
error: null,
},
},
];
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/match3d-container.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
);
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
});
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
const smallRun = startLocalMatch3DRun(12);
const hardRun = startLocalMatch3DRun(20);

View File

@@ -47,6 +47,12 @@ import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
import {
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from './match3dRuntimeUiStyles';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
@@ -158,6 +164,11 @@ function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
.sort(compareMatch3DGeneratedTypeId);
}
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
const parsed = Number.parseInt(value?.match(/(\d+)$/u)?.[1] ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
}
function buildMatch3DImageSourcesByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
@@ -166,14 +177,28 @@ function buildMatch3DImageSourcesByType(
return new Map<string, string[]>();
}
const typeIds = resolveMatch3DGeneratedTypeIds(run);
const readyAssets = generatedItemAssets
.map((asset) => getMatch3DGeneratedImageViewSources(asset))
.filter((sources) => sources.length > 0);
const readyAssets = generatedItemAssets.flatMap((asset, fallbackIndex) => {
const sources = getMatch3DGeneratedImageViewSources(asset);
return sources.length > 0
? [
{
fallbackIndex,
itemIndex: resolveMatch3DGeneratedItemIndex(asset.itemId),
sources,
},
]
: [];
});
return new Map(
typeIds.flatMap((typeId, index) => {
const sources = readyAssets[index];
return sources ? [[typeId, sources] as const] : [];
const directIndex = resolveMatch3DGeneratedItemIndex(typeId);
const asset =
readyAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ??
readyAssets.find((entry) => entry.fallbackIndex === index);
return asset ? [[typeId, asset.sources] as const] : [];
}),
);
}
@@ -543,6 +568,15 @@ export function Match3DRuntimeShell({
)
.find(Boolean) ||
'';
const containerAssetSrc =
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || '';
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, generatedItemAssets),
[generatedItemAssets, run],
@@ -566,6 +600,7 @@ export function Match3DRuntimeShell({
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -584,7 +619,7 @@ export function Match3DRuntimeShell({
);
}, [generatedItemAssets, run]);
useEffect(() => {
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (audio) {
@@ -596,6 +631,10 @@ export function Match3DRuntimeShell({
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, run]);
useEffect(() => {
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
useEffect(() => {
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
@@ -668,6 +707,35 @@ export function Match3DRuntimeShell({
};
}, [backgroundAssetSrc]);
useEffect(() => {
if (!containerAssetSrc) {
setResolvedContainerImageSrc('');
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [containerAssetSrc]);
useEffect(() => {
const rawSources = [
...new Set(
@@ -730,6 +798,7 @@ export function Match3DRuntimeShell({
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
tryPlayBackgroundMusic();
playClickSound(item);
setPendingClick({
clientEventId,
@@ -823,19 +892,19 @@ export function Match3DRuntimeShell({
<header className="flex items-center justify-between gap-2">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onBack}
aria-label="返回"
>
<ArrowLeft size={20} />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<div className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
<Clock3 size={16} />
<span>{formatTimer(timeLeftMs)}</span>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onRestart}
aria-label="重新开始"
>
@@ -853,7 +922,17 @@ export function Match3DRuntimeShell({
onPointerDown={handleBoardPointerDown}
data-testid="match3d-board"
>
<div className="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%)]" />
{resolvedContainerImageSrc ? (
<img
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] z-0 h-[108%] w-[108%] object-contain"
data-testid="match3d-container-image"
/>
) : (
<div className="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%)]" />
)}
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
@@ -876,7 +955,7 @@ export function Match3DRuntimeShell({
</div>
</section>
<section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<section className={MATCH3D_RUNTIME_GLASS_TRAY_CLASS}>
<div
className="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
@@ -892,7 +971,7 @@ export function Match3DRuntimeShell({
return (
<div
key={slot.slotIndex}
className="relative z-0 h-14 min-w-0 rounded-xl bg-white/10 p-1 sm:h-16"
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
data-testid="match3d-tray-slot"
>
<Match3DTrayToken

View File

@@ -0,0 +1,15 @@
// 中文注释:运行态 HUD 使用题材无关的半透明玻璃样式,避免和 AI 生成背景、容器素材绑定。
export const MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS =
'flex h-10 w-10 items-center justify-center rounded-full border border-white/65 bg-white/72 text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md transition hover:bg-white/86 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/82';
export const MATCH3D_RUNTIME_GLASS_TIMER_CLASS =
'flex min-w-[4.25rem] items-center justify-center gap-1.5 rounded-full border border-white/65 bg-white/72 px-3 py-2 text-sm font-black text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md';
export const MATCH3D_RUNTIME_GLASS_SPINNER_CLASS =
'h-4 w-4 rounded-full border-2 border-slate-700/76 border-l-transparent';
export const MATCH3D_RUNTIME_GLASS_TRAY_CLASS =
'mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/56 bg-white/34 p-2 shadow-[0_14px_32px_rgba(15,23,42,0.16)] backdrop-blur-md';
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';