fix: improve match3d tray preview readability
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-05 17:17:27 +08:00
parent 06b8b46530
commit 2252afb080
8 changed files with 862 additions and 102 deletions
File diff suppressed because it is too large Load Diff
@@ -12,9 +12,22 @@ import {
confirmLocalMatch3DClick,
startLocalMatch3DRun,
} from '../../services/match3d-runtime';
import {
MATCH3D_RENDER_ITEM_SCALE,
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import {
MATCH3D_EXTRUDED_READABLE_SHAPES,
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
MATCH3D_TRAY_MODEL_TARGET_SIZE,
buildMatch3DPhysicsEntrySignature,
createMatch3DCannonShape,
createMatch3DThreeGeometry,
measureMatch3DItemPreviewDimension,
resolveMatch3DColliderBounds,
resolveMatch3DTrayPreviewRotation,
resolveMatch3DTrayPreviewReferenceDimension,
resolveMatch3DTrayPreviewScale,
} from './Match3DPhysicsBoard';
import { resolveGeometryAsset } from './match3dVisualAssets';
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
@@ -38,6 +51,9 @@ vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
}, [onFallback]);
return <div data-testid="match3d-physics-board-fallback" />;
},
Match3DTrayPreviewBoard: () => (
<div data-testid="match3d-tray-model-board" />
),
};
});
@@ -91,6 +107,29 @@ test('展示圆形空间和 7 格备选栏', () => {
expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7);
});
test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
const run = startLocalMatch3DRun(25);
const firstItemByType = [...new Map(
run.items.map((item) => [item.itemTypeId, item]),
).values()];
const smallItem = firstItemByType.reduce((smallest, item) =>
item.radius < smallest.radius ? item : smallest,
);
const largeItem = firstItemByType.reduce((largest, item) =>
item.radius > largest.radius ? item : largest,
);
const smallFrame = resolveRenderableItemFrame(smallItem);
const largeFrame = resolveRenderableItemFrame(largeItem);
expect(smallFrame.radius).toBeCloseTo(
smallItem.radius * MATCH3D_RENDER_ITEM_SCALE,
);
expect(largeFrame.radius / smallFrame.radius).toBeCloseTo(
largeItem.radius / smallItem.radius,
);
});
test('点击可见物品后先乐观入槽再等待确认', async () => {
const run = startLocalMatch3DRun(4);
const clickableItem = run.items.find((item) => item.clickable);
@@ -103,6 +142,7 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
expect(onOptimisticRunChange).toHaveBeenCalled();
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
});
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
@@ -142,6 +182,25 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
});
test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用到新局', () => {
const run = startLocalMatch3DRun(10);
const item = run.items[0]!;
const sameIdDifferentVisual = {
...item,
visualKey:
item.visualKey === 'block-red-2x4'
? 'block-blue-1x2'
: 'block-red-2x4',
};
expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe(
buildMatch3DPhysicsEntrySignature(`${run.runId}-next`, item),
);
expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe(
buildMatch3DPhysicsEntrySignature(run.runId, sameIdDifferentVisual),
);
});
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
const smallRun = startLocalMatch3DRun(12);
const largeRun = startLocalMatch3DRun(100);
@@ -280,6 +339,67 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
});
test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
const three = await import('three');
const run = startLocalMatch3DRun(25);
const firstItemByType = [...new Map(
run.items.map((item) => [item.itemTypeId, item]),
).values()];
const referenceDimension = resolveMatch3DTrayPreviewReferenceDimension(
three,
firstItemByType,
);
const previewRatios = new Set(
firstItemByType.map((item) =>
Math.round(
(measureMatch3DItemPreviewDimension(three, item) /
referenceDimension) *
1_000,
),
),
);
expect(previewRatios.size).toBeGreaterThan(1);
});
test('托盘 3D 预览放大模型并展示俯视 3/4 体积感', () => {
expect(MATCH3D_TRAY_MODEL_TARGET_SIZE).toBeGreaterThanOrEqual(0.85);
expect(MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE).toBeGreaterThanOrEqual(0.9);
const brickRotation = resolveMatch3DTrayPreviewRotation('block-red-2x4');
const tileRotation = resolveMatch3DTrayPreviewRotation(
'block-lavender-tile-2x2',
);
const slopeRotation = resolveMatch3DTrayPreviewRotation(
'block-purple-slope-1x2',
);
expect(brickRotation.x).toBeLessThan(-0.28);
expect(brickRotation.z).toBeGreaterThan(0.2);
expect(brickRotation.y).toBeGreaterThan(0.6);
expect(tileRotation.x).toBeLessThan(-0.25);
expect(tileRotation.z).toBeGreaterThan(0.2);
expect(slopeRotation.x).toBeLessThan(-0.3);
expect(slopeRotation.z).toBeGreaterThan(0.22);
});
test('托盘 3D 预览为小模型保留最低可读显示尺寸', () => {
const smallDimension = 0.4;
const referenceDimension = 1;
const scale = resolveMatch3DTrayPreviewScale(
smallDimension,
referenceDimension,
);
expect(scale * smallDimension).toBeGreaterThanOrEqual(
MATCH3D_TRAY_MODEL_TARGET_SIZE *
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
);
expect(scale).toBeGreaterThan(
MATCH3D_TRAY_MODEL_TARGET_SIZE / referenceDimension,
);
});
test('积木 3D 资源可以为本局类型创建几何体', async () => {
const three = await import('three');
const run = startLocalMatch3DRun(15);
@@ -296,6 +416,37 @@ test('积木 3D 资源可以为本局类型创建几何体', async () => {
}
});
test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
const cannon = await import('cannon-es');
const longBrick = resolveGeometryAsset('block-black-1x8');
const tile = resolveGeometryAsset('block-lavender-tile-2x2');
const cylinder = resolveGeometryAsset('block-green-cylinder');
const radius = 1;
const longBrickBounds = resolveMatch3DColliderBounds(longBrick, radius);
const longBrickShape = createMatch3DCannonShape(cannon, longBrick, radius);
expect(longBrickShape.type).toBe(cannon.Shape.types.BOX);
expect((longBrickShape as import('cannon-es').Box).halfExtents.x * 2).toBeCloseTo(
longBrickBounds.width,
);
expect((longBrickShape as import('cannon-es').Box).halfExtents.z * 2).toBeCloseTo(
longBrickBounds.depth,
);
const tileBounds = resolveMatch3DColliderBounds(tile, radius);
const tileShape = createMatch3DCannonShape(cannon, tile, radius);
expect((tileShape as import('cannon-es').Box).halfExtents.y * 2).toBeCloseTo(
tileBounds.height,
);
const cylinderBounds = resolveMatch3DColliderBounds(cylinder, radius);
const cylinderShape = createMatch3DCannonShape(cannon, cylinder, radius);
expect(cylinderShape.type).toBe(cannon.Shape.types.CYLINDER);
expect(
(cylinderShape as import('cannon-es').Cylinder).height,
).toBeCloseTo(cylinderBounds.height);
});
test('积木视觉键不会被统一兜底成红色苹字', () => {
const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({
@@ -6,7 +6,14 @@ import {
Sparkles,
XCircle,
} from 'lucide-react';
import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';
import {
type PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
Match3DClickItemRequest,
@@ -54,6 +61,26 @@ type Match3DFeedbackEvent = {
itemIds: string[];
};
function resolveTrayPreviewItem(
run: Match3DRunSnapshot,
slot: Match3DTraySlot,
) {
if (!slot.itemInstanceId) {
return null;
}
const item = run.items.find(
(entry) => entry.itemInstanceId === slot.itemInstanceId,
);
if (!item) {
return null;
}
return {
...item,
itemTypeId: slot.itemTypeId ?? item.itemTypeId,
visualKey: slot.visualKey ?? item.visualKey,
};
}
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
function formatTimer(value: number) {
@@ -333,17 +360,14 @@ export function Match3DRuntimeShell({
}, [run]);
const shouldUse3DRender = !force2DRender;
const handleTrayPreviewFallback = useCallback(() => {
setForce2DRender(true);
}, []);
const trayPreviewItems = useMemo(() => {
if (!run) {
return [];
}
return run.traySlots.map((slot) =>
slot.itemInstanceId
? (run.items.find(
(item) => item.itemInstanceId === slot.itemInstanceId,
) ?? null)
: null,
);
return run.traySlots.map((slot) => resolveTrayPreviewItem(run, slot));
}, [run]);
const handleItemClick = async (item: Match3DItemSnapshot) => {
@@ -505,13 +529,17 @@ export function Match3DRuntimeShell({
data-testid="match3d-tray"
>
{shouldUse3DRender ? (
<Match3DTrayPreviewBoard slotItems={trayPreviewItems} />
<Match3DTrayPreviewBoard
onFallback={handleTrayPreviewFallback}
referenceItems={run.items}
slotItems={trayPreviewItems}
/>
) : null}
{run.traySlots.map((slot) => {
return (
<div
key={slot.slotIndex}
className="relative z-0 aspect-square min-w-0 rounded-xl bg-white/10 p-1"
className="relative z-0 h-14 min-w-0 rounded-xl bg-white/10 p-1 sm:h-16"
data-testid="match3d-tray-slot"
>
<Match3DTrayToken
@@ -6,6 +6,8 @@ import type {
const MATCH3D_RENDER_CENTER = 0.5;
const MATCH3D_RENDER_RADIUS = 0.5;
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
// 中文注释:当前 3D 体验中物体偏小,这里只放大显示和点击半径,不改变后端权威尺寸档位的相对关系。
export const MATCH3D_RENDER_ITEM_SCALE = 2;
export function isRunState(
status: Match3DRunSnapshot['status'],
@@ -27,8 +29,9 @@ export function isItemState(
export function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
const sourceRadius = Number.isFinite(item.radius) ? item.radius : 0.06;
const radius = Math.min(
Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035),
Math.max(sourceRadius * MATCH3D_RENDER_ITEM_SCALE, 0.035),
maxRadius,
);
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;