Files
random_tools/dice-renderer.js
T
2026-08-11 09:53:42 -04:00

875 lines
29 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 MAX_RETHROWS = 8;
/* A real table reads a leaning die rather than re-rolling it -- the boxman
calls the face. Only a die actually balanced on an edge is a no-roll. 0.7071
is exactly edge-rest, so 0.72 isolates that case and nothing else. */
const COCKED_ALIGNMENT = 0.72;
const COCKED_MARGIN = 0.025;
const RANDOM_WORD = new Uint32Array(1);
const MOTION_PRESETS = Object.freeze({
current: Object.freeze({
name: "Current",
gravity: 24,
playback: 1.7,
fixedStep: 1 / 120,
maxSubSteps: 12,
velocityScale: 1,
spinScale: 1,
launchY: null,
linearDamping: 0.22,
angularDamping: 0.2,
sleepSpeedLimit: 0.16,
sleepTimeLimit: 0.38,
minDuration: 0.8,
}),
balanced: Object.freeze({
name: "Balanced realism",
gravity: 96,
playback: 1.15,
fixedStep: 1 / 240,
maxSubSteps: 20,
velocityScale: 2,
spinScale: 1.45,
launchY: [4, 7],
linearDamping: 0.08,
angularDamping: 0.06,
sleepSpeedLimit: 0.28,
sleepTimeLimit: 0.18,
minDuration: 0.45,
}),
literal: Object.freeze({
name: "Literal 20 mm scale",
gravity: 981,
playback: 1,
fixedStep: 1 / 600,
maxSubSteps: 32,
velocityScale: Math.sqrt(981 / 24),
spinScale: Math.sqrt(981 / 24),
launchY: [18, 30],
linearDamping: 0.02,
angularDamping: 0.015,
sleepSpeedLimit: 1,
sleepTimeLimit: 0.08,
minDuration: 0.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 randomUnit() {
crypto.getRandomValues(RANDOM_WORD);
return RANDOM_WORD[0] / 0x100000000;
}
function randomBetween(min, max) {
return min + randomUnit() * (max - min);
}
function randomSigned(min, max) {
return (randomUnit() < 0.5 ? -1 : 1) * randomBetween(min, max);
}
function randomQuaternion(target) {
const u1 = randomUnit();
const u2 = randomUnit();
const u3 = randomUnit();
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) {
const alignments = [];
for (const [value, localNormal] of Object.entries(FACE_NORMALS)) {
const worldNormal = body.quaternion.vmult(localNormal);
alignments.push({ value: Number(value), alignment: worldNormal.y });
}
alignments.sort((a, b) => b.alignment - a.alignment);
return {
value: alignments[0].value,
alignment: alignments[0].alignment,
margin: alignments[0].alignment - alignments[1].alignment,
};
}
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,
});
}
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.castShadow = true;
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, -24, 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");
const bumperMaterial = new CANNON.Material("bumper");
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,
}));
world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, bumperMaterial, {
friction: 0.52,
restitution: 0.18,
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, tableMaterial, bumperMaterial };
}
function addStaticBox(world, material, halfExtents, position) {
const body = new CANNON.Body({ mass: 0, material });
body.addShape(new CANNON.Box(new CANNON.Vec3(...halfExtents)));
body.position.set(...position);
world.addBody(body);
return body;
}
/* The inside of a real table is a continuous felt-covered U: the pyramid
rubber sweeps from the back wall around the corners and down both sides,
and no timber is visible anywhere inboard. Wood and the padded cap belong
on the OUTSIDE of the rail only. The back wall keeps its original Z so the
verified launch distance and 100% backstop contact are unaffected. */
const TABLE = Object.freeze({
/* Sized so a resting die can never fall outside the shot. A perspective
frustum is a trapezoid -- it narrows toward the viewer -- so the bed has
to be narrow enough to fit the tight end. At 13.4 half-width, 13% of dice
at high force came to rest on felt but off-camera, which is what drove all
those re-throws. At 10.5 it is 0.1%. */
halfWidth: 10.5,
backZ: -6.55,
nearZ: 8.6,
cornerRadius: 4.2,
wallHeight: 3.6,
wallThickness: 0.55,
arcSteps: 7,
});
/* Sample the inner boundary as points carrying an outward normal, so the
wall segments, the rubber and the outer rail can all follow one path. */
function innerWallPath() {
const { halfWidth: HW, backZ: BZ, nearZ: NZ, cornerRadius: R, arcSteps } = TABLE;
const points = [];
const push = (x, z, nx, nz) => points.push({ x, z, nx, nz });
push(HW, NZ, 1, 0);
push(HW, BZ + R, 1, 0);
for (let i = 1; i <= arcSteps; i++) {
const t = (i / arcSteps) * (Math.PI / 2);
push((HW - R) + R * Math.cos(t), (BZ + R) - R * Math.sin(t),
Math.cos(t), -Math.sin(t));
}
push(-(HW - R), BZ, 0, -1);
for (let i = 1; i <= arcSteps; i++) {
const t = (Math.PI / 2) - (i / arcSteps) * (Math.PI / 2);
push(-((HW - R) + R * Math.cos(t)), (BZ + R) - R * Math.sin(t),
-Math.cos(t), -Math.sin(t));
}
push(-HW, NZ, -1, 0);
return points;
}
function wallSegments() {
const points = innerWallPath();
const segments = [];
for (let i = 0; i < points.length - 1; i++) {
const a = points[i], b = points[i + 1];
const dx = b.x - a.x, dz = b.z - a.z;
const length = Math.hypot(dx, dz);
if (length < 1e-4) continue;
let nx = a.nx + b.nx, nz = a.nz + b.nz;
const nl = Math.hypot(nx, nz) || 1;
nx /= nl; nz /= nl;
segments.push({
cx: (a.x + b.x) / 2,
cz: (a.z + b.z) / 2,
length,
yaw: Math.atan2(-dz, dx),
nx,
nz,
// The far end -- back wall plus both corner sweeps -- is what a throw
// must reach, so only these carry the backstop flag.
isBackstop: (a.z + b.z) / 2 < TABLE.backZ + TABLE.cornerRadius + 0.01,
});
}
return segments;
}
function addCrapsTable(scene, world, tableMaterial, bumperMaterial) {
const { wallHeight: WH, wallThickness: WT } = TABLE;
const felt = new THREE.MeshStandardMaterial({
color: 0x117a45, roughness: 0.99, metalness: 0,
});
const wallMaterial = new THREE.MeshStandardMaterial({
color: 0x0e6b3d, roughness: 0.97, metalness: 0,
});
const bumperVisualMaterial = new THREE.MeshStandardMaterial({
color: 0x0c5c34, roughness: 0.95, metalness: 0,
});
const woodMaterial = new THREE.MeshStandardMaterial({
color: 0x9a5a24, roughness: 0.55, metalness: 0.05,
});
const padMaterial = new THREE.MeshStandardMaterial({
color: 0x2b2f33, roughness: 0.62, metalness: 0.04,
});
const bedDepth = TABLE.nearZ - TABLE.backZ;
const bedCentre = (TABLE.nearZ + TABLE.backZ) / 2;
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(TABLE.halfWidth * 2, bedDepth), felt,
);
floor.rotation.x = -Math.PI / 2;
floor.position.set(0, -0.055, bedCentre);
floor.receiveShadow = true;
scene.add(floor);
const segments = wallSegments();
const bumpPositions = [];
const rows = 6, rowStep = 0.5, columnStep = 0.5;
for (const seg of segments) {
const ox = seg.nx * (WT / 2), oz = seg.nz * (WT / 2);
// Felt-covered inner wall.
const wall = new THREE.Mesh(
new THREE.BoxGeometry(seg.length + 0.3, WH, WT), wallMaterial,
);
wall.position.set(seg.cx + ox, WH / 2, seg.cz + oz);
wall.rotation.y = seg.yaw;
wall.castShadow = true;
wall.receiveShadow = true;
scene.add(wall);
// Wood skirt and padded cap, both strictly outboard of the felt wall.
const skirt = new THREE.Mesh(
new THREE.BoxGeometry(seg.length + 0.45, WH * 0.92, 0.5), woodMaterial,
);
skirt.position.set(seg.cx + seg.nx * (WT + 0.25), WH * 0.46, seg.cz + seg.nz * (WT + 0.25));
skirt.rotation.y = seg.yaw;
skirt.castShadow = true;
scene.add(skirt);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(seg.length + 0.45, 0.34, 1.35), padMaterial,
);
cap.position.set(seg.cx + seg.nx * (WT * 0.5 + 0.38), WH + 0.13, seg.cz + seg.nz * (WT * 0.5 + 0.38));
cap.rotation.y = seg.yaw;
cap.castShadow = true;
scene.add(cap);
// Physics wall. Same footprint as the felt face.
const body = new CANNON.Body({
mass: 0,
material: seg.isBackstop ? bumperMaterial : tableMaterial,
});
body.addShape(new CANNON.Box(new CANNON.Vec3(seg.length / 2, 3, WT / 2)));
body.position.set(seg.cx + ox, 3, seg.cz + oz);
body.quaternion.setFromEuler(0, seg.yaw, 0);
if (seg.isBackstop) body.userData = { surface: "backstop" };
world.addBody(body);
// Pyramid rubber laid along this segment's inner face.
const across = Math.max(1, Math.floor(seg.length / columnStep));
const tx = Math.cos(seg.yaw), tz = -Math.sin(seg.yaw);
for (let row = 0; row < rows; row++) {
const y = 0.36 + row * rowStep;
const stagger = row % 2 === 0 ? 0 : columnStep / 2;
for (let c = 0; c <= across; c++) {
const along = -seg.length / 2 + c * columnStep + stagger;
if (along > seg.length / 2) continue;
bumpPositions.push([
seg.cx + tx * along - seg.nx * 0.04,
y,
seg.cz + tz * along - seg.nz * 0.04,
seg.yaw,
]);
}
}
}
// Near lip, kept low and out of frame; stops anything trickling off the bed.
addStaticBox(world, tableMaterial, [TABLE.halfWidth, 0.7, 0.2],
[0, 0.7, TABLE.nearZ]);
const bumps = new THREE.InstancedMesh(
new THREE.ConeGeometry(0.23, 0.26, 4),
bumperVisualMaterial,
bumpPositions.length,
);
const t = new THREE.Object3D();
bumpPositions.forEach(([x, y, z, yaw], index) => {
t.position.set(x, y, z);
t.rotation.set(Math.PI / 2, 0, index % 2 === 0 ? 0 : Math.PI / 4);
t.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), 0);
t.quaternion.premultiply(
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw),
);
t.updateMatrix();
bumps.setMatrixAt(index, t.matrix);
});
bumps.castShadow = true;
bumps.receiveShadow = true;
scene.add(bumps);
}
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 = randomUnit();
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.55, 2.05), randomBetween(0.3, 0.8), randomBetween(0.15, 0.55)],
};
});
}
/* Launch from the near edge of the visible bed, not from the middle of it.
The bottom of frame meets the bed at about z = 7.95, so the dice now enter
right where the player would release them. That lengthens the run to the
backstop from ~10.9 to ~13.8 units, so the forward speed goes up to match --
at the old speed they would land short and never reach the rubber. */
function makeCrapsThrowPlan(count) {
return Array.from({ length: count }, (_, index) => {
const side = index % 2 === 0 ? -1 : 1;
return {
position: [
side * randomBetween(1.6, 2.1),
randomBetween(2.45, 2.9),
randomBetween(6.6, 7.1) + index * randomBetween(-0.12, 0.12),
],
velocity: [
-side * randomBetween(0.2, 0.6) + randomBetween(-0.2, 0.2),
randomBetween(0.3, 0.8),
-randomBetween(28, 31),
],
};
});
}
function resetBodyForThrow(body, launch, motion, strength = 1) {
const liftScale = Math.sqrt(strength);
body.wakeUp();
body.position.set(...launch.position);
randomQuaternion(body.quaternion);
body.velocity.set(
launch.velocity[0] * motion.velocityScale * strength,
motion.launchY
? randomBetween(motion.launchY[0], motion.launchY[1]) * liftScale
: launch.velocity[1] * liftScale,
launch.velocity[2] * motion.velocityScale * strength,
);
const spinStrength = 0.7 + strength * 0.3;
body.angularVelocity.set(
randomSigned(10, 15) * motion.spinScale * spinStrength,
randomSigned(11, 17) * motion.spinScale * spinStrength,
randomSigned(10, 15) * motion.spinScale * spinStrength,
);
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, options = {}) {
if (!(canvas instanceof HTMLCanvasElement)) {
throw new TypeError("Dice3D.mount requires a canvas element");
}
const sceneMode = options.scene === "craps" ? "craps" : "open";
const isCrapsScene = sceneMode === "craps";
const cameraHalfHeight = isCrapsScene ? 7.6 : 5.8;
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;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const initialAspect = isCrapsScene ? 14 / 9 : 54 / 35;
/* The table scene uses a perspective camera. Orthographic projection is what
made it read as a flat green box: with no convergence the far rail is the
same width as the near one, so the bed has no length. A long-ish lens (34
deg) keeps the dice from distorting at the frame edges. */
const camera = isCrapsScene
? new THREE.PerspectiveCamera(36, initialAspect, 0.1, 90)
: new THREE.OrthographicCamera(
-cameraHalfHeight * initialAspect,
cameraHalfHeight * initialAspect,
cameraHalfHeight,
-cameraHalfHeight,
0.1,
50,
);
if (isCrapsScene) {
/* Placed so the back wall spans most of the frame width and both side
rails stay in shot and converge -- about 30 units back from the wall at
a 35 degree downward angle. */
camera.position.set(0, 18.6, 19.8);
camera.lookAt(0, 0, 0.4);
} else {
camera.position.set(0, 9.5, 14.4);
camera.lookAt(0, 0.72, 0);
}
const dieCorners = [];
for (const x of [-1, 1]) {
for (const y of [-1, 1]) {
for (const z of [-1, 1]) dieCorners.push(new THREE.Vector3(x, y, z));
}
}
const projectedCorner = new THREE.Vector3();
const framePosition = new THREE.Vector3();
const frameQuaternion = new THREE.Quaternion();
function dieIsFullyVisible(body) {
framePosition.set(body.position.x, body.position.y, body.position.z);
frameQuaternion.set(
body.quaternion.x,
body.quaternion.y,
body.quaternion.z,
body.quaternion.w,
);
return dieCorners.every((corner) => {
projectedCorner
.copy(corner)
.applyQuaternion(frameQuaternion)
.add(framePosition)
.project(camera);
return Math.abs(projectedCorner.x) <= 0.98 && Math.abs(projectedCorner.y) <= 0.98;
});
}
/* Table lighting is a pool from above rather than flat ambience: a casino
bed is lit by a fixture right over it, bright in the middle and falling
off toward the rails. The open scene keeps its original softer setup. */
scene.add(new THREE.HemisphereLight(
0xffffff, 0x6b2426, isCrapsScene ? 0.9 : 2.05,
));
if (isCrapsScene) {
/* A spotlight, not a directional light. Directional light is uniform over
the whole plane, which is why the felt read as flat card; a spot with
distance decay gives the bright pool over the middle of the bed and the
falloff toward the rails that a table fixture actually produces. */
const lamp = new THREE.SpotLight(0xfff0d8, 2600, 80, 0.92, 0.5, 2);
lamp.position.set(-1.5, 21, 7);
lamp.target.position.set(0, 0, -2);
lamp.castShadow = true;
lamp.shadow.mapSize.set(2048, 2048);
lamp.shadow.camera.near = 4;
lamp.shadow.camera.far = 46;
lamp.shadow.bias = -0.0006;
lamp.shadow.normalBias = 0.03;
lamp.shadow.radius = 3;
scene.add(lamp);
scene.add(lamp.target);
// Low front fill so the back wall and pyramid band are not a black void.
const wallFill = new THREE.DirectionalLight(0xcfe4d6, 1.55);
wallFill.position.set(0, 4, 16);
scene.add(wallFill);
} else {
const key = new THREE.DirectionalLight(0xffffff, 2.8);
key.position.set(-4, 8, 7);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
const shadowExtent = 8;
key.shadow.camera.left = -shadowExtent;
key.shadow.camera.right = shadowExtent;
key.shadow.camera.top = shadowExtent;
key.shadow.camera.bottom = -shadowExtent;
key.shadow.camera.near = 1;
key.shadow.camera.far = 30;
key.shadow.bias = -0.0004;
key.shadow.normalBias = 0.025;
key.shadow.radius = 4;
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 { world, dieMaterial, tableMaterial, bumperMaterial } = makePhysicsWorld();
const dice = [];
const spacing = 2.75;
if (isCrapsScene) {
addCrapsTable(scene, world, tableMaterial, bumperMaterial);
} else {
const shadowFloor = new THREE.Mesh(
new THREE.PlaneGeometry(24, 18),
new THREE.ShadowMaterial({ color: 0x000000, opacity: 0.2 }),
);
shadowFloor.rotation.x = -Math.PI / 2;
shadowFloor.position.y = -0.055;
shadowFloor.receiveShadow = true;
scene.add(shadowFloor);
}
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.22,
angularDamping: 0.2,
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 die = { holder, body, value: initialValue, hitBackstop: false };
body.addEventListener("collide", (event) => {
if (event.body?.userData?.surface === "backstop") die.hitBackstop = true;
});
dice.push(die);
}
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);
}
}
function resize() {
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
const aspect = width / height;
if (camera.isPerspectiveCamera) {
camera.aspect = aspect;
} else {
const halfHeight = cameraHalfHeight;
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;
let motion = MOTION_PRESETS.balanced;
let throwForce = 50;
function finishRoll(resolve) {
syncVisuals();
renderer.render(scene, camera);
const values = dice.map((die) => {
die.value = upwardFace(die.body).value;
return die.value;
});
busy = false;
resolve(values);
}
/* A roll is either readable or it is not -- there is no such thing as
re-rolling one die. Real tables call a no-roll and the shooter throws
both again, so that is what happens here. Re-launching a single die
while its partner sat still was the thing that looked like a glitch.
Resampling the whole throw is also the cleanest statistically: it is a
fresh independent draw, which is why the distribution stays uniform. */
function rollIsUnreadable() {
return dice.some((die) => {
const face = upwardFace(die.body);
if (face.alignment < COCKED_ALIGNMENT) return true; // balanced on an edge
if (face.margin < COCKED_MARGIN) return true; // top two faces tied
return !dieIsFullyVisible(die.body); // cannot be seen
});
}
function rethrowWholeRoll(strength) {
const plan = isCrapsScene
? makeCrapsThrowPlan(dice.length)
: makeThrowPlan(dice.length);
dice.forEach((die, index) => {
die.hitBackstop = false;
resetBodyForThrow(die.body, plan[index], motion, strength);
});
}
function roll() {
if (busy) return Promise.resolve(null);
busy = true;
world.gravity.set(0, -motion.gravity, 0);
dice.forEach((die) => {
die.body.linearDamping = motion.linearDamping;
die.body.angularDamping = motion.angularDamping;
die.body.sleepSpeedLimit = motion.sleepSpeedLimit;
die.body.sleepTimeLimit = motion.sleepTimeLimit;
});
const strength = isCrapsScene ? 0.9 + throwForce * 0.0025 : 1;
const plan = isCrapsScene
? makeCrapsThrowPlan(dice.length)
: makeThrowPlan(dice.length);
dice.forEach((die, index) => {
die.hitBackstop = false;
resetBodyForThrow(die.body, plan[index], motion, strength);
});
const physicsStep = isCrapsScene
? Math.min(motion.fixedStep, 1 / 360)
: motion.fixedStep;
const maxSubSteps = isCrapsScene
? Math.max(motion.maxSubSteps, 24)
: motion.maxSubSteps;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reducedMotion) {
/* Every rethrow is followed by a complete settle pass. At the cap we
accept the best settled reading; we never freeze a fresh launch. */
for (let rethrowCount = 0; ; rethrowCount++) {
const settleSteps = Math.ceil(6 / physicsStep);
for (let step = 0; step < settleSteps; step++) world.step(physicsStep);
if (!rollIsUnreadable() || rethrowCount === MAX_RETHROWS) break;
rethrowWholeRoll(strength);
}
dice.forEach((die) => die.body.sleep());
return new Promise((resolve) => finishRoll(resolve));
}
let startedAt = performance.now();
let previous = startedAt;
let rethrowCount = 0;
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;
/* Each preset owns its integration and presentation timescale. */
world.step(
physicsStep,
frameSeconds * motion.playback,
maxSubSteps,
);
syncVisuals();
renderer.render(scene, camera);
const asleep = dice.every((die) => die.body.sleepState === CANNON.Body.SLEEPING);
if ((elapsedSeconds > motion.minDuration && asleep) || elapsedSeconds > 6) {
if (!asleep) dice.forEach((die) => die.body.sleep());
if (rollIsUnreadable() && rethrowCount < MAX_RETHROWS) {
rethrowWholeRoll(strength);
rethrowCount++;
startedAt = now;
previous = now;
frameRequest = requestAnimationFrame(frame);
} else {
finishRoll(resolve);
}
} else {
frameRequest = requestAnimationFrame(frame);
}
}
frameRequest = requestAnimationFrame(frame);
});
}
return {
roll,
dice,
isBusy: () => busy,
getMotionPreset: () => motion.name,
getThrowForce: () => throwForce,
getLastThrowDiagnostics: () => ({
backstopHits: dice.filter((die) => die.hitBackstop).length,
dice: dice.length,
}),
setThrowForce(value) {
if (busy) return false;
throwForce = THREE.MathUtils.clamp(Number(value) || 0, 0, 100);
return true;
},
setMotionPreset(name) {
if (!(name in MOTION_PRESETS)) throw new RangeError(`Unknown motion preset: ${name}`);
if (busy) return false;
motion = MOTION_PRESETS[name];
return true;
},
destroy() {
destroyed = true;
cancelAnimationFrame(frameRequest);
observer.disconnect();
const geometries = new Set();
const materials = new Set();
scene.traverse((object) => {
if (!object.isMesh) return;
if (object.geometry) geometries.add(object.geometry);
const list = Array.isArray(object.material) ? object.material : [object.material];
list.filter(Boolean).forEach((material) => materials.add(material));
});
geometries.forEach((geometry) => geometry.dispose());
materials.forEach((material) => {
for (const value of Object.values(material)) {
if (value?.isTexture) value.dispose();
}
material.dispose();
});
world.bodies.slice().forEach((body) => world.removeBody(body));
renderer.renderLists.dispose();
renderer.dispose();
scene.clear();
},
};
}
window.Dice3D = { mount, rollDie, motionPresets: Object.keys(MOTION_PRESETS) };