253 lines
9.5 KiB
JavaScript
253 lines
9.5 KiB
JavaScript
// ── 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();
|
||
});
|
||
});
|
||
})();
|