399 lines
12 KiB
JavaScript
399 lines
12 KiB
JavaScript
import * as THREE from "three";
|
|
import * as CANNON from "cannon-es";
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import dieModelBytes from "./assets/dice/die.glb";
|
|
|
|
const LIMIT = 252;
|
|
const TAU = Math.PI * 2;
|
|
const FACE_NORMALS = {
|
|
1: new CANNON.Vec3(0, 0, -1),
|
|
2: new CANNON.Vec3(0, -1, 0),
|
|
3: new CANNON.Vec3(-1, 0, 0),
|
|
4: new CANNON.Vec3(1, 0, 0),
|
|
5: new CANNON.Vec3(0, 1, 0),
|
|
6: new CANNON.Vec3(0, 0, 1),
|
|
};
|
|
|
|
function rollDie() {
|
|
const bytes = new Uint8Array(1);
|
|
for (;;) {
|
|
crypto.getRandomValues(bytes);
|
|
if (bytes[0] < LIMIT) return (bytes[0] % 6) + 1;
|
|
}
|
|
}
|
|
|
|
function randomBetween(min, max) {
|
|
return min + Math.random() * (max - min);
|
|
}
|
|
|
|
function randomSigned(min, max) {
|
|
return (Math.random() < 0.5 ? -1 : 1) * randomBetween(min, max);
|
|
}
|
|
|
|
function randomQuaternion(target) {
|
|
const u1 = Math.random();
|
|
const u2 = Math.random();
|
|
const u3 = Math.random();
|
|
const a = Math.sqrt(1 - u1);
|
|
const b = Math.sqrt(u1);
|
|
target.set(
|
|
a * Math.sin(TAU * u2),
|
|
a * Math.cos(TAU * u2),
|
|
b * Math.sin(TAU * u3),
|
|
b * Math.cos(TAU * u3),
|
|
);
|
|
return target;
|
|
}
|
|
|
|
function restingQuaternion(value, yaw = 0) {
|
|
const rotations = {
|
|
1: [Math.PI / 2, 0, 0],
|
|
2: [Math.PI, 0, 0],
|
|
3: [0, 0, -Math.PI / 2],
|
|
4: [0, 0, Math.PI / 2],
|
|
5: [0, 0, 0],
|
|
6: [-Math.PI / 2, 0, 0],
|
|
};
|
|
const face = new THREE.Quaternion().setFromEuler(
|
|
new THREE.Euler(...rotations[value], "XYZ"),
|
|
);
|
|
const aroundUp = new THREE.Quaternion().setFromAxisAngle(
|
|
new THREE.Vector3(0, 1, 0),
|
|
yaw,
|
|
);
|
|
return aroundUp.multiply(face);
|
|
}
|
|
|
|
function upwardFace(body) {
|
|
let result = 1;
|
|
let highest = -Infinity;
|
|
for (const [value, localNormal] of Object.entries(FACE_NORMALS)) {
|
|
const worldNormal = body.quaternion.vmult(localNormal);
|
|
if (worldNormal.y > highest) {
|
|
highest = worldNormal.y;
|
|
result = Number(value);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function modelBuffer() {
|
|
return dieModelBytes.buffer.slice(
|
|
dieModelBytes.byteOffset,
|
|
dieModelBytes.byteOffset + dieModelBytes.byteLength,
|
|
);
|
|
}
|
|
|
|
function makeMaterial(source) {
|
|
const isPip = /white|enamel/i.test(source?.name || "");
|
|
return new THREE.MeshPhysicalMaterial({
|
|
name: isPip ? "Ivory pips" : "Red resin",
|
|
color: isPip ? 0xf8f5ec : 0xc91f2b,
|
|
roughness: isPip ? 0.38 : 0.27,
|
|
metalness: 0,
|
|
clearcoat: isPip ? 0.14 : 0.34,
|
|
clearcoatRoughness: isPip ? 0.42 : 0.32,
|
|
});
|
|
}
|
|
|
|
function makeShadowTexture() {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 128;
|
|
canvas.height = 128;
|
|
const context = canvas.getContext("2d");
|
|
const gradient = context.createRadialGradient(64, 64, 3, 64, 64, 58);
|
|
gradient.addColorStop(0, "rgba(0,0,0,.44)");
|
|
gradient.addColorStop(0.42, "rgba(0,0,0,.27)");
|
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
|
context.fillStyle = gradient;
|
|
context.fillRect(0, 0, 128, 128);
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
return texture;
|
|
}
|
|
|
|
async function loadTemplate() {
|
|
const gltf = await new GLTFLoader().parseAsync(modelBuffer(), "");
|
|
gltf.scene.traverse((object) => {
|
|
if (!object.isMesh) return;
|
|
object.material = Array.isArray(object.material)
|
|
? object.material.map(makeMaterial)
|
|
: makeMaterial(object.material);
|
|
object.geometry.computeBoundingSphere();
|
|
});
|
|
|
|
const bounds = new THREE.Box3().setFromObject(gltf.scene);
|
|
const center = bounds.getCenter(new THREE.Vector3());
|
|
const size = bounds.getSize(new THREE.Vector3());
|
|
const centered = new THREE.Group();
|
|
gltf.scene.position.sub(center);
|
|
centered.add(gltf.scene);
|
|
centered.scale.setScalar(2 / Math.max(size.x, size.y, size.z));
|
|
return centered;
|
|
}
|
|
|
|
/* A slightly inset box is the stable collision proxy. The visible mesh keeps
|
|
Fusion's exact fillets; the 5% inset keeps the proxy inside that silhouette
|
|
while avoiding stacked contacts that can inject energy into small bodies. */
|
|
function addDieCollider(body) {
|
|
body.addShape(new CANNON.Box(new CANNON.Vec3(0.95, 0.95, 0.95)));
|
|
}
|
|
|
|
function makePhysicsWorld() {
|
|
const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -22, 0) });
|
|
world.allowSleep = true;
|
|
world.broadphase = new CANNON.SAPBroadphase(world);
|
|
world.solver.iterations = 18;
|
|
|
|
const dieMaterial = new CANNON.Material("die");
|
|
const tableMaterial = new CANNON.Material("table");
|
|
world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, tableMaterial, {
|
|
friction: 0.6,
|
|
restitution: 0.2,
|
|
contactEquationStiffness: 1e8,
|
|
contactEquationRelaxation: 3,
|
|
}));
|
|
world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, dieMaterial, {
|
|
friction: 0.3,
|
|
restitution: 0.3,
|
|
contactEquationStiffness: 1e8,
|
|
contactEquationRelaxation: 3,
|
|
}));
|
|
|
|
const table = new CANNON.Body({ mass: 0, material: tableMaterial });
|
|
table.addShape(new CANNON.Plane());
|
|
table.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
|
|
world.addBody(table);
|
|
return { world, dieMaterial };
|
|
}
|
|
|
|
function makeThrowPlan(count) {
|
|
if (count !== 2) {
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const side = index % 2 === 0 ? -1 : 1;
|
|
return {
|
|
position: [side * 1.8, randomBetween(2.45, 2.8), randomBetween(-0.75, -0.45)],
|
|
velocity: [-side * randomBetween(1.7, 2.3), randomBetween(0.3, 0.8), randomBetween(0.2, 0.65)],
|
|
};
|
|
});
|
|
}
|
|
|
|
const chance = Math.random();
|
|
const mode = chance < 0.55 ? "cross" : chance < 0.9 ? "collision" : "overtake";
|
|
return [0, 1].map((index) => {
|
|
const side = index === 0 ? -1 : 1;
|
|
if (mode === "cross") {
|
|
return {
|
|
position: [side * 1.8, randomBetween(2.45, 2.8), index === 0 ? 1.65 : -1.65],
|
|
velocity: [-side * randomBetween(2.6, 3.2), randomBetween(0.3, 0.8), randomBetween(-0.1, 0.1)],
|
|
};
|
|
}
|
|
if (mode === "overtake") {
|
|
return {
|
|
position: [index === 0 ? -0.8 : 0.8, randomBetween(2.45, 2.8), index === 0 ? -2.15 : 1.25],
|
|
velocity: [randomBetween(-0.25, 0.25), randomBetween(0.3, 0.8), index === 0 ? randomBetween(2.1, 2.7) : randomBetween(0.35, 0.8)],
|
|
};
|
|
}
|
|
return {
|
|
position: [side * 1.8, randomBetween(2.45, 2.8), randomBetween(-0.75, -0.45)],
|
|
velocity: [-side * randomBetween(1.7, 2.3), randomBetween(0.3, 0.8), randomBetween(0.2, 0.65)],
|
|
};
|
|
});
|
|
}
|
|
|
|
function resetBodyForThrow(body, launch) {
|
|
body.wakeUp();
|
|
body.position.set(...launch.position);
|
|
randomQuaternion(body.quaternion);
|
|
body.velocity.set(...launch.velocity);
|
|
body.angularVelocity.set(
|
|
randomSigned(9, 14),
|
|
randomSigned(10, 16),
|
|
randomSigned(9, 14),
|
|
);
|
|
body.force.setZero();
|
|
body.torque.setZero();
|
|
body.previousPosition.copy(body.position);
|
|
body.interpolatedPosition.copy(body.position);
|
|
body.previousQuaternion.copy(body.quaternion);
|
|
body.interpolatedQuaternion.copy(body.quaternion);
|
|
body.aabbNeedsUpdate = true;
|
|
}
|
|
|
|
async function mount(canvas, count = 2) {
|
|
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
throw new TypeError("Dice3D.mount requires a canvas element");
|
|
}
|
|
|
|
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
|
renderer.setClearColor(0x000000, 0);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.08;
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.OrthographicCamera(-6.85, 6.85, 4.35, -4.35, 0.1, 40);
|
|
camera.position.set(0, 9.1, 13.8);
|
|
camera.lookAt(0, 0.72, 0);
|
|
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x6b2426, 2.05));
|
|
const key = new THREE.DirectionalLight(0xffffff, 2.8);
|
|
key.position.set(-4, 8, 7);
|
|
scene.add(key);
|
|
const fill = new THREE.DirectionalLight(0xffc8c4, 0.9);
|
|
fill.position.set(5, 3, 7);
|
|
scene.add(fill);
|
|
|
|
const template = await loadTemplate();
|
|
const shadowTexture = makeShadowTexture();
|
|
const { world, dieMaterial } = makePhysicsWorld();
|
|
const dice = [];
|
|
const spacing = 2.75;
|
|
|
|
for (let index = 0; index < count; index++) {
|
|
const holder = new THREE.Group();
|
|
holder.add(template.clone(true));
|
|
const x = (index - (count - 1) / 2) * spacing;
|
|
const initialValue = rollDie();
|
|
const initialRotation = restingQuaternion(initialValue, randomBetween(0, TAU));
|
|
holder.position.set(x, 1, 0);
|
|
holder.quaternion.copy(initialRotation);
|
|
scene.add(holder);
|
|
|
|
const body = new CANNON.Body({
|
|
mass: 1,
|
|
material: dieMaterial,
|
|
linearDamping: 0.24,
|
|
angularDamping: 0.22,
|
|
allowSleep: true,
|
|
sleepSpeedLimit: 0.16,
|
|
sleepTimeLimit: 0.38,
|
|
});
|
|
addDieCollider(body);
|
|
body.position.set(x, 1, 0);
|
|
body.quaternion.set(
|
|
initialRotation.x,
|
|
initialRotation.y,
|
|
initialRotation.z,
|
|
initialRotation.w,
|
|
);
|
|
world.addBody(body);
|
|
|
|
const shadowMaterial = new THREE.MeshBasicMaterial({
|
|
map: shadowTexture,
|
|
transparent: true,
|
|
opacity: 0.44,
|
|
depthWrite: false,
|
|
toneMapped: false,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 1.55), shadowMaterial);
|
|
shadow.rotation.x = -Math.PI / 2;
|
|
shadow.position.set(x, 0.01, 0);
|
|
scene.add(shadow);
|
|
|
|
dice.push({ holder, body, shadow, value: initialValue });
|
|
}
|
|
|
|
function syncVisuals() {
|
|
for (const die of dice) {
|
|
const { position, quaternion } = die.body;
|
|
die.holder.position.set(position.x, position.y, position.z);
|
|
die.holder.quaternion.set(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
|
|
die.shadow.position.set(position.x, 0.01, position.z);
|
|
const clearance = Math.max(0, position.y - 1);
|
|
const shadowScale = 1 - Math.min(0.36, clearance * 0.18);
|
|
die.shadow.scale.set(shadowScale, shadowScale, 1);
|
|
die.shadow.material.opacity = 0.44 - Math.min(0.31, clearance * 0.16);
|
|
}
|
|
}
|
|
|
|
function resize() {
|
|
const width = Math.max(1, canvas.clientWidth);
|
|
const height = Math.max(1, canvas.clientHeight);
|
|
const aspect = width / height;
|
|
const halfHeight = 4.35;
|
|
camera.left = -halfHeight * aspect;
|
|
camera.right = halfHeight * aspect;
|
|
camera.top = halfHeight;
|
|
camera.bottom = -halfHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(width, height, false);
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
const observer = new ResizeObserver(resize);
|
|
observer.observe(canvas);
|
|
resize();
|
|
|
|
let busy = false;
|
|
let frameRequest = 0;
|
|
let destroyed = false;
|
|
|
|
function finishRoll(resolve) {
|
|
syncVisuals();
|
|
renderer.render(scene, camera);
|
|
const values = dice.map((die) => {
|
|
die.value = upwardFace(die.body);
|
|
return die.value;
|
|
});
|
|
busy = false;
|
|
resolve(values);
|
|
}
|
|
|
|
function roll() {
|
|
if (busy) return Promise.resolve(null);
|
|
busy = true;
|
|
const plan = makeThrowPlan(dice.length);
|
|
dice.forEach((die, index) => resetBodyForThrow(die.body, plan[index]));
|
|
|
|
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
if (reducedMotion) {
|
|
for (let step = 0; step < 720; step++) world.step(1 / 120);
|
|
dice.forEach((die) => die.body.sleep());
|
|
return new Promise((resolve) => finishRoll(resolve));
|
|
}
|
|
|
|
const startedAt = performance.now();
|
|
let previous = startedAt;
|
|
return new Promise((resolve) => {
|
|
function frame(now) {
|
|
if (destroyed) return;
|
|
const elapsedSeconds = (now - startedAt) / 1000;
|
|
const frameSeconds = Math.min(0.05, (now - previous) / 1000);
|
|
previous = now;
|
|
/* The rigid-body path remains physical; playing simulated time at
|
|
1.55x removes the slow-motion feel of a tiny object on screen. */
|
|
world.step(1 / 120, frameSeconds * 1.55, 10);
|
|
syncVisuals();
|
|
renderer.render(scene, camera);
|
|
|
|
const asleep = dice.every((die) => die.body.sleepState === CANNON.Body.SLEEPING);
|
|
if ((elapsedSeconds > 0.8 && asleep) || elapsedSeconds > 6) {
|
|
if (!asleep) dice.forEach((die) => die.body.sleep());
|
|
finishRoll(resolve);
|
|
} else {
|
|
frameRequest = requestAnimationFrame(frame);
|
|
}
|
|
}
|
|
frameRequest = requestAnimationFrame(frame);
|
|
});
|
|
}
|
|
|
|
return {
|
|
roll,
|
|
dice,
|
|
isBusy: () => busy,
|
|
destroy() {
|
|
destroyed = true;
|
|
cancelAnimationFrame(frameRequest);
|
|
observer.disconnect();
|
|
renderer.dispose();
|
|
shadowTexture.dispose();
|
|
},
|
|
};
|
|
}
|
|
|
|
window.Dice3D = { mount, rollDie };
|