This commit is contained in:
@@ -8,7 +8,11 @@ import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
import {
|
||||
resolveGeometryAsset,
|
||||
type Match3DGeometryAsset,
|
||||
type Match3DGeometryShape,
|
||||
} from './match3dVisualAssets';
|
||||
|
||||
type Match3DPhysicsBoardProps = {
|
||||
run: Match3DRunSnapshot;
|
||||
@@ -21,15 +25,17 @@ 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 ThreeObject3D = import('three').Object3D;
|
||||
type ThreeScene = import('three').Scene;
|
||||
type ThreeRenderer = import('three').WebGLRenderer;
|
||||
type ThreeCamera = import('three').PerspectiveCamera;
|
||||
type ThreeCamera = import('three').OrthographicCamera;
|
||||
|
||||
type PhysicsEntry = {
|
||||
item: Match3DItemSnapshot;
|
||||
body: PhysicsBody;
|
||||
mesh: ThreeMesh;
|
||||
lockReadableTop: boolean;
|
||||
mesh: ThreeObject3D;
|
||||
topRotationY: number;
|
||||
};
|
||||
|
||||
type PhysicsRuntime = {
|
||||
@@ -48,11 +54,19 @@ 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_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_CENTER_GRAVITY_COEFFICIENT = 0;
|
||||
const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
|
||||
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
|
||||
new Set([
|
||||
'ring',
|
||||
'arch',
|
||||
]);
|
||||
|
||||
function hasWebGLSupport() {
|
||||
try {
|
||||
@@ -67,7 +81,7 @@ function hasWebGLSupport() {
|
||||
|
||||
function toWorldPosition(item: Match3DItemSnapshot) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
const radius = Math.max(0.32, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.32);
|
||||
const radius = Math.max(0.28, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.02);
|
||||
let x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
|
||||
let z = (frame.y - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
|
||||
const horizontalDistance = Math.hypot(x, z);
|
||||
@@ -112,92 +126,330 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyCenterGravity(entry: PhysicsEntry) {
|
||||
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalDistance = Math.hypot(
|
||||
entry.body.position.x,
|
||||
entry.body.position.z,
|
||||
);
|
||||
if (horizontalDistance <= 0.08) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visualRadius = toWorldPosition(entry.item).radius;
|
||||
const maxDistance = Math.max(
|
||||
0.1,
|
||||
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
|
||||
);
|
||||
const edgePressure = Math.min(1, horizontalDistance / maxDistance);
|
||||
const centerFalloff = Math.min(1, Math.max(0, (horizontalDistance - 1.15) / maxDistance));
|
||||
const forceStrength =
|
||||
MATCH3D_CENTER_GRAVITY_COEFFICIENT *
|
||||
entry.body.mass *
|
||||
(10.5 + edgePressure * 13) *
|
||||
centerFalloff;
|
||||
|
||||
// 中文注释:中心引力只拉水平面,垂直方向仍交给锅底重力和物体堆叠处理。
|
||||
entry.body.force.x +=
|
||||
(-entry.body.position.x / horizontalDistance) * forceStrength;
|
||||
entry.body.force.z +=
|
||||
(-entry.body.position.z / horizontalDistance) * forceStrength;
|
||||
}
|
||||
|
||||
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));
|
||||
case 'ring':
|
||||
case 'cylinder':
|
||||
case 'cone':
|
||||
return new cannon.Cylinder(radius * 0.82, radius * 0.82, radius * 1.1, 18);
|
||||
case 'slope':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.66));
|
||||
case 'arch':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.35, radius * 0.92, radius * 0.56));
|
||||
case 'tile':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.36, radius * 0.72));
|
||||
case 'brick':
|
||||
default:
|
||||
return new cannon.Sphere(radius);
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.72));
|
||||
}
|
||||
}
|
||||
|
||||
function createThreeGeometry(
|
||||
function buildPointShape(
|
||||
three: ThreeModule,
|
||||
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
|
||||
radius: number,
|
||||
points: Array<[number, number]>,
|
||||
) {
|
||||
const shape = new three.Shape();
|
||||
points.forEach(([x, y], index) => {
|
||||
if (index === 0) {
|
||||
shape.moveTo(x * radius, y * radius);
|
||||
} else {
|
||||
shape.lineTo(x * radius, y * radius);
|
||||
}
|
||||
});
|
||||
shape.closePath();
|
||||
return shape;
|
||||
}
|
||||
|
||||
function buildRingShape(three: ThreeModule, radius: number) {
|
||||
const shape = new three.Shape();
|
||||
shape.absarc(0, 0, radius * 0.92, 0, Math.PI * 2, false);
|
||||
const hole = new three.Path();
|
||||
hole.absarc(0, 0, radius * 0.43, 0, Math.PI * 2, true);
|
||||
shape.holes.push(hole);
|
||||
return shape;
|
||||
}
|
||||
|
||||
function buildReadableShape(
|
||||
three: ThreeModule,
|
||||
shape: Match3DGeometryShape,
|
||||
radius: number,
|
||||
) {
|
||||
switch (shape) {
|
||||
case '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);
|
||||
case 'ring':
|
||||
return buildRingShape(three, radius);
|
||||
case 'arch':
|
||||
return buildPointShape(three, radius, [
|
||||
[-1, 0.8],
|
||||
[1, 0.8],
|
||||
[1, -0.7],
|
||||
[0.42, -0.7],
|
||||
[0.42, 0.24],
|
||||
[-0.42, 0.24],
|
||||
[-0.42, -0.7],
|
||||
[-1, -0.7],
|
||||
]);
|
||||
default:
|
||||
return new three.SphereGeometry(radius, 28, 18);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createExtrudedReadableGeometry(
|
||||
three: ThreeModule,
|
||||
shape: Match3DGeometryShape,
|
||||
radius: number,
|
||||
) {
|
||||
const path = buildReadableShape(three, shape, radius);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
const geometry = new three.ExtrudeGeometry(path, {
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 2,
|
||||
bevelSize: radius * 0.045,
|
||||
bevelThickness: radius * 0.04,
|
||||
depth: radius * 0.42,
|
||||
steps: 1,
|
||||
});
|
||||
geometry.center();
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function createMatch3DThreeGeometry(
|
||||
three: ThreeModule,
|
||||
shape: Match3DGeometryShape,
|
||||
radius: number,
|
||||
) {
|
||||
const readableGeometry = createExtrudedReadableGeometry(three, shape, radius);
|
||||
if (readableGeometry) {
|
||||
return readableGeometry;
|
||||
}
|
||||
|
||||
switch (shape) {
|
||||
case 'cylinder':
|
||||
return new three.CylinderGeometry(radius * 0.72, radius * 0.72, radius * 1.35, 26);
|
||||
case 'cone':
|
||||
return new three.ConeGeometry(radius * 0.78, radius * 1.62, 28);
|
||||
case 'tile':
|
||||
case 'brick':
|
||||
case 'slope':
|
||||
case 'arch':
|
||||
default:
|
||||
return new three.BoxGeometry(radius * 1.8, radius * 0.9, radius * 1.2);
|
||||
}
|
||||
}
|
||||
|
||||
function createRoundedBlockBase(
|
||||
three: ThreeModule,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
) {
|
||||
const width = radius * (0.9 + asset.studsX * 0.62);
|
||||
const depth = radius * (0.9 + asset.studsY * 0.62);
|
||||
const height = Math.max(radius * 0.24, radius * asset.heightScale);
|
||||
return new three.BoxGeometry(width, height, depth);
|
||||
}
|
||||
|
||||
function createStudGeometry(three: ThreeModule, radius: number) {
|
||||
return new three.CylinderGeometry(radius * 0.18, radius * 0.18, radius * 0.12, 20);
|
||||
}
|
||||
|
||||
function createSlopeGeometry(
|
||||
three: ThreeModule,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
) {
|
||||
const width = radius * (1 + asset.studsX * 0.66);
|
||||
const depth = radius * (0.95 + asset.studsY * 0.62);
|
||||
const height = radius * asset.heightScale;
|
||||
const halfW = width / 2;
|
||||
const halfD = depth / 2;
|
||||
const halfH = height / 2;
|
||||
const vertices = new Float32Array([
|
||||
-halfW, -halfH, -halfD,
|
||||
halfW, -halfH, -halfD,
|
||||
halfW, -halfH, halfD,
|
||||
-halfW, -halfH, halfD,
|
||||
halfW, halfH, -halfD,
|
||||
halfW, halfH, halfD,
|
||||
]);
|
||||
const indices = [
|
||||
0, 1, 2, 0, 2, 3,
|
||||
1, 4, 5, 1, 5, 2,
|
||||
3, 2, 5, 3, 5, 0,
|
||||
0, 5, 4, 0, 4, 1,
|
||||
];
|
||||
const geometry = new three.BufferGeometry();
|
||||
geometry.setAttribute('position', new three.BufferAttribute(vertices, 3));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function addBrickStuds(
|
||||
three: ThreeModule,
|
||||
group: import('three').Group,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
material: import('three').Material,
|
||||
) {
|
||||
if (asset.shape === 'tile') {
|
||||
return;
|
||||
}
|
||||
const studGeometry = createStudGeometry(three, radius);
|
||||
const width = radius * (0.9 + asset.studsX * 0.62);
|
||||
const depth = radius * (0.9 + asset.studsY * 0.62);
|
||||
const y = Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06;
|
||||
for (let row = 0; row < asset.studsY; row += 1) {
|
||||
for (let column = 0; column < asset.studsX; column += 1) {
|
||||
const stud = new three.Mesh(studGeometry.clone(), material);
|
||||
stud.position.set(
|
||||
((column + 0.5) / asset.studsX - 0.5) * width * 0.74,
|
||||
y,
|
||||
((row + 0.5) / asset.studsY - 0.5) * depth * 0.72,
|
||||
);
|
||||
group.add(stud);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createBlockMesh(
|
||||
three: ThreeModule,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
material: import('three').Material,
|
||||
) {
|
||||
const group = new three.Group();
|
||||
let baseGeometry: import('three').BufferGeometry;
|
||||
if (asset.shape === 'slope') {
|
||||
baseGeometry = createSlopeGeometry(three, asset, radius);
|
||||
} else if (asset.shape === 'cylinder') {
|
||||
baseGeometry = new three.CylinderGeometry(radius * 0.58, radius * 0.58, radius * 1.18, 28);
|
||||
} else if (asset.shape === 'cone') {
|
||||
baseGeometry = new three.ConeGeometry(radius * 0.68, radius * 1.48, 30);
|
||||
} else if (MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)) {
|
||||
baseGeometry = createMatch3DThreeGeometry(three, asset.shape, radius);
|
||||
} else {
|
||||
baseGeometry = createRoundedBlockBase(three, asset, radius);
|
||||
}
|
||||
const base = new three.Mesh(baseGeometry, material);
|
||||
group.add(base);
|
||||
|
||||
if (asset.shape === 'brick' || asset.shape === 'slope') {
|
||||
addBrickStuds(three, group, asset, radius, material);
|
||||
}
|
||||
if (asset.shape === 'cylinder') {
|
||||
const topStud = new three.Mesh(createStudGeometry(three, radius * 1.2), material);
|
||||
topStud.position.y = radius * 0.65;
|
||||
group.add(topStud);
|
||||
}
|
||||
if (asset.shape === 'cone') {
|
||||
const lip = new three.Mesh(
|
||||
new three.TorusGeometry(radius * 0.38, radius * 0.07, 8, 24),
|
||||
material,
|
||||
);
|
||||
lip.rotation.x = Math.PI / 2;
|
||||
lip.position.y = radius * 0.52;
|
||||
group.add(lip);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function markObjectForItem(object: ThreeObject3D, itemInstanceId: string) {
|
||||
object.userData.itemInstanceId = itemInstanceId;
|
||||
object.traverse((child) => {
|
||||
child.userData.itemInstanceId = itemInstanceId;
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
});
|
||||
}
|
||||
|
||||
function disposeThreeObject(object: ThreeObject3D) {
|
||||
object.traverse((child) => {
|
||||
const maybeMesh = child as import('three').Mesh;
|
||||
maybeMesh.geometry?.dispose();
|
||||
const material = maybeMesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createMatch3DItemMesh(
|
||||
three: ThreeModule,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
const position = toWorldPosition(item);
|
||||
const material = new three.MeshStandardMaterial({
|
||||
color: asset.fill,
|
||||
emissive: asset.fill,
|
||||
emissiveIntensity: 0.08,
|
||||
metalness: 0.16,
|
||||
opacity: asset.transparent ? 0.58 : 1,
|
||||
roughness: 0.46,
|
||||
transparent: Boolean(asset.transparent),
|
||||
side: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)
|
||||
? three.DoubleSide
|
||||
: three.FrontSide,
|
||||
});
|
||||
const mesh = createBlockMesh(three, asset, position.radius, material);
|
||||
markObjectForItem(mesh, item.itemInstanceId);
|
||||
return {
|
||||
lockReadableTop: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape),
|
||||
mesh,
|
||||
radius: position.radius,
|
||||
shape: asset.shape,
|
||||
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
function createItemMesh(
|
||||
three: ThreeModule,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
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 };
|
||||
return createMatch3DItemMesh(three, item);
|
||||
}
|
||||
|
||||
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
@@ -208,18 +460,182 @@ function disposeRuntime(runtime: PhysicsRuntime | 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();
|
||||
}
|
||||
disposeThreeObject(entry.mesh);
|
||||
});
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
type TrayPreviewRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, ThreeObject3D>;
|
||||
renderer: ThreeRenderer;
|
||||
scene: ThreeScene;
|
||||
three: ThreeModule;
|
||||
};
|
||||
|
||||
function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
if (runtime.animationId !== null) {
|
||||
window.cancelAnimationFrame(runtime.animationId);
|
||||
}
|
||||
runtime.entries.forEach((mesh) => {
|
||||
disposeThreeObject(mesh);
|
||||
});
|
||||
runtime.entries.clear();
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
export function Match3DTrayPreviewBoard({
|
||||
slotItems,
|
||||
}: {
|
||||
slotItems: Array<Match3DItemSnapshot | null>;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const runtimeRef = useRef<TrayPreviewRuntime | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cleanupResize: (() => void) | undefined;
|
||||
|
||||
async function setup() {
|
||||
const container = containerRef.current;
|
||||
if (!container || !hasWebGLSupport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const three = await import('three');
|
||||
if (cancelled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = new three.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new three.Scene();
|
||||
scene.background = null;
|
||||
const camera = new three.OrthographicCamera(-3.7, 3.7, 1.1, -1.1, 0.1, 40);
|
||||
camera.position.set(4.2, 3.2, 4.2);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
scene.add(new three.AmbientLight(0xffffff, 1.55));
|
||||
const keyLight = new three.DirectionalLight(0xffffff, 2.2);
|
||||
keyLight.position.set(-2.6, 4.4, 3.2);
|
||||
scene.add(keyLight);
|
||||
const fillLight = new three.DirectionalLight(0xfef3c7, 0.95);
|
||||
fillLight.position.set(3.2, 2.8, -2.8);
|
||||
scene.add(fillLight);
|
||||
|
||||
const resize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = Math.max(1, rect.width);
|
||||
const height = Math.max(1, rect.height);
|
||||
renderer.setSize(width, height, false);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
resize();
|
||||
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(container);
|
||||
|
||||
const animate = () => {
|
||||
const activeRuntime = runtimeRef.current;
|
||||
if (!activeRuntime) {
|
||||
return;
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
activeRuntime.animationId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtimeRef.current = {
|
||||
animationId: window.requestAnimationFrame(animate),
|
||||
camera,
|
||||
entries: new Map(),
|
||||
renderer,
|
||||
scene,
|
||||
three,
|
||||
};
|
||||
setReady(true);
|
||||
|
||||
cleanupResize = () => ro.disconnect();
|
||||
}
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanupResize?.();
|
||||
disposeTrayPreview(runtimeRef.current);
|
||||
runtimeRef.current = null;
|
||||
setReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
const activeIds = new Set(
|
||||
slotItems
|
||||
.filter((item): item is Match3DItemSnapshot => Boolean(item))
|
||||
.map((item) => item.itemInstanceId),
|
||||
);
|
||||
|
||||
runtime.entries.forEach((mesh, itemInstanceId) => {
|
||||
if (!activeIds.has(itemInstanceId)) {
|
||||
runtime.scene.remove(mesh);
|
||||
disposeThreeObject(mesh);
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
}
|
||||
});
|
||||
|
||||
slotItems.forEach((item, slotIndex) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
let mesh = runtime.entries.get(item.itemInstanceId);
|
||||
if (!mesh) {
|
||||
const preview = createMatch3DItemMesh(runtime.three, item);
|
||||
mesh = preview.mesh;
|
||||
mesh.rotation.set(-0.12, Math.PI / 4, 0.08);
|
||||
|
||||
const bounds = new runtime.three.Box3().setFromObject(mesh);
|
||||
const size = bounds.getSize(new runtime.three.Vector3());
|
||||
const maxDimension = Math.max(size.x, size.y, size.z, 0.001);
|
||||
mesh.scale.multiplyScalar(0.82 / maxDimension);
|
||||
const centeredBounds = new runtime.three.Box3().setFromObject(mesh);
|
||||
const center = centeredBounds.getCenter(new runtime.three.Vector3());
|
||||
mesh.position.sub(center);
|
||||
runtime.scene.add(mesh);
|
||||
runtime.entries.set(item.itemInstanceId, mesh);
|
||||
}
|
||||
mesh.position.x = (slotIndex - 3) * 1.03;
|
||||
mesh.position.y = 0;
|
||||
mesh.position.z = 0;
|
||||
});
|
||||
|
||||
runtime.renderer.render(runtime.scene, runtime.camera);
|
||||
}, [ready, slotItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
data-testid="match3d-tray-model-board"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DPhysicsBoard({
|
||||
run,
|
||||
disabled,
|
||||
@@ -272,13 +688,29 @@ export function Match3DPhysicsBoard({
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
container.appendChild(renderer.domElement);
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
fallbackRef.current();
|
||||
};
|
||||
renderer.domElement.addEventListener(
|
||||
'webglcontextlost',
|
||||
handleContextLost,
|
||||
false,
|
||||
);
|
||||
|
||||
const scene = new three.Scene();
|
||||
scene.background = null;
|
||||
|
||||
const camera = new three.PerspectiveCamera(32, 1, 0.1, 80);
|
||||
camera.position.set(0, 14.8, 2.3);
|
||||
camera.lookAt(0, 0.48, 0);
|
||||
const camera = new three.OrthographicCamera(
|
||||
-MATCH3D_CAMERA_HALF_SIZE,
|
||||
MATCH3D_CAMERA_HALF_SIZE,
|
||||
MATCH3D_CAMERA_HALF_SIZE,
|
||||
-MATCH3D_CAMERA_HALF_SIZE,
|
||||
0.1,
|
||||
80,
|
||||
);
|
||||
camera.position.set(0, 17.5, 0.01);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const ambient = new three.AmbientLight(0xffffff, 1.28);
|
||||
scene.add(ambient);
|
||||
@@ -407,7 +839,10 @@ export function Match3DPhysicsBoard({
|
||||
const rect = container.getBoundingClientRect();
|
||||
const size = Math.max(1, Math.min(rect.width, rect.height));
|
||||
renderer.setSize(size, size, false);
|
||||
camera.aspect = 1;
|
||||
camera.left = -MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.right = MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.top = MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.bottom = -MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
resize();
|
||||
@@ -423,9 +858,13 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||
lastTime = now;
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
applyCenterGravity(entry);
|
||||
});
|
||||
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
|
||||
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
applyCenterGravity(entry);
|
||||
constrainBodyInsidePot(entry);
|
||||
entry.mesh.position.set(
|
||||
entry.body.position.x,
|
||||
@@ -433,11 +872,14 @@ export function Match3DPhysicsBoard({
|
||||
entry.body.position.z,
|
||||
);
|
||||
entry.mesh.quaternion.set(
|
||||
entry.body.quaternion.x,
|
||||
entry.body.quaternion.y,
|
||||
entry.body.quaternion.z,
|
||||
entry.body.quaternion.w,
|
||||
entry.lockReadableTop ? 0 : entry.body.quaternion.x,
|
||||
entry.lockReadableTop ? 0 : entry.body.quaternion.y,
|
||||
entry.lockReadableTop ? 0 : entry.body.quaternion.z,
|
||||
entry.lockReadableTop ? 1 : entry.body.quaternion.w,
|
||||
);
|
||||
if (entry.lockReadableTop) {
|
||||
entry.mesh.rotation.y = entry.topRotationY;
|
||||
}
|
||||
});
|
||||
|
||||
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
|
||||
@@ -447,6 +889,11 @@ export function Match3DPhysicsBoard({
|
||||
setReady(true);
|
||||
|
||||
return () => {
|
||||
renderer.domElement.removeEventListener(
|
||||
'webglcontextlost',
|
||||
handleContextLost,
|
||||
false,
|
||||
);
|
||||
ro.disconnect();
|
||||
};
|
||||
} catch {
|
||||
@@ -475,11 +922,7 @@ export function Match3DPhysicsBoard({
|
||||
|
||||
const activeItemIds = new Set(
|
||||
run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
isItemState(item.state, 'in_board') ||
|
||||
isItemState(item.state, 'flying'),
|
||||
)
|
||||
.filter((item) => isItemState(item.state, 'in_board'))
|
||||
.map((item) => item.itemInstanceId),
|
||||
);
|
||||
|
||||
@@ -487,29 +930,20 @@ export function Match3DPhysicsBoard({
|
||||
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();
|
||||
}
|
||||
disposeThreeObject(entry.mesh);
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
}
|
||||
});
|
||||
|
||||
run.items.forEach((item) => {
|
||||
if (
|
||||
!isItemState(item.state, 'in_board') &&
|
||||
!isItemState(item.state, 'flying')
|
||||
) {
|
||||
if (!isItemState(item.state, 'in_board')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = runtime.entries.get(item.itemInstanceId);
|
||||
if (existing) {
|
||||
existing.item = item;
|
||||
existing.mesh.visible = isItemState(item.state, 'in_board');
|
||||
existing.mesh.visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,7 +955,7 @@ export function Match3DPhysicsBoard({
|
||||
shape: createCannonShape(runtime.cannon, visual.shape, visual.radius),
|
||||
position: new runtime.cannon.Vec3(
|
||||
visual.position.x,
|
||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * 0.055,
|
||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
|
||||
visual.position.z,
|
||||
),
|
||||
});
|
||||
@@ -541,7 +975,9 @@ export function Match3DPhysicsBoard({
|
||||
runtime.entries.set(item.itemInstanceId, {
|
||||
body,
|
||||
item,
|
||||
lockReadableTop: visual.lockReadableTop,
|
||||
mesh: visual.mesh,
|
||||
topRotationY: visual.topRotationY,
|
||||
});
|
||||
});
|
||||
}, [ready, run.items, run.snapshotVersion]);
|
||||
@@ -568,7 +1004,7 @@ export function Match3DPhysicsBoard({
|
||||
entry.mesh.visible,
|
||||
)
|
||||
.map((entry) => entry.mesh);
|
||||
const hit = runtime.raycaster.intersectObjects(meshes, false)[0];
|
||||
const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
|
||||
const itemInstanceId =
|
||||
typeof hit?.object.userData.itemInstanceId === 'string'
|
||||
? hit.object.userData.itemInstanceId
|
||||
@@ -587,7 +1023,7 @@ export function Match3DPhysicsBoard({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-10 overflow-hidden rounded-full"
|
||||
className="absolute inset-0 z-10 overflow-visible"
|
||||
data-testid="match3d-physics-board"
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user