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:
2026-05-15 06:24:07 +08:00
parent 2eded08bc7
commit 3cb3efb4d0
708 changed files with 4033 additions and 142328 deletions

View File

@@ -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 绘图缓冲区会乘设备 DPRCSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
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%)]" />