Add existing to tracked

This commit is contained in:
Jay
2026-08-11 09:53:42 -04:00
parent afe07f3055
commit ffd6e3d73c
8531 changed files with 4396230 additions and 0 deletions
@@ -0,0 +1,8 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Paint_-_Enamel_Glossy_(White)
Kd 0.964706 0.964706 0.952941
newmtl Plastic_-_Glossy_(Red)
Kd 0.768627 0.207843 0.152941
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Side Cards — Dice Motion Lab</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 32px;
color: #fff;
background: radial-gradient(ellipse at center, #0b6b3a 0%, #074d28 100%);
font-family: Georgia, "Times New Roman", serif;
}
h1 {
font-size: 26px;
letter-spacing: 1px;
text-shadow: 0 2px 6px rgba(0,0,0,.5);
}
.dice-stage {
position: relative;
width: min(540px, calc(100vw - 32px));
aspect-ratio: 54 / 35;
}
#dice-canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
}
.loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(255,255,255,.65);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
pointer-events: none;
transition: opacity .18s;
}
.dice-stage.ready .loading { opacity: 0; }
.readout {
min-height: 62px;
display: flex;
flex-direction: column;
gap: 8px;
text-align: center;
}
.total { font-size: 34px; letter-spacing: 2px; }
.detail { font-size: 13px; letter-spacing: 3px; text-transform: uppercase; opacity: .7; }
.tally { font-size: 11px; letter-spacing: 1.5px; opacity: .55; font-variant-numeric: tabular-nums; }
.controls { display: flex; gap: 12px; }
.motion-options {
width: min(540px, calc(100vw - 32px));
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.motion-options button {
min-height: 58px;
padding: 8px 10px;
border: 1px solid rgba(255,255,255,.28);
border-radius: 8px;
color: rgba(255,255,255,.76);
background: rgba(0,0,0,.12);
font: 600 12px Georgia, "Times New Roman", serif;
cursor: pointer;
}
.motion-options button span {
display: block;
margin-top: 4px;
font-size: 9px;
font-weight: 400;
letter-spacing: 1px;
opacity: .7;
}
.motion-options button.active {
border-color: rgba(255,255,255,.75);
color: #fff;
background: rgba(255,255,255,.16);
box-shadow: 0 0 0 1px rgba(255,255,255,.1) inset;
}
.motion-options button[disabled] { cursor: default; opacity: .5; }
.mode-note { min-height: 16px; font-size: 11px; letter-spacing: 1px; opacity: .65; }
.controls button {
padding: 11px 24px;
border: 1px solid rgba(255,255,255,.35);
border-radius: 8px;
color: #fff;
background: rgba(255,255,255,.12);
font: inherit;
font-size: 14px;
letter-spacing: 1px;
cursor: pointer;
transition: background .15s, transform .1s;
}
.controls button:hover { background: rgba(255,255,255,.22); }
.controls button:active { transform: translateY(1px); }
.controls button[disabled] { opacity: .45; cursor: default; transform: none; }
</style>
</head>
<body>
<h1>Dice Motion Lab</h1>
<div class="motion-options" id="motion-options" aria-label="Motion preset">
<button class="active" data-preset="current">Current<span>g 24 · playback 1.70×</span></button>
<button data-preset="balanced">Balanced<span>g 96 · playback 1.15×</span></button>
<button data-preset="literal">Literal 20 mm<span>g 981 · playback 1.00×</span></button>
</div>
<div class="mode-note" id="mode-note">Approved motion — our control</div>
<div class="dice-stage" id="stage">
<canvas id="dice-canvas" aria-label="Two red dice"></canvas>
<div class="loading" id="loading">loading dice</div>
</div>
<div class="readout">
<div class="total" id="total">&mdash;</div>
<div class="detail" id="detail">ready</div>
<div class="tally" id="tally"></div>
</div>
<div class="controls">
<button id="throw" disabled>Throw</button>
<button id="reset">Reset Tally</button>
</div>
<script src="dice-renderer.bundle.js"></script>
<script>
const stage = document.getElementById("stage");
const canvas = document.getElementById("dice-canvas");
const total = document.getElementById("total");
const detail = document.getElementById("detail");
const tallyText = document.getElementById("tally");
const throwButton = document.getElementById("throw");
const presetButtons = [...document.querySelectorAll("[data-preset]")];
const modeNote = document.getElementById("mode-note");
const presetNotes = {
current: "Approved motion — our control",
balanced: "Heavier fall and impact, while keeping the tumble readable",
literal: "20 mm Earth-scale physics — intentionally fast",
};
const tally = new Array(7).fill(0);
let rolls = 0;
let tray;
function activatePreset(button) {
if (!tray || tray.isBusy() || !tray.setMotionPreset(button.dataset.preset)) return false;
presetButtons.forEach((candidate) => {
candidate.classList.toggle("active", candidate === button);
});
modeNote.textContent = presetNotes[button.dataset.preset];
detail.textContent = button.dataset.preset;
return true;
}
function updateTally(values) {
values.forEach((value) => tally[value]++);
rolls += values.length;
const parts = [];
for (let face = 1; face <= 6; face++) {
parts.push(`${face}: ${(100 * tally[face] / rolls).toFixed(1)}%`);
}
tallyText.textContent = `${rolls} faces — ${parts.join(" ")}`;
}
async function throwDice() {
if (!tray || tray.isBusy()) return;
throwButton.disabled = true;
presetButtons.forEach((button) => { button.disabled = true; });
detail.textContent = "rolling";
total.textContent = "··";
const values = await tray.roll();
if (values) {
total.textContent = values[0] + values[1];
detail.textContent = `${values[0]} + ${values[1]}`;
updateTally(values);
}
throwButton.disabled = false;
presetButtons.forEach((button) => { button.disabled = false; });
}
Dice3D.mount(canvas, 2).then((mounted) => {
tray = mounted;
stage.classList.add("ready");
throwButton.disabled = false;
const parameters = new URLSearchParams(location.search);
const requestedPreset = parameters.get("preset");
const requestedButton = presetButtons.find(
(button) => button.dataset.preset === requestedPreset,
);
activatePreset(requestedButton || presetButtons[0]);
if (parameters.get("autoroll") === "1") setTimeout(throwDice, 100);
}).catch((error) => {
console.error(error);
document.getElementById("loading").textContent = "3D dice unavailable";
detail.textContent = "renderer error";
});
throwButton.addEventListener("click", throwDice);
presetButtons.forEach((button) => {
button.addEventListener("click", () => activatePreset(button));
});
document.getElementById("reset").addEventListener("click", () => {
tally.fill(0);
rolls = 0;
tallyText.textContent = "";
});
window.addEventListener("keydown", (event) => {
if (event.code === "Space" || event.code === "Enter") {
event.preventDefault();
throwDice();
}
});
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,574 @@
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;
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");
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 = 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)],
};
});
}
function resetBodyForThrow(body, launch, motion) {
body.wakeUp();
body.position.set(...launch.position);
randomQuaternion(body.quaternion);
body.velocity.set(
launch.velocity[0] * motion.velocityScale,
motion.launchY
? randomBetween(motion.launchY[0], motion.launchY[1])
: launch.velocity[1],
launch.velocity[2] * motion.velocityScale,
);
body.angularVelocity.set(
randomSigned(10, 15) * motion.spinScale,
randomSigned(11, 17) * motion.spinScale,
randomSigned(10, 15) * motion.spinScale,
);
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;
}
function rethrowBody(body, towardCenter, motion) {
const velocityScale = motion.velocityScale;
body.wakeUp();
body.position.y += 0.18;
body.velocity.set(
towardCenter
? THREE.MathUtils.clamp(
(-body.position.x * 0.48 + randomBetween(-0.3, 0.3)) * velocityScale,
-3.2 * velocityScale,
3.2 * velocityScale,
)
: randomBetween(-1.15, 1.15) * velocityScale,
randomBetween(2.1, 2.8) * velocityScale,
towardCenter
? THREE.MathUtils.clamp(
(-body.position.z * 0.48 + randomBetween(-0.3, 0.3)) * velocityScale,
-3.2 * velocityScale,
3.2 * velocityScale,
)
: randomBetween(-1.15, 1.15) * velocityScale,
);
body.angularVelocity.set(
randomSigned(8, 13) * motion.spinScale,
randomSigned(9, 14) * motion.spinScale,
randomSigned(8, 13) * motion.spinScale,
);
body.force.setZero();
body.torque.setZero();
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;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-8.95, 8.95, 5.8, -5.8, 0.1, 40);
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;
});
}
scene.add(new THREE.HemisphereLight(0xffffff, 0x6b2426, 2.05));
const key = new THREE.DirectionalLight(0xffffff, 2.8);
key.position.set(-4, 8, 7);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.left = -8;
key.shadow.camera.right = 8;
key.shadow.camera.top = 8;
key.shadow.camera.bottom = -8;
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 } = makePhysicsWorld();
const dice = [];
const spacing = 2.75;
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);
dice.push({ holder, body, 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);
}
}
function resize() {
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
const aspect = width / height;
const halfHeight = 5.8;
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;
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);
}
function diceNeedingRethrow() {
return dice.flatMap((die) => {
const face = upwardFace(die.body);
const cocked = face.alignment < 0.9 || face.margin < 0.08;
const outsideFrame = !dieIsFullyVisible(die.body);
return cocked || outsideFrame ? [{ die, outsideFrame }] : [];
});
}
function rethrowInvalidDice(invalidDice) {
invalidDice.forEach(({ die, outsideFrame }) => {
rethrowBody(die.body, outsideFrame, motion);
});
}
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 plan = makeThrowPlan(dice.length);
dice.forEach((die, index) => resetBodyForThrow(die.body, plan[index], motion));
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 / motion.fixedStep);
for (let step = 0; step < settleSteps; step++) world.step(motion.fixedStep);
const invalidDice = diceNeedingRethrow();
if (!invalidDice.length || rethrowCount === MAX_RETHROWS) break;
rethrowInvalidDice(invalidDice);
}
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(
motion.fixedStep,
frameSeconds * motion.playback,
motion.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());
const invalidDice = diceNeedingRethrow();
if (invalidDice.length && rethrowCount < MAX_RETHROWS) {
rethrowInvalidDice(invalidDice);
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,
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) };
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Side Cards — Dice</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 36px;
padding: 32px;
color: #fff;
background: radial-gradient(ellipse at center, #0b6b3a 0%, #074d28 100%);
font-family: Georgia, "Times New Roman", serif;
}
h1 {
font-size: 26px;
letter-spacing: 1px;
text-shadow: 0 2px 6px rgba(0,0,0,.5);
}
.dice-stage {
position: relative;
width: min(540px, calc(100vw - 32px));
aspect-ratio: 54 / 35;
}
#dice-canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
}
.loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(255,255,255,.65);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
pointer-events: none;
transition: opacity .18s;
}
.dice-stage.ready .loading { opacity: 0; }
.readout {
min-height: 62px;
display: flex;
flex-direction: column;
gap: 8px;
text-align: center;
}
.total { font-size: 34px; letter-spacing: 2px; }
.detail { font-size: 13px; letter-spacing: 3px; text-transform: uppercase; opacity: .7; }
.tally { font-size: 11px; letter-spacing: 1.5px; opacity: .55; font-variant-numeric: tabular-nums; }
.controls { display: flex; gap: 12px; }
.controls button {
padding: 11px 24px;
border: 1px solid rgba(255,255,255,.35);
border-radius: 8px;
color: #fff;
background: rgba(255,255,255,.12);
font: inherit;
font-size: 14px;
letter-spacing: 1px;
cursor: pointer;
transition: background .15s, transform .1s;
}
.controls button:hover { background: rgba(255,255,255,.22); }
.controls button:active { transform: translateY(1px); }
.controls button[disabled] { opacity: .45; cursor: default; transform: none; }
</style>
</head>
<body>
<h1>Dice</h1>
<div class="dice-stage" id="stage">
<canvas id="dice-canvas" aria-label="Two red dice"></canvas>
<div class="loading" id="loading">loading dice</div>
</div>
<div class="readout">
<div class="total" id="total">&mdash;</div>
<div class="detail" id="detail">ready</div>
<div class="tally" id="tally"></div>
</div>
<div class="controls">
<button id="throw" disabled>Throw</button>
<button id="reset">Reset Tally</button>
</div>
<script src="dice-renderer.bundle.js"></script>
<script>
const stage = document.getElementById("stage");
const canvas = document.getElementById("dice-canvas");
const total = document.getElementById("total");
const detail = document.getElementById("detail");
const tallyText = document.getElementById("tally");
const throwButton = document.getElementById("throw");
const tally = new Array(7).fill(0);
let rolls = 0;
let tray;
function updateTally(values) {
values.forEach((value) => tally[value]++);
rolls += values.length;
const parts = [];
for (let face = 1; face <= 6; face++) {
parts.push(`${face}: ${(100 * tally[face] / rolls).toFixed(1)}%`);
}
tallyText.textContent = `${rolls} faces — ${parts.join(" ")}`;
}
async function throwDice() {
if (!tray || tray.isBusy()) return;
throwButton.disabled = true;
detail.textContent = "rolling";
total.textContent = "··";
const values = await tray.roll();
if (values) {
total.textContent = values[0] + values[1];
detail.textContent = `${values[0]} + ${values[1]}`;
updateTally(values);
}
throwButton.disabled = false;
}
Dice3D.mount(canvas, 2).then((mounted) => {
tray = mounted;
stage.classList.add("ready");
throwButton.disabled = false;
}).catch((error) => {
console.error(error);
document.getElementById("loading").textContent = "3D dice unavailable";
detail.textContent = "renderer error";
});
throwButton.addEventListener("click", throwDice);
document.getElementById("reset").addEventListener("click", () => {
tally.fill(0);
rolls = 0;
tallyText.textContent = "";
});
window.addEventListener("keydown", (event) => {
if (event.code === "Space" || event.code === "Enter") {
event.preventDefault();
throwDice();
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"name": "side-cards-assets",
"private": true,
"scripts": {
"convert:die": "obj2gltf -i assets/dice/source/DieBody1.obj -o assets/dice/die.glb --binary",
"build:dice": "esbuild dice-renderer.js --bundle --format=iife --platform=browser --target=es2020 --minify --legal-comments=none --loader:.glb=binary --outfile=dice-renderer.bundle.js"
},
"dependencies": {
"cannon-es": "0.20.0",
"three": "0.184.0"
},
"devDependencies": {
"esbuild": "0.25.9",
"obj2gltf": "3.2.0"
}
}