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; pendingSpawns: Map; raycaster: import('three').Raycaster; renderer: ThreeRenderer; scene: ThreeScene; spawnTimingPlan: Match3DSpawnTimingPlan; stabilityPlan: PhysicsStabilityPlan; world: PhysicsWorld; three: ThreeModule; cannon: CannonModule; }; type Match3DStackHeightPlan = { layerCapacity: number; targets: Map; }; 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 = 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, 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(); 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; 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(); 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; }) { const containerRef = useRef(null); const runtimeRef = useRef(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 (
); } export function Match3DPhysicsBoard({ run, disabled, onClickItem, onFallback, }: Match3DPhysicsBoardProps) { const containerRef = useRef(null); const runtimeRef = useRef(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) => { 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 (
{!ready ? (
) : null}
); } export default Match3DPhysicsBoard;