This commit is contained in:
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;
|
||||
|
||||
Reference in New Issue
Block a user