This commit is contained in:
2026-08-09 23:42:23 +02:00
parent 21da2edd79
commit bd2ccdf73e
46 changed files with 3540 additions and 788 deletions
+252
View File
@@ -0,0 +1,252 @@
// ── 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();
});
});
})();