fix: improve match3d tray preview readability
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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