Prune stale docs and update .hermes content
Delete a large set of outdated documentation (many files under docs/ and .hermes/plans/, including audits, design, prd, technical, planning, assets, and todos). Update and consolidate .hermes content: refresh shared-memory pages (decision-log, development-workflow, document-map, pitfalls, project-overview, team-conventions) and several skills/references under .hermes/skills. Also modify AGENTS.md, README.md, UI_CODING_STANDARD.md, docs/README.md and .encoding-check-ignore. Purpose: clean up stale planning/audit material and keep current hermes documentation and related top-level docs in sync.
This commit is contained in:
@@ -28,6 +28,11 @@ type Match3DPhysicsBoardProps = {
|
||||
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;
|
||||
@@ -41,7 +46,10 @@ type Match3DGeneratedModelTemplate = {
|
||||
source: string;
|
||||
scene: ThreeObject3D;
|
||||
};
|
||||
type Match3DGeneratedModelTemplateMap = Map<string, Match3DGeneratedModelTemplate>;
|
||||
type Match3DGeneratedModelTemplateMap = Map<
|
||||
string,
|
||||
Match3DGeneratedModelTemplate
|
||||
>;
|
||||
|
||||
type PhysicsEntry = {
|
||||
boundaryRadius: number;
|
||||
@@ -166,11 +174,9 @@ 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',
|
||||
]);
|
||||
new Set(['ring', 'arch']);
|
||||
|
||||
function normalizeMatch3DGeneratedModelSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
@@ -189,9 +195,7 @@ function compareMatch3DGeneratedTypeId(left: string, right: string) {
|
||||
|
||||
function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) {
|
||||
return [
|
||||
...new Set(
|
||||
items.map((item) => item.itemTypeId.trim()).filter(Boolean),
|
||||
),
|
||||
...new Set(items.map((item) => item.itemTypeId.trim()).filter(Boolean)),
|
||||
].sort(compareMatch3DGeneratedTypeId);
|
||||
}
|
||||
|
||||
@@ -264,10 +268,7 @@ function warnMatch3DGeneratedModelLoadFailure(
|
||||
});
|
||||
}
|
||||
|
||||
function debugMatch3DGeneratedModelLoaded(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
) {
|
||||
function debugMatch3DGeneratedModelLoaded(itemTypeId: string, source: string) {
|
||||
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
@@ -277,10 +278,7 @@ function debugMatch3DGeneratedModelLoaded(
|
||||
});
|
||||
}
|
||||
|
||||
function debugMatch3DGeneratedModelMapped(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
) {
|
||||
function debugMatch3DGeneratedModelMapped(itemTypeId: string, source: string) {
|
||||
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
@@ -393,9 +391,7 @@ function createGeneratedModelMesh(
|
||||
function hasWebGLSupport() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return Boolean(
|
||||
canvas.getContext('webgl2') ?? canvas.getContext('webgl'),
|
||||
);
|
||||
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -446,16 +442,13 @@ export function resolveMatch3DBoardDepthPlan(
|
||||
MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE +
|
||||
normalizedTotalItemCount * MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE,
|
||||
);
|
||||
const remainingRatio =
|
||||
normalizedActiveItemCount / normalizedTotalItemCount;
|
||||
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,
|
||||
),
|
||||
Math.round(MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8),
|
||||
);
|
||||
const layerCount = Math.max(
|
||||
1,
|
||||
@@ -545,8 +538,7 @@ export function resolveMatch3DSpawnY(
|
||||
}
|
||||
|
||||
// 中文注释:新物体生成时先避开同位置已有堆叠顶部,避免最后一波直接塞进未稳定的上层模型。
|
||||
const obstacleTopY =
|
||||
obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
|
||||
const obstacleTopY = obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
|
||||
return Math.max(
|
||||
spawnY,
|
||||
obstacleTopY + selfHalfHeight + MATCH3D_ITEM_SPAWN_STACK_CLEARANCE,
|
||||
@@ -571,9 +563,7 @@ export function resolveMatch3DSpawnDelay(
|
||||
1,
|
||||
timingPlan?.burstSize ?? normalizedLayerCapacity,
|
||||
);
|
||||
const layerIndex = Math.floor(
|
||||
normalizedLayerRank / normalizedLayerCapacity,
|
||||
);
|
||||
const layerIndex = Math.floor(normalizedLayerRank / normalizedLayerCapacity);
|
||||
const burstIndex = Math.floor(normalizedLayerRank / burstSize);
|
||||
return (
|
||||
Math.max(layerIndex, burstIndex) * layerDelayMs +
|
||||
@@ -654,17 +644,14 @@ function buildMatch3DStackHeightTargets(
|
||||
activeItems.length,
|
||||
);
|
||||
activeItems.forEach((item, activeLayerRank) => {
|
||||
targets.set(
|
||||
item.itemInstanceId,
|
||||
{
|
||||
targets.set(item.itemInstanceId, {
|
||||
activeLayerRank,
|
||||
targetY: resolveMatch3DStackTargetY(
|
||||
run.totalItemCount,
|
||||
activeItems.length,
|
||||
activeLayerRank,
|
||||
targetY: resolveMatch3DStackTargetY(
|
||||
run.totalItemCount,
|
||||
activeItems.length,
|
||||
activeLayerRank,
|
||||
),
|
||||
},
|
||||
);
|
||||
),
|
||||
});
|
||||
});
|
||||
return {
|
||||
layerCapacity: depthPlan.layerCapacity,
|
||||
@@ -740,8 +727,7 @@ function syncRuntimeStabilityPlan(
|
||||
) {
|
||||
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(totalItemCount);
|
||||
runtime.stabilityPlan = stabilityPlan;
|
||||
runtime.world.defaultContactMaterial.friction =
|
||||
stabilityPlan.contactFriction;
|
||||
runtime.world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
|
||||
runtime.world.defaultContactMaterial.restitution =
|
||||
stabilityPlan.contactRestitution;
|
||||
const solver = runtime.world.solver as import('cannon-es').GSSolver;
|
||||
@@ -789,10 +775,7 @@ 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,
|
||||
),
|
||||
Math.max(0, (now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -827,7 +810,10 @@ function applyCenterGravity(entry: PhysicsEntry) {
|
||||
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 centerFalloff = Math.min(
|
||||
1,
|
||||
Math.max(0, (horizontalDistance - 1.15) / maxDistance),
|
||||
);
|
||||
const forceStrength =
|
||||
MATCH3D_CENTER_GRAVITY_COEFFICIENT *
|
||||
entry.body.mass *
|
||||
@@ -886,7 +872,8 @@ export function resolveMatch3DColliderBounds(
|
||||
default:
|
||||
return {
|
||||
depth: radius * (0.9 + asset.studsY * 0.62),
|
||||
height: Math.max(radius * 0.24, radius * asset.heightScale) + radius * 0.12,
|
||||
height:
|
||||
Math.max(radius * 0.24, radius * asset.heightScale) + radius * 0.12,
|
||||
width: radius * (0.9 + asset.studsX * 0.62),
|
||||
};
|
||||
}
|
||||
@@ -911,11 +898,7 @@ export function createMatch3DCannonShape(
|
||||
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,
|
||||
),
|
||||
new cannon.Vec3(bounds.width / 2, bounds.height / 2, bounds.depth / 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1004,7 +987,12 @@ export function createMatch3DThreeGeometry(
|
||||
|
||||
switch (shape) {
|
||||
case 'cylinder':
|
||||
return new three.CylinderGeometry(radius * 0.72, radius * 0.72, radius * 1.35, 26);
|
||||
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':
|
||||
@@ -1028,7 +1016,12 @@ function createRoundedBlockBase(
|
||||
}
|
||||
|
||||
function createStudGeometry(three: ThreeModule, radius: number) {
|
||||
return new three.CylinderGeometry(radius * 0.18, radius * 0.18, radius * 0.12, 20);
|
||||
return new three.CylinderGeometry(
|
||||
radius * 0.18,
|
||||
radius * 0.18,
|
||||
radius * 0.12,
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
function createSlopeGeometry(
|
||||
@@ -1043,18 +1036,27 @@ function createSlopeGeometry(
|
||||
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,
|
||||
-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,
|
||||
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));
|
||||
@@ -1076,7 +1078,8 @@ function addBrickStuds(
|
||||
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;
|
||||
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);
|
||||
@@ -1101,7 +1104,12 @@ function createBlockMesh(
|
||||
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);
|
||||
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)) {
|
||||
@@ -1116,7 +1124,10 @@ function createBlockMesh(
|
||||
addBrickStuds(three, group, asset, radius, material);
|
||||
}
|
||||
if (asset.shape === 'cylinder') {
|
||||
const topStud = new three.Mesh(createStudGeometry(three, radius * 1.2), material);
|
||||
const topStud = new three.Mesh(
|
||||
createStudGeometry(three, radius * 1.2),
|
||||
material,
|
||||
);
|
||||
topStud.position.y = radius * 0.65;
|
||||
group.add(topStud);
|
||||
}
|
||||
@@ -1240,6 +1251,35 @@ function removePhysicsEntry(
|
||||
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,
|
||||
@@ -1261,7 +1301,10 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
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 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;
|
||||
@@ -1269,8 +1312,7 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
position.z *= ratio;
|
||||
}
|
||||
const spawnLayerIndex = Math.floor(
|
||||
Math.max(0, pendingSpawn.activeLayerRank) /
|
||||
pendingSpawn.layerCapacity,
|
||||
Math.max(0, pendingSpawn.activeLayerRank) / pendingSpawn.layerCapacity,
|
||||
);
|
||||
const plannedSpawnY =
|
||||
pendingSpawn.targetY +
|
||||
@@ -1297,11 +1339,7 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
|
||||
sleepSpeedLimit: 0.12,
|
||||
sleepTimeLimit: 0.18,
|
||||
position: new runtime.cannon.Vec3(
|
||||
position.x,
|
||||
spawnY,
|
||||
position.z,
|
||||
),
|
||||
position: new runtime.cannon.Vec3(position.x, spawnY, position.z),
|
||||
});
|
||||
body.velocity.set(
|
||||
((pendingSpawn.item.layer % 5) - 2) * 0.06,
|
||||
@@ -1358,15 +1396,17 @@ function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
|
||||
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,
|
||||
);
|
||||
});
|
||||
readySpawns
|
||||
.slice(0, spawnBudget)
|
||||
.forEach(([itemInstanceId, pendingSpawn]) => {
|
||||
runtime.pendingSpawns.delete(itemInstanceId);
|
||||
createPhysicsEntryFromPendingSpawn(
|
||||
runtime,
|
||||
pendingSpawn,
|
||||
now,
|
||||
runtime.generatedModelTemplates,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
@@ -1455,8 +1495,7 @@ export function resolveMatch3DTrayPreviewReferenceDimension(
|
||||
|
||||
export function resolveMatch3DTrayPreviewRotation(visualKey: string) {
|
||||
const asset = resolveGeometryAsset(visualKey);
|
||||
const yaw =
|
||||
asset.studsX >= asset.studsY ? Math.PI / 4 : Math.PI / 5;
|
||||
const yaw = asset.studsX >= asset.studsY ? Math.PI / 4 : Math.PI / 5;
|
||||
|
||||
// 中文注释:托盘里用轻微俯视 3/4 姿态展示体积,固定朝向只影响 UI 预览,不反写场内物理姿态。
|
||||
switch (asset.shape) {
|
||||
@@ -1495,14 +1534,12 @@ export function resolveMatch3DTrayPreviewScale(
|
||||
itemDimension: number,
|
||||
referenceMaxDimension: number,
|
||||
) {
|
||||
const maxScale = MATCH3D_TRAY_MODEL_TARGET_SIZE / Math.max(referenceMaxDimension, 0.001);
|
||||
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,
|
||||
);
|
||||
return Math.max(maxScale, readableScale);
|
||||
}
|
||||
|
||||
function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
@@ -1524,9 +1561,7 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
export function applyMatch3DRendererCanvasLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
export function applyMatch3DRendererCanvasLayout(canvas: HTMLCanvasElement) {
|
||||
// 中文注释:WebGL 绘图缓冲区会乘设备 DPR,CSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
@@ -1794,11 +1829,7 @@ export function Match3DTrayPreviewBoard({
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId,
|
||||
source,
|
||||
caughtError,
|
||||
);
|
||||
warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
|
||||
setTrayModelRevision((current) => current + 1);
|
||||
@@ -1841,8 +1872,8 @@ export function Match3DTrayPreviewBoard({
|
||||
runtime.three,
|
||||
referenceItems.length > 0
|
||||
? referenceItems
|
||||
: slotItems.filter(
|
||||
(item): item is Match3DItemSnapshot => Boolean(item),
|
||||
: slotItems.filter((item): item is Match3DItemSnapshot =>
|
||||
Boolean(item),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1885,10 +1916,7 @@ export function Match3DTrayPreviewBoard({
|
||||
0.001,
|
||||
);
|
||||
model.scale.multiplyScalar(
|
||||
resolveMatch3DTrayPreviewScale(
|
||||
itemDimension,
|
||||
referenceMaxDimension,
|
||||
),
|
||||
resolveMatch3DTrayPreviewScale(itemDimension, referenceMaxDimension),
|
||||
);
|
||||
const centeredBounds = new runtime.three.Box3().setFromObject(model);
|
||||
const center = centeredBounds.getCenter(new runtime.three.Vector3());
|
||||
@@ -1935,6 +1963,9 @@ export function Match3DPhysicsBoard({
|
||||
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],
|
||||
@@ -2033,7 +2064,11 @@ export function Match3DPhysicsBoard({
|
||||
scene.add(floor);
|
||||
|
||||
const basinShade = new three.Mesh(
|
||||
new three.RingGeometry(MATCH3D_POT_INNER_RADIUS * 0.72, MATCH3D_POT_FLOOR_RADIUS, 112),
|
||||
new three.RingGeometry(
|
||||
MATCH3D_POT_INNER_RADIUS * 0.72,
|
||||
MATCH3D_POT_FLOOR_RADIUS,
|
||||
112,
|
||||
),
|
||||
new three.MeshBasicMaterial({
|
||||
color: '#8a4f1f',
|
||||
opacity: 0.2,
|
||||
@@ -2123,7 +2158,9 @@ export function Match3DPhysicsBoard({
|
||||
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)),
|
||||
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);
|
||||
@@ -2170,7 +2207,10 @@ export function Match3DPhysicsBoard({
|
||||
if (!activeRuntime) {
|
||||
return;
|
||||
}
|
||||
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||
const delta = Math.min(
|
||||
0.04,
|
||||
Math.max(0.001, (now - lastTime) / 1000),
|
||||
);
|
||||
lastTime = now;
|
||||
flushPendingPhysicsSpawns(activeRuntime, now);
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
@@ -2188,7 +2228,14 @@ export function Match3DPhysicsBoard({
|
||||
constrainBodyInsidePot(entry);
|
||||
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
|
||||
const spawnScale = resolveMatch3DSpawnVisualScale(spawnProgress);
|
||||
entry.mesh.scale.copy(entry.baseVisualScale).multiplyScalar(spawnScale);
|
||||
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 -
|
||||
@@ -2206,7 +2253,10 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
});
|
||||
|
||||
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
|
||||
activeRuntime.renderer.render(
|
||||
activeRuntime.scene,
|
||||
activeRuntime.camera,
|
||||
);
|
||||
activeRuntime.animationId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationId = window.requestAnimationFrame(animate);
|
||||
@@ -2283,11 +2333,7 @@ export function Match3DPhysicsBoard({
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId,
|
||||
source,
|
||||
caughtError,
|
||||
);
|
||||
warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
|
||||
setGeneratedModelRevision((current) => current + 1);
|
||||
@@ -2409,39 +2455,63 @@ export function Match3DPhysicsBoard({
|
||||
run.snapshotVersion,
|
||||
]);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
const resolvePointerHitItemId = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const runtime = runtimeRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!runtime || !container || disabledRef.current) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
return resolveMatch3DPhysicsHitItemId(
|
||||
runtime,
|
||||
container,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
};
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const pointer = new runtime.three.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-(((event.clientY - rect.top) / rect.height) * 2 - 1),
|
||||
);
|
||||
runtime.raycaster.setFromCamera(pointer, runtime.camera);
|
||||
const meshes = [...runtime.entries.values()]
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.item.clickable &&
|
||||
isItemState(entry.item.state, 'in_board') &&
|
||||
entry.mesh.visible,
|
||||
)
|
||||
.map((entry) => entry.mesh);
|
||||
const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
|
||||
const itemInstanceId =
|
||||
typeof hit?.object.userData.itemInstanceId === 'string'
|
||||
? hit.object.userData.itemInstanceId
|
||||
: null;
|
||||
if (!itemInstanceId) {
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!runtimeRef.current || !containerRef.current || disabledRef.current) {
|
||||
return;
|
||||
}
|
||||
const item = runRef.current.items.find(
|
||||
(entry) => entry.itemInstanceId === itemInstanceId,
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -2453,6 +2523,9 @@ export function Match3DPhysicsBoard({
|
||||
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%)]" />
|
||||
|
||||
Reference in New Issue
Block a user