1
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
15
src/components/match3d-runtime/match3dRuntimeUiStyles.ts
Normal file
15
src/components/match3d-runtime/match3dRuntimeUiStyles.ts
Normal 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';
|
||||
Reference in New Issue
Block a user