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