← Mike Higgins Home
Custom Code Experiment •

Krump

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Glitch krump loader</title>
<style>
  :root {
    box-sizing: border-box;
    padding-top: env(safe-area-inset-top, 0px);
    padding-bottom: env(safe-area-inset-bottom, 0px);
    --page: #EDEDED;
    --ring-track: rgba(0, 0, 0, 0.12);
    --accent: #FFC21A;
    --void: #0A0A0A;
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --page: #141414;
      --ring-track: rgba(255, 255, 255, 0.12);
    }
  }
  :root[data-theme="dark"] {
    --page: #141414;
    --ring-track: rgba(255, 255, 255, 0.12);
  }
  *, *::before, *::after { box-sizing: inherit; }
  html { height: 100%; scroll-padding-top: env(safe-area-inset-top, 0px); }
  body {
    margin: 0;
    height: 100%;
    background: var(--page);
    display: grid;
    place-items: center;
  }

  .orb {
    position: relative;
    width: min(260px, 70vw);
    aspect-ratio: 1;
  }

  /* Animated yellow ring */
  .ring {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    transform: rotate(-90deg);
  }
  .ring circle { fill: none; stroke-width: 3; }
  .ring .track { stroke: var(--ring-track); }
  .ring .arc {
    stroke: var(--accent);
    stroke-linecap: round;
    stroke-dasharray: 90 400;
    animation: sweep 1.6s cubic-bezier(.6,.1,.3,.9) infinite;
    transform-origin: 50% 50%;
  }
  @keyframes sweep {
    0%   { stroke-dasharray: 20 400; transform: rotate(0deg); }
    50%  { stroke-dasharray: 160 400; }
    100% { stroke-dasharray: 20 400; transform: rotate(360deg); }
  }

  /* Circular viewport */
  .window {
    position: absolute;
    inset: 12px;
    border-radius: 50%;
    overflow: hidden;
    background: radial-gradient(circle at 50% 40%, #262626 0%, var(--void) 70%);
    box-shadow: inset 0 0 40px rgba(0,0,0,.8), inset 0 0 0 1px rgba(255,255,255,.2);
  }
  .window canvas { display: block; width: 100%; height: 100%; }
  /* spotlight from above */
  .window::before {
    content: "";
    position: absolute;
    inset: 0;
    background: radial-gradient(ellipse 38% 70% at 50% 0%, rgba(255,255,255,.09), transparent 70%);
    pointer-events: none;
  }
  /* scanlines */
  .window::after {
    content: "";
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.28) 2px 3px);
    pointer-events: none;
  }

  @media (prefers-reduced-motion: reduce) {
    .ring .arc { animation-duration: 4s; }
  }
</style>
</head>
<body>
  <div class="orb" role="status" aria-label="Loading">
    <svg class="ring" viewBox="0 0 100 100" aria-hidden="true">
      <circle class="track" cx="50" cy="50" r="48"/>
      <circle class="arc" cx="50" cy="50" r="48"/>
    </svg>
    <div class="window">
      <canvas id="dancer" aria-hidden="true"></canvas>
    </div>
  </div>

<script>
(() => {
  const canvas = document.getElementById('dancer');
  const ctx = canvas.getContext('2d');
  const buf = document.createElement('canvas');
  const bctx = buf.getContext('2d');
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const WHITE = '#FFFFFF';
  const FLOOR = '#8C8C8C';
  const GHOST_A = '#8C8C8C', GHOST_B = '#E0E0E0';

  let W = 0, H = 0, dpr = 1;
  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    const r = canvas.getBoundingClientRect();
    W = Math.round(r.width * dpr); H = Math.round(r.height * dpr);
    canvas.width = buf.width = W;
    canvas.height = buf.height = H;
  }
  new ResizeObserver(resize).observe(canvas);
  resize();

  // ---------- Vector helpers ----------
  const add = (a, b) => [a[0]+b[0], a[1]+b[1], a[2]+b[2]];
  const mul = (a, k) => [a[0]*k, a[1]*k, a[2]*k];
  const rotX = ([x, y, z], a) => [x, y*Math.cos(a) - z*Math.sin(a), y*Math.sin(a) + z*Math.cos(a)];
  const rotY = ([x, y, z], a) => [x*Math.cos(a) + z*Math.sin(a), y, -x*Math.sin(a) + z*Math.cos(a)];
  const rotZ = ([x, y, z], a) => [x*Math.cos(a) - y*Math.sin(a), x*Math.sin(a) + y*Math.cos(a), z];
  // Limb direction: starts pointing down, raised sideways by `abd`, swung forward (+z) by `fwd`
  const limbDir = (side, abd, fwd) => rotX(rotZ([0, -1, 0], side * abd), -fwd);

  // ---------- Skeleton ----------
  const SPINE = 0.72, NECK = 0.1, HEAD_R = 0.17;
  const UPPER_ARM = 0.36, FOREARM = 0.34;
  const THIGH = 0.44, SHIN = 0.44;
  const FLOOR_Y = -1.0;

  // ---------- Krump ----------
  const s = Math.sin, c = Math.cos, PI = Math.PI, TAU = PI * 2;
  const BPM = 96;
  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 len = (a) => Math.hypot(a[0], a[1], a[2]);
  const norm = (a) => mul(a, 1 / (len(a) || 1));
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
  const smooth = (x) => { x = clamp(x, 0, 1); return x * x * (3 - 2 * x); };
  const lerp = (a, b, w) => a + (b - a) * w;
  const fract = (x) => ((x % 1) + 1) % 1;

  function ik(hip, target, a, b, hint) {
    const toT = sub(target, hip);
    const d = Math.min(a + b - 1e-4, Math.max(0.05, len(toT)));
    const u = norm(toT);
    const cosA = (a * a + d * d - b * b) / (2 * a * d);
    const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
    const n = norm(sub(hint, mul(u, dot(hint, u))));
    return [add(hip, add(mul(u, a * cosA), mul(n, a * sinA))), add(hip, mul(u, d))];
  }
  function blend(a, b, w) {
    if (typeof a === 'number') return a + (b - a) * w;
    const o = {};
    for (const k in a) o[k] = blend(a[k], b[k], w);
    return o;
  }

  const G = 9.81 / 0.83;
  const secPerBeat = () => 60 / (reduced ? BPM / 3 : BPM);

  const foot = (x, y, z, point = 0) => ({ x, y, z, point });
  const base = (o) => Object.assign({
    hipH: 0.74, air: 0, lean: 0.18, leanSide: 0, yaw: 0, headTurn: 0, headNod: 0.05, kneeOut: 0.55, pop: 0,
    fR: foot(0.22, 0.06, 0.02), fL: foot(-0.22, 0.06, 0.02)
  }, o);
  const GUARD = { abd: 0.4, fwd: 0.9, eAbd: -0.3, eFwd: 1.2 };

  // 1. Stomps: foot lifts high and slams down on every beat; fists drive down with it
  function stomp(b) {
    const f = fract(b), u = clamp((f - 0.35) / 0.65, 0, 1);
    const h = 0.3 * s(PI * Math.pow(u, 0.7));                  // up slowly, down fast
    const right = Math.floor(b) % 2 === 0;                      // lands on the next beat
    const imp = Math.exp(-f * 6);                               // recoil from the last stomp
    const lift = (sd) => foot(sd * 0.22, 0.06 + h, 0.02 + 0.12 * h, 0.2 * h);
    const arm = { abd: 0.5, fwd: lerp(0.3, 1.4, u), eAbd: 0, eFwd: lerp(0.25, 1.3, u) };
    return base({
      fR: right ? lift(1) : foot(0.22, 0.06, 0.02), fL: right ? foot(-0.22, 0.06, 0.02) : lift(-1),
      hipH: 0.74 - 0.13 * imp, lean: 0.2 + 0.16 * imp, headNod: 0.05 + 0.35 * imp,
      leanSide: (right ? -1 : 1) * 0.1 * s(PI * u),
      armR: arm, armL: arm
    });
  }

  // 2. Chest pops: planted wide, arms flexed out, chest punching forward in rapid bursts
  const POP_TIMES = [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 3, 3.5];
  function pops(b) {
    const bar = Math.floor(b / 4), lb = b - bar * 4;
    let env = 0;
    for (const tp of POP_TIMES) if (lb >= tp) env += Math.exp(-(lb - tp) * 14);
    const pop = Math.min(1, env);
    const flex = { abd: 1.3 + 0.18 * pop, fwd: 0.3, eAbd: 1.05, eFwd: 0.2 };
    return base({
      pop, hipH: 0.72 - 0.05 * pop, lean: 0.08 - 0.12 * pop, headNod: -0.1 - 0.2 * pop,
      yaw: (bar % 2 ? 1 : -1) * 0.3, headTurn: (bar % 2 ? -1 : 1) * 0.15,
      armR: flex, armL: flex
    });
  }

  // 3. Arm swings: wind back, then whip over the top and chop down, stomping forward on the hit
  function swings(b) {
    const p = fract(b / 2), side = Math.floor(b / 2) % 2 === 0 ? 1 : -1;
    let fwd;
    if (p < 0.5) fwd = lerp(0.9, -1.95, smooth(p / 0.5));                 // wind up: back and up
    else fwd = lerp(-1.95, 0.9 - TAU, smooth((p - 0.5) / 0.35));         // over the top and down
    let swing = { abd: 0.25, fwd, eAbd: 0, eFwd: 0.15 };
    if (p > 0.85) swing = blend({ abd: 0.25, fwd: 0.9, eAbd: 0, eFwd: 0.15 }, GUARD, smooth((p - 0.85) / 0.15));
    const hit = p >= 0.85 ? Math.exp(-(p - 0.85) * 2 * 6) : 0;
    const windup = smooth(p / 0.5) * (p < 0.85 ? 1 : 0);
    const stepH = p > 0.5 && p < 0.85 ? 0.22 * s(PI * (p - 0.5) / 0.35) : 0;
    const lead = foot(side * 0.22, 0.06 + stepH, lerp(0.02, 0.22, smooth((p - 0.5) / 0.35)), 0);
    const back = foot(-side * 0.22, 0.06, -0.05);
    return base({
      fR: side > 0 ? lead : back, fL: side > 0 ? back : lead,
      armR: side > 0 ? swing : GUARD, armL: side > 0 ? GUARD : swing,
      yaw: side * lerp(-0.45 * windup, 0.35, p >= 0.85 ? 1 : smooth((p - 0.5) / 0.35) * (p > 0.5 ? 1 : 0)),
      hipH: 0.74 - 0.14 * hit, lean: 0.15 + 0.25 * hit - 0.1 * windup, headNod: 0.35 * hit
    });
  }

  // 4. Jabs, then a buck hop into a huge landing and a flexing kill-off
  function finale(b) {
    if (b < 5.5) {
      const q = fract(b * 2), side = Math.floor(b * 2) % 2 === 0 ? 1 : -1;
      const ext = Math.min(1, q * 12) * Math.exp(-q * 5);
      const jab = blend(GUARD, { abd: 0.3, fwd: 1.5, eAbd: -0.05, eFwd: 0.05 }, ext);
      const bounce = (1 + c(TAU * b * 2)) / 2;
      return base({
        armR: side > 0 ? jab : GUARD, armL: side > 0 ? GUARD : jab,
        yaw: side * 0.18 * ext, hipH: 0.73 - 0.04 * bounce, lean: 0.2 + 0.06 * ext, headNod: 0.1
      });
    }
    if (b < 6) {                                                 // load up
      const w = smooth((b - 5.5) / 0.5);
      const back = { abd: 0.4, fwd: -0.9, eAbd: 0, eFwd: 0.3 };
      return base({ hipH: 0.74 - 0.26 * w, lean: 0.2 + 0.25 * w, headNod: 0.2 * w, armR: blend(GUARD, back, w), armL: blend(GUARD, back, w) });
    }
    if (b < 7) {                                                 // buck hop: one beat of real flight
      const u = b - 6, T = secPerBeat(), H = G * T * T / 8;
      const tuck = s(PI * u);
      const up = { abd: 2.4, fwd: 0.4, eAbd: 0.2, eFwd: 0 };
      return base({
        air: 4 * H * u * (1 - u), hipH: 0.48 + 0.3 * tuck,
        fR: foot(0.2, 0.06 + 0.12 * tuck, 0.08 * tuck, 0.5 * tuck), fL: foot(-0.2, 0.06 + 0.12 * tuck, 0.08 * tuck, 0.5 * tuck),
        lean: 0.45 - 0.35 * tuck, headNod: -0.2 * tuck,
        armR: blend({ abd: 0.4, fwd: -0.9, eAbd: 0, eFwd: 0.3 }, up, smooth(u / 0.4)),
        armL: blend({ abd: 0.4, fwd: -0.9, eAbd: 0, eFwd: 0.3 }, up, smooth(u / 0.4))
      });
    }
    // landed: absorb, then rise into a double-bicep flex, chest out, chin up
    const u = b - 7, land = Math.exp(-u * 5), rise = smooth(u / 0.35);
    const flex = { abd: 1.45, fwd: 0.15, eAbd: 1.5, eFwd: 0 };
    return base({
      fR: foot(0.24, 0.06, 0.04), fL: foot(-0.24, 0.06, 0.04),
      hipH: 0.46 + 0.2 * rise, pop: rise, lean: lerp(0.45, -0.15, rise), headNod: lerp(0.3, -0.25, rise),
      armR: blend({ abd: 2.4, fwd: 0.4, eAbd: 0.2, eFwd: 0 }, flex, rise),
      armL: blend({ abd: 2.4, fwd: 0.4, eAbd: 0.2, eFwd: 0 }, flex, rise),
      kneeOut: 0.6 + 0.2 * land
    });
  }

  const SCHEDULE = [[stomp, 8], [pops, 8], [swings, 8], [finale, 8]];
  const TOTAL = SCHEDULE.reduce((n, [, l]) => n + l, 0);
  const FADE = 0.4;
  function poseAt(beat) {
    let m = ((beat % TOTAL) + TOTAL) % TOTAL, i = 0;
    while (m >= SCHEDULE[i][1]) { m -= SCHEDULE[i][1]; i++; }
    const [fn, l] = SCHEDULE[i];
    const cur = fn(m);
    if (m > l - FADE) return blend(cur, SCHEDULE[(i + 1) % SCHEDULE.length][0](0), smooth((m - (l - FADE)) / FADE));
    return cur;
  }

  // Every stomp and landing in the loop, with where the foot hits the floor
  const IMPACTS = (() => {
    const list = [], at = (beat, key, size) => {
      const p = poseAt(beat - 0.001);
      const f = p[key];
      const w = rotY([f.x, 0, f.z], p.yaw);
      list.push({ beat, x: w[0], z: w[2], size });
    };
    for (let n = 1; n <= 8; n++) at(n, (n - 1) % 2 === 0 ? 'fR' : 'fL', 0.55);          // stomps
    for (let k = 0; k < 4; k++) at(16 + 2 * k + 1.7, k % 2 === 0 ? 'fR' : 'fL', 0.7);   // swing hits
    list.push({ beat: 24 + 7, x: 0, z: 0.04, size: 1.3 });                               // buck hop landing
    return list;
  })();
  // impacts that happened in the last few beats, with how long ago
  function recentImpacts(beat) {
    const m = ((beat % TOTAL) + TOTAL) % TOTAL, out = [];
    for (const im of IMPACTS) {
      const dt = ((m - im.beat) % TOTAL + TOTAL) % TOTAL;
      if (dt < 3) out.push({ ...im, dt });
    }
    return out;
  }

  function buildFigure(pose) {
    const pelvisY = FLOOR_Y + pose.hipH + pose.air;
    const pelvis = [0, pelvisY, 0];
    const hipR = [0.09, pelvisY, 0], hipL = [-0.09, pelvisY, 0];
    const ANK = 0.06;
    const legPts = (hip, side, F) => {
      const target = [F.x, FLOOR_Y + F.y + pose.air, F.z];
      const [knee, ank] = ik(hip, target, THIGH, SHIN, norm([side * pose.kneeOut, 0.1, 1]));
      const turn = side * 0.3;
      return { knee, ank, heel: add(ank, rotY([0, -ANK, -0.03], turn)), toe: add(ank, rotY(rotX([0, -ANK, 0.15], F.point), turn)) };
    };
    const LR = legPts(hipR, 1, pose.fR), LL = legPts(hipL, -1, pose.fL);

    const up = rotZ(rotX([0, 1, 0], pose.lean), -pose.leanSide);
    const torso = (v) => rotZ(rotX(v, pose.lean), -pose.leanSide);
    const chest = mul(torso([0, 0.035, 0.1]), pose.pop);        // chest pop: the rib cage punches forward
    const waist = add(add(pelvis, mul(up, 0.34)), mul(chest, 0.55));
    const neck = add(add(pelvis, mul(up, SPINE)), chest);
    const shoulder = add(add(pelvis, mul(up, SPINE - 0.06)), chest);
    const headOrient = (v) => rotZ(rotX(rotY(v, pose.headTurn), pose.lean + pose.headNod), -pose.leanSide);
    const headC = add(neck, mul(headOrient([0, 1, 0]), NECK + HEAD_R));
    const arm = (side, A) => {
      const sh = add(shoulder, torso([side * 0.12, 0, -0.03 * pose.pop]));
      const elbow = add(sh, mul(torso(limbDir(side, A.abd, A.fwd)), UPPER_ARM));
      return [sh, elbow, add(elbow, mul(torso(limbDir(side, A.abd + A.eAbd, A.fwd + A.eFwd)), FOREARM))];
    };
    const ar = arm(1, pose.armR), al = arm(-1, pose.armL);

    const place = (p) => rotY(p, pose.yaw);
    const polys = [];
    const line = (pts, w = 2.4) => polys.push({ pts: pts.map(place), color: WHITE, w });
    line([pelvis, waist, neck]);
    line([hipR, pelvis, hipL]);
    line([ar[0], shoulder, al[0]], 2.2);
    line(ar); line(al);
    line([hipR, LR.knee, LR.ank]); line([hipL, LL.knee, LL.ank]);
    for (const L of [LR, LL]) line([L.ank, L.heel, L.toe, L.ank], 2.0);

    const headPt = (v) => place(add(headC, headOrient(v)));
    for (const lat of [-0.5, 0, 0.5]) {
      const pts = [], r = HEAD_R * c(lat), y = HEAD_R * s(lat);
      for (let k = 0; k <= 18; k++) { const a = TAU * k / 18; pts.push(headPt([r * c(a), y, r * s(a)])); }
      polys.push({ pts, color: WHITE, w: 1.2, dim: lat !== 0 });
    }
    for (let m = 0; m < 4; m++) {
      const pts = [], a = PI * m / 4;
      for (let k = 0; k <= 18; k++) { const q = TAU * k / 18; pts.push(headPt([HEAD_R * c(q) * c(a), HEAD_R * s(q), HEAD_R * c(q) * s(a)])); }
      polys.push({ pts, color: WHITE, w: 1.0, dim: true });
    }
    // fists
    for (const A of [ar, al]) {
      const h = A[2], pts = [];
      for (let k = 0; k <= 10; k++) { const a = TAU * k / 10; pts.push(place(add(h, [0.045 * c(a), 0.045 * s(a), 0]))); }
      polys.push({ pts, color: WHITE, w: 1.6 });
    }
    return polys;
  }

  // ---------- Floor: tiles, shockwaves and cracks from every stomp ----------
  const N = 7, TILE = 0.26, HALF_W = N * TILE / 2;
  const hash = (n) => { let x = Math.imul((n | 0) ^ 0x9e3779b9, 0x85ebca6b); x ^= x >>> 13; x = Math.imul(x, 0xc2b2ae35); x ^= x >>> 16; return (x >>> 0) / 4294967296; };
  let floorBeat = 0;
  function buildFloor() {
    const polys = [];
    for (let i = 0; i <= N; i++) {
      const v = -HALF_W + i * TILE;
      polys.push({ pts: [[-HALF_W, FLOOR_Y, v], [HALF_W, FLOOR_Y, v]], color: FLOOR, w: 0.8, dim: true });
      polys.push({ pts: [[v, FLOOR_Y, -HALF_W], [v, FLOOR_Y, HALF_W]], color: FLOOR, w: 0.8, dim: true });
    }
    const hits = recentImpacts(floorBeat);
    // tiles light up as the shockwave passes under them
    for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
      const cx = -HALF_W + (i + 0.5) * TILE, cz = -HALF_W + (j + 0.5) * TILE;
      let v = 0;
      for (const h of hits) {
        const d = Math.hypot(cx - h.x, cz - h.z), r = h.dt * 2.4 * h.size;
        v += h.size * Math.exp(-h.dt * 3) * Math.exp(-Math.pow(d - r, 2) / 0.02);
      }
      v = clamp(v, 0, 1);
      if (v < 0.08) continue;
      for (const [inset, th] of [[0.025, 0], [0.07, 0.5]]) {
        if (v <= th) break;
        const x0 = cx - TILE / 2 + inset, x1 = cx + TILE / 2 - inset, z0 = cz - TILE / 2 + inset, z1 = cz + TILE / 2 - inset;
        polys.push({ pts: [[x0, FLOOR_Y, z0], [x1, FLOOR_Y, z0], [x1, FLOOR_Y, z1], [x0, FLOOR_Y, z1], [x0, FLOOR_Y, z0]], color: WHITE, w: 1.2, alpha: v });
      }
    }
    for (const h of hits) {
      // shock ring (short-lived, so it never spreads past the floor)
      if (h.dt < 0.8) {
      const r = 0.05 + h.dt * 0.9 * h.size, ring = [];
      for (let k = 0; k <= 28; k++) { const a = TAU * k / 28; ring.push([h.x + r * c(a), FLOOR_Y + 0.002, h.z + r * s(a)]); }
      polys.push({ pts: ring, color: WHITE, w: 1.4, alpha: 0.8 * Math.exp(-h.dt * 4) });
      }
      // cracks: jagged lines that snap out from the impact and fade
      const grow = smooth(h.dt / 0.12), fade = Math.exp(-h.dt * 1.1);
      const seed = Math.round(h.beat * 100);
      const count = 4 + Math.round(h.size * 3);
      for (let k = 0; k < count; k++) {
        let a = TAU * (k + hash(seed + k) * 0.7) / count;
        const L = (0.12 + 0.3 * h.size * (0.5 + hash(seed + k + 50))) * grow;
        const pts = [[h.x, FLOOR_Y + 0.003, h.z]];
        let x = h.x, z = h.z;
        for (let sgm = 0; sgm < 4; sgm++) {
          a += (hash(seed + k * 7 + sgm) - 0.5) * 0.9;
          x += c(a) * L / 4; z += s(a) * L / 4;
          pts.push([x, FLOOR_Y + 0.003, z]);
        }
        polys.push({ pts, color: WHITE, w: 1.1, alpha: 0.85 * fade });
      }
    }
    return polys;
  }

  // ---------- Projection ----------
  // The camera follows the dancer, so he stays centred and the floor glides beneath him
  const CENTER_Y = 0.27;
  let focusX = 0, focusZ = 0;
  function project(p, view, jitter) {
    let [x, y, z] = p;
    x -= focusX; y -= CENTER_Y; z -= focusZ;
    if (jitter) { x += (Math.random()-.5)*jitter; y += (Math.random()-.5)*jitter; }
    const cy = Math.cos(view.yaw), sy = Math.sin(view.yaw);
    const x1 = x * cy + z * sy, z1 = -x * sy + z * cy;
    const cx = Math.cos(view.pitch), sx = Math.sin(view.pitch);
    const y1 = y * cx - z1 * sx, z2 = y * sx + z1 * cx;
    const d = 5, persp = d / (d - z2);
    const scale = W * 0.255;
    return [W/2 + x1 * scale * persp, H/2 - y1 * scale * persp, z2];
  }

  function strokeAll(polys, view, color, alpha, ox, oy, jitter) {
    for (const poly of polys) {
      bctx.beginPath();
      let depthSum = 0;
      poly.pts.forEach((p, i) => {
        const [sx, sy, sz] = project(p, view, jitter);
        depthSum += sz;
        if (i === 0) bctx.moveTo(sx + ox, sy + oy); else bctx.lineTo(sx + ox, sy + oy);
      });
      const depth = depthSum / poly.pts.length;
      const fade = 0.45 + 0.55 * Math.min(1, Math.max(0, (depth + 1.2) / 2.4));
      bctx.globalAlpha = alpha * fade * (poly.dim ? 0.55 : 1) * (poly.alpha ?? 1);
      bctx.strokeStyle = color || poly.color;
      bctx.lineWidth = poly.w * dpr;
      bctx.stroke();
    }
    bctx.globalAlpha = 1;
  }

  // ---------- Loop ----------
  let glitch = 0, glitchStrength = 0;
  let orbit = 0, last = performance.now();
  let prevM = null;
  const ringEl = document.querySelector('.ring');

  function frame(now) {
    if (W === 0 || H === 0) {
      resize();
      if (W === 0 || H === 0) { last = now; requestAnimationFrame(frame); return; }
    }
    const dt = Math.min(50, now - last) / 1000; last = now;
    orbit += dt * (reduced ? 0.15 : 0.4);

    const t = now / 1000;
    const beat = t * (reduced ? BPM / 3 : BPM) / 60;
    const m = ((beat % TOTAL) + TOTAL) % TOTAL;

    // Glitches come from the hits: the buck-hop landing always, stomps sometimes
    if (prevM !== null && !reduced) {
      for (const im of IMPACTS) {
        const crossed = prevM <= m ? (prevM < im.beat && im.beat <= m) : (im.beat > prevM || im.beat <= m);
        if (!crossed) continue;
        if (im.size > 1) { glitch = 14; glitchStrength = 1; }
        else if (glitch <= 0 && Math.random() < 0.18) { glitch = 3 + Math.floor(Math.random() * 5); glitchStrength = 0.4 + Math.random() * 0.4; }
      }
    }
    prevM = m;
    const g = glitch > 0;
    if (g) glitch--;

    // camera shake and a ring thump from recent impacts
    let shake = 0;
    for (const h of recentImpacts(beat)) shake += h.size * Math.exp(-h.dt * 9);
    if (reduced) shake = 0;
    const shX = (Math.random() - 0.5) * shake * 9 * dpr, shY = (Math.random() - 0.5) * shake * 9 * dpr;
    if (ringEl) ringEl.style.transform = `rotate(-90deg) scale(${1 + 0.03 * Math.min(1, shake)})`;

    const view = { yaw: orbit, pitch: 0.24 + Math.sin(t * 0.7) * 0.04 };
    const pose = poseAt(beat);
    focusX = 0; focusZ = 0; floorBeat = beat;
    const polys = buildFloor().concat(buildFigure(pose));

    bctx.clearRect(0, 0, W, H);
    bctx.lineJoin = 'round'; bctx.lineCap = 'round';
    bctx.globalCompositeOperation = 'lighter';
    if (g) {
      const off = (4 + Math.random() * 8) * dpr * glitchStrength;
      strokeAll(polys, view, GHOST_A, 0.7, -off + shX, shY, 0.03 * glitchStrength);
      strokeAll(polys, view, GHOST_B, 0.6, off + shX, shY + (Math.random()-.5)*2*dpr, 0.03 * glitchStrength);
    }
    strokeAll(polys, view, null, g ? 0.85 : 1, shX, shY, g ? 0.02 : (reduced ? 0 : 0.004));
    bctx.globalCompositeOperation = 'source-over';

    ctx.clearRect(0, 0, W, H);
    if (g) {
      let y = 0;
      while (y < H) {
        const h = Math.min(H - y, Math.max(2, Math.floor((4 + Math.random() * 30) * dpr)));
        const shift = Math.random() < 0.35 ? (Math.random() - .5) * 40 * dpr * glitchStrength : 0;
        ctx.drawImage(buf, 0, y, W, h, shift, y, W, h);
        y += h;
      }
      const blocks = Math.floor(Math.random() * 4);
      for (let i = 0; i < blocks; i++) {
        ctx.fillStyle = [WHITE, GHOST_A, GHOST_B][i % 3];
        ctx.globalAlpha = 0.18 + Math.random() * 0.3;
        ctx.fillRect(Math.random() * W, Math.random() * H, (10 + Math.random() * 60) * dpr, (1 + Math.random() * 3) * dpr);
      }
      ctx.globalAlpha = 1;
    } else {
      ctx.drawImage(buf, 0, 0);
    }

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>