// ── 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();
    });
  });
})();
