Files
Genarrative/src/components/match3d-runtime/Match3DPhysicsBoard.tsx
高物 3cb3efb4d0 Prune stale docs and update .hermes content
Delete a large set of outdated documentation (many files under docs/ and .hermes/plans/, including audits, design, prd, technical, planning, assets, and todos). Update and consolidate .hermes content: refresh shared-memory pages (decision-log, development-workflow, document-map, pitfalls, project-overview, team-conventions) and several skills/references under .hermes/skills. Also modify AGENTS.md, README.md, UI_CODING_STANDARD.md, docs/README.md and .encoding-check-ignore. Purpose: clean up stale planning/audit material and keep current hermes documentation and related top-level docs in sync.
2026-05-15 06:24:07 +08:00

2538 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, PhysicsEntry>;
failedGeneratedModelTypeIds: Set<string>;
generatedModelByType: Map<string, Match3DGeneratedItemAsset>;
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
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_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<Match3DGeometryShape> =
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<string, Match3DGeneratedItemAsset>();
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<string, Match3DGeneratedItemAsset>,
) {
return [...generatedModelByType.entries()]
.map(
([itemTypeId, asset]) =>
`${itemTypeId}:${normalizeMatch3DGeneratedModelSource(asset)}`,
)
.join('|');
}
function resolveGeneratedModelSourceForItemType(
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
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<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),
);
}
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<string, Match3DGeneratedItemAsset>,
templateMap: Match3DGeneratedModelTemplateMap,
failedTypeIds: ReadonlySet<string>,
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<string, ThreeObject3D>;
failedGeneratedModelTypeIds: Set<string>;
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<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.generatedModelTemplates.forEach((template) => {
disposeThreeObject(template.scene);
});
runtime.generatedModelTemplates.clear();
runtime.renderer.dispose();
runtime.renderer.domElement.remove();
}
export function applyMatch3DRendererCanvasLayout(canvas: HTMLCanvasElement) {
// 中文注释WebGL 绘图缓冲区会乘设备 DPRCSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
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<Match3DItemSnapshot | null>,
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<string, string>();
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<Match3DItemSnapshot | null>;
generatedItemAssets?: Match3DGeneratedItemAsset[];
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TrayPreviewRuntime | null>(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 (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0 z-10"
data-testid="match3d-tray-model-board"
/>
);
}
export function Match3DPhysicsBoard({
run,
generatedItemAssets = [],
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 pointerSelectionRef = useRef<Match3DPhysicsPointerSelection | null>(
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
return;
}
pointerSelectionRef.current = {
itemInstanceId: resolvePointerHitItemId(event),
pointerId: event.pointerId,
};
};
const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
return;
}
pointerSelectionRef.current = null;
event.currentTarget.releasePointerCapture?.(event.pointerId);
};
const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className="absolute inset-0 z-10 overflow-visible"
data-testid="match3d-physics-board"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerCancel={handlePointerCancel}
onPointerUp={handlePointerUp}
>
{!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;