This commit is contained in:
2026-05-11 20:27:41 +08:00
parent e30b733b17
commit 481a27fc53
60 changed files with 6357 additions and 1100 deletions

View File

@@ -189,7 +189,7 @@ function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) {
].sort(compareMatch3DGeneratedTypeId);
}
function buildMatch3DGeneratedAssetTypeMap(
export function buildMatch3DGeneratedAssetTypeMap(
run: Match3DRunSnapshot,
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
@@ -1467,7 +1467,7 @@ function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) {
});
}
function buildMatch3DTrayModelSourceMap(
export function buildMatch3DTrayModelSourceMap(
referenceItems: Match3DItemSnapshot[],
slotItems: Array<Match3DItemSnapshot | null>,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],

View File

@@ -4,6 +4,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useEffect } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import type {
Match3DGeneratedItemAsset,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type {
Match3DClickItemRequest,
Match3DRunSnapshot,
@@ -21,7 +24,9 @@ import {
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
MATCH3D_TRAY_MODEL_TARGET_SIZE,
applyMatch3DRendererCanvasLayout,
buildMatch3DGeneratedAssetTypeMap,
buildMatch3DPhysicsEntrySignature,
buildMatch3DTrayModelSourceMap,
createMatch3DCannonShape,
createMatch3DThreeGeometry,
measureMatch3DItemPreviewDimension,
@@ -222,6 +227,62 @@ test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用
);
});
test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey 的历史素材', () => {
const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({
...item,
itemInstanceId: `generated-model-${index}`,
itemTypeId:
index === 0
? 'match3d-type-02'
: index === 1
? 'match3d-type-01'
: 'match3d-type-03',
visualKey: 'block-red-2x4',
}));
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
status: 'model_ready',
modelSrc: null,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
status: 'model_ready',
modelSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-2-item/model/model.glb',
modelObjectKey: null,
},
];
const boardMap = buildMatch3DGeneratedAssetTypeMap(
run,
generatedItemAssets,
);
const trayMap = buildMatch3DTrayModelSourceMap(
run.items,
[],
generatedItemAssets,
);
expect(boardMap.get('match3d-type-01')?.modelSrc).toBe(
generatedItemAssets[0]!.modelObjectKey,
);
expect(boardMap.get('match3d-type-02')?.modelSrc).toBe(
generatedItemAssets[1]!.modelSrc,
);
expect(trayMap.get('match3d-type-01')).toBe(
generatedItemAssets[0]!.modelObjectKey,
);
expect(trayMap.get('match3d-type-02')).toBe(
generatedItemAssets[1]!.modelSrc,
);
});
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
const smallRun = startLocalMatch3DRun(12);
const largeRun = startLocalMatch3DRun(100);

View File

@@ -36,6 +36,7 @@ import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
import { useAuthUi } from '../auth/AuthUiContext';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
@@ -85,6 +86,7 @@ function resolveTrayPreviewItem(
}
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
function formatTimer(value: number) {
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
@@ -312,7 +314,10 @@ export function Match3DRuntimeShell({
onClickItem,
onTimeExpired,
}: Match3DRuntimeShellProps) {
const authUi = useAuthUi();
const stageRef = useRef<HTMLDivElement | null>(null);
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
const clickAudioRefs = useRef<Record<string, HTMLAudioElement>>({});
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
const [feedbackEvent, setFeedbackEvent] =
useState<Match3DFeedbackEvent | null>(null);
@@ -365,6 +370,60 @@ export function Match3DRuntimeShell({
}, [run]);
const shouldUse3DRender = !force2DRender;
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const backgroundMusicSrc =
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
}
const readyAssets = generatedItemAssets.filter(
(asset) => asset.clickSound?.audioSrc,
);
const sortedTypes = [
...new Set(run.items.map((item) => item.itemTypeId)),
].sort();
return new Map(
sortedTypes.flatMap((typeId, index) => {
const src = readyAssets[index]?.clickSound?.audioSrc?.trim();
return src ? [[typeId, src] as const] : [];
}),
);
}, [generatedItemAssets, run]);
useEffect(() => {
const audio = backgroundAudioRef.current;
if (!audio || !backgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [backgroundMusicSrc, musicVolume, run]);
useEffect(() => {
Object.values(clickAudioRefs.current).forEach((audio) => {
audio.volume = Math.max(0, Math.min(1, musicVolume));
});
}, [musicVolume]);
const playClickSound = useCallback(
(item: Match3DItemSnapshot) => {
const src = clickSoundByTypeId.get(item.itemTypeId);
if (!src) {
return;
}
const current = clickAudioRefs.current[src] ?? new Audio(src);
clickAudioRefs.current[src] = current;
current.currentTime = 0;
current.volume = Math.max(0, Math.min(1, musicVolume));
void current.play().catch(() => {});
},
[clickSoundByTypeId, musicVolume],
);
const handleTrayPreviewFallback = useCallback(() => {
setForce2DRender(true);
}, []);
@@ -382,6 +441,7 @@ export function Match3DRuntimeShell({
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
playClickSound(item);
setPendingClick({
clientEventId,
itemInstanceId: item.itemInstanceId,
@@ -447,6 +507,14 @@ export function Match3DRuntimeShell({
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
{backgroundMusicSrc ? (
<audio
ref={backgroundAudioRef}
src={backgroundMusicSrc}
loop
preload="auto"
/>
) : null}
<div
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]`}
style={{