feat: add puzzle onboarding and match3d entry updates
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-07 23:30:54 +08:00
parent df80876f60
commit e8fee0172a
27 changed files with 1802 additions and 68 deletions

View File

@@ -32,34 +32,116 @@ type ThreeRenderer = import('three').WebGLRenderer;
type ThreeCamera = import('three').OrthographicCamera;
type PhysicsEntry = {
boundaryRadius: number;
colliderHeight: number;
item: Match3DItemSnapshot;
body: PhysicsBody;
lockReadableTop: boolean;
mesh: ThreeObject3D;
renderSignature: string;
spawnStartedAt: number;
targetY: number;
topRotationY: number;
};
type PendingPhysicsSpawn = {
activeLayerRank: number;
item: Match3DItemSnapshot;
renderSignature: string;
spawnAtMs: number;
layerCapacity: number;
targetY: number;
};
type StackHeightTarget = {
activeLayerRank: number;
targetY: number;
};
type BoardDepthPlan = {
activeDepth: number;
activeItemCount: number;
baseY: number;
initialDepth: number;
layerCapacity: number;
layerCount: number;
layerStep: number;
maxVerticalSpeed: number;
surfaceY: number;
};
type PhysicsStabilityPlan = {
angularDamping: number;
contactFriction: number;
contactRestitution: number;
linearDamping: number;
maxHorizontalSpeed: number;
maxVerticalSpeed: number;
solverIterations: number;
solverTolerance: number;
};
type Match3DSpawnTimingPlan = {
frameSpawnLimit: number;
initialDelayMs: number;
layerDelayMs: number;
burstSize: number;
staggerMs: number;
};
type Match3DSpawnHeightObstacle = {
boundaryRadius: number;
colliderHeight: number;
x: number;
y: number;
z: number;
};
type PhysicsRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, PhysicsEntry>;
pendingSpawns: Map<string, PendingPhysicsSpawn>;
raycaster: import('three').Raycaster;
renderer: ThreeRenderer;
scene: ThreeScene;
spawnTimingPlan: Match3DSpawnTimingPlan;
stabilityPlan: PhysicsStabilityPlan;
world: PhysicsWorld;
three: ThreeModule;
cannon: CannonModule;
};
type Match3DStackHeightPlan = {
layerCapacity: number;
targets: Map<string, StackHeightTarget>;
};
const MATCH3D_POT_FLOOR_RADIUS = 4.75;
const MATCH3D_POT_INNER_RADIUS = 4.52;
const MATCH3D_POT_OUTER_RADIUS = 5.18;
const MATCH3D_POT_WALL_HEIGHT = 2.15;
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.25;
const MATCH3D_ITEM_STACK_HEIGHT_STEP = 0.024;
const MATCH3D_ITEM_BASE_HEIGHT = 1.18;
const MATCH3D_ITEM_VERTICAL_DEPTH_BASE = 2.8;
const MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE = 0.52;
const MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE = 0.032;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE = 12;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE = 0.04;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE = 18;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN = 10;
const MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX = 1.04;
const MATCH3D_ITEM_LIFT_FORCE_SCALE = 18;
const MATCH3D_ITEM_LIFT_MAX_SPEED = 4.2;
const MATCH3D_ITEM_SPAWN_RISE_OFFSET = 0.42;
const MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS = 54;
const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4;
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
const MATCH3D_BOARD_CENTER = 0.5;
const MATCH3D_PHYSICS_STEP = 1 / 60;
@@ -100,12 +182,350 @@ function toWorldPosition(item: Match3DItemSnapshot) {
};
}
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 visualRadius = toWorldPosition(entry.item).radius;
// 中文注释:锅壁和锅沿是视觉边界,物体活动圈要更内缩,避免 3D 透视下贴边后被圆形 DOM 裁切。
// 中文注释:空气墙按真实碰撞外接半径收束,长条积木不能再只按近似圆半径贴近锅边。
const maxDistance = Math.max(
0,
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
MATCH3D_ITEM_ACTIVITY_RADIUS - entry.boundaryRadius,
);
const horizontalDistance = Math.hypot(
entry.body.position.x,
@@ -128,6 +548,16 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
}
}
function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
return Math.min(
1,
Math.max(
0,
(now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS,
),
);
}
function applyCenterGravity(entry: PhysicsEntry) {
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
return;
@@ -536,6 +966,105 @@ function removePhysicsEntry(
runtime.entries.delete(itemInstanceId);
}
function createPhysicsEntryFromPendingSpawn(
runtime: PhysicsRuntime,
pendingSpawn: PendingPhysicsSpawn,
now: number,
) {
const visual = createItemMesh(runtime.three, pendingSpawn.item);
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
const position = visual.position;
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius);
const horizontalDistance = Math.hypot(position.x, position.z);
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
const ratio = maxDistance / horizontalDistance;
position.x *= ratio;
position.z *= ratio;
}
const spawnLayerIndex = Math.floor(
Math.max(0, pendingSpawn.activeLayerRank) /
pendingSpawn.layerCapacity,
);
const plannedSpawnY =
pendingSpawn.targetY +
MATCH3D_ITEM_SPAWN_RISE_OFFSET +
Math.min(spawnLayerIndex * 0.05, 0.62);
const spawnY = resolveMatch3DSpawnY(
plannedSpawnY,
colliderBounds.height,
boundaryRadius,
position,
[...runtime.entries.values()].map((entry) => ({
boundaryRadius: entry.boundaryRadius,
colliderHeight: entry.colliderHeight,
x: entry.body.position.x,
y: entry.body.position.y,
z: entry.body.position.z,
})),
);
const body = new runtime.cannon.Body({
angularDamping: runtime.stabilityPlan.angularDamping,
allowSleep: true,
linearDamping: runtime.stabilityPlan.linearDamping,
mass: 1 + visual.radius * 0.7,
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
sleepSpeedLimit: 0.12,
sleepTimeLimit: 0.18,
position: new runtime.cannon.Vec3(
position.x,
spawnY,
position.z,
),
});
body.velocity.set(
((pendingSpawn.item.layer % 5) - 2) * 0.06,
-0.35,
(((pendingSpawn.item.layer + 2) % 5) - 2) * 0.06,
);
body.angularVelocity.set(
0.1 + (pendingSpawn.item.layer % 3) * 0.025,
0.08,
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
);
visual.mesh.scale.setScalar(0.82);
runtime.world.addBody(body);
runtime.scene.add(visual.mesh);
runtime.entries.set(pendingSpawn.item.itemInstanceId, {
body,
boundaryRadius,
colliderHeight: colliderBounds.height,
item: pendingSpawn.item,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh,
renderSignature: pendingSpawn.renderSignature,
spawnStartedAt: now,
targetY: pendingSpawn.targetY,
topRotationY: visual.topRotationY,
});
}
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
const readySpawns = [...runtime.pendingSpawns.entries()]
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
.sort((left, right) => {
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
return left[1].spawnAtMs - right[1].spawnAtMs;
}
if (left[1].activeLayerRank !== right[1].activeLayerRank) {
return left[1].activeLayerRank - right[1].activeLayerRank;
}
return left[0].localeCompare(right[0]);
});
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
runtime.pendingSpawns.delete(itemInstanceId);
createPhysicsEntryFromPendingSpawn(runtime, pendingSpawn, now);
});
}
function disposeRuntime(runtime: PhysicsRuntime | null) {
if (!runtime) {
return;
@@ -1092,13 +1621,23 @@ export function Match3DPhysicsBoard({
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 = 0.55;
world.defaultContactMaterial.restitution = 0.28;
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,
@@ -1125,9 +1664,12 @@ export function Match3DPhysicsBoard({
animationId: null,
camera,
entries: new Map(),
pendingSpawns: new Map(),
raycaster: new three.Raycaster(),
renderer,
scene,
spawnTimingPlan,
stabilityPlan,
world,
three,
cannon,
@@ -1157,17 +1699,26 @@ export function Match3DPhysicsBoard({
}
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, 3);
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 5);
activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
applyDynamicStackLift(entry);
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
constrainBodyInsidePot(entry);
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
const spawnScale = 0.82 + spawnProgress * 0.18;
entry.mesh.scale.setScalar(spawnScale);
entry.mesh.position.set(
entry.body.position.x,
entry.body.position.y,
entry.body.position.y - (1 - spawnProgress) * 0.06,
entry.body.position.z,
);
entry.mesh.quaternion.set(
@@ -1231,6 +1782,16 @@ export function Match3DPhysicsBoard({
}
});
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;
@@ -1247,44 +1808,46 @@ export function Match3DPhysicsBoard({
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 visual = createItemMesh(runtime.three, item);
const asset = resolveGeometryAsset(item.visualKey);
const body = new runtime.cannon.Body({
angularDamping: 0.48,
linearDamping: 0.38,
mass: 1 + visual.radius * 0.7,
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
position: new runtime.cannon.Vec3(
visual.position.x,
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
visual.position.z,
),
});
body.velocity.set(
((item.layer % 5) - 2) * 0.08,
0,
(((item.layer + 2) % 5) - 2) * 0.08,
);
body.angularVelocity.set(
0.18 + (item.layer % 3) * 0.04,
0.12,
0.1 + (item.layer % 4) * 0.03,
);
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;
}
}
runtime.world.addBody(body);
runtime.scene.add(visual.mesh);
runtime.entries.set(item.itemInstanceId, {
body,
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,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh,
layerCapacity: stackHeightPlan.layerCapacity,
renderSignature,
topRotationY: visual.topRotationY,
spawnAtMs,
targetY:
stackTarget?.targetY ??
resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
});
});
}, [ready, run.items, run.runId, run.snapshotVersion]);

View File

@@ -25,6 +25,13 @@ import {
createMatch3DThreeGeometry,
measureMatch3DItemPreviewDimension,
resolveMatch3DColliderBounds,
resolveMatch3DBoardDepthPlan,
resolveMatch3DBoundaryRadius,
resolveMatch3DPhysicsStabilityPlan,
resolveMatch3DSpawnTimingPlan,
resolveMatch3DStackTargetY,
resolveMatch3DSpawnDelay,
resolveMatch3DSpawnY,
resolveMatch3DTrayPreviewRotation,
resolveMatch3DTrayPreviewReferenceDimension,
resolveMatch3DTrayPreviewScale,
@@ -447,6 +454,143 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
).toBeCloseTo(cylinderBounds.height);
});
test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
const earlyBottomY = resolveMatch3DStackTargetY(300, 300, 0);
const lateBottomY = resolveMatch3DStackTargetY(300, 60, 0);
expect(largeDepthPlan.initialDepth).toBeGreaterThan(
smallDepthPlan.initialDepth,
);
expect(largeDepthPlan.layerCapacity).toBeLessThan(
smallDepthPlan.layerCapacity,
);
expect(largeDepthPlan.layerCount).toBeGreaterThan(
smallDepthPlan.layerCount,
);
expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
expect(lateBottomY).toBeGreaterThan(earlyBottomY);
expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
});
test('高数量 3D 局面使用更稳定的物理参数', () => {
const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
const largePlan = resolveMatch3DPhysicsStabilityPlan(300);
expect(largePlan.contactFriction).toBeGreaterThan(
smallPlan.contactFriction,
);
expect(largePlan.contactRestitution).toBeLessThan(
smallPlan.contactRestitution,
);
expect(largePlan.linearDamping).toBeGreaterThan(smallPlan.linearDamping);
expect(largePlan.angularDamping).toBeGreaterThan(smallPlan.angularDamping);
expect(largePlan.solverIterations).toBeGreaterThan(
smallPlan.solverIterations,
);
expect(largePlan.maxHorizontalSpeed).toBeLessThan(
smallPlan.maxHorizontalSpeed,
);
});
test('3D 真实边界半径比视觉半径更保守,避免长条贴边穿出锅壁', () => {
const longBrick = resolveGeometryAsset('block-black-1x8');
const radius = 1;
const boundaryRadius = resolveMatch3DBoundaryRadius(longBrick, radius);
const visualRadius = Math.hypot(
resolveMatch3DColliderBounds(longBrick, radius).width / 2,
resolveMatch3DColliderBounds(longBrick, radius).depth / 2,
);
expect(boundaryRadius).toBeCloseTo(visualRadius);
expect(boundaryRadius).toBeGreaterThan(2.4);
});
test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
const fastTimingPlan = resolveMatch3DSpawnTimingPlan(29);
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
const middleDelay = resolveMatch3DSpawnDelay(30, largeDepthPlan.layerCapacity);
const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
120,
largeDepthPlan.layerCapacity,
);
const defaultCapacityDelay = resolveMatch3DSpawnDelay(
120,
smallDepthPlan.layerCapacity,
);
expect(bottomDelay).toBe(0);
expect(middleDelay).toBeGreaterThan(bottomDelay);
expect(topDelay).toBeGreaterThan(middleDelay);
expect(dynamicCapacityDelay).toBeGreaterThan(defaultCapacityDelay);
expect(smallTimingPlan.frameSpawnLimit).toBeLessThan(
fastTimingPlan.frameSpawnLimit,
);
expect(smallTimingPlan.burstSize).toBeLessThan(fastTimingPlan.burstSize);
expect(smallTimingPlan.layerDelayMs).toBeGreaterThan(
fastTimingPlan.layerDelayMs,
);
expect(
resolveMatch3DSpawnDelay(29, smallDepthPlan.layerCapacity, smallTimingPlan),
).toBeGreaterThan(450);
expect(largeTimingPlan.initialDelayMs).toBeGreaterThan(
smallTimingPlan.initialDelayMs,
);
expect(largeTimingPlan.frameSpawnLimit).toBeLessThan(
smallTimingPlan.frameSpawnLimit,
);
expect(largeTimingPlan.burstSize).toBeLessThanOrEqual(6);
expect(largeTimingPlan.layerDelayMs).toBeGreaterThanOrEqual(
smallTimingPlan.layerDelayMs,
);
expect(
resolveMatch3DSpawnDelay(299, largeDepthPlan.layerCapacity, largeTimingPlan),
).toBeGreaterThan(5000);
});
test('3D 新物体生成高度会避让同位置已有堆叠', () => {
const plannedSpawnY = 2;
const raisedSpawnY = resolveMatch3DSpawnY(
plannedSpawnY,
0.8,
0.7,
{ x: 0.1, z: 0.1 },
[
{
boundaryRadius: 0.7,
colliderHeight: 0.9,
x: 0.18,
y: 2.4,
z: 0.15,
},
],
);
const unchangedSpawnY = resolveMatch3DSpawnY(
plannedSpawnY,
0.8,
0.7,
{ x: 0.1, z: 0.1 },
[
{
boundaryRadius: 0.7,
colliderHeight: 0.9,
x: 3,
y: 4,
z: 3,
},
],
);
expect(raisedSpawnY).toBeGreaterThan(plannedSpawnY);
expect(unchangedSpawnY).toBe(plannedSpawnY);
});
test('积木视觉键不会被统一兜底成红色苹字', () => {
const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({