875 lines
29 KiB
JavaScript
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) };
|