Files
2026-08-09 23:42:23 +02:00

253 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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();
});
});
})();