import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react'; import type { Match3DItemSnapshot, Match3DRunSnapshot, } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks'; import { isDebugMode } from '../../config/debugMode'; import { readMatch3DGeneratedModelBytes, resolveMatch3DGeneratedModelAssetSource, } from '../../services/match3dGeneratedModelCache'; import { isItemState, resolveRenderableItemFrame, } from './match3dRuntimePresentation'; import { type Match3DGeometryAsset, type Match3DGeometryShape, resolveGeometryAsset, } from './match3dVisualAssets'; type Match3DPhysicsBoardProps = { run: Match3DRunSnapshot; generatedItemAssets?: Match3DGeneratedItemAsset[]; disabled: boolean; onClickItem: (item: Match3DItemSnapshot) => void; onFallback: () => void; }; type Match3DPhysicsPointerSelection = { itemInstanceId: string | null; pointerId: number; }; 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 Match3DGeneratedModelTemplate = { source: string; scene: ThreeObject3D; }; type Match3DGeneratedModelTemplateMap = Map< string, Match3DGeneratedModelTemplate >; type PhysicsEntry = { boundaryRadius: number; colliderHeight: number; item: Match3DItemSnapshot; baseVisualScale: import('three').Vector3; 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; failedGeneratedModelTypeIds: Set; generatedModelByType: Map; generatedModelTemplates: Match3DGeneratedModelTemplateMap; 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_SPAWN_VISUAL_SCALE_START = 0.18; const MATCH3D_ITEM_SPAWN_VISUAL_DROP_OFFSET = 0.04; 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; const MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE = 1.9; const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 25; const MATCH3D_SELECTED_MODEL_SCALE = 1.1; export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet = new Set(['ring', 'arch']); function normalizeMatch3DGeneratedModelSource( asset: Match3DGeneratedItemAsset, ) { return resolveMatch3DGeneratedModelAssetSource(asset); } function compareMatch3DGeneratedTypeId(left: string, right: string) { const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10); const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10); if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) { return leftIndex - rightIndex; } return left.localeCompare(right); } function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) { return [ ...new Set(items.map((item) => item.itemTypeId.trim()).filter(Boolean)), ].sort(compareMatch3DGeneratedTypeId); } export function buildMatch3DGeneratedAssetTypeMap( run: Match3DRunSnapshot, generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [], ) { const typeIds = resolveMatch3DGeneratedModelTypeIds(run.items); const readyAssets = generatedItemAssets .map((asset) => ({ asset, source: normalizeMatch3DGeneratedModelSource(asset), })) .filter(({ source }) => Boolean(source)) .slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT); const assetMap = new Map(); typeIds.forEach((itemTypeId, index) => { const resolved = readyAssets[index]; if (!resolved) { return; } assetMap.set(itemTypeId, { ...resolved.asset, modelSrc: resolved.source, }); debugMatch3DGeneratedModelMapped(itemTypeId, resolved.source); }); return assetMap; } function buildGeneratedModelMapSignature( generatedModelByType: Map, ) { return [...generatedModelByType.entries()] .map( ([itemTypeId, asset]) => `${itemTypeId}:${normalizeMatch3DGeneratedModelSource(asset)}`, ) .join('|'); } function resolveGeneratedModelSourceForItemType( generatedModelByType: Map, itemTypeId: string, ) { const asset = generatedModelByType.get(itemTypeId); return asset ? normalizeMatch3DGeneratedModelSource(asset) : ''; } function shouldLogMatch3DGeneratedModelDiagnostics() { return isDebugMode() && import.meta.env.MODE !== 'test'; } function warnMatch3DGeneratedModelLoadFailure( itemTypeId: string, source: string, error: unknown, ) { if (!shouldLogMatch3DGeneratedModelDiagnostics()) { return; } const message = error instanceof Error ? error.message : String(error || 'unknown error'); console.warn('[match3d] generated model load failed', { itemTypeId, source, message, }); } function debugMatch3DGeneratedModelLoaded(itemTypeId: string, source: string) { if (!shouldLogMatch3DGeneratedModelDiagnostics()) { return; } console.debug('[match3d] generated model loaded', { itemTypeId, source, }); } function debugMatch3DGeneratedModelMapped(itemTypeId: string, source: string) { if (!shouldLogMatch3DGeneratedModelDiagnostics()) { return; } console.debug('[match3d] generated model mapped', { itemTypeId, source, }); } async function loadMatch3DGeneratedModelTemplate( templateMap: Match3DGeneratedModelTemplateMap, three: ThreeModule, itemTypeId: string, source: string, signal?: AbortSignal, ) { const cached = templateMap.get(itemTypeId); if (cached?.source === source) { return cached.scene; } const bytes = await readMatch3DGeneratedModelBytes(source, { expireSeconds: 300, signal, }); if (bytes.byteLength === 0) { throw new Error('抓大鹅 3D 模型内容为空'); } if (signal?.aborted) { throw new DOMException('加载已取消', 'AbortError'); } const [{ GLTFLoader }] = await Promise.all([ import('three/examples/jsm/loaders/GLTFLoader.js'), ]); const loader = new GLTFLoader(); const gltf = await loader.parseAsync(bytes, ''); if (signal?.aborted) { throw new DOMException('加载已取消', 'AbortError'); } const scene = gltf.scene; scene.traverse((child) => { child.castShadow = true; child.receiveShadow = true; }); const previous = templateMap.get(itemTypeId); if (previous && previous.source !== source) { disposeThreeObject(previous.scene); } templateMap.set(itemTypeId, { scene, source, }); debugMatch3DGeneratedModelLoaded(itemTypeId, source); return scene; } function cloneThreeObjectWithMaterials(template: ThreeObject3D) { const clone = template.clone(true); clone.traverse((child) => { const maybeMesh = child as import('three').Mesh; if (maybeMesh.geometry) { maybeMesh.geometry = maybeMesh.geometry.clone(); } if (maybeMesh.material) { maybeMesh.material = Array.isArray(maybeMesh.material) ? maybeMesh.material.map((material) => material.clone()) : maybeMesh.material.clone(); } }); return clone; } function createGeneratedModelMesh( three: ThreeModule, item: Match3DItemSnapshot, templateMap: Match3DGeneratedModelTemplateMap | null | undefined, ) { const template = templateMap?.get(item.itemTypeId)?.scene; if (!template) { return null; } const position = toWorldPosition(item); const model = cloneThreeObjectWithMaterials(template); const bounds = new three.Box3().setFromObject(model); const size = bounds.getSize(new three.Vector3()); const dimension = Math.max(size.x, size.y, size.z, 0.001); const targetDimension = position.radius * MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE; const scale = targetDimension / dimension; model.scale.multiplyScalar(scale); const scaledBounds = new three.Box3().setFromObject(model); const center = scaledBounds.getCenter(new three.Vector3()); model.position.sub(center); const bottomY = scaledBounds.min.y - center.y; model.position.y -= bottomY; const pivot = new three.Group(); pivot.add(model); markObjectForItem(pivot, item.itemInstanceId); return { lockReadableTop: false, mesh: pivot, radius: position.radius, shape: 'brick' as Match3DGeometryShape, topRotationY: ((item.layer % 12) / 12) * Math.PI * 2, position, }; } 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), ); } export function resolveMatch3DSpawnVisualScale(progress: number) { const clampedProgress = Math.min( 1, Math.max(0, Number.isFinite(progress) ? progress : 0), ); const easedProgress = 1 - Math.pow(1 - clampedProgress, 3); return ( MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START + (1 - MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START) * easedProgress ); } 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, templateMap?: Match3DGeneratedModelTemplateMap | null, ) { return ( createGeneratedModelMesh(three, item, templateMap) ?? createMatch3DItemMesh(three, item) ); } function shouldWaitForGeneratedModelTemplate( generatedModelByType: Map, templateMap: Match3DGeneratedModelTemplateMap, failedTypeIds: ReadonlySet, itemTypeId: string, ) { const source = resolveGeneratedModelSourceForItemType( generatedModelByType, itemTypeId, ); // 中文注释:坏 GLB 或过期链接不能让整局空等模板;失败类型应立即走默认几何降级。 return Boolean( source && !templateMap.has(itemTypeId) && !failedTypeIds.has(itemTypeId), ); } export function buildMatch3DPhysicsEntrySignature( runId: string, item: Match3DItemSnapshot, generatedModelSource = '', generatedModelRevision = 0, ) { return [ runId, item.itemInstanceId, item.itemTypeId, item.visualKey, item.radius.toFixed(5), item.layer, generatedModelSource, generatedModelRevision, ].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 resolveMatch3DPhysicsHitItemId( runtime: PhysicsRuntime, container: HTMLElement, clientX: number, clientY: number, ) { const rect = container.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return null; } const pointer = new runtime.three.Vector2( ((clientX - rect.left) / rect.width) * 2 - 1, -(((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]; return typeof hit?.object.userData.itemInstanceId === 'string' ? hit.object.userData.itemInstanceId : null; } function createPhysicsEntryFromPendingSpawn( runtime: PhysicsRuntime, pendingSpawn: PendingPhysicsSpawn, now: number, templateMap?: Match3DGeneratedModelTemplateMap | null, ) { if ( shouldWaitForGeneratedModelTemplate( runtime.generatedModelByType, runtime.generatedModelTemplates, runtime.failedGeneratedModelTypeIds, pendingSpawn.item.itemTypeId, ) ) { return; } const visual = createItemMesh(runtime.three, pendingSpawn.item, templateMap); 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, ); const baseVisualScale = visual.mesh.scale.clone(); visual.mesh.scale .copy(baseVisualScale) .multiplyScalar(MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START); runtime.world.addBody(body); runtime.scene.add(visual.mesh); runtime.entries.set(pendingSpawn.item.itemInstanceId, { baseVisualScale, 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]) => { if (now < pendingSpawn.spawnAtMs) { return false; } return !shouldWaitForGeneratedModelTemplate( runtime.generatedModelByType, runtime.generatedModelTemplates, runtime.failedGeneratedModelTypeIds, pendingSpawn.item.itemTypeId, ); }) .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, runtime.generatedModelTemplates, ); }); } function disposeRuntime(runtime: PhysicsRuntime | null) { if (!runtime) { return; } if (runtime.animationId !== null) { window.cancelAnimationFrame(runtime.animationId); } runtime.entries.forEach((entry) => { disposeThreeObject(entry.mesh); }); runtime.generatedModelTemplates.forEach((template) => { disposeThreeObject(template.scene); }); runtime.generatedModelTemplates.clear(); runtime.renderer.dispose(); runtime.renderer.domElement.remove(); } type TrayPreviewRuntime = { animationId: number | null; camera: ThreeCamera; entries: Map; failedGeneratedModelTypeIds: Set; generatedModelTemplates: Match3DGeneratedModelTemplateMap; 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, generatedModelSource = '', generatedModelRevision = 0, ) { return [ item.visualKey, item.itemTypeId, item.radius.toFixed(5), referenceMaxDimension.toFixed(5), MATCH3D_TRAY_MODEL_TARGET_SIZE.toFixed(5), MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE.toFixed(5), generatedModelSource, generatedModelRevision, ].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.generatedModelTemplates.forEach((template) => { disposeThreeObject(template.scene); }); runtime.generatedModelTemplates.clear(); runtime.renderer.dispose(); runtime.renderer.domElement.remove(); } export function applyMatch3DRendererCanvasLayout(canvas: HTMLCanvasElement) { // 中文注释:WebGL 绘图缓冲区会乘设备 DPR,CSS 尺寸必须单独锁住,否则手机端画布会放大溢出。 canvas.style.display = 'block'; canvas.style.height = '100%'; canvas.style.inset = '0'; canvas.style.position = 'absolute'; canvas.style.width = '100%'; } 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 buildMatch3DTrayModelSourceMap( referenceItems: Match3DItemSnapshot[], slotItems: Array, generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { const itemTypeIds = resolveMatch3DGeneratedModelTypeIds([ ...referenceItems, ...slotItems.filter((item): item is Match3DItemSnapshot => Boolean(item)), ]); const readyAssets = generatedItemAssets .map((asset) => ({ asset, source: normalizeMatch3DGeneratedModelSource(asset), })) .filter(({ source }) => Boolean(source)) .slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT); const result = new Map(); itemTypeIds.forEach((itemTypeId, index) => { const resolved = readyAssets[index]; if (!resolved) { return; } result.set(itemTypeId, resolved.source); }); return result; } export function Match3DTrayPreviewBoard({ onFallback, referenceItems, slotItems, generatedItemAssets = [], }: { onFallback: () => void; referenceItems: Match3DItemSnapshot[]; slotItems: Array; generatedItemAssets?: Match3DGeneratedItemAsset[]; }) { const containerRef = useRef(null); const runtimeRef = useRef(null); const [ready, setReady] = useState(false); const trayModelSourceByType = useMemo( () => buildMatch3DTrayModelSourceMap( referenceItems, slotItems, generatedItemAssets, ), [generatedItemAssets, referenceItems, slotItems], ); const trayModelSignature = useMemo( () => [...trayModelSourceByType.entries()] .map(([type, source]) => `${type}:${source}`) .join('|'), [trayModelSourceByType], ); const [trayModelRevision, setTrayModelRevision] = useState(0); 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; applyMatch3DRendererCanvasLayout(renderer.domElement); 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(), failedGeneratedModelTypeIds: runtimeRef.current?.failedGeneratedModelTypeIds ?? new Set(), generatedModelTemplates: runtimeRef.current?.generatedModelTemplates ?? 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(), failedGeneratedModelTypeIds: new Set(), generatedModelTemplates: 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 undefined; } const abortController = new AbortController(); const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys()); trayModelSourceByType.forEach((source, itemTypeId) => { staleItemTypeIds.delete(itemTypeId); const hadFreshTemplate = runtime.generatedModelTemplates.get(itemTypeId)?.source === source; runtime.failedGeneratedModelTypeIds.delete(itemTypeId); void loadMatch3DGeneratedModelTemplate( runtime.generatedModelTemplates, runtime.three, itemTypeId, source, abortController.signal, ) .then(() => { if (hadFreshTemplate) { return; } setTrayModelRevision((current) => current + 1); runtime.entries.forEach((mesh, itemInstanceId) => { const itemType = slotItems.find( (item) => item?.itemInstanceId === itemInstanceId, )?.itemTypeId; if (itemType !== itemTypeId) { return; } runtime.scene.remove(mesh); disposeThreeObject(mesh); runtime.entries.delete(itemInstanceId); }); }) .catch((caughtError) => { if (abortController.signal.aborted) { return; } warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError); runtime.generatedModelTemplates.delete(itemTypeId); runtime.failedGeneratedModelTypeIds.add(itemTypeId); setTrayModelRevision((current) => current + 1); }); }); staleItemTypeIds.forEach((itemTypeId) => { const template = runtime.generatedModelTemplates.get(itemTypeId); if (template) { disposeThreeObject(template.scene); } runtime.generatedModelTemplates.delete(itemTypeId); runtime.failedGeneratedModelTypeIds.delete(itemTypeId); }); return () => { abortController.abort(); }; }, [slotItems, trayModelSignature, trayModelSourceByType]); 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, trayModelSourceByType.get(item.itemTypeId) ?? '', trayModelRevision, ); 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 = createItemMesh( runtime.three, item, runtime.failedGeneratedModelTypeIds.has(item.itemTypeId) ? null : runtime.generatedModelTemplates, ); 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, trayModelRevision, trayModelSignature, trayModelSourceByType, ]); return (
); } export function Match3DPhysicsBoard({ run, generatedItemAssets = [], 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 pointerSelectionRef = useRef( null, ); const generatedModelByType = useMemo( () => buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets), [generatedItemAssets, run], ); const generatedModelSignature = useMemo( () => buildGeneratedModelMapSignature(generatedModelByType), [generatedModelByType], ); const [ready, setReady] = useState(false); const [generatedModelRevision, setGeneratedModelRevision] = useState(0); 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; applyMatch3DRendererCanvasLayout(renderer.domElement); 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(), failedGeneratedModelTypeIds: new Set(), generatedModelByType, generatedModelTemplates: 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 = resolveMatch3DSpawnVisualScale(spawnProgress); const selectedScale = pointerSelectionRef.current?.itemInstanceId === entry.item.itemInstanceId ? MATCH3D_SELECTED_MODEL_SCALE : 1; entry.mesh.scale .copy(entry.baseVisualScale) .multiplyScalar(spawnScale * selectedScale); entry.mesh.position.set( entry.body.position.x, entry.body.position.y - (1 - spawnProgress) * MATCH3D_ITEM_SPAWN_VISUAL_DROP_OFFSET, 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 undefined; } runtime.generatedModelByType = generatedModelByType; const abortController = new AbortController(); const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys()); generatedModelByType.forEach((asset, itemTypeId) => { const source = normalizeMatch3DGeneratedModelSource(asset); staleItemTypeIds.delete(itemTypeId); if (!source) { return; } const hadFreshTemplate = runtime.generatedModelTemplates.get(itemTypeId)?.source === source; runtime.failedGeneratedModelTypeIds.delete(itemTypeId); void loadMatch3DGeneratedModelTemplate( runtime.generatedModelTemplates, runtime.three, itemTypeId, source, abortController.signal, ) .then(() => { if (hadFreshTemplate) { return; } setGeneratedModelRevision((current) => current + 1); const hasActiveEntry = [...runtime.entries.values()].some( (entry) => entry.item.itemTypeId === itemTypeId, ); if (!hasActiveEntry) { return; } runtime.entries.forEach((entry, itemInstanceId) => { if (entry.item.itemTypeId === itemTypeId) { removePhysicsEntry(runtime, itemInstanceId, entry); } }); }) .catch((caughtError) => { if (abortController.signal.aborted) { return; } warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError); runtime.generatedModelTemplates.delete(itemTypeId); runtime.failedGeneratedModelTypeIds.add(itemTypeId); setGeneratedModelRevision((current) => current + 1); }); }); staleItemTypeIds.forEach((itemTypeId) => { const template = runtime.generatedModelTemplates.get(itemTypeId); if (template) { disposeThreeObject(template.scene); } runtime.generatedModelTemplates.delete(itemTypeId); runtime.failedGeneratedModelTypeIds.delete(itemTypeId); }); return () => { abortController.abort(); }; }, [generatedModelByType, generatedModelSignature]); 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, resolveGeneratedModelSourceForItemType( generatedModelByType, item.itemTypeId, ), generatedModelRevision, ); 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), }); }); }, [ generatedModelSignature, generatedModelRevision, generatedModelByType, ready, run, run.items, run.runId, run.snapshotVersion, ]); const resolvePointerHitItemId = (event: PointerEvent) => { const runtime = runtimeRef.current; const container = containerRef.current; if (!runtime || !container || disabledRef.current) { return null; } return resolveMatch3DPhysicsHitItemId( runtime, container, event.clientX, event.clientY, ); }; const handlePointerDown = (event: PointerEvent) => { event.stopPropagation(); if (!runtimeRef.current || !containerRef.current || disabledRef.current) { return; } event.currentTarget.setPointerCapture?.(event.pointerId); pointerSelectionRef.current = { itemInstanceId: resolvePointerHitItemId(event), pointerId: event.pointerId, }; }; const handlePointerMove = (event: PointerEvent) => { if (pointerSelectionRef.current?.pointerId !== event.pointerId) { return; } pointerSelectionRef.current = { itemInstanceId: resolvePointerHitItemId(event), pointerId: event.pointerId, }; }; const handlePointerCancel = (event: PointerEvent) => { if (pointerSelectionRef.current?.pointerId !== event.pointerId) { return; } pointerSelectionRef.current = null; event.currentTarget.releasePointerCapture?.(event.pointerId); }; const handlePointerUp = (event: PointerEvent) => { event.stopPropagation(); if (pointerSelectionRef.current?.pointerId !== event.pointerId) { return; } const itemInstanceId = resolvePointerHitItemId(event); pointerSelectionRef.current = null; event.currentTarget.releasePointerCapture?.(event.pointerId); const item = itemInstanceId ? runRef.current.items.find( (entry) => entry.itemInstanceId === itemInstanceId, ) : null; if (item?.clickable && isItemState(item.state, 'in_board')) { onClickItem(item); } }; return (
{!ready ? (
) : null}
); } export default Match3DPhysicsBoard;