Add existing to tracked
This commit is contained in:
Binary file not shown.
@@ -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">—</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">—</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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user