// ── D20 on "Dungeons & Dragons": a 3D die built from matrix3d ─ (function () { const anchor = document.querySelector('.dnd-roll'); if (!anchor) return; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); // Icosahedron in CSS coordinates (x right, y down, z toward viewer) const R = 44; // circumradius, px const PHI = (1 + Math.sqrt(5)) / 2; const RAW = [ [-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0], [0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI], [PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1], ]; const FACES = [ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], ]; // The standard d20 layout (per Alea Kybos' dice configuration catalog), // embedded onto the face list above: 20 bordered by 2/8/14, opposite // faces sum to 21, evens on 20's hemisphere and odds on 1's const NUMBERS = [20, 2, 12, 10, 8, 18, 14, 16, 15, 17, 11, 9, 19, 1, 13, 6, 4, 3, 7, 5]; // Theme pastels the die can be cast from, one picked per roll const PASTELS = [ [255, 216, 77], [140, 188, 255], [255, 159, 202], [169, 231, 108], [185, 156, 255], [255, 157, 87], ]; const scale = R / Math.hypot(1, PHI); const V = RAW.map(([x, y, z]) => [x * scale, -y * scale, z * scale]); const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const cross = (a, b) => [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ]; const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]); return [a[0] / l, a[1] / l, a[2] / l]; }; const mat3d = ([X, Y, Z], t) => `matrix3d(${X[0]},${X[1]},${X[2]},0,${Y[0]},${Y[1]},${Y[2]},0,` + `${Z[0]},${Z[1]},${Z[2]},0,${t[0]},${t[1]},${t[2]},1)`; let scene = null; let die = null; const bases = []; const faceEls = []; function build() { scene = document.createElement('span'); scene.className = 'die-scene'; die = document.createElement('span'); die.className = 'die'; scene.appendChild(die); const light = norm([-0.35, -0.5, 0.78]); FACES.forEach((f, idx) => { let [A, B, C] = f.map((i) => V[i]); const G = [ (A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3, (A[2] + B[2] + C[2]) / 3, ]; let n = norm(cross(sub(B, A), sub(C, A))); if (dot(n, G) < 0) { // ensure the normal points outward [B, C] = [C, B]; n = [-n[0], -n[1], -n[2]]; } const X = norm(sub(B, A)); // face-local right const Y = norm(cross(n, X)); // face-local down (X × Y = n, no mirroring) bases.push([X, Y, n]); const D = Math.hypot(...sub(B, A)); // edge length const face = document.createElement('span'); face.className = 'die-face'; face.textContent = NUMBERS[idx]; // Real dice underline the ambiguous digits if (NUMBERS[idx] === 6 || NUMBERS[idx] === 9) face.classList.add('die-face--mark'); const pts = [A, B, C].map((P) => { const p = sub(P, G); return `${dot(p, X) + D / 2}px ${dot(p, Y) + D / 2}px`; }); face.style.width = face.style.height = D + 'px'; face.style.margin = -D / 2 + 'px'; face.style.fontSize = D * 0.3 + 'px'; face.style.clipPath = `polygon(${pts.join(', ')})`; // Static lighting factor, applied to the roll's color when it's cast faceEls.push({ el: face, shade: 0.6 + 0.4 * Math.max(0, dot(n, light)), n, G, t: mat3d([X, Y, n], G), }); die.appendChild(face); }); anchor.appendChild(scene); } // No preserve-3d anywhere: Firefox's plane-splitting misrenders faces // that meet at shared edges (halves of a face vanish at grazing // angles), so each face is projected to its own flat 2D transform and // back faces are culled by hand — being convex, the visible faces // never overlap, so no browser z-sorting is needed either const PERSP = 420; // projection depth, px function pose(dieT) { const m = new DOMMatrix(dieT); faceEls.forEach(({ el, n, G, t }) => { const nx = m.m11 * n[0] + m.m21 * n[1] + m.m31 * n[2]; const ny = m.m12 * n[0] + m.m22 * n[1] + m.m32 * n[2]; const nz = m.m13 * n[0] + m.m23 * n[1] + m.m33 * n[2]; const cx = m.m11 * G[0] + m.m21 * G[1] + m.m31 * G[2] + m.m41; const cy = m.m12 * G[0] + m.m22 * G[1] + m.m32 * G[2] + m.m42; const cz = m.m13 * G[0] + m.m23 * G[1] + m.m33 * G[2] + m.m43; if (nx * cx + ny * cy + nz * (cz - PERSP) < 0) { el.style.transform = `perspective(${PERSP}px) ${dieT} ${t}`; el.style.visibility = ''; } else { el.style.visibility = 'hidden'; } }); } // cubic-bezier(0.16, 0.6, 0.28, 1) by hand, since the tumble is now a // rAF loop instead of a WAAPI animation function ease(x) { if (x <= 0) return 0; if (x >= 1) return 1; const bez = (a, b, u) => 3 * a * u * (1 - u) * (1 - u) + 3 * b * u * u * (1 - u) + u * u * u; let lo = 0; let hi = 1; for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2; if (bez(0.16, 0.28, mid) < x) lo = mid; else hi = mid; } return bez(0.6, 1, (lo + hi) / 2); } // Rotation that lands face i flat toward the viewer, number upright function landing(i) { const [X, Y, Z] = bases[i]; return `matrix3d(${X[0]},${Y[0]},${Z[0]},0,${X[1]},${Y[1]},${Z[1]},0,` + `${X[2]},${Y[2]},${Z[2]},0,0,0,0,1)`; } let busy = false; let rolls = 0; anchor.addEventListener('click', () => { if (busy) return; busy = true; if (window.achv) window.achv.mark('d20'); if (!scene) build(); scene.style.display = ''; scene.style.opacity = '1'; // The first two rolls of a page load can never land a 20 — the // achievement trigger stays out of reach of casual clicking rolls++; let result = Math.floor(Math.random() * 20) + 1; if (rolls <= 2 && result === 20) result = Math.floor(Math.random() * 19) + 1; const final = landing(NUMBERS.indexOf(result)); // Cast the die in one of the theme pastels for this roll const [pr, pg, pb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; faceEls.forEach(({ el, shade }) => { el.style.background = `rgb(${Math.round(pr * shade)}, ${Math.round(pg * shade)}, ${Math.round(pb * shade)})`; }); // Nat 20: DOM sprinkles burst from the die — unlike the canvas ones, // these render above the cards, so the effect survives any backdrop const burst = () => { const r = scene.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; for (let i = 0; i < 24; i++) { const bit = document.createElement('span'); bit.className = 'burst-bit'; const [br, bg, bb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; bit.style.background = `rgb(${br}, ${bg}, ${bb})`; bit.style.left = cx + 'px'; bit.style.top = cy + 'px'; document.body.appendChild(bit); const a = Math.random() * Math.PI * 2; const d = 60 + Math.random() * 90; bit.animate( [ { transform: `translate(-50%, -50%) rotate(${Math.random() * 360}deg)`, opacity: 1, }, { transform: `translate(calc(-50% + ${(Math.cos(a) * d).toFixed(1)}px), ` + `calc(-50% + ${(Math.sin(a) * d).toFixed(1)}px)) ` + `rotate(${((Math.random() - 0.5) * 720).toFixed(0)}deg)`, opacity: 0, }, ], { duration: 700 + Math.random() * 300, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)', fill: 'forwards' } ).finished.then(() => bit.remove()); } }; const finish = () => { if (result === 20 && !reduceMotion.matches) burst(); if (result === 20 && window.achv) window.achv.unlock('natural'); setTimeout(() => { scene.style.opacity = '0'; setTimeout(() => { scene.style.display = 'none'; busy = false; }, 320); }, 1100); }; if (reduceMotion.matches) { pose(final); finish(); return; } // One continuous decelerating roll from a fully random direction: the // die tumbles end-over-end along its travel (axis perpendicular to the // motion, in-plane) with a slower barrel roll around the travel axis. // Both extra rotations unwind to exactly zero on top of the landing // matrix, so it settles on the result with no correcting jerk. const ang = Math.random() * Math.PI * 2; const dx = Math.cos(ang); const dy = Math.sin(ang); const roll = 810 + Math.floor(Math.random() * 3) * 72; const wobble = 240 + Math.random() * 240; const tumbleAxis = `${dy.toFixed(4)},${(-dx).toFixed(4)},0`; const travelAxis = `${dx.toFixed(4)},${dy.toFixed(4)},0`; const at = (e) => `translate(${(-dx * 150 * (1 - e)).toFixed(2)}px, ` + `${(-dy * 150 * (1 - e)).toFixed(2)}px) ` + `rotate3d(${tumbleAxis},${(roll * (1 - e)).toFixed(3)}deg) ` + `rotate3d(${travelAxis},${(-wobble * (1 - e)).toFixed(3)}deg) ${final}`; pose(at(0)); const t0 = performance.now(); requestAnimationFrame(function frame(now) { const p = Math.min(1, Math.max(0, (now - t0) / 1400)); pose(p < 1 ? at(ease(p)) : final); if (p < 1) requestAnimationFrame(frame); else finish(); }); }); })();