1
This commit is contained in:
@@ -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[],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user