Files
Genarrative/src/components/match3d-runtime/Match3DPhysicsBoard.tsx
五香丸子 e8fee0172a
Some checks failed
CI / verify (push) Has been cancelled
feat: add puzzle onboarding and match3d entry updates
2026-05-07 23:30:54 +08:00

1908 lines
56 KiB
TypeScript

import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type {
Match3DItemSnapshot,
Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
isItemState,
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import {
resolveGeometryAsset,
type Match3DGeometryAsset,
type Match3DGeometryShape,
} from './match3dVisualAssets';
type Match3DPhysicsBoardProps = {
run: Match3DRunSnapshot;
disabled: boolean;
onClickItem: (item: Match3DItemSnapshot) => void;
onFallback: () => void;
};
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;
type ThreeRenderer = import('three').WebGLRenderer;
type ThreeCamera = import('three').OrthographicCamera;
type PhysicsEntry = {
boundaryRadius: number;
colliderHeight: number;
item: Match3DItemSnapshot;
body: PhysicsBody;
lockReadableTop: boolean;
mesh: ThreeObject3D;
renderSignature: string;
spawnStartedAt: number;
targetY: number;
topRotationY: number;
};
type PendingPhysicsSpawn = {
activeLayerRank: number;
item: Match3DItemSnapshot;
renderSignature: string;
spawnAtMs: number;
layerCapacity: number;
targetY: number;
};
type StackHeightTarget = {
activeLayerRank: number;
targetY: number;
};
type BoardDepthPlan = {
activeDepth: number;
activeItemCount: number;
baseY: number;
initialDepth: number;
layerCapacity: number;
layerCount: number;
layerStep: number;
maxVerticalSpeed: number;
surfaceY: number;
};
type PhysicsStabilityPlan = {
angularDamping: number;
contactFriction: number;
contactRestitution: number;
linearDamping: number;
maxHorizontalSpeed: number;
maxVerticalSpeed: number;
solverIterations: number;
solverTolerance: number;
};
type Match3DSpawnTimingPlan = {
frameSpawnLimit: number;
initialDelayMs: number;
layerDelayMs: number;
burstSize: number;
staggerMs: number;
};
type Match3DSpawnHeightObstacle = {
boundaryRadius: number;
colliderHeight: number;
x: number;
y: number;
z: number;
};
type PhysicsRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, PhysicsEntry>;
pendingSpawns: Map<string, PendingPhysicsSpawn>;
raycaster: import('three').Raycaster;
renderer: ThreeRenderer;
scene: ThreeScene;
spawnTimingPlan: Match3DSpawnTimingPlan;
stabilityPlan: PhysicsStabilityPlan;
world: PhysicsWorld;
three: ThreeModule;
cannon: CannonModule;
};
type Match3DStackHeightPlan = {
layerCapacity: number;
targets: Map<string, StackHeightTarget>;
};
const MATCH3D_POT_FLOOR_RADIUS = 4.75;
const MATCH3D_POT_INNER_RADIUS = 4.52;
const MATCH3D_POT_OUTER_RADIUS = 5.18;
const MATCH3D_POT_WALL_HEIGHT = 2.15;
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
const MATCH3D_ITEM_BASE_HEIGHT = 1.18;
const MATCH3D_ITEM_VERTICAL_DEPTH_BASE = 2.8;
const MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE = 0.52;
const MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE = 0.032;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE = 12;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE = 0.04;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE = 18;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN = 10;
const MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX = 1.04;
const MATCH3D_ITEM_LIFT_FORCE_SCALE = 18;
const MATCH3D_ITEM_LIFT_MAX_SPEED = 4.2;
const MATCH3D_ITEM_SPAWN_RISE_OFFSET = 0.42;
const MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS = 54;
const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4;
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
const MATCH3D_BOARD_CENTER = 0.5;
const MATCH3D_PHYSICS_STEP = 1 / 60;
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
new Set([
'ring',
'arch',
]);
function hasWebGLSupport() {
try {
const canvas = document.createElement('canvas');
return Boolean(
canvas.getContext('webgl2') ?? canvas.getContext('webgl'),
);
} catch {
return false;
}
}
function toWorldPosition(item: Match3DItemSnapshot) {
const frame = resolveRenderableItemFrame(item);
const radius = Math.max(0.28, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.02);
let x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
let z = (frame.y - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
const horizontalDistance = Math.hypot(x, z);
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - radius * 1.1);
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
const ratio = maxDistance / horizontalDistance;
x *= ratio;
z *= ratio;
}
return {
x,
z,
radius,
};
}
export function resolveMatch3DBoardDepthPlan(
totalItemCount: number,
activeItemCount: number,
): BoardDepthPlan {
const normalizedTotalItemCount = Math.max(
1,
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
);
const normalizedActiveItemCount = Math.max(
0,
Math.min(
normalizedTotalItemCount,
Math.round(Number.isFinite(activeItemCount) ? activeItemCount : 0),
),
);
const volumePressure = Math.max(0, normalizedTotalItemCount - 90);
const depthMax =
MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE +
volumePressure * MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE;
const initialDepth = Math.min(
depthMax,
MATCH3D_ITEM_VERTICAL_DEPTH_BASE +
Math.sqrt(normalizedTotalItemCount) *
MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE +
normalizedTotalItemCount * MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE,
);
const remainingRatio =
normalizedActiveItemCount / normalizedTotalItemCount;
const activeDepth =
normalizedActiveItemCount <= 1 ? 0 : initialDepth * remainingRatio;
const pressureRatio = Math.min(1, volumePressure / 210);
const layerCapacity = Math.max(
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN,
Math.round(
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8,
),
);
const layerCount = Math.max(
1,
Math.ceil(normalizedActiveItemCount / layerCapacity),
);
const layerStep =
layerCount <= 1
? 0
: Math.min(
MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX,
activeDepth / Math.max(1, layerCount - 1),
);
return {
activeDepth,
activeItemCount: normalizedActiveItemCount,
baseY: MATCH3D_ITEM_BASE_HEIGHT,
initialDepth,
layerCapacity,
layerCount,
layerStep,
maxVerticalSpeed:
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
surfaceY: MATCH3D_ITEM_BASE_HEIGHT + initialDepth,
};
}
export function resolveMatch3DStackTargetY(
totalItemCount: number,
activeItemCount: number,
activeLayerRank: number,
) {
const depthPlan = resolveMatch3DBoardDepthPlan(
totalItemCount,
activeItemCount,
);
if (depthPlan.activeItemCount <= 1) {
return depthPlan.surfaceY;
}
const clampedRank = Math.max(
0,
Math.min(depthPlan.activeItemCount - 1, activeLayerRank),
);
const layerIndex = Math.floor(
(clampedRank / Math.max(1, depthPlan.activeItemCount - 1)) *
(depthPlan.layerCount - 1),
);
return (
depthPlan.surfaceY -
(depthPlan.layerCount - 1 - layerIndex) * depthPlan.layerStep
);
}
export function resolveMatch3DBoundaryRadius(
asset: Match3DGeometryAsset,
radius: number,
) {
const bounds = resolveMatch3DColliderBounds(asset, radius);
return Math.hypot(bounds.width / 2, bounds.depth / 2);
}
export function resolveMatch3DSpawnY(
plannedSpawnY: number,
colliderHeight: number,
boundaryRadius: number,
position: Pick<Match3DSpawnHeightObstacle, 'x' | 'z'>,
obstacles: readonly Match3DSpawnHeightObstacle[],
) {
const normalizedPlannedY = Number.isFinite(plannedSpawnY)
? plannedSpawnY
: MATCH3D_ITEM_BASE_HEIGHT;
const selfHalfHeight = Math.max(0, colliderHeight / 2);
const selfBoundaryRadius = Math.max(0, boundaryRadius);
return obstacles.reduce((spawnY, obstacle) => {
const horizontalDistance = Math.hypot(
position.x - obstacle.x,
position.z - obstacle.z,
);
const overlapDistance =
selfBoundaryRadius +
Math.max(0, obstacle.boundaryRadius) +
MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING;
if (horizontalDistance > overlapDistance) {
return spawnY;
}
// 中文注释:新物体生成时先避开同位置已有堆叠顶部,避免最后一波直接塞进未稳定的上层模型。
const obstacleTopY =
obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
return Math.max(
spawnY,
obstacleTopY + selfHalfHeight + MATCH3D_ITEM_SPAWN_STACK_CLEARANCE,
);
}, normalizedPlannedY);
}
export function resolveMatch3DSpawnDelay(
activeLayerRank: number,
layerCapacity = MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE,
timingPlan?: Pick<
Match3DSpawnTimingPlan,
'burstSize' | 'layerDelayMs' | 'staggerMs'
>,
) {
const normalizedLayerCapacity = Math.max(1, layerCapacity);
const normalizedLayerRank = Math.max(0, activeLayerRank);
const layerDelayMs =
timingPlan?.layerDelayMs ?? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS;
const staggerMs = timingPlan?.staggerMs ?? MATCH3D_ITEM_SPAWN_STAGGER_MS;
const burstSize = Math.max(
1,
timingPlan?.burstSize ?? normalizedLayerCapacity,
);
const layerIndex = Math.floor(
normalizedLayerRank / normalizedLayerCapacity,
);
const burstIndex = Math.floor(normalizedLayerRank / burstSize);
return (
Math.max(layerIndex, burstIndex) * layerDelayMs +
(normalizedLayerRank % burstSize) * staggerMs
);
}
export function resolveMatch3DSpawnTimingPlan(
totalItemCount: number,
): Match3DSpawnTimingPlan {
const normalizedTotalItemCount = Math.max(
1,
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
);
const crowdPressureRatio = Math.min(
1,
Math.max(0, normalizedTotalItemCount - 50) / 250,
);
const highCrowdRatio = Math.pow(crowdPressureRatio, 0.82);
const midCrowdRatio = Math.min(
1,
Math.max(0, normalizedTotalItemCount - 30) / 20,
);
return {
frameSpawnLimit:
normalizedTotalItemCount < 30
? 4
: normalizedTotalItemCount <= 120
? 2
: 1,
initialDelayMs: Math.round(
normalizedTotalItemCount < 30
? 220
: normalizedTotalItemCount <= 50
? 240 + midCrowdRatio * 40
: 260 + highCrowdRatio * 140,
),
layerDelayMs: Math.round(
normalizedTotalItemCount < 30
? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS
: normalizedTotalItemCount <= 50
? 96 + midCrowdRatio * 24
: 110 + highCrowdRatio * 50,
),
burstSize:
normalizedTotalItemCount < 30
? 8
: normalizedTotalItemCount <= 50
? 5
: normalizedTotalItemCount <= 120
? 7
: 6,
staggerMs: Math.round(
normalizedTotalItemCount < 30
? MATCH3D_ITEM_SPAWN_STAGGER_MS
: normalizedTotalItemCount <= 50
? 9 + midCrowdRatio * 3
: 12 + highCrowdRatio * 6,
),
};
}
function buildMatch3DStackHeightTargets(
run: Match3DRunSnapshot,
): Match3DStackHeightPlan {
const activeItems = run.items
.filter((item) => isItemState(item.state, 'in_board'))
.sort((left, right) => {
if (left.layer !== right.layer) {
return left.layer - right.layer;
}
return left.itemInstanceId.localeCompare(right.itemInstanceId);
});
const targets = new Map<string, StackHeightTarget>();
const depthPlan = resolveMatch3DBoardDepthPlan(
run.totalItemCount,
activeItems.length,
);
activeItems.forEach((item, activeLayerRank) => {
targets.set(
item.itemInstanceId,
{
activeLayerRank,
targetY: resolveMatch3DStackTargetY(
run.totalItemCount,
activeItems.length,
activeLayerRank,
),
},
);
});
return {
layerCapacity: depthPlan.layerCapacity,
targets,
};
}
export function resolveMatch3DPhysicsStabilityPlan(
totalItemCount: number,
): PhysicsStabilityPlan {
const normalizedTotalItemCount = Math.max(
1,
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
);
const pressureRatio = Math.min(
1,
Math.max(0, normalizedTotalItemCount - 90) / 210,
);
return {
angularDamping: 0.48 + pressureRatio * 0.18,
contactFriction: 0.55 + pressureRatio * 0.22,
contactRestitution: 0.28 - pressureRatio * 0.14,
linearDamping: 0.38 + pressureRatio * 0.2,
maxHorizontalSpeed:
MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT - pressureRatio * 0.9,
maxVerticalSpeed:
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
solverIterations: Math.round(10 + pressureRatio * 8),
solverTolerance: 0.001 - pressureRatio * 0.0006,
};
}
function applyDynamicStackLift(entry: PhysicsEntry) {
const liftDistance = entry.targetY - entry.body.position.y;
if (liftDistance <= 0.04) {
return;
}
const liftSpeed = Math.min(
MATCH3D_ITEM_LIFT_MAX_SPEED,
Math.max(0.7, liftDistance * 1.8),
);
// 中文注释:纵深只作为隐藏的表现层支撑;消除后给低层物体向上的托举,避免它们长期陷在锅底。
entry.body.force.y +=
entry.body.mass * liftDistance * MATCH3D_ITEM_LIFT_FORCE_SCALE;
entry.body.velocity.y = Math.max(entry.body.velocity.y, liftSpeed);
entry.body.wakeUp();
}
function applyStabilityPlanToBody(
entry: PhysicsEntry,
stabilityPlan: PhysicsStabilityPlan,
) {
const horizontalSpeed = Math.hypot(
entry.body.velocity.x,
entry.body.velocity.z,
);
if (horizontalSpeed > stabilityPlan.maxHorizontalSpeed) {
const ratio = stabilityPlan.maxHorizontalSpeed / horizontalSpeed;
entry.body.velocity.x *= ratio;
entry.body.velocity.z *= ratio;
}
entry.body.velocity.y = Math.max(
-stabilityPlan.maxVerticalSpeed,
Math.min(stabilityPlan.maxVerticalSpeed, entry.body.velocity.y),
);
}
function syncRuntimeStabilityPlan(
runtime: PhysicsRuntime,
totalItemCount: number,
) {
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(totalItemCount);
runtime.stabilityPlan = stabilityPlan;
runtime.world.defaultContactMaterial.friction =
stabilityPlan.contactFriction;
runtime.world.defaultContactMaterial.restitution =
stabilityPlan.contactRestitution;
const solver = runtime.world.solver as import('cannon-es').GSSolver;
solver.iterations = stabilityPlan.solverIterations;
solver.tolerance = stabilityPlan.solverTolerance;
runtime.entries.forEach((entry) => {
entry.body.angularDamping = stabilityPlan.angularDamping;
entry.body.linearDamping = stabilityPlan.linearDamping;
entry.body.sleepSpeedLimit = Math.max(
0.08,
0.16 - Math.min(1, totalItemCount / 300) * 0.05,
);
entry.body.sleepTimeLimit = 0.18;
});
}
function constrainBodyInsidePot(entry: PhysicsEntry) {
// 中文注释:空气墙按真实碰撞外接半径收束,长条积木不能再只按近似圆半径贴近锅边。
const maxDistance = Math.max(
0,
MATCH3D_ITEM_ACTIVITY_RADIUS - entry.boundaryRadius,
);
const horizontalDistance = Math.hypot(
entry.body.position.x,
entry.body.position.z,
);
if (horizontalDistance <= maxDistance || horizontalDistance <= 0) {
return;
}
const normalX = entry.body.position.x / horizontalDistance;
const normalZ = entry.body.position.z / horizontalDistance;
entry.body.position.x = normalX * maxDistance;
entry.body.position.z = normalZ * maxDistance;
const outwardSpeed =
entry.body.velocity.x * normalX + entry.body.velocity.z * normalZ;
if (outwardSpeed > 0) {
entry.body.velocity.x -= normalX * outwardSpeed * 1.35;
entry.body.velocity.z -= normalZ * outwardSpeed * 1.35;
}
}
function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
return Math.min(
1,
Math.max(
0,
(now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS,
),
);
}
function applyCenterGravity(entry: PhysicsEntry) {
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
return;
}
const horizontalDistance = Math.hypot(
entry.body.position.x,
entry.body.position.z,
);
if (horizontalDistance <= 0.08) {
return;
}
const visualRadius = toWorldPosition(entry.item).radius;
const maxDistance = Math.max(
0.1,
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
);
const edgePressure = Math.min(1, horizontalDistance / maxDistance);
const centerFalloff = Math.min(1, Math.max(0, (horizontalDistance - 1.15) / maxDistance));
const forceStrength =
MATCH3D_CENTER_GRAVITY_COEFFICIENT *
entry.body.mass *
(10.5 + edgePressure * 13) *
centerFalloff;
// 中文注释:中心引力只拉水平面,垂直方向仍交给锅底重力和物体堆叠处理。
entry.body.force.x +=
(-entry.body.position.x / horizontalDistance) * forceStrength;
entry.body.force.z +=
(-entry.body.position.z / horizontalDistance) * forceStrength;
}
export function resolveMatch3DColliderBounds(
asset: Match3DGeometryAsset,
radius: number,
) {
switch (asset.shape) {
case 'cylinder':
return {
depth: radius * 1.16,
height: radius * 1.312,
width: radius * 1.16,
};
case 'cone':
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 {
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 {
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 {
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,
),
);
}
}
function buildPointShape(
three: ThreeModule,
radius: number,
points: Array<[number, number]>,
) {
const shape = new three.Shape();
points.forEach(([x, y], index) => {
if (index === 0) {
shape.moveTo(x * radius, y * radius);
} else {
shape.lineTo(x * radius, y * radius);
}
});
shape.closePath();
return shape;
}
function buildRingShape(three: ThreeModule, radius: number) {
const shape = new three.Shape();
shape.absarc(0, 0, radius * 0.92, 0, Math.PI * 2, false);
const hole = new three.Path();
hole.absarc(0, 0, radius * 0.43, 0, Math.PI * 2, true);
shape.holes.push(hole);
return shape;
}
function buildReadableShape(
three: ThreeModule,
shape: Match3DGeometryShape,
radius: number,
) {
switch (shape) {
case 'ring':
return buildRingShape(three, radius);
case 'arch':
return buildPointShape(three, radius, [
[-1, 0.8],
[1, 0.8],
[1, -0.7],
[0.42, -0.7],
[0.42, 0.24],
[-0.42, 0.24],
[-0.42, -0.7],
[-1, -0.7],
]);
default:
return null;
}
}
function createExtrudedReadableGeometry(
three: ThreeModule,
shape: Match3DGeometryShape,
radius: number,
) {
const path = buildReadableShape(three, shape, radius);
if (!path) {
return null;
}
const geometry = new three.ExtrudeGeometry(path, {
bevelEnabled: true,
bevelSegments: 2,
bevelSize: radius * 0.045,
bevelThickness: radius * 0.04,
depth: radius * 0.42,
steps: 1,
});
geometry.center();
geometry.rotateX(-Math.PI / 2);
return geometry;
}
export function createMatch3DThreeGeometry(
three: ThreeModule,
shape: Match3DGeometryShape,
radius: number,
) {
const readableGeometry = createExtrudedReadableGeometry(three, shape, radius);
if (readableGeometry) {
return readableGeometry;
}
switch (shape) {
case 'cylinder':
return new three.CylinderGeometry(radius * 0.72, radius * 0.72, radius * 1.35, 26);
case 'cone':
return new three.ConeGeometry(radius * 0.78, radius * 1.62, 28);
case 'tile':
case 'brick':
case 'slope':
case 'arch':
default:
return new three.BoxGeometry(radius * 1.8, radius * 0.9, radius * 1.2);
}
}
function createRoundedBlockBase(
three: ThreeModule,
asset: Match3DGeometryAsset,
radius: number,
) {
const width = radius * (0.9 + asset.studsX * 0.62);
const depth = radius * (0.9 + asset.studsY * 0.62);
const height = Math.max(radius * 0.24, radius * asset.heightScale);
return new three.BoxGeometry(width, height, depth);
}
function createStudGeometry(three: ThreeModule, radius: number) {
return new three.CylinderGeometry(radius * 0.18, radius * 0.18, radius * 0.12, 20);
}
function createSlopeGeometry(
three: ThreeModule,
asset: Match3DGeometryAsset,
radius: number,
) {
const width = radius * (1 + asset.studsX * 0.66);
const depth = radius * (0.95 + asset.studsY * 0.62);
const height = radius * asset.heightScale;
const halfW = width / 2;
const halfD = depth / 2;
const halfH = height / 2;
const vertices = new Float32Array([
-halfW, -halfH, -halfD,
halfW, -halfH, -halfD,
halfW, -halfH, halfD,
-halfW, -halfH, halfD,
halfW, halfH, -halfD,
halfW, halfH, halfD,
]);
const indices = [
0, 1, 2, 0, 2, 3,
1, 4, 5, 1, 5, 2,
3, 2, 5, 3, 5, 0,
0, 5, 4, 0, 4, 1,
];
const geometry = new three.BufferGeometry();
geometry.setAttribute('position', new three.BufferAttribute(vertices, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
}
function addBrickStuds(
three: ThreeModule,
group: import('three').Group,
asset: Match3DGeometryAsset,
radius: number,
material: import('three').Material,
) {
if (asset.shape === 'tile') {
return;
}
const studGeometry = createStudGeometry(three, radius);
const width = radius * (0.9 + asset.studsX * 0.62);
const depth = radius * (0.9 + asset.studsY * 0.62);
const y = Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06;
for (let row = 0; row < asset.studsY; row += 1) {
for (let column = 0; column < asset.studsX; column += 1) {
const stud = new three.Mesh(studGeometry.clone(), material);
stud.position.set(
((column + 0.5) / asset.studsX - 0.5) * width * 0.74,
y,
((row + 0.5) / asset.studsY - 0.5) * depth * 0.72,
);
group.add(stud);
}
}
}
function createBlockMesh(
three: ThreeModule,
asset: Match3DGeometryAsset,
radius: number,
material: import('three').Material,
) {
const group = new three.Group();
let baseGeometry: import('three').BufferGeometry;
if (asset.shape === 'slope') {
baseGeometry = createSlopeGeometry(three, asset, radius);
} else if (asset.shape === 'cylinder') {
baseGeometry = new three.CylinderGeometry(radius * 0.58, radius * 0.58, radius * 1.18, 28);
} else if (asset.shape === 'cone') {
baseGeometry = new three.ConeGeometry(radius * 0.68, radius * 1.48, 30);
} else if (MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)) {
baseGeometry = createMatch3DThreeGeometry(three, asset.shape, radius);
} else {
baseGeometry = createRoundedBlockBase(three, asset, radius);
}
const base = new three.Mesh(baseGeometry, material);
group.add(base);
if (asset.shape === 'brick' || asset.shape === 'slope') {
addBrickStuds(three, group, asset, radius, material);
}
if (asset.shape === 'cylinder') {
const topStud = new three.Mesh(createStudGeometry(three, radius * 1.2), material);
topStud.position.y = radius * 0.65;
group.add(topStud);
}
if (asset.shape === 'cone') {
const lip = new three.Mesh(
new three.TorusGeometry(radius * 0.38, radius * 0.07, 8, 24),
material,
);
lip.rotation.x = Math.PI / 2;
lip.position.y = radius * 0.52;
group.add(lip);
}
return group;
}
function markObjectForItem(object: ThreeObject3D, itemInstanceId: string) {
object.userData.itemInstanceId = itemInstanceId;
object.traverse((child) => {
child.userData.itemInstanceId = itemInstanceId;
child.castShadow = true;
child.receiveShadow = true;
});
}
function disposeThreeObject(object: ThreeObject3D) {
object.traverse((child) => {
const maybeMesh = child as import('three').Mesh;
maybeMesh.geometry?.dispose();
const material = maybeMesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
export function createMatch3DItemMesh(
three: ThreeModule,
item: Match3DItemSnapshot,
) {
const asset = resolveGeometryAsset(item.visualKey);
const position = toWorldPosition(item);
const material = new three.MeshStandardMaterial({
color: asset.fill,
emissive: asset.fill,
emissiveIntensity: 0.08,
metalness: 0.16,
opacity: asset.transparent ? 0.58 : 1,
roughness: 0.46,
transparent: Boolean(asset.transparent),
side: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)
? three.DoubleSide
: three.FrontSide,
});
const mesh = createBlockMesh(three, asset, position.radius, material);
markObjectForItem(mesh, item.itemInstanceId);
return {
lockReadableTop: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape),
mesh,
radius: position.radius,
shape: asset.shape,
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
position,
};
}
function createItemMesh(
three: ThreeModule,
item: Match3DItemSnapshot,
) {
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 createPhysicsEntryFromPendingSpawn(
runtime: PhysicsRuntime,
pendingSpawn: PendingPhysicsSpawn,
now: number,
) {
const visual = createItemMesh(runtime.three, pendingSpawn.item);
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
const position = visual.position;
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius);
const horizontalDistance = Math.hypot(position.x, position.z);
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
const ratio = maxDistance / horizontalDistance;
position.x *= ratio;
position.z *= ratio;
}
const spawnLayerIndex = Math.floor(
Math.max(0, pendingSpawn.activeLayerRank) /
pendingSpawn.layerCapacity,
);
const plannedSpawnY =
pendingSpawn.targetY +
MATCH3D_ITEM_SPAWN_RISE_OFFSET +
Math.min(spawnLayerIndex * 0.05, 0.62);
const spawnY = resolveMatch3DSpawnY(
plannedSpawnY,
colliderBounds.height,
boundaryRadius,
position,
[...runtime.entries.values()].map((entry) => ({
boundaryRadius: entry.boundaryRadius,
colliderHeight: entry.colliderHeight,
x: entry.body.position.x,
y: entry.body.position.y,
z: entry.body.position.z,
})),
);
const body = new runtime.cannon.Body({
angularDamping: runtime.stabilityPlan.angularDamping,
allowSleep: true,
linearDamping: runtime.stabilityPlan.linearDamping,
mass: 1 + visual.radius * 0.7,
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
sleepSpeedLimit: 0.12,
sleepTimeLimit: 0.18,
position: new runtime.cannon.Vec3(
position.x,
spawnY,
position.z,
),
});
body.velocity.set(
((pendingSpawn.item.layer % 5) - 2) * 0.06,
-0.35,
(((pendingSpawn.item.layer + 2) % 5) - 2) * 0.06,
);
body.angularVelocity.set(
0.1 + (pendingSpawn.item.layer % 3) * 0.025,
0.08,
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
);
visual.mesh.scale.setScalar(0.82);
runtime.world.addBody(body);
runtime.scene.add(visual.mesh);
runtime.entries.set(pendingSpawn.item.itemInstanceId, {
body,
boundaryRadius,
colliderHeight: colliderBounds.height,
item: pendingSpawn.item,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh,
renderSignature: pendingSpawn.renderSignature,
spawnStartedAt: now,
targetY: pendingSpawn.targetY,
topRotationY: visual.topRotationY,
});
}
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
const readySpawns = [...runtime.pendingSpawns.entries()]
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
.sort((left, right) => {
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
return left[1].spawnAtMs - right[1].spawnAtMs;
}
if (left[1].activeLayerRank !== right[1].activeLayerRank) {
return left[1].activeLayerRank - right[1].activeLayerRank;
}
return left[0].localeCompare(right[0]);
});
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
runtime.pendingSpawns.delete(itemInstanceId);
createPhysicsEntryFromPendingSpawn(runtime, pendingSpawn, now);
});
}
function disposeRuntime(runtime: PhysicsRuntime | null) {
if (!runtime) {
return;
}
if (runtime.animationId !== null) {
window.cancelAnimationFrame(runtime.animationId);
}
runtime.entries.forEach((entry) => {
disposeThreeObject(entry.mesh);
});
runtime.renderer.dispose();
runtime.renderer.domElement.remove();
}
type TrayPreviewRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, ThreeObject3D>;
renderer: ThreeRenderer;
scene: ThreeScene;
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;
}
if (runtime.animationId !== null) {
window.cancelAnimationFrame(runtime.animationId);
}
runtime.entries.forEach((mesh) => {
disposeThreeObject(mesh);
});
runtime.entries.clear();
runtime.renderer.dispose();
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);
const runtimeRef = useRef<TrayPreviewRuntime | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
let cleanupResize: (() => void) | undefined;
async function setup() {
const container = containerRef.current;
if (!container || !hasWebGLSupport()) {
onFallback();
return;
}
try {
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;
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();
return () => {
cancelled = true;
cleanupResize?.();
disposeTrayPreview(runtimeRef.current);
runtimeRef.current = null;
setReady(false);
};
}, [onFallback]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
const activeIds = new Set(
slotItems
.filter((item): item is Match3DItemSnapshot => Boolean(item))
.map((item) => item.itemInstanceId),
);
runtime.entries.forEach((mesh, itemInstanceId) => {
if (!activeIds.has(itemInstanceId)) {
runtime.scene.remove(mesh);
disposeThreeObject(mesh);
runtime.entries.delete(itemInstanceId);
}
});
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);
const model = preview.mesh;
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
model.rotation.set(rotation.x, rotation.y, rotation.z);
// 中文注释:模型先在自身 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());
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);
}
const activeMesh = mesh;
activeMesh.userData.traySlotIndex = slotIndex;
positionTrayPreviewObject(runtime, activeMesh, slotIndex);
});
runtime.renderer.render(runtime.scene, runtime.camera);
}, [ready, referenceItems, slotItems]);
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0 z-10"
data-testid="match3d-tray-model-board"
/>
);
}
export function Match3DPhysicsBoard({
run,
disabled,
onClickItem,
onFallback,
}: Match3DPhysicsBoardProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<PhysicsRuntime | null>(null);
const disabledRef = useRef(disabled);
const fallbackRef = useRef(onFallback);
const runRef = useRef(run);
const [ready, setReady] = useState(false);
useEffect(() => {
fallbackRef.current = onFallback;
}, [onFallback]);
useEffect(() => {
disabledRef.current = disabled;
}, [disabled]);
useEffect(() => {
runRef.current = run;
}, [run]);
useEffect(() => {
let cancelled = false;
async function setup() {
const container = containerRef.current;
if (!container || !hasWebGLSupport()) {
fallbackRef.current();
return;
}
try {
const [three, cannon] = await Promise.all([
import('three'),
import('cannon-es'),
]);
if (cancelled || !containerRef.current) {
return;
}
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.shadowMap.enabled = true;
renderer.outputColorSpace = three.SRGBColorSpace;
container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();
fallbackRef.current();
};
renderer.domElement.addEventListener(
'webglcontextlost',
handleContextLost,
false,
);
const scene = new three.Scene();
scene.background = null;
const camera = new three.OrthographicCamera(
-MATCH3D_CAMERA_HALF_SIZE,
MATCH3D_CAMERA_HALF_SIZE,
MATCH3D_CAMERA_HALF_SIZE,
-MATCH3D_CAMERA_HALF_SIZE,
0.1,
80,
);
camera.position.set(0, 17.5, 0.01);
camera.lookAt(0, 0, 0);
const ambient = new three.AmbientLight(0xffffff, 1.28);
scene.add(ambient);
const keyLight = new three.DirectionalLight(0xffffff, 2.35);
keyLight.position.set(-3.5, 10, 3.2);
keyLight.castShadow = true;
scene.add(keyLight);
const fillLight = new three.DirectionalLight(0xfef3c7, 1.05);
fillLight.position.set(4, 6, -4.5);
scene.add(fillLight);
const floor = new three.Mesh(
new three.CircleGeometry(MATCH3D_POT_FLOOR_RADIUS, 112),
new three.MeshStandardMaterial({
color: '#d89943',
metalness: 0.05,
roughness: 0.72,
}),
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
const basinShade = new three.Mesh(
new three.RingGeometry(MATCH3D_POT_INNER_RADIUS * 0.72, MATCH3D_POT_FLOOR_RADIUS, 112),
new three.MeshBasicMaterial({
color: '#8a4f1f',
opacity: 0.2,
side: three.DoubleSide,
transparent: true,
}),
);
basinShade.rotation.x = -Math.PI / 2;
basinShade.position.y = 0.012;
scene.add(basinShade);
const potWall = new three.Mesh(
new three.CylinderGeometry(
MATCH3D_POT_OUTER_RADIUS,
MATCH3D_POT_FLOOR_RADIUS,
MATCH3D_POT_WALL_HEIGHT,
112,
1,
true,
),
new three.MeshStandardMaterial({
color: '#b76d2b',
metalness: 0.08,
opacity: 0.46,
roughness: 0.64,
side: three.DoubleSide,
transparent: true,
}),
);
potWall.position.y = MATCH3D_POT_WALL_HEIGHT / 2;
potWall.receiveShadow = true;
scene.add(potWall);
const innerRim = new three.Mesh(
new three.TorusGeometry(MATCH3D_POT_INNER_RADIUS, 0.08, 10, 112),
new three.MeshStandardMaterial({
color: '#f7dd9c',
metalness: 0.08,
roughness: 0.5,
}),
);
innerRim.rotation.x = Math.PI / 2;
innerRim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.035;
scene.add(innerRim);
const rim = new three.Mesh(
new three.TorusGeometry(MATCH3D_POT_OUTER_RADIUS, 0.22, 12, 112),
new three.MeshStandardMaterial({
color: '#f1d38e',
metalness: 0.1,
roughness: 0.52,
}),
);
rim.rotation.x = Math.PI / 2;
rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1;
scene.add(rim);
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(
runRef.current.totalItemCount,
);
const spawnTimingPlan = resolveMatch3DSpawnTimingPlan(
runRef.current.totalItemCount,
);
const world = new cannon.World({
gravity: new cannon.Vec3(0, -6.2, 0),
});
world.allowSleep = true;
world.broadphase = new cannon.SAPBroadphase(world);
world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
world.defaultContactMaterial.restitution =
stabilityPlan.contactRestitution;
const solver = world.solver as import('cannon-es').GSSolver;
solver.iterations = stabilityPlan.solverIterations;
solver.tolerance = stabilityPlan.solverTolerance;
const floorBody = new cannon.Body({
mass: 0,
shape: new cannon.Plane(),
});
floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(floorBody);
const wallSegments = 56;
for (let index = 0; index < wallSegments; index += 1) {
const angle = (index / wallSegments) * Math.PI * 2;
const x = Math.cos(angle) * (MATCH3D_POT_INNER_RADIUS + 0.18);
const z = Math.sin(angle) * (MATCH3D_POT_INNER_RADIUS + 0.18);
const wall = new cannon.Body({
mass: 0,
shape: new cannon.Box(new cannon.Vec3(0.22, MATCH3D_POT_WALL_HEIGHT, 0.34)),
position: new cannon.Vec3(x, MATCH3D_POT_WALL_HEIGHT, z),
});
wall.quaternion.setFromEuler(0, -angle, 0);
world.addBody(wall);
}
const runtime: PhysicsRuntime = {
animationId: null,
camera,
entries: new Map(),
pendingSpawns: new Map(),
raycaster: new three.Raycaster(),
renderer,
scene,
spawnTimingPlan,
stabilityPlan,
world,
three,
cannon,
};
runtimeRef.current = runtime;
const resize = () => {
const rect = container.getBoundingClientRect();
const size = Math.max(1, Math.min(rect.width, rect.height));
renderer.setSize(size, size, false);
camera.left = -MATCH3D_CAMERA_HALF_SIZE;
camera.right = MATCH3D_CAMERA_HALF_SIZE;
camera.top = MATCH3D_CAMERA_HALF_SIZE;
camera.bottom = -MATCH3D_CAMERA_HALF_SIZE;
camera.updateProjectionMatrix();
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
let lastTime = performance.now();
const animate = (now: number) => {
const activeRuntime = runtimeRef.current;
if (!activeRuntime) {
return;
}
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
lastTime = now;
flushPendingPhysicsSpawns(activeRuntime, now);
activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
applyDynamicStackLift(entry);
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
constrainBodyInsidePot(entry);
});
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 5);
activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
applyDynamicStackLift(entry);
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
constrainBodyInsidePot(entry);
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
const spawnScale = 0.82 + spawnProgress * 0.18;
entry.mesh.scale.setScalar(spawnScale);
entry.mesh.position.set(
entry.body.position.x,
entry.body.position.y - (1 - spawnProgress) * 0.06,
entry.body.position.z,
);
entry.mesh.quaternion.set(
entry.lockReadableTop ? 0 : entry.body.quaternion.x,
entry.lockReadableTop ? 0 : entry.body.quaternion.y,
entry.lockReadableTop ? 0 : entry.body.quaternion.z,
entry.lockReadableTop ? 1 : entry.body.quaternion.w,
);
if (entry.lockReadableTop) {
entry.mesh.rotation.y = entry.topRotationY;
}
});
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
activeRuntime.animationId = window.requestAnimationFrame(animate);
};
runtime.animationId = window.requestAnimationFrame(animate);
setReady(true);
return () => {
renderer.domElement.removeEventListener(
'webglcontextlost',
handleContextLost,
false,
);
ro.disconnect();
};
} catch {
fallbackRef.current();
}
}
let cleanupResize: (() => void) | undefined;
void setup().then((cleanup) => {
cleanupResize = cleanup;
});
return () => {
cancelled = true;
cleanupResize?.();
disposeRuntime(runtimeRef.current);
runtimeRef.current = null;
};
}, []);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
const activeItemIds = new Set(
run.items
.filter((item) => isItemState(item.state, 'in_board'))
.map((item) => item.itemInstanceId),
);
runtime.entries.forEach((entry, itemInstanceId) => {
if (!activeItemIds.has(itemInstanceId)) {
removePhysicsEntry(runtime, itemInstanceId, entry);
}
});
runtime.pendingSpawns.forEach((pendingSpawn, itemInstanceId) => {
if (!activeItemIds.has(itemInstanceId)) {
runtime.pendingSpawns.delete(itemInstanceId);
}
});
syncRuntimeStabilityPlan(runtime, run.totalItemCount);
runtime.spawnTimingPlan = resolveMatch3DSpawnTimingPlan(run.totalItemCount);
const stackHeightPlan = buildMatch3DStackHeightTargets(run);
run.items.forEach((item) => {
if (!isItemState(item.state, 'in_board')) {
return;
}
const renderSignature = buildMatch3DPhysicsEntrySignature(
run.runId,
item,
);
const existing = runtime.entries.get(item.itemInstanceId);
if (existing) {
if (existing.renderSignature !== renderSignature) {
// 中文注释:后端重开局时 itemInstanceId 可能复用,旧 3D 模型必须随当前 run 快照重建。
removePhysicsEntry(runtime, item.itemInstanceId, existing);
} else {
existing.item = item;
existing.targetY =
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
existing.body.position.y;
existing.mesh.visible = true;
return;
}
}
const existingPending = runtime.pendingSpawns.get(item.itemInstanceId);
if (existingPending) {
if (existingPending.renderSignature !== renderSignature) {
runtime.pendingSpawns.delete(item.itemInstanceId);
} else {
existingPending.item = item;
existingPending.layerCapacity = stackHeightPlan.layerCapacity;
existingPending.targetY =
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
existingPending.targetY;
return;
}
}
const stackTarget = stackHeightPlan.targets.get(item.itemInstanceId);
const spawnAtMs =
performance.now() +
runtime.spawnTimingPlan.initialDelayMs +
resolveMatch3DSpawnDelay(
stackTarget?.activeLayerRank ?? item.layer - 1,
stackHeightPlan.layerCapacity,
runtime.spawnTimingPlan,
);
runtime.pendingSpawns.set(item.itemInstanceId, {
activeLayerRank: stackTarget?.activeLayerRank ?? item.layer - 1,
item,
layerCapacity: stackHeightPlan.layerCapacity,
renderSignature,
spawnAtMs,
targetY:
stackTarget?.targetY ??
resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
});
});
}, [ready, run.items, run.runId, run.snapshotVersion]);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
event.stopPropagation();
const runtime = runtimeRef.current;
const container = containerRef.current;
if (!runtime || !container || disabledRef.current) {
return;
}
const rect = container.getBoundingClientRect();
const pointer = new runtime.three.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-(((event.clientY - rect.top) / rect.height) * 2 - 1),
);
runtime.raycaster.setFromCamera(pointer, runtime.camera);
const meshes = [...runtime.entries.values()]
.filter(
(entry) =>
entry.item.clickable &&
isItemState(entry.item.state, 'in_board') &&
entry.mesh.visible,
)
.map((entry) => entry.mesh);
const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
const itemInstanceId =
typeof hit?.object.userData.itemInstanceId === 'string'
? hit.object.userData.itemInstanceId
: null;
if (!itemInstanceId) {
return;
}
const item = runRef.current.items.find(
(entry) => entry.itemInstanceId === itemInstanceId,
);
if (item?.clickable && isItemState(item.state, 'in_board')) {
onClickItem(item);
}
};
return (
<div
ref={containerRef}
className="absolute inset-0 z-10 overflow-visible"
data-testid="match3d-physics-board"
onPointerDown={handlePointerDown}
>
{!ready ? (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.18),transparent_28%)]" />
) : null}
</div>
);
}
export default Match3DPhysicsBoard;