This commit is contained in:
601
src/components/match3d-runtime/Match3DPhysicsBoard.tsx
Normal file
601
src/components/match3d-runtime/Match3DPhysicsBoard.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
|
||||
type Match3DPhysicsBoardProps = {
|
||||
run: Match3DRunSnapshot;
|
||||
disabled: boolean;
|
||||
onClickItem: (item: Match3DItemSnapshot) => void;
|
||||
onFallback: () => void;
|
||||
};
|
||||
|
||||
type ThreeModule = typeof import('three');
|
||||
type CannonModule = typeof import('cannon-es');
|
||||
type PhysicsBody = import('cannon-es').Body;
|
||||
type PhysicsWorld = import('cannon-es').World;
|
||||
type ThreeMesh = import('three').Mesh;
|
||||
type ThreeScene = import('three').Scene;
|
||||
type ThreeRenderer = import('three').WebGLRenderer;
|
||||
type ThreeCamera = import('three').PerspectiveCamera;
|
||||
|
||||
type PhysicsEntry = {
|
||||
item: Match3DItemSnapshot;
|
||||
body: PhysicsBody;
|
||||
mesh: ThreeMesh;
|
||||
};
|
||||
|
||||
type PhysicsRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, PhysicsEntry>;
|
||||
raycaster: import('three').Raycaster;
|
||||
renderer: ThreeRenderer;
|
||||
scene: ThreeScene;
|
||||
world: PhysicsWorld;
|
||||
three: ThreeModule;
|
||||
cannon: CannonModule;
|
||||
};
|
||||
|
||||
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.82;
|
||||
const MATCH3D_ITEM_POSITION_RADIUS = 3.64;
|
||||
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.85;
|
||||
const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
|
||||
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.32, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.32);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||
const visualRadius = toWorldPosition(entry.item).radius;
|
||||
// 中文注释:锅壁和锅沿是视觉边界,物体活动圈要更内缩,避免 3D 透视下贴边后被圆形 DOM 裁切。
|
||||
const maxDistance = Math.max(
|
||||
0,
|
||||
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
|
||||
);
|
||||
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 createCannonShape(
|
||||
cannon: CannonModule,
|
||||
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
|
||||
radius: number,
|
||||
) {
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
case 'heart':
|
||||
return new cannon.Sphere(radius);
|
||||
case 'square':
|
||||
return new cannon.Box(new cannon.Vec3(radius, radius, radius));
|
||||
case 'triangle':
|
||||
return new cannon.Cylinder(radius * 0.55, radius, radius * 1.5, 3);
|
||||
case 'diamond':
|
||||
return new cannon.Sphere(radius * 0.92);
|
||||
case 'star':
|
||||
return new cannon.Sphere(radius * 0.88);
|
||||
case 'hexagon':
|
||||
return new cannon.Cylinder(radius, radius, radius * 1.2, 6);
|
||||
case 'capsule':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.28, radius * 0.68, radius * 0.68));
|
||||
case 'trapezoid':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.02, radius * 0.78, radius * 0.78));
|
||||
case 'parallelogram':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.12, radius * 0.72, radius * 0.72));
|
||||
default:
|
||||
return new cannon.Sphere(radius);
|
||||
}
|
||||
}
|
||||
|
||||
function createThreeGeometry(
|
||||
three: ThreeModule,
|
||||
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
|
||||
radius: number,
|
||||
) {
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
return new three.SphereGeometry(radius, 28, 18);
|
||||
case 'square':
|
||||
return new three.BoxGeometry(radius * 1.65, radius * 1.65, radius * 1.65);
|
||||
case 'triangle':
|
||||
return new three.ConeGeometry(radius, radius * 1.9, 3);
|
||||
case 'diamond':
|
||||
return new three.OctahedronGeometry(radius * 1.04, 1);
|
||||
case 'star':
|
||||
return new three.IcosahedronGeometry(radius * 0.96, 0);
|
||||
case 'hexagon':
|
||||
return new three.CylinderGeometry(radius, radius, radius * 1.35, 6);
|
||||
case 'capsule':
|
||||
return new three.CapsuleGeometry(radius * 0.62, radius * 1.18, 6, 14);
|
||||
case 'heart':
|
||||
return new three.SphereGeometry(radius, 24, 16);
|
||||
case 'trapezoid':
|
||||
return new three.CylinderGeometry(radius * 0.78, radius * 1.12, radius * 1.1, 4);
|
||||
case 'parallelogram':
|
||||
return new three.BoxGeometry(radius * 1.9, radius * 1.05, radius * 1.05);
|
||||
default:
|
||||
return new three.SphereGeometry(radius, 28, 18);
|
||||
}
|
||||
}
|
||||
|
||||
function createItemMesh(
|
||||
three: ThreeModule,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
const position = toWorldPosition(item);
|
||||
const geometry = createThreeGeometry(three, asset.shape, position.radius);
|
||||
if (asset.shape === 'parallelogram') {
|
||||
geometry.applyMatrix4(new three.Matrix4().makeShear(0.28, 0, 0, 0, 0, 0));
|
||||
}
|
||||
if (asset.shape === 'heart') {
|
||||
geometry.scale(1, 0.92, 0.82);
|
||||
}
|
||||
const material = new three.MeshStandardMaterial({
|
||||
color: asset.fill,
|
||||
emissive: asset.fill,
|
||||
emissiveIntensity: 0.08,
|
||||
metalness: 0.16,
|
||||
roughness: 0.46,
|
||||
});
|
||||
const mesh = new three.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData.itemInstanceId = item.itemInstanceId;
|
||||
return { mesh, shape: asset.shape, radius: position.radius, position };
|
||||
}
|
||||
|
||||
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
if (runtime.animationId !== null) {
|
||||
window.cancelAnimationFrame(runtime.animationId);
|
||||
}
|
||||
runtime.entries.forEach((entry) => {
|
||||
entry.mesh.geometry.dispose();
|
||||
const material = entry.mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
export function Match3DPhysicsBoard({
|
||||
run,
|
||||
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 [ready, setReady] = useState(false);
|
||||
|
||||
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;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new three.Scene();
|
||||
scene.background = null;
|
||||
|
||||
const camera = new three.PerspectiveCamera(32, 1, 0.1, 80);
|
||||
camera.position.set(0, 14.8, 2.3);
|
||||
camera.lookAt(0, 0.48, 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 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;
|
||||
|
||||
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(),
|
||||
raycaster: new three.Raycaster(),
|
||||
renderer,
|
||||
scene,
|
||||
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.aspect = 1;
|
||||
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;
|
||||
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
|
||||
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
constrainBodyInsidePot(entry);
|
||||
entry.mesh.position.set(
|
||||
entry.body.position.x,
|
||||
entry.body.position.y,
|
||||
entry.body.position.z,
|
||||
);
|
||||
entry.mesh.quaternion.set(
|
||||
entry.body.quaternion.x,
|
||||
entry.body.quaternion.y,
|
||||
entry.body.quaternion.z,
|
||||
entry.body.quaternion.w,
|
||||
);
|
||||
});
|
||||
|
||||
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
|
||||
activeRuntime.animationId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationId = window.requestAnimationFrame(animate);
|
||||
setReady(true);
|
||||
|
||||
return () => {
|
||||
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;
|
||||
}
|
||||
|
||||
const activeItemIds = new Set(
|
||||
run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
isItemState(item.state, 'in_board') ||
|
||||
isItemState(item.state, 'flying'),
|
||||
)
|
||||
.map((item) => item.itemInstanceId),
|
||||
);
|
||||
|
||||
runtime.entries.forEach((entry, itemInstanceId) => {
|
||||
if (!activeItemIds.has(itemInstanceId)) {
|
||||
runtime.scene.remove(entry.mesh);
|
||||
runtime.world.removeBody(entry.body);
|
||||
entry.mesh.geometry.dispose();
|
||||
const material = entry.mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material.dispose();
|
||||
}
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
}
|
||||
});
|
||||
|
||||
run.items.forEach((item) => {
|
||||
if (
|
||||
!isItemState(item.state, 'in_board') &&
|
||||
!isItemState(item.state, 'flying')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = runtime.entries.get(item.itemInstanceId);
|
||||
if (existing) {
|
||||
existing.item = item;
|
||||
existing.mesh.visible = isItemState(item.state, 'in_board');
|
||||
return;
|
||||
}
|
||||
|
||||
const visual = createItemMesh(runtime.three, item);
|
||||
const body = new runtime.cannon.Body({
|
||||
angularDamping: 0.48,
|
||||
linearDamping: 0.38,
|
||||
mass: 1 + visual.radius * 0.7,
|
||||
shape: createCannonShape(runtime.cannon, visual.shape, visual.radius),
|
||||
position: new runtime.cannon.Vec3(
|
||||
visual.position.x,
|
||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * 0.055,
|
||||
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,
|
||||
);
|
||||
|
||||
runtime.world.addBody(body);
|
||||
runtime.scene.add(visual.mesh);
|
||||
runtime.entries.set(item.itemInstanceId, {
|
||||
body,
|
||||
item,
|
||||
mesh: visual.mesh,
|
||||
});
|
||||
});
|
||||
}, [ready, run.items, run.snapshotVersion]);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
const runtime = runtimeRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!runtime || !container || disabledRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
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, false)[0];
|
||||
const itemInstanceId =
|
||||
typeof hit?.object.userData.itemInstanceId === 'string'
|
||||
? hit.object.userData.itemInstanceId
|
||||
: null;
|
||||
if (!itemInstanceId) {
|
||||
return;
|
||||
}
|
||||
const item = runRef.current.items.find(
|
||||
(entry) => entry.itemInstanceId === itemInstanceId,
|
||||
);
|
||||
if (item?.clickable && isItemState(item.state, 'in_board')) {
|
||||
onClickItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-10 overflow-hidden rounded-full"
|
||||
data-testid="match3d-physics-board"
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{!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;
|
||||
Reference in New Issue
Block a user