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.
2538 lines
75 KiB
TypeScript
2538 lines
75 KiB
TypeScript
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 绘图缓冲区会乘设备 DPR,CSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
|
||
canvas.style.display = 'block';
|
||
canvas.style.height = '100%';
|
||
canvas.style.inset = '0';
|
||
canvas.style.position = 'absolute';
|
||
canvas.style.width = '100%';
|
||
}
|
||
|
||
function positionTrayPreviewObject(
|
||
runtime: TrayPreviewRuntime,
|
||
object: ThreeObject3D,
|
||
slotIndex: number,
|
||
) {
|
||
const slotWidth =
|
||
(runtime.camera.right - runtime.camera.left) / MATCH3D_TRAY_SLOT_COUNT;
|
||
const slotCenter = runtime.camera.left + slotWidth * (slotIndex + 0.5);
|
||
const screenX = new runtime.three.Vector3(1, 0, 0).applyQuaternion(
|
||
runtime.camera.quaternion,
|
||
);
|
||
// 中文注释:托盘模型按相机屏幕横轴排布,保留斜视角但不让 UI 格子投影成斜线。
|
||
object.position.copy(screenX.multiplyScalar(slotCenter));
|
||
}
|
||
|
||
function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) {
|
||
runtime.entries.forEach((object) => {
|
||
const slotIndex =
|
||
typeof object.userData.traySlotIndex === 'number'
|
||
? object.userData.traySlotIndex
|
||
: 0;
|
||
positionTrayPreviewObject(runtime, object, slotIndex);
|
||
});
|
||
}
|
||
|
||
export function buildMatch3DTrayModelSourceMap(
|
||
referenceItems: Match3DItemSnapshot[],
|
||
slotItems: Array<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;
|