|
import * as THREE from "three"; |
|
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; |
|
import * as CANNON from "cannon"; |
|
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js"; |
|
import { RenderPass } from "three/addons/postprocessing/RenderPass.js"; |
|
import { OutlinePass } from "three/addons/postprocessing/OutlinePass.js"; |
|
import { OutputPass } from "three/addons/postprocessing/OutputPass.js"; |
|
|
|
/* ======================= CONFIG ======================= */ |
|
const ROWS = 6; |
|
const COLS = 4; |
|
const WORLD_H = 2.4; |
|
const TARGET_RATIO = 10 / 16; |
|
const WORLD_W = WORLD_H * TARGET_RATIO; |
|
const PW = WORLD_W / COLS; |
|
const PH = WORLD_H / ROWS; |
|
// ====== CUSTOM DEPTH ====== |
|
const THICK = 0.2; |
|
const JITTER = 0.05; |
|
const NECK_WIDTH_F = 0.3; |
|
const TAB_SIZE_F = 0.25; |
|
const BOARD_Z = -0.05; |
|
const FLOOR_Y = 0.25; |
|
|
|
/* ======================= SETUP ======================= */ |
|
const renderer = new THREE.WebGLRenderer({ |
|
antialias: true, |
|
powerPreference: "high-performance" |
|
}); |
|
renderer.setSize(innerWidth, innerHeight); |
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); |
|
renderer.outputColorSpace = THREE.SRGBColorSpace; |
|
renderer.toneMapping = THREE.ACESFilmicToneMapping; |
|
renderer.shadowMap.enabled = true; |
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
renderer.domElement.style.touchAction = "none"; |
|
document.body.appendChild(renderer.domElement); |
|
|
|
const scene = new THREE.Scene(); |
|
scene.background = new THREE.Color(0x14161a); |
|
|
|
const pmrem = new THREE.PMREMGenerator(renderer); |
|
scene.environment = pmrem.fromScene( |
|
new RoomEnvironment(renderer), |
|
0.04 |
|
).texture; |
|
|
|
const camera = new THREE.PerspectiveCamera( |
|
35, |
|
innerWidth / innerHeight, |
|
0.1, |
|
100 |
|
); |
|
camera.position.set(0, WORLD_H / 2, 5.5); |
|
camera.lookAt(0, WORLD_H / 2, 0); |
|
|
|
/* ======================= POST-PROCESSING ======================= */ |
|
const composer = new EffectComposer(renderer); |
|
composer.addPass(new RenderPass(scene, camera)); |
|
const outlinePass = new OutlinePass( |
|
new THREE.Vector2(innerWidth, innerHeight), |
|
scene, |
|
camera |
|
); |
|
outlinePass.edgeStrength = 8; |
|
outlinePass.edgeGlow = 1; |
|
outlinePass.edgeThickness = 3; |
|
outlinePass.visibleEdgeColor.set("#00ff00"); |
|
composer.addPass(outlinePass); |
|
composer.addPass(new OutputPass()); |
|
|
|
/* ======================= PHYSICS ======================= */ |
|
const world = new CANNON.World({ |
|
gravity: new CANNON.Vec3(0, -9.82, 0), |
|
allowSleep: true |
|
}); |
|
world.solver.iterations = 20; // Increased for better stability |
|
|
|
const GROUP_FLOOR = 1 << 0; |
|
const GROUP_TRAY = 1 << 1; // Renamed for clarity |
|
const GROUP_PIECE = 1 << 2; |
|
|
|
const floorBody = new CANNON.Body({ |
|
type: CANNON.Body.STATIC, |
|
shape: new CANNON.Plane(), |
|
position: new CANNON.Vec3(0, FLOOR_Y, 0) |
|
}); |
|
floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); |
|
floorBody.collisionFilterGroup = GROUP_FLOOR; |
|
floorBody.collisionFilterMask = GROUP_PIECE; |
|
world.addBody(floorBody); |
|
|
|
// ====== SOLID TRAY BEHIND THE BOARD To avoid CRAZY Ghost ====== |
|
const trayBody = new CANNON.Body({ |
|
type: CANNON.Body.STATIC, |
|
// A thick box to prevent pieces from passing through |
|
shape: new CANNON.Box(new CANNON.Vec3(WORLD_W / 2, WORLD_H / 2, THICK * 5)), |
|
position: new CANNON.Vec3(0, WORLD_H / 2, BOARD_Z - THICK * 4) |
|
}); |
|
trayBody.collisionFilterGroup = GROUP_TRAY; |
|
trayBody.collisionFilterMask = GROUP_PIECE; // In case It collides with pieces |
|
world.addBody(trayBody); |
|
|
|
/* ======================= BOARD BACKGROUND ======================= */ |
|
const boardGeometry = new THREE.PlaneGeometry(WORLD_W, WORLD_H); |
|
const boardMaterial = new THREE.MeshStandardMaterial({ |
|
color: 0x0a0a0c, |
|
roughness: 0.8 |
|
}); |
|
const boardMesh = new THREE.Mesh(boardGeometry, boardMaterial); |
|
boardMesh.position.set(0, WORLD_H / 2, BOARD_Z); |
|
boardMesh.receiveShadow = true; |
|
scene.add(boardMesh); |
|
|
|
boardMesh.updateMatrixWorld(true); |
|
const boardBox = new THREE.Box3().setFromObject(boardMesh); |
|
const boardPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -BOARD_Z); |
|
|
|
/* ======================= LIGHTS ======================= */ |
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x2f3440, 0.9)); |
|
const dir = new THREE.DirectionalLight(0xffffff, 2.5); // Brighter light for glass effect |
|
dir.position.set(3, 5, 4); |
|
dir.castShadow = true; |
|
dir.shadow.mapSize.set(2048, 2048); |
|
scene.add(dir); |
|
|
|
const floorMesh = new THREE.Mesh( |
|
new THREE.PlaneGeometry(20, 20), |
|
new THREE.ShadowMaterial({ opacity: 0.25 }) |
|
); |
|
floorMesh.rotation.x = -Math.PI / 2; |
|
floorMesh.position.y = FLOOR_Y; |
|
floorMesh.receiveShadow = true; |
|
scene.add(floorMesh); |
|
|
|
/* ======================= LOAD TEXTURE ======================= */ |
|
const imageUrl = |
|
"https://assets.codepen.io/453571/halloween-pumpkins-smiling.avif"; |
|
const mainTexture = await new THREE.TextureLoader().loadAsync(imageUrl); |
|
mainTexture.colorSpace = THREE.SRGBColorSpace; |
|
mainTexture.flipY = false; |
|
/*document.getElementById("preview-image")?.setAttribute("src", imageUrl); loaded direcly in html*/ |
|
|
|
// ====== ENHANCEMENT: GLASS-LIKE MATERIAL ====== |
|
const textureMaterial = new THREE.MeshPhysicalMaterial({ |
|
map: mainTexture, |
|
roughness: 0.1, // More glossy |
|
metalness: 0.0, |
|
transmission: 0.8, // Semi-transparent |
|
ior: 1.5, // Index of Refraction for glass |
|
thickness: THICK, // Required for transmission to look right |
|
side: THREE.DoubleSide |
|
}); |
|
|
|
/* ======================= EDGE CURVES ======================= */ |
|
const rand = (() => { |
|
const array = new Uint32Array(1); |
|
return () => { |
|
crypto.getRandomValues(array); |
|
return array[0] / 4294967296; |
|
}; |
|
})(); |
|
const alea = (min, max) => min + (max - min) * rand(); |
|
|
|
// Store the boolean type of each edge (true=tab) for physics shape generation |
|
const edgeTypes = { |
|
horizontal: Array.from({ length: ROWS - 1 }, () => |
|
Array.from({ length: COLS }, () => rand() < 0.5) |
|
), |
|
vertical: Array.from({ length: ROWS }, () => |
|
Array.from({ length: COLS - 1 }, () => rand() < 0.5) |
|
) |
|
}; |
|
// Generate the curve functions from the types |
|
function generateEdgeCurve(isTab) { |
|
const sign = isTab ? 1 : -1; |
|
const jitter = alea(1 - JITTER, 1 + JITTER); |
|
return (t, length, perpSign) => { |
|
const neckWidth = length * NECK_WIDTH_F; |
|
const tabDepth = length * TAB_SIZE_F * sign * jitter * perpSign; |
|
if (t < 0.5 - neckWidth / length / 2 || t > 0.5 + neckWidth / length / 2) |
|
return { along: t, perp: 0 }; |
|
const localT = (t - (0.5 - neckWidth / length / 2)) / (neckWidth / length); |
|
return { |
|
along: t, |
|
perp: 4 * Math.abs(tabDepth) * localT * (1 - localT) * Math.sign(tabDepth) |
|
}; |
|
}; |
|
} |
|
const edgeCurves = { |
|
horizontal: edgeTypes.horizontal.map((row) => |
|
row.map((isTab) => generateEdgeCurve(isTab)) |
|
), |
|
vertical: edgeTypes.vertical.map((row) => |
|
row.map((isTab) => generateEdgeCurve(isTab)) |
|
) |
|
}; |
|
|
|
/* ======================= CREATE PIECE SHAPE ======================= */ |
|
function createPieceShape(col, row) { |
|
const shape = new THREE.Shape(); |
|
const segments = 32; |
|
function addCurvedEdge(x1, y1, x2, y2, curveFunc, perpSign) { |
|
if (!curveFunc) { |
|
shape.lineTo(x2, y2); |
|
return; |
|
} |
|
const dx = x2 - x1, |
|
dy = y2 - y1, |
|
length = Math.sqrt(dx * dx + dy * dy); |
|
const dirX = dx / length, |
|
dirY = dy / length, |
|
perpX = -dirY * perpSign, |
|
perpY = dirX * perpSign; |
|
for (let i = 1; i <= segments; i++) { |
|
const t = i / segments, |
|
curve = curveFunc(t, length, 1); |
|
shape.lineTo( |
|
x1 + dirX * curve.along * length + perpX * curve.perp, |
|
y1 + dirY * curve.along * length + perpY * curve.perp |
|
); |
|
} |
|
} |
|
shape.moveTo(-PW / 2, -PH / 2); |
|
addCurvedEdge( |
|
-PW / 2, |
|
-PH / 2, |
|
PW / 2, |
|
-PH / 2, |
|
row === ROWS - 1 ? null : edgeCurves.horizontal[row][col], |
|
1 |
|
); |
|
addCurvedEdge( |
|
PW / 2, |
|
-PH / 2, |
|
PW / 2, |
|
PH / 2, |
|
col === COLS - 1 ? null : edgeCurves.vertical[row][col], |
|
1 |
|
); |
|
addCurvedEdge( |
|
PW / 2, |
|
PH / 2, |
|
-PW / 2, |
|
PH / 2, |
|
row === 0 ? null : edgeCurves.horizontal[row - 1][col], |
|
-1 |
|
); |
|
addCurvedEdge( |
|
-PW / 2, |
|
PH / 2, |
|
-PW / 2, |
|
-PH / 2, |
|
col === 0 ? null : edgeCurves.vertical[row][col - 1], |
|
-1 |
|
); |
|
return shape; |
|
} |
|
|
|
/* ======================= BUILD PIECES ======================= */ |
|
const pieces = []; |
|
|
|
function buildPiece(col, row) { |
|
const shape = createPieceShape(col, row); |
|
|
|
// ====== ENHANCEMENT: DEEPER EMBOSS EFFECT ====== |
|
const geometry = new THREE.ExtrudeGeometry(shape, { |
|
depth: THICK * 0.03, |
|
bevelEnabled: true, |
|
bevelThickness: THICK * 0.2, |
|
bevelSize: THICK * 0.2, |
|
bevelSegments: 20 |
|
}); |
|
|
|
const positions = geometry.getAttribute("position"); |
|
const uvs = new Float32Array(positions.count * 2); |
|
for (let i = 0; i < positions.count; i++) { |
|
const x = positions.getX(i), |
|
y = positions.getY(i); |
|
const localU = (x + PW / 2) / PW, |
|
localV = (y + PH / 2) / PH; |
|
uvs[i * 2] = (col + localU) / COLS; |
|
uvs[i * 2 + 1] = (row + (1.0 - localV)) / ROWS; |
|
} |
|
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); |
|
geometry.computeVertexNormals(); |
|
|
|
const mesh = new THREE.Mesh(geometry, textureMaterial); |
|
mesh.castShadow = true; |
|
mesh.receiveShadow = true; |
|
|
|
// ====== ENHANCEMENT: STABLE COMPOUND PHYSICS SHAPE ====== |
|
const body = new CANNON.Body({ mass: 0.15, sleepTimeLimit: 0.5 }); |
|
|
|
// 1. Add the main center box |
|
const centerShape = new CANNON.Box( |
|
new CANNON.Vec3(PW * 0.48, PH * 0.48, THICK / 2) |
|
); |
|
body.addShape(centerShape, new CANNON.Vec3(0, 0, 0)); |
|
|
|
// 2. Add smaller boxes for the tabs, if they exist |
|
const tabWidth = PW * NECK_WIDTH_F; |
|
const tabHeight = PH * TAB_SIZE_F; |
|
const tabHeightV = PH * NECK_WIDTH_F; |
|
const tabWidthV = PW * TAB_SIZE_F; |
|
|
|
if (row > 0 && edgeTypes.horizontal[row - 1][col]) { |
|
body.addShape( |
|
new CANNON.Box(new CANNON.Vec3(tabWidth / 2, tabHeight / 2, THICK / 2)), |
|
new CANNON.Vec3(0, PH / 2, 0) |
|
); |
|
} |
|
if (row < ROWS - 1 && edgeTypes.horizontal[row][col]) { |
|
body.addShape( |
|
new CANNON.Box(new CANNON.Vec3(tabWidth / 2, tabHeight / 2, THICK / 2)), |
|
new CANNON.Vec3(0, -PH / 2, 0) |
|
); |
|
} |
|
if (col < COLS - 1 && edgeTypes.vertical[row][col]) { |
|
body.addShape( |
|
new CANNON.Box(new CANNON.Vec3(tabWidthV / 2, tabHeightV / 2, THICK / 2)), |
|
new CANNON.Vec3(PW / 2, 0, 0) |
|
); |
|
} |
|
if (col > 0 && edgeTypes.vertical[row][col - 1]) { |
|
body.addShape( |
|
new CANNON.Box(new CANNON.Vec3(tabWidthV / 2, tabHeightV / 2, THICK / 2)), |
|
new CANNON.Vec3(-PW / 2, 0, 0) |
|
); |
|
} |
|
|
|
body.linearDamping = 0.5; |
|
body.angularDamping = 0.5; |
|
body.collisionFilterGroup = GROUP_PIECE; |
|
// Make sure pieces collide with the floor, other pieces, AND the new tray |
|
body.collisionFilterMask = GROUP_FLOOR | GROUP_PIECE | GROUP_TRAY; |
|
|
|
return { mesh, body }; |
|
} |
|
|
|
/* ======================= CREATE ALL PIECES ======================= */ |
|
for (let row = 0; row < ROWS; row++) { |
|
for (let col = 0; col < COLS; col++) { |
|
const { mesh, body } = buildPiece(col, row); |
|
const targetX = -WORLD_W / 2 + PW / 2 + col * PW; |
|
const targetY = WORLD_H - PH / 2 - row * PH; |
|
const targetZ = BOARD_Z + THICK / 2; |
|
const targetPos = new THREE.Vector3(targetX, targetY, targetZ); |
|
body.position.set( |
|
alea(-WORLD_W / 2, WORLD_W / 2), |
|
FLOOR_Y + 0.3 + Math.random() * 0.8, |
|
0.8 + Math.random() * 0.7 |
|
); |
|
body.quaternion.setFromEuler(Math.random(), Math.random(), Math.random()); // Random initial rotation |
|
mesh.position.copy(body.position); |
|
mesh.quaternion.copy(body.quaternion); |
|
world.addBody(body); |
|
scene.add(mesh); |
|
const piece = { |
|
mesh, |
|
body, |
|
grid: { col, row }, |
|
targetPos, |
|
isSolved: false |
|
}; |
|
pieces.push(piece); |
|
mesh.userData.piece = piece; |
|
} |
|
} |
|
|
|
/* ======================= AAAARGH Still missing a clean collision) ======================= */ |
|
const raycaster = new THREE.Raycaster(); |
|
raycaster.near = 0.1; |
|
raycaster.far = 100; |
|
|
|
const freeDragPlane = new THREE.Plane(); |
|
const tmpVec = new THREE.Vector3(); |
|
let draggedPiece = null; |
|
let dragOffset = new THREE.Vector3(); |
|
const originalMasks = new WeakMap(); |
|
|
|
function clampToBoardXY(v) { |
|
const marginX = PW * 0.48; |
|
const marginY = PH * 0.48; |
|
v.x = Math.max(-WORLD_W / 2 + marginX, Math.min(WORLD_W / 2 - marginX, v.x)); |
|
v.y = Math.max(marginY, Math.min(WORLD_H - marginY, v.y)); |
|
return v; |
|
} |
|
|
|
function getGridSlotFromWorldPos(worldPos) { |
|
if (Math.abs(worldPos.z - (BOARD_Z + THICK / 2)) > THICK * 2) return null; |
|
const col = Math.round((worldPos.x - (-WORLD_W / 2 + PW / 2)) / PW); |
|
const row = Math.round((WORLD_H - PH / 2 - worldPos.y) / PH); |
|
if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return null; |
|
return { col, row }; |
|
} |
|
|
|
function isNearTarget(piece, pos, tol = Math.min(PW, PH) * 0.15) { |
|
return ( |
|
Math.abs(pos.x - piece.targetPos.x) <= tol && |
|
Math.abs(pos.y - piece.targetPos.y) <= tol |
|
); |
|
} |
|
|
|
function onPointerDown(e) { |
|
if (draggedPiece) return; |
|
const ndc = { |
|
x: (e.clientX / innerWidth) * 2 - 1, |
|
y: -(e.clientY / innerHeight) * 2 + 1 |
|
}; |
|
raycaster.setFromCamera(ndc, camera); |
|
const unsolved = pieces.filter((p) => !p.isSolved).map((p) => p.mesh); |
|
const hits = raycaster.intersectObjects(unsolved); |
|
if (hits.length > 0) { |
|
draggedPiece = hits[0].object.userData.piece; |
|
draggedPiece.body.type = CANNON.Body.KINEMATIC; |
|
originalMasks.set(draggedPiece.body, { |
|
group: draggedPiece.body.collisionFilterGroup, |
|
mask: draggedPiece.body.collisionFilterMask |
|
}); |
|
draggedPiece.body.collisionFilterMask = 0; |
|
camera.getWorldDirection(tmpVec); |
|
tmpVec.normalize(); |
|
freeDragPlane.setFromNormalAndCoplanarPoint(tmpVec.negate(), hits[0].point); |
|
dragOffset.copy(hits[0].point).sub(draggedPiece.mesh.position); |
|
} |
|
} |
|
|
|
function onPointerMove(e) { |
|
if (!draggedPiece) return; |
|
const ndc = { |
|
x: (e.clientX / innerWidth) * 2 - 1, |
|
y: -(e.clientY / innerHeight) * 2 + 1 |
|
}; |
|
raycaster.setFromCamera(ndc, camera); |
|
const pBoard = new THREE.Vector3(); |
|
const hitBoard = raycaster.ray.intersectPlane(boardPlane, pBoard); |
|
const isOverBoard = |
|
!!hitBoard && |
|
boardBox.containsPoint(new THREE.Vector3(pBoard.x, pBoard.y, BOARD_Z)); |
|
const pFree = new THREE.Vector3(); |
|
if (!isOverBoard) raycaster.ray.intersectPlane(freeDragPlane, pFree); |
|
const intersectionPoint = isOverBoard ? pBoard : pFree; |
|
if (!intersectionPoint) return; |
|
let newPos = intersectionPoint.sub(dragOffset); |
|
if (isOverBoard) { |
|
newPos.z = BOARD_Z + THICK / 2; |
|
clampToBoardXY(newPos); |
|
} else { |
|
newPos.z = Math.max(BOARD_Z + THICK / 2 + 0.05, newPos.z); |
|
} |
|
draggedPiece.body.position.copy(newPos); |
|
draggedPiece.body.quaternion.setFromEuler(0, 0, 0); |
|
const targetSlot = getGridSlotFromWorldPos(newPos); |
|
const inSlot = |
|
targetSlot && |
|
targetSlot.col === draggedPiece.grid.col && |
|
targetSlot.row === draggedPiece.grid.row; |
|
const near = isNearTarget(draggedPiece, newPos, Math.min(PW, PH) * 0.2); |
|
outlinePass.selectedObjects = |
|
isOverBoard && inSlot && near ? [draggedPiece.mesh] : []; |
|
} |
|
|
|
function onPointerUp() { |
|
if (!draggedPiece) return; |
|
const currentPos = new THREE.Vector3().copy(draggedPiece.body.position); |
|
const targetSlot = getGridSlotFromWorldPos(currentPos); |
|
let placed = false; |
|
const correctSlot = |
|
targetSlot && |
|
targetSlot.col === draggedPiece.grid.col && |
|
targetSlot.row === draggedPiece.grid.row; |
|
const nearTarget = isNearTarget(draggedPiece, currentPos); |
|
if (correctSlot && nearTarget) { |
|
draggedPiece.body.type = CANNON.Body.STATIC; |
|
draggedPiece.body.position.copy(draggedPiece.targetPos); |
|
draggedPiece.body.quaternion.setFromEuler(0, 0, 0); |
|
draggedPiece.isSolved = true; |
|
placed = true; |
|
} |
|
const saved = originalMasks.get(draggedPiece.body); |
|
if (saved) { |
|
draggedPiece.body.collisionFilterGroup = saved.group; |
|
draggedPiece.body.collisionFilterMask = saved.mask; |
|
originalMasks.delete(draggedPiece.body); |
|
} |
|
if (!placed) { |
|
draggedPiece.body.type = CANNON.Body.DYNAMIC; |
|
draggedPiece.body.position.z = Math.max( |
|
BOARD_Z + THICK / 2 + 0.05, |
|
draggedPiece.body.position.z |
|
); |
|
draggedPiece.body.wakeUp(); |
|
} |
|
outlinePass.selectedObjects = []; |
|
draggedPiece = null; |
|
} |
|
|
|
window.addEventListener("pointerdown", onPointerDown, { passive: true }); |
|
window.addEventListener("pointermove", onPointerMove, { passive: true }); |
|
window.addEventListener("pointerup", onPointerUp, { passive: true }); |
|
|
|
/* ======================= ANIMATION LOOP ======================= */ |
|
const clock = new THREE.Clock(); |
|
function tick() { |
|
const dt = clock.getDelta(); |
|
world.step(1 / 60, dt, 3); |
|
for (const p of pieces) { |
|
p.mesh.position.copy(p.body.position); |
|
p.mesh.quaternion.copy(p.body.quaternion); |
|
} |
|
composer.render(); |
|
requestAnimationFrame(tick); |
|
} |
|
tick(); |
|
|
|
/* ======================= RESIZE ======================= */ |
|
window.addEventListener("resize", () => { |
|
camera.aspect = innerWidth / innerHeight; |
|
camera.updateProjectionMatrix(); |
|
renderer.setSize(innerWidth, innerHeight); |
|
composer.setSize(innerWidth, innerHeight); |
|
}); |