fix: improve match3d tray preview readability
Some checks failed
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

View File

@@ -24,6 +24,7 @@ type Match3DPhysicsBoardProps = {
type ThreeModule = typeof import('three');
type CannonModule = typeof import('cannon-es');
type PhysicsBody = import('cannon-es').Body;
type CannonShape = import('cannon-es').Shape;
type PhysicsWorld = import('cannon-es').World;
type ThreeObject3D = import('three').Object3D;
type ThreeScene = import('three').Scene;
@@ -35,6 +36,7 @@ type PhysicsEntry = {
body: PhysicsBody;
lockReadableTop: boolean;
mesh: ThreeObject3D;
renderSignature: string;
topRotationY: number;
};
@@ -159,25 +161,82 @@ function applyCenterGravity(entry: PhysicsEntry) {
(-entry.body.position.z / horizontalDistance) * forceStrength;
}
function createCannonShape(
cannon: CannonModule,
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
export function resolveMatch3DColliderBounds(
asset: Match3DGeometryAsset,
radius: number,
) {
switch (shape) {
case 'ring':
switch (asset.shape) {
case 'cylinder':
return {
depth: radius * 1.16,
height: radius * 1.312,
width: radius * 1.16,
};
case 'cone':
return new cannon.Cylinder(radius * 0.82, radius * 0.82, radius * 1.1, 18);
case 'slope':
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.66));
return {
depth: radius * 1.36,
height: radius * 1.48,
width: radius * 1.36,
};
case 'ring':
return {
depth: radius * 1.84,
height: radius * 0.42,
width: radius * 1.84,
};
case 'arch':
return new cannon.Box(new cannon.Vec3(radius * 1.35, radius * 0.92, radius * 0.56));
return {
depth: radius * 1.5,
height: radius * 0.42,
width: radius * 2,
};
case 'slope':
return {
depth: radius * (0.95 + asset.studsY * 0.62),
height: radius * asset.heightScale + radius * 0.12,
width: radius * (1 + asset.studsX * 0.66),
};
case 'tile':
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.36, radius * 0.72));
return {
depth: radius * (0.9 + asset.studsY * 0.62),
height: Math.max(radius * 0.24, radius * asset.heightScale),
width: radius * (0.9 + asset.studsX * 0.62),
};
case 'brick':
default:
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.72));
return {
depth: radius * (0.9 + asset.studsY * 0.62),
height: Math.max(radius * 0.24, radius * asset.heightScale) + radius * 0.12,
width: radius * (0.9 + asset.studsX * 0.62),
};
}
}
export function createMatch3DCannonShape(
cannon: CannonModule,
asset: Match3DGeometryAsset,
radius: number,
): CannonShape {
const bounds = resolveMatch3DColliderBounds(asset, radius);
switch (asset.shape) {
case 'cylinder':
case 'ring':
return new cannon.Cylinder(
bounds.width / 2,
bounds.width / 2,
bounds.height,
asset.shape === 'ring' ? 24 : 18,
);
case 'cone':
return new cannon.Cylinder(0, bounds.width / 2, bounds.height, 24);
default:
return new cannon.Box(
new cannon.Vec3(
bounds.width / 2,
bounds.height / 2,
bounds.depth / 2,
),
);
}
}
@@ -452,6 +511,31 @@ function createItemMesh(
return createMatch3DItemMesh(three, item);
}
export function buildMatch3DPhysicsEntrySignature(
runId: string,
item: Match3DItemSnapshot,
) {
return [
runId,
item.itemInstanceId,
item.itemTypeId,
item.visualKey,
item.radius.toFixed(5),
item.layer,
].join(':');
}
function removePhysicsEntry(
runtime: PhysicsRuntime,
itemInstanceId: string,
entry: PhysicsEntry,
) {
runtime.scene.remove(entry.mesh);
runtime.world.removeBody(entry.body);
disposeThreeObject(entry.mesh);
runtime.entries.delete(itemInstanceId);
}
function disposeRuntime(runtime: PhysicsRuntime | null) {
if (!runtime) {
return;
@@ -475,6 +559,108 @@ type TrayPreviewRuntime = {
three: ThreeModule;
};
const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE = 0.5;
export const MATCH3D_TRAY_MODEL_TARGET_SIZE = 0.86;
export const MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE = 0.9;
function buildTrayPreviewMeasureKey(item: Match3DItemSnapshot) {
return `${item.visualKey}:${item.radius.toFixed(5)}`;
}
function buildTrayPreviewSignature(
item: Match3DItemSnapshot,
referenceMaxDimension: number,
) {
return [
item.visualKey,
item.radius.toFixed(5),
referenceMaxDimension.toFixed(5),
MATCH3D_TRAY_MODEL_TARGET_SIZE.toFixed(5),
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE.toFixed(5),
].join(':');
}
export function measureMatch3DItemPreviewDimension(
three: ThreeModule,
item: Match3DItemSnapshot,
) {
const preview = createMatch3DItemMesh(three, item);
const bounds = new three.Box3().setFromObject(preview.mesh);
const size = bounds.getSize(new three.Vector3());
disposeThreeObject(preview.mesh);
return Math.max(size.x, size.y, size.z, 0.001);
}
export function resolveMatch3DTrayPreviewReferenceDimension(
three: ThreeModule,
referenceItems: Match3DItemSnapshot[],
) {
const measuredDimensions = new Map<string, number>();
let maxDimension = 0;
for (const item of referenceItems) {
const key = buildTrayPreviewMeasureKey(item);
const dimension =
measuredDimensions.get(key) ??
measureMatch3DItemPreviewDimension(three, item);
measuredDimensions.set(key, dimension);
maxDimension = Math.max(maxDimension, dimension);
}
return Math.max(maxDimension, 0.001);
}
export function resolveMatch3DTrayPreviewRotation(visualKey: string) {
const asset = resolveGeometryAsset(visualKey);
const yaw =
asset.studsX >= asset.studsY ? Math.PI / 4 : Math.PI / 5;
// 中文注释:托盘里用轻微俯视 3/4 姿态展示体积,固定朝向只影响 UI 预览,不反写场内物理姿态。
switch (asset.shape) {
case 'tile':
case 'ring':
return {
x: -0.28,
y: yaw,
z: 0.22,
};
case 'slope':
case 'arch':
return {
x: -0.34,
y: yaw,
z: 0.24,
};
case 'cylinder':
case 'cone':
return {
x: -0.3,
y: Math.PI / 4,
z: 0.2,
};
case 'brick':
default:
return {
x: -0.32,
y: yaw,
z: 0.24,
};
}
}
export function resolveMatch3DTrayPreviewScale(
itemDimension: number,
referenceMaxDimension: number,
) {
const maxScale = MATCH3D_TRAY_MODEL_TARGET_SIZE / Math.max(referenceMaxDimension, 0.001);
const readableScale =
(MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE) /
Math.max(itemDimension, 0.001);
return Math.max(
maxScale,
readableScale,
);
}
function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
if (!runtime) {
return;
@@ -490,9 +676,38 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
runtime.renderer.domElement.remove();
}
function positionTrayPreviewObject(
runtime: TrayPreviewRuntime,
object: ThreeObject3D,
slotIndex: number,
) {
const slotWidth =
(runtime.camera.right - runtime.camera.left) / MATCH3D_TRAY_SLOT_COUNT;
const slotCenter = runtime.camera.left + slotWidth * (slotIndex + 0.5);
const screenX = new runtime.three.Vector3(1, 0, 0).applyQuaternion(
runtime.camera.quaternion,
);
// 中文注释:托盘模型按相机屏幕横轴排布,保留斜视角但不让 UI 格子投影成斜线。
object.position.copy(screenX.multiplyScalar(slotCenter));
}
function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) {
runtime.entries.forEach((object) => {
const slotIndex =
typeof object.userData.traySlotIndex === 'number'
? object.userData.traySlotIndex
: 0;
positionTrayPreviewObject(runtime, object, slotIndex);
});
}
export function Match3DTrayPreviewBoard({
onFallback,
referenceItems,
slotItems,
}: {
onFallback: () => void;
referenceItems: Match3DItemSnapshot[];
slotItems: Array<Match3DItemSnapshot | null>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -506,67 +721,117 @@ export function Match3DTrayPreviewBoard({
async function setup() {
const container = containerRef.current;
if (!container || !hasWebGLSupport()) {
onFallback();
return;
}
const three = await import('three');
if (cancelled || !containerRef.current) {
return;
}
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
container.appendChild(renderer.domElement);
const scene = new three.Scene();
scene.background = null;
const camera = new three.OrthographicCamera(-3.7, 3.7, 1.1, -1.1, 0.1, 40);
camera.position.set(4.2, 3.2, 4.2);
camera.lookAt(0, 0, 0);
scene.add(new three.AmbientLight(0xffffff, 1.55));
const keyLight = new three.DirectionalLight(0xffffff, 2.2);
keyLight.position.set(-2.6, 4.4, 3.2);
scene.add(keyLight);
const fillLight = new three.DirectionalLight(0xfef3c7, 0.95);
fillLight.position.set(3.2, 2.8, -2.8);
scene.add(fillLight);
const resize = () => {
const rect = container.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
renderer.setSize(width, height, false);
renderer.render(scene, camera);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
const animate = () => {
const activeRuntime = runtimeRef.current;
if (!activeRuntime) {
try {
const three = await import('three');
if (cancelled || !containerRef.current) {
return;
}
renderer.render(scene, camera);
activeRuntime.animationId = window.requestAnimationFrame(animate);
};
runtimeRef.current = {
animationId: window.requestAnimationFrame(animate),
camera,
entries: new Map(),
renderer,
scene,
three,
};
setReady(true);
cleanupResize = () => ro.disconnect();
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
renderer.domElement.style.display = 'block';
renderer.domElement.style.height = '100%';
renderer.domElement.style.inset = '0';
renderer.domElement.style.position = 'absolute';
renderer.domElement.style.width = '100%';
container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();
onFallback();
};
renderer.domElement.addEventListener(
'webglcontextlost',
handleContextLost,
false,
);
const scene = new three.Scene();
scene.background = null;
const camera = new three.OrthographicCamera(
-4.4,
4.4,
MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE,
-MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE,
0.1,
40,
);
camera.position.set(4.1, 5.4, 4.45);
camera.lookAt(0, 0, 0);
scene.add(new three.AmbientLight(0xffffff, 0.82));
const keyLight = new three.DirectionalLight(0xffffff, 3.1);
keyLight.position.set(-3.4, 5.2, 3.8);
scene.add(keyLight);
const fillLight = new three.DirectionalLight(0xfef3c7, 0.55);
fillLight.position.set(3.2, 2.4, -3.2);
scene.add(fillLight);
const rimLight = new three.DirectionalLight(0xffffff, 0.75);
rimLight.position.set(1.2, 2.2, -4.4);
scene.add(rimLight);
const resize = () => {
const rect = container.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
const aspect = width / height;
camera.top = MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE;
camera.bottom = -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE;
camera.left = -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE * aspect;
camera.right = MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE * aspect;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
relayoutTrayPreviewEntries({
animationId: null,
camera,
entries: runtimeRef.current?.entries ?? new Map(),
renderer,
scene,
three,
});
renderer.render(scene, camera);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
const animate = () => {
const activeRuntime = runtimeRef.current;
if (!activeRuntime) {
return;
}
renderer.render(scene, camera);
activeRuntime.animationId = window.requestAnimationFrame(animate);
};
runtimeRef.current = {
animationId: window.requestAnimationFrame(animate),
camera,
entries: new Map(),
renderer,
scene,
three,
};
setReady(true);
cleanupResize = () => {
renderer.domElement.removeEventListener(
'webglcontextlost',
handleContextLost,
false,
);
ro.disconnect();
};
} catch {
onFallback();
}
}
void setup();
@@ -578,7 +843,7 @@ export function Match3DTrayPreviewBoard({
runtimeRef.current = null;
setReady(false);
};
}, []);
}, [onFallback]);
useEffect(() => {
const runtime = runtimeRef.current;
@@ -599,33 +864,67 @@ export function Match3DTrayPreviewBoard({
}
});
const referenceMaxDimension = resolveMatch3DTrayPreviewReferenceDimension(
runtime.three,
referenceItems.length > 0
? referenceItems
: slotItems.filter(
(item): item is Match3DItemSnapshot => Boolean(item),
),
);
slotItems.forEach((item, slotIndex) => {
if (!item) {
return;
}
const previewSignature = buildTrayPreviewSignature(
item,
referenceMaxDimension,
);
let mesh = runtime.entries.get(item.itemInstanceId);
if (mesh && mesh.userData.trayPreviewSignature !== previewSignature) {
runtime.scene.remove(mesh);
disposeThreeObject(mesh);
runtime.entries.delete(item.itemInstanceId);
mesh = undefined;
}
if (!mesh) {
const preview = createMatch3DItemMesh(runtime.three, item);
mesh = preview.mesh;
mesh.rotation.set(-0.12, Math.PI / 4, 0.08);
const model = preview.mesh;
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
model.rotation.set(rotation.x, rotation.y, rotation.z);
const bounds = new runtime.three.Box3().setFromObject(mesh);
const size = bounds.getSize(new runtime.three.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z, 0.001);
mesh.scale.multiplyScalar(0.82 / maxDimension);
const centeredBounds = new runtime.three.Box3().setFromObject(mesh);
// 中文注释:模型先在自身 pivot 内居中,再把 pivot 放进对应格子,避免非对称积木偏出 UI 栏。
const itemBounds = new runtime.three.Box3().setFromObject(model);
const itemSize = itemBounds.getSize(new runtime.three.Vector3());
const itemDimension = Math.max(
itemSize.x,
itemSize.y,
itemSize.z,
0.001,
);
model.scale.multiplyScalar(
resolveMatch3DTrayPreviewScale(
itemDimension,
referenceMaxDimension,
),
);
const centeredBounds = new runtime.three.Box3().setFromObject(model);
const center = centeredBounds.getCenter(new runtime.three.Vector3());
mesh.position.sub(center);
model.position.sub(center);
mesh = new runtime.three.Group();
mesh.add(model);
mesh.userData.trayPreviewSignature = previewSignature;
runtime.scene.add(mesh);
runtime.entries.set(item.itemInstanceId, mesh);
}
mesh.position.x = (slotIndex - 3) * 1.03;
mesh.position.y = 0;
mesh.position.z = 0;
const activeMesh = mesh;
activeMesh.userData.traySlotIndex = slotIndex;
positionTrayPreviewObject(runtime, activeMesh, slotIndex);
});
runtime.renderer.render(runtime.scene, runtime.camera);
}, [ready, slotItems]);
}, [ready, referenceItems, slotItems]);
return (
<div
@@ -928,10 +1227,7 @@ export function Match3DPhysicsBoard({
runtime.entries.forEach((entry, itemInstanceId) => {
if (!activeItemIds.has(itemInstanceId)) {
runtime.scene.remove(entry.mesh);
runtime.world.removeBody(entry.body);
disposeThreeObject(entry.mesh);
runtime.entries.delete(itemInstanceId);
removePhysicsEntry(runtime, itemInstanceId, entry);
}
});
@@ -940,19 +1236,29 @@ export function Match3DPhysicsBoard({
return;
}
const renderSignature = buildMatch3DPhysicsEntrySignature(
run.runId,
item,
);
const existing = runtime.entries.get(item.itemInstanceId);
if (existing) {
existing.item = item;
existing.mesh.visible = true;
return;
if (existing.renderSignature !== renderSignature) {
// 中文注释:后端重开局时 itemInstanceId 可能复用,旧 3D 模型必须随当前 run 快照重建。
removePhysicsEntry(runtime, item.itemInstanceId, existing);
} else {
existing.item = item;
existing.mesh.visible = true;
return;
}
}
const visual = createItemMesh(runtime.three, item);
const asset = resolveGeometryAsset(item.visualKey);
const body = new runtime.cannon.Body({
angularDamping: 0.48,
linearDamping: 0.38,
mass: 1 + visual.radius * 0.7,
shape: createCannonShape(runtime.cannon, visual.shape, visual.radius),
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
position: new runtime.cannon.Vec3(
visual.position.x,
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
@@ -977,10 +1283,11 @@ export function Match3DPhysicsBoard({
item,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh,
renderSignature,
topRotationY: visual.topRotationY,
});
});
}, [ready, run.items, run.snapshotVersion]);
}, [ready, run.items, run.runId, run.snapshotVersion]);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
event.stopPropagation();